goldsync 0.1.16 → 0.1.34
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 +69 -78
- package/assets/GoldSync.rbxmx +1206 -204
- package/bin/install-companion.mjs +38 -6
- package/media/configuration-assets.png +0 -0
- package/media/file-boundaries.png +0 -0
- package/package.json +2 -2
- package/src/broker.mjs +17 -1
- package/src/companion.mjs +16 -1
- package/src/config.mjs +39 -6
- package/src/git-conflicts.mjs +1 -1
- package/src/restore.mjs +34 -0
- package/src/server.mjs +191 -13
- package/src/store.mjs +246 -5
package/assets/GoldSync.rbxmx
CHANGED
|
@@ -19,10 +19,18 @@ local ENDPOINT = "http://127.0.0.1:34873"
|
|
|
19
19
|
local SESSION_MARKER_NAME = "__GoldSyncSession"
|
|
20
20
|
local CLIENT_HEADERS = {
|
|
21
21
|
["X-GoldSync-Client"] = "studio-plugin",
|
|
22
|
+
["X-GoldSync-Tree-Roots"] = "4",
|
|
23
|
+
["X-GoldSync-Restore"] = "1",
|
|
22
24
|
}
|
|
23
25
|
local SETTINGS_PREFIX = "GoldSync:v2:"
|
|
24
26
|
local UI_SETTINGS_PREFIX = "GoldSync:ui:v1:"
|
|
25
|
-
local VERSION = "0.1.
|
|
27
|
+
local VERSION = "0.1.34"
|
|
28
|
+
local startupProfile = { settings = 0, watchers = 0, reconcile = 0, downloads = 0 }
|
|
29
|
+
local savedHashes = {}
|
|
30
|
+
local savedHashesKey
|
|
31
|
+
local hashesDirty = false
|
|
32
|
+
local TreeRoots = require(script.Parent.TreeRoots)
|
|
33
|
+
local StudioRestore = require(script.Parent.StudioRestore)
|
|
26
34
|
local REQUEST_INTERVAL_SECONDS = 0.15
|
|
27
35
|
local REQUEST_RETRY_DELAYS = { 1, 2, 4 }
|
|
28
36
|
|
|
@@ -387,11 +395,18 @@ rootsLayout.Parent = rootsContainer
|
|
|
387
395
|
|
|
388
396
|
local running = true
|
|
389
397
|
local bulkRunning = false
|
|
398
|
+
local refreshing = false
|
|
390
399
|
local requestLocked = false
|
|
391
400
|
local requestWaiters = {}
|
|
392
401
|
local lastRequestAt = 0
|
|
393
402
|
local serverConfig
|
|
394
403
|
local states = {}
|
|
404
|
+
local treeDiscovery = {}
|
|
405
|
+
local snapshotBatch = {}
|
|
406
|
+
local configETag
|
|
407
|
+
local filteringTree = false
|
|
408
|
+
local filterScheduled = false
|
|
409
|
+
local filterAgain = false
|
|
395
410
|
local groups = {}
|
|
396
411
|
local batchingStatusUpdates = false
|
|
397
412
|
local splitRootConnections = {}
|
|
@@ -402,32 +417,47 @@ local refreshUnsyncedButton
|
|
|
402
417
|
local upload
|
|
403
418
|
local pull
|
|
404
419
|
local deleteSnapshot
|
|
420
|
+
local pathStartsWith
|
|
405
421
|
local openGitConflict
|
|
406
422
|
local resolveGitConflict
|
|
407
423
|
local applyTreeFilter
|
|
408
424
|
local resetInstanceTrees
|
|
409
425
|
|
|
426
|
+
local function pullStates(affected)
|
|
427
|
+
table.sort(affected, function(left, right)
|
|
428
|
+
local leftDepth, rightDepth = #left.manifest.studioPath, #right.manifest.studioPath
|
|
429
|
+
if leftDepth ~= rightDepth then return leftDepth < rightDepth end
|
|
430
|
+
return left.manifest.id < right.manifest.id
|
|
431
|
+
end)
|
|
432
|
+
for _, state in affected do
|
|
433
|
+
if states[state.manifest.id] == state and not state.manifest.gitConflict then
|
|
434
|
+
pull(state)
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
410
439
|
for index, button in attentionBulkButtons do
|
|
411
440
|
button.Activated:Connect(function()
|
|
412
441
|
if bulkRunning or not serverConfig or not serverConfig.rojoConnected then
|
|
413
442
|
return
|
|
414
443
|
end
|
|
444
|
+
bulkRunning = true
|
|
445
|
+
while refreshing do task.wait() end
|
|
415
446
|
local affected = {}
|
|
416
447
|
for _, state in states do
|
|
417
448
|
if state.statusKind == "issue" and not state.manifest.gitConflict then
|
|
418
449
|
table.insert(affected, state)
|
|
419
450
|
end
|
|
420
451
|
end
|
|
421
|
-
bulkRunning = true
|
|
422
452
|
refreshStatusSummary()
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
453
|
+
if index == 2 then
|
|
454
|
+
pullStates(affected)
|
|
455
|
+
else
|
|
456
|
+
for _, state in affected do
|
|
457
|
+
if states[state.manifest.id] ~= state or state.manifest.gitConflict then
|
|
458
|
+
continue
|
|
459
|
+
end
|
|
428
460
|
upload(state, true)
|
|
429
|
-
else
|
|
430
|
-
pull(state)
|
|
431
461
|
end
|
|
432
462
|
end
|
|
433
463
|
bulkRunning = false
|
|
@@ -550,10 +580,10 @@ local function responseError(response)
|
|
|
550
580
|
return string.format("HTTP %d · %s", response.StatusCode, tostring(response.StatusMessage))
|
|
551
581
|
end
|
|
552
582
|
|
|
553
|
-
local function resolveRoot(studioPath)
|
|
583
|
+
local function resolveRoot(studioPath, instanceIds)
|
|
554
584
|
local current = game:GetService(studioPath[1])
|
|
555
585
|
for index = 2, #studioPath do
|
|
556
|
-
current = current:FindFirstChild(studioPath[index])
|
|
586
|
+
current = if instanceIds then TreeRoots.findChild(current, studioPath[index], instanceIds[index]) else current:FindFirstChild(studioPath[index])
|
|
557
587
|
if not current then
|
|
558
588
|
return nil
|
|
559
589
|
end
|
|
@@ -561,10 +591,10 @@ local function resolveRoot(studioPath)
|
|
|
561
591
|
return current
|
|
562
592
|
end
|
|
563
593
|
|
|
564
|
-
local function resolveParent(studioPath)
|
|
594
|
+
local function resolveParent(studioPath, instanceIds)
|
|
565
595
|
local current = game:GetService(studioPath[1])
|
|
566
596
|
for index = 2, #studioPath - 1 do
|
|
567
|
-
current = current:FindFirstChild(studioPath[index])
|
|
597
|
+
current = if instanceIds then TreeRoots.findChild(current, studioPath[index], instanceIds[index]) else current:FindFirstChild(studioPath[index])
|
|
568
598
|
if not current then
|
|
569
599
|
return nil
|
|
570
600
|
end
|
|
@@ -719,21 +749,9 @@ local function addButtonHover(button)
|
|
|
719
749
|
Instance.new("UICorner", button).CornerRadius = UDim.new(0, 3)
|
|
720
750
|
end
|
|
721
751
|
|
|
722
|
-
local function findCode(root)
|
|
723
|
-
if root:IsA("BaseScript") then
|
|
724
|
-
return root
|
|
725
|
-
end
|
|
726
|
-
for _, descendant in root:GetDescendants() do
|
|
727
|
-
if descendant:IsA("BaseScript") then
|
|
728
|
-
return descendant
|
|
729
|
-
end
|
|
730
|
-
end
|
|
731
|
-
return nil
|
|
732
|
-
end
|
|
733
|
-
|
|
734
752
|
local function setStatus(state, text, color)
|
|
735
753
|
local statusColor = color or Color3.fromRGB(180, 180, 190)
|
|
736
|
-
local statusKind = if string.sub(text, 1, 6) == "Synced"
|
|
754
|
+
local statusKind = if string.sub(text, 1, 6) == "Synced" or string.sub(text, 1, 7) == "Deleted"
|
|
737
755
|
then "synced"
|
|
738
756
|
elseif string.find(text, "syncing")
|
|
739
757
|
or string.find(text, "Serializing")
|
|
@@ -743,18 +761,29 @@ local function setStatus(state, text, color)
|
|
|
743
761
|
then "working"
|
|
744
762
|
elseif string.find(text, "Rojo") and string.find(text, "offline") then "paused"
|
|
745
763
|
else "issue"
|
|
746
|
-
if state.
|
|
764
|
+
if state.statusText == text and state.statusColor == statusColor and state.statusKind == statusKind then
|
|
747
765
|
return
|
|
748
766
|
end
|
|
749
767
|
local previousStatusKind = state.statusKind
|
|
750
|
-
state.
|
|
751
|
-
state.
|
|
752
|
-
state.
|
|
768
|
+
state.statusText = text
|
|
769
|
+
state.statusColor = statusColor
|
|
770
|
+
if state.row then
|
|
771
|
+
state.status.Text = text
|
|
772
|
+
state.status.TextColor3 = statusColor
|
|
773
|
+
state.dot.TextColor3 = statusColor
|
|
774
|
+
end
|
|
753
775
|
state.searchQuery = nil
|
|
754
776
|
state.statusKind = statusKind
|
|
755
777
|
if updateGroupSummaries and not batchingStatusUpdates then
|
|
756
778
|
if previousStatusKind ~= state.statusKind then
|
|
757
779
|
updateGroupSummaries()
|
|
780
|
+
if applyTreeFilter and not filterScheduled then
|
|
781
|
+
filterScheduled = true
|
|
782
|
+
task.defer(function()
|
|
783
|
+
filterScheduled = false
|
|
784
|
+
if running then applyTreeFilter() end
|
|
785
|
+
end)
|
|
786
|
+
end
|
|
758
787
|
elseif refreshStatusSummary then
|
|
759
788
|
refreshStatusSummary()
|
|
760
789
|
end
|
|
@@ -813,6 +842,9 @@ local function getSerializedPropertyNames(className)
|
|
|
813
842
|
table.insert(names, property.Name)
|
|
814
843
|
end
|
|
815
844
|
end
|
|
845
|
+
if (className == "ModuleScript" or className == "Script" or className == "LocalScript") and not table.find(names, "Source") then
|
|
846
|
+
table.insert(names, "Source")
|
|
847
|
+
end
|
|
816
848
|
table.sort(names)
|
|
817
849
|
end
|
|
818
850
|
propertyNamesByClass[className] = names
|
|
@@ -850,7 +882,7 @@ local function compareConflictWorkspace(state, versions, resultRoot)
|
|
|
850
882
|
local generation = inspectorGeneration
|
|
851
883
|
table.clear(inspectorEntries)
|
|
852
884
|
inspectorRendered = 0
|
|
853
|
-
inspectorTitle.Text =
|
|
885
|
+
inspectorTitle.Text = TreeRoots.displayPath(state.manifest.studioPath, state.manifest.instanceIds)
|
|
854
886
|
inspectorSummary.Text = "Scanning serialized properties..."
|
|
855
887
|
inspectorWidget.Enabled = true
|
|
856
888
|
|
|
@@ -1110,10 +1142,20 @@ local function watchInstance(state, instance)
|
|
|
1110
1142
|
return
|
|
1111
1143
|
end
|
|
1112
1144
|
local connections = {
|
|
1113
|
-
instance.AttributeChanged:Connect(function()
|
|
1145
|
+
instance.AttributeChanged:Connect(function(attribute)
|
|
1114
1146
|
markDirty(state)
|
|
1147
|
+
if attribute == "GoldSyncId" and instance == state.root and state.manifest.treeRootId then
|
|
1148
|
+
local tree = treeDiscovery[state.manifest.treeRootId]
|
|
1149
|
+
if tree then tree.dirty = true end
|
|
1150
|
+
end
|
|
1115
1151
|
end),
|
|
1116
1152
|
}
|
|
1153
|
+
if instance == state.root and state.manifest.treeRootId then
|
|
1154
|
+
table.insert(connections, instance:GetPropertyChangedSignal("Name"):Connect(function()
|
|
1155
|
+
local tree = treeDiscovery[state.manifest.treeRootId]
|
|
1156
|
+
if tree then tree.dirty = true end
|
|
1157
|
+
end))
|
|
1158
|
+
end
|
|
1117
1159
|
if instance:IsA("ValueBase") then
|
|
1118
1160
|
for _, propertyName in getSerializedPropertyNames(instance.ClassName) do
|
|
1119
1161
|
local connected, connection = pcall(function()
|
|
@@ -1137,9 +1179,23 @@ local function watchInstance(state, instance)
|
|
|
1137
1179
|
end
|
|
1138
1180
|
|
|
1139
1181
|
local function watchRoot(state, root)
|
|
1182
|
+
local started = os.clock()
|
|
1140
1183
|
disconnectRoot(state)
|
|
1141
1184
|
state.root = root
|
|
1142
1185
|
watchInstance(state, root)
|
|
1186
|
+
if state.manifest.container then
|
|
1187
|
+
local tree = treeDiscovery[state.manifest.treeRootId]
|
|
1188
|
+
if tree then
|
|
1189
|
+
for _, signal in { root.ChildAdded, root.ChildRemoved } do
|
|
1190
|
+
table.insert(state.rootConnections, signal:Connect(function()
|
|
1191
|
+
tree.dirty = true
|
|
1192
|
+
markDirty(state)
|
|
1193
|
+
end))
|
|
1194
|
+
end
|
|
1195
|
+
end
|
|
1196
|
+
if startupProfile then startupProfile.watchers += os.clock() - started end
|
|
1197
|
+
return
|
|
1198
|
+
end
|
|
1143
1199
|
for _, descendant in root:GetDescendants() do
|
|
1144
1200
|
watchInstance(state, descendant)
|
|
1145
1201
|
end
|
|
@@ -1158,34 +1214,86 @@ local function watchRoot(state, root)
|
|
|
1158
1214
|
markDirty(state)
|
|
1159
1215
|
end))
|
|
1160
1216
|
table.insert(state.rootConnections, root.AncestryChanged:Connect(function()
|
|
1161
|
-
if not state.suppress and resolveRoot(state.manifest.studioPath) ~= root then
|
|
1217
|
+
if not state.suppress and resolveRoot(state.manifest.studioPath, state.manifest.instanceIds) ~= root then
|
|
1162
1218
|
deleteSnapshot(state)
|
|
1163
1219
|
end
|
|
1164
1220
|
end))
|
|
1221
|
+
if startupProfile then startupProfile.watchers += os.clock() - started end
|
|
1222
|
+
end
|
|
1223
|
+
|
|
1224
|
+
local function saveHashes()
|
|
1225
|
+
if not hashesDirty or not savedHashesKey then return end
|
|
1226
|
+
plugin:SetSetting(savedHashesKey, savedHashes)
|
|
1227
|
+
hashesDirty = false
|
|
1165
1228
|
end
|
|
1166
1229
|
|
|
1167
1230
|
local function rememberHash(state, hash)
|
|
1168
1231
|
state.lastHash = hash
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
)
|
|
1232
|
+
if savedHashes[state.manifest.id] == hash then return end
|
|
1233
|
+
savedHashes[state.manifest.id] = hash
|
|
1234
|
+
hashesDirty = true
|
|
1173
1235
|
end
|
|
1174
1236
|
|
|
1175
1237
|
local function forgetHash(state)
|
|
1176
1238
|
state.lastHash = nil
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
)
|
|
1239
|
+
if savedHashes[state.manifest.id] == nil then return end
|
|
1240
|
+
savedHashes[state.manifest.id] = nil
|
|
1241
|
+
hashesDirty = true
|
|
1181
1242
|
end
|
|
1182
1243
|
|
|
1183
|
-
deleteSnapshot = function(state)
|
|
1244
|
+
deleteSnapshot = function(state, explicit)
|
|
1245
|
+
if state.boundary or state.coveredByBoundary then return end
|
|
1246
|
+
if state.manifest.container then
|
|
1247
|
+
state.conflict = true
|
|
1248
|
+
setStatus(state, "Folder removed. Push to remove its saved subtree; Pull to restore it.", Color3.fromRGB(245, 106, 106))
|
|
1249
|
+
if not explicit or state.deleting or not serverConfig.rojoConnected then return end
|
|
1250
|
+
local expected, affected = {}, {}
|
|
1251
|
+
for _, candidate in states do
|
|
1252
|
+
local manifest = candidate.manifest
|
|
1253
|
+
if manifest.treeRootId ~= state.manifest.treeRootId or not pathStartsWith(state.manifest.studioPath, manifest.studioPath, state.manifest.instanceIds, manifest.instanceIds) then continue end
|
|
1254
|
+
if manifest.gitConflict or resolveRoot(manifest.studioPath, manifest.instanceIds) then
|
|
1255
|
+
setStatus(state, "Deletion blocked: a saved descendant still exists in Studio or has a Git conflict.", Color3.fromRGB(245, 106, 106))
|
|
1256
|
+
return
|
|
1257
|
+
end
|
|
1258
|
+
expected[manifest.id] = manifest.hash or false
|
|
1259
|
+
table.insert(affected, candidate)
|
|
1260
|
+
end
|
|
1261
|
+
local header = HttpService:JSONEncode({ entries = {}, expected = expected })
|
|
1262
|
+
local length = buffer.create(4)
|
|
1263
|
+
buffer.writeu32(length, 0, #header)
|
|
1264
|
+
state.deleting = true
|
|
1265
|
+
local response, failure = request("PUT", "/v1/roots/" .. state.manifest.id .. "/boundary", buffer.tostring(length) .. header, { ["Content-Type"] = "application/octet-stream" })
|
|
1266
|
+
state.deleting = false
|
|
1267
|
+
if not response or not response.Success then
|
|
1268
|
+
setStatus(state, "Deletion failed: " .. (failure or responseError(response)), Color3.fromRGB(245, 106, 106))
|
|
1269
|
+
return
|
|
1270
|
+
end
|
|
1271
|
+
for _, candidate in affected do
|
|
1272
|
+
disconnectRoot(candidate)
|
|
1273
|
+
forgetHash(candidate)
|
|
1274
|
+
candidate.coveredByBoundary = state.manifest.id
|
|
1275
|
+
candidate.conflict = false
|
|
1276
|
+
setStatus(candidate, "Deleted Studio → file", Color3.fromRGB(102, 214, 139))
|
|
1277
|
+
end
|
|
1278
|
+
configETag = nil
|
|
1279
|
+
treeDiscovery[state.manifest.treeRootId].dirty = true
|
|
1280
|
+
return
|
|
1281
|
+
end
|
|
1282
|
+
if not explicit and state.manifest.treeRootId then
|
|
1283
|
+
local parentPath = table.clone(state.manifest.studioPath)
|
|
1284
|
+
table.remove(parentPath)
|
|
1285
|
+
if not resolveRoot(parentPath, state.manifest.instanceIds) then
|
|
1286
|
+
state.conflict = true
|
|
1287
|
+
setStatus(state, "Parent removed. Push to remove its saved subtree; Pull to restore it.", Color3.fromRGB(245, 106, 106))
|
|
1288
|
+
return
|
|
1289
|
+
end
|
|
1290
|
+
end
|
|
1184
1291
|
if state.uploading or state.pulling or state.deleting then
|
|
1185
1292
|
return
|
|
1186
1293
|
end
|
|
1187
1294
|
state.generation += 1
|
|
1188
1295
|
state.dirty = false
|
|
1296
|
+
if explicit then state.lastHash = state.manifest.hash end
|
|
1189
1297
|
if state.manifest.gitConflict or not state.lastHash then
|
|
1190
1298
|
state.conflict = true
|
|
1191
1299
|
setStatus(state, "Root removed; resolve the existing file conflict manually.", Color3.fromRGB(245, 106, 106))
|
|
@@ -1233,6 +1341,7 @@ deleteSnapshot = function(state)
|
|
|
1233
1341
|
end
|
|
1234
1342
|
|
|
1235
1343
|
upload = function(state, overwrite)
|
|
1344
|
+
if state.coveredByBoundary or state.suppress then return end
|
|
1236
1345
|
if state.uploading or state.pulling or state.deleting then
|
|
1237
1346
|
return
|
|
1238
1347
|
end
|
|
@@ -1244,25 +1353,133 @@ upload = function(state, overwrite)
|
|
|
1244
1353
|
setStatus(state, "Paused — Rojo is offline.", Color3.fromRGB(244, 194, 92))
|
|
1245
1354
|
return
|
|
1246
1355
|
end
|
|
1247
|
-
local root = resolveRoot(state.manifest.studioPath)
|
|
1356
|
+
local root = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
|
|
1248
1357
|
if not root then
|
|
1358
|
+
if state.manifest.deleted and not state.manifest.exists then
|
|
1359
|
+
state.conflict = false
|
|
1360
|
+
setStatus(state, "Deleted", Color3.fromRGB(102, 214, 139))
|
|
1361
|
+
return
|
|
1362
|
+
end
|
|
1363
|
+
if overwrite then
|
|
1364
|
+
local owner = state
|
|
1365
|
+
for _, candidate in states do
|
|
1366
|
+
if candidate.manifest.treeRootId == state.manifest.treeRootId and candidate.manifest.container
|
|
1367
|
+
and #candidate.manifest.studioPath < #owner.manifest.studioPath
|
|
1368
|
+
and pathStartsWith(candidate.manifest.studioPath, state.manifest.studioPath, candidate.manifest.instanceIds, state.manifest.instanceIds)
|
|
1369
|
+
and not resolveRoot(candidate.manifest.studioPath, candidate.manifest.instanceIds) then owner = candidate end
|
|
1370
|
+
end
|
|
1371
|
+
deleteSnapshot(owner, true)
|
|
1372
|
+
return
|
|
1373
|
+
end
|
|
1249
1374
|
state.conflict = true
|
|
1250
1375
|
setStatus(state, "Studio root is missing. Pull to restore it.", Color3.fromRGB(245, 106, 106))
|
|
1251
1376
|
return
|
|
1252
1377
|
end
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1378
|
+
if state.manifest.treeRootId and not state.boundary then
|
|
1379
|
+
for _, tree in serverConfig.treeRoots do
|
|
1380
|
+
if tree.id ~= state.manifest.treeRootId then continue end
|
|
1381
|
+
if TreeRoots.isContainer(root, #tree.studioPath == #state.manifest.studioPath) ~= state.manifest.container then
|
|
1382
|
+
state.boundary = true
|
|
1383
|
+
treeDiscovery[tree.id].dirty = true
|
|
1384
|
+
setStatus(state, "Storage boundary changed. Refreshing conversion details...", Color3.fromRGB(244, 194, 92))
|
|
1385
|
+
return
|
|
1386
|
+
end
|
|
1387
|
+
end
|
|
1388
|
+
end
|
|
1389
|
+
if state.boundary then
|
|
1390
|
+
if not overwrite then
|
|
1391
|
+
setStatus(state, "Storage boundary changed. Push to convert the saved subtree.", Color3.fromRGB(244, 194, 92))
|
|
1392
|
+
return
|
|
1393
|
+
end
|
|
1394
|
+
state.uploading = true
|
|
1395
|
+
setStatus(state, "Serializing replacement boundary...", Color3.fromRGB(115, 182, 255))
|
|
1396
|
+
local success, packet = pcall(function()
|
|
1397
|
+
local tree
|
|
1398
|
+
for _, candidate in serverConfig.treeRoots do if candidate.id == state.manifest.treeRootId then tree = candidate break end end
|
|
1399
|
+
local entries, snapshots = {}, {}
|
|
1400
|
+
if root.ClassName == "Folder" then TreeRoots.discover({ id = tree.id, studioPath = state.manifest.studioPath }, root, {}, serverConfig.maxPathDepth) end
|
|
1401
|
+
local expected = {}
|
|
1402
|
+
for _, candidate in serverConfig.roots do
|
|
1403
|
+
if candidate.id == state.manifest.id or (states[candidate.id] and states[candidate.id].coveredByBoundary == state.manifest.id) then
|
|
1404
|
+
expected[candidate.id] = candidate.hash or false
|
|
1405
|
+
end
|
|
1406
|
+
end
|
|
1407
|
+
local function visit(instance, path, ids)
|
|
1408
|
+
local container = instance.ClassName == "Folder"
|
|
1409
|
+
local snapshot = if container then TreeRoots.snapshot(instance, getSerializedPropertyNames) else instance
|
|
1410
|
+
local bytes = SerializationService:SerializeInstancesAsync({ snapshot })
|
|
1411
|
+
local restored = SerializationService:DeserializeInstancesAsync(bytes)
|
|
1412
|
+
assert(#restored == 1 and TreeRoots.equal(snapshot, restored[1], getSerializedPropertyNames, container), "Replacement snapshot failed verification")
|
|
1413
|
+
restored[1]:Destroy()
|
|
1414
|
+
if snapshot ~= instance then snapshot:Destroy() end
|
|
1415
|
+
if container and not TreeRoots.needsSnapshot(instance) then bytes = buffer.create(0) end
|
|
1416
|
+
table.insert(entries, { path = path, instanceIds = ids, container = container, size = buffer.len(bytes) })
|
|
1417
|
+
table.insert(snapshots, buffer.tostring(bytes))
|
|
1418
|
+
if not container then return end
|
|
1419
|
+
for _, child in instance:GetChildren() do
|
|
1420
|
+
local childPath, childIds = table.clone(path), table.clone(ids)
|
|
1421
|
+
table.insert(childPath, child.Name)
|
|
1422
|
+
table.insert(childIds, child:GetAttribute("GoldSyncId") or "")
|
|
1423
|
+
visit(child, childPath, childIds)
|
|
1424
|
+
end
|
|
1425
|
+
end
|
|
1426
|
+
local relativePath, relativeIds = {}, {}
|
|
1427
|
+
for depth = #tree.studioPath + 1, #state.manifest.studioPath do
|
|
1428
|
+
table.insert(relativePath, state.manifest.studioPath[depth])
|
|
1429
|
+
table.insert(relativeIds, state.manifest.instanceIds[depth])
|
|
1430
|
+
end
|
|
1431
|
+
visit(root, relativePath, relativeIds)
|
|
1432
|
+
local header = HttpService:JSONEncode({ entries = entries, expected = expected })
|
|
1433
|
+
local length = buffer.create(4)
|
|
1434
|
+
buffer.writeu32(length, 0, #header)
|
|
1435
|
+
return buffer.tostring(length) .. header .. table.concat(snapshots)
|
|
1436
|
+
end)
|
|
1437
|
+
if not success then
|
|
1438
|
+
state.uploading = false
|
|
1439
|
+
setStatus(state, "Conversion failed: " .. tostring(packet), Color3.fromRGB(245, 106, 106))
|
|
1440
|
+
return
|
|
1441
|
+
end
|
|
1442
|
+
if state.suppress then state.uploading = false return end
|
|
1443
|
+
local response, failure = request("PUT", "/v1/roots/" .. state.manifest.id .. "/boundary", packet, { ["Content-Type"] = "application/octet-stream" })
|
|
1444
|
+
state.uploading = false
|
|
1445
|
+
if not response or not response.Success then
|
|
1446
|
+
setStatus(state, "Conversion failed: " .. (failure or responseError(response)), Color3.fromRGB(245, 106, 106))
|
|
1447
|
+
return
|
|
1448
|
+
end
|
|
1449
|
+
for _, manifest in HttpService:JSONDecode(response.Body).roots do
|
|
1450
|
+
if manifest.id == state.manifest.id then state.manifest = manifest end
|
|
1451
|
+
end
|
|
1452
|
+
state.boundary = nil
|
|
1453
|
+
state.conflict = false
|
|
1454
|
+
state.dirty = false
|
|
1455
|
+
configETag = nil
|
|
1456
|
+
treeDiscovery[state.manifest.treeRootId].dirty = true
|
|
1457
|
+
setStatus(state, "Synced converted boundary", Color3.fromRGB(102, 214, 139))
|
|
1458
|
+
return
|
|
1459
|
+
end
|
|
1460
|
+
|
|
1461
|
+
local plainFolder = state.manifest.container and not TreeRoots.needsSnapshot(root)
|
|
1462
|
+
if plainFolder and not state.manifest.exists then
|
|
1463
|
+
state.dirty = false
|
|
1464
|
+
state.conflict = false
|
|
1465
|
+
forgetHash(state)
|
|
1466
|
+
watchRoot(state, root)
|
|
1467
|
+
setStatus(state, "Synced Folder", Color3.fromRGB(102, 214, 139))
|
|
1257
1468
|
return
|
|
1258
1469
|
end
|
|
1259
1470
|
|
|
1260
1471
|
state.uploading = true
|
|
1261
1472
|
setStatus(state, "Serializing Studio root...", Color3.fromRGB(115, 182, 255))
|
|
1262
1473
|
local serialized, serializationError
|
|
1474
|
+
local snapshot
|
|
1263
1475
|
local success, result = pcall(function()
|
|
1264
|
-
return
|
|
1476
|
+
if plainFolder then return buffer.create(0) end
|
|
1477
|
+
snapshot = if state.manifest.container then TreeRoots.snapshot(root, getSerializedPropertyNames) else root
|
|
1478
|
+
return SerializationService:SerializeInstancesAsync({ snapshot })
|
|
1265
1479
|
end)
|
|
1480
|
+
if snapshot and snapshot ~= root then
|
|
1481
|
+
snapshot:Destroy()
|
|
1482
|
+
end
|
|
1266
1483
|
if success then
|
|
1267
1484
|
serialized = result
|
|
1268
1485
|
else
|
|
@@ -1275,14 +1492,16 @@ upload = function(state, overwrite)
|
|
|
1275
1492
|
end
|
|
1276
1493
|
|
|
1277
1494
|
local expectedHash = "*"
|
|
1495
|
+
if state.suppress then state.uploading = false return end
|
|
1278
1496
|
if overwrite then
|
|
1279
1497
|
expectedHash = state.manifest.exists and state.manifest.hash or "*"
|
|
1280
1498
|
elseif state.lastHash then
|
|
1281
1499
|
expectedHash = state.lastHash
|
|
1282
1500
|
end
|
|
1283
|
-
local response, requestError = request("PUT", "/v1/roots/" .. state.manifest.id .. "/snapshot", buffer.tostring(serialized), {
|
|
1501
|
+
local response, requestError = request(if plainFolder then "DELETE" else "PUT", "/v1/roots/" .. state.manifest.id .. "/snapshot", buffer.tostring(serialized), {
|
|
1284
1502
|
["Content-Type"] = "application/octet-stream",
|
|
1285
1503
|
["If-Match"] = expectedHash,
|
|
1504
|
+
["X-GoldSync-Plain-Folder"] = if plainFolder then "1" else "0",
|
|
1286
1505
|
})
|
|
1287
1506
|
state.uploading = false
|
|
1288
1507
|
if not response then
|
|
@@ -1308,12 +1527,13 @@ upload = function(state, overwrite)
|
|
|
1308
1527
|
state.manifest = manifest
|
|
1309
1528
|
state.dirty = false
|
|
1310
1529
|
state.conflict = false
|
|
1311
|
-
rememberHash(state, manifest.hash)
|
|
1530
|
+
if manifest.hash then rememberHash(state, manifest.hash) else forgetHash(state) end
|
|
1312
1531
|
watchRoot(state, root)
|
|
1313
1532
|
setStatus(state, "Synced Studio → file", Color3.fromRGB(102, 214, 139))
|
|
1314
1533
|
end
|
|
1315
1534
|
|
|
1316
|
-
pull = function(state)
|
|
1535
|
+
pull = function(state, adoptOnly)
|
|
1536
|
+
if state.coveredByBoundary then return end
|
|
1317
1537
|
if state.uploading or state.pulling or state.deleting then
|
|
1318
1538
|
return
|
|
1319
1539
|
end
|
|
@@ -1326,25 +1546,57 @@ pull = function(state)
|
|
|
1326
1546
|
return
|
|
1327
1547
|
end
|
|
1328
1548
|
if not state.manifest.exists then
|
|
1549
|
+
if state.manifest.container then
|
|
1550
|
+
local parent = resolveParent(state.manifest.studioPath, state.manifest.instanceIds)
|
|
1551
|
+
if not parent then
|
|
1552
|
+
setStatus(state, "Studio parent path is missing.", Color3.fromRGB(245, 106, 106))
|
|
1553
|
+
return
|
|
1554
|
+
end
|
|
1555
|
+
local root = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
|
|
1556
|
+
if root and root.ClassName ~= "Folder" then
|
|
1557
|
+
setStatus(state, "Missing metadata represents a Folder. Push to keep this class.", Color3.fromRGB(245, 106, 106))
|
|
1558
|
+
return
|
|
1559
|
+
end
|
|
1560
|
+
local snapshot = Instance.new("Folder")
|
|
1561
|
+
snapshot.Name = state.manifest.studioPath[#state.manifest.studioPath]
|
|
1562
|
+
local id = state.manifest.instanceIds[#state.manifest.studioPath]
|
|
1563
|
+
if id ~= "" then snapshot:SetAttribute("GoldSyncId", id) end
|
|
1564
|
+
if root then
|
|
1565
|
+
local applied, failure = pcall(TreeRoots.applyMetadata, root, snapshot, getSerializedPropertyNames)
|
|
1566
|
+
snapshot:Destroy()
|
|
1567
|
+
if not applied then
|
|
1568
|
+
setStatus(state, tostring(failure), Color3.fromRGB(245, 106, 106))
|
|
1569
|
+
return
|
|
1570
|
+
end
|
|
1571
|
+
else
|
|
1572
|
+
root = snapshot
|
|
1573
|
+
root.Parent = parent
|
|
1574
|
+
end
|
|
1575
|
+
forgetHash(state)
|
|
1576
|
+
watchRoot(state, root)
|
|
1577
|
+
state.dirty = false
|
|
1578
|
+
state.conflict = false
|
|
1579
|
+
setStatus(state, "Synced Folder", Color3.fromRGB(102, 214, 139))
|
|
1580
|
+
return
|
|
1581
|
+
end
|
|
1329
1582
|
setStatus(state, "No file snapshot exists. Push Studio first.", Color3.fromRGB(245, 106, 106))
|
|
1330
1583
|
return
|
|
1331
1584
|
end
|
|
1332
|
-
local parent = resolveParent(state.manifest.studioPath)
|
|
1585
|
+
local parent = resolveParent(state.manifest.studioPath, state.manifest.instanceIds)
|
|
1333
1586
|
if not parent then
|
|
1334
1587
|
setStatus(state, "Studio parent path is missing.", Color3.fromRGB(245, 106, 106))
|
|
1335
1588
|
return
|
|
1336
1589
|
end
|
|
1337
|
-
local currentRoot = resolveRoot(state.manifest.studioPath)
|
|
1338
|
-
local currentScript = currentRoot and findCode(currentRoot)
|
|
1339
|
-
if currentScript then
|
|
1340
|
-
state.conflict = true
|
|
1341
|
-
setStatus(state, "Blocked: Studio root contains code at " .. currentScript:GetFullName(), Color3.fromRGB(245, 106, 106))
|
|
1342
|
-
return
|
|
1343
|
-
end
|
|
1590
|
+
local currentRoot = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
|
|
1344
1591
|
|
|
1345
1592
|
state.pulling = true
|
|
1346
1593
|
setStatus(state, "Loading file snapshot...", Color3.fromRGB(115, 182, 255))
|
|
1347
|
-
local response
|
|
1594
|
+
local response = snapshotBatch[state.manifest.id]
|
|
1595
|
+
snapshotBatch[state.manifest.id] = nil
|
|
1596
|
+
local requestError
|
|
1597
|
+
if not response then
|
|
1598
|
+
response, requestError = request("GET", "/v1/roots/" .. state.manifest.id .. "/snapshot")
|
|
1599
|
+
end
|
|
1348
1600
|
if not response then
|
|
1349
1601
|
state.pulling = false
|
|
1350
1602
|
setStatus(state, "Daemon unavailable: " .. requestError, Color3.fromRGB(245, 106, 106))
|
|
@@ -1355,6 +1607,12 @@ pull = function(state)
|
|
|
1355
1607
|
setStatus(state, "Download failed (HTTP " .. response.StatusCode .. ")", Color3.fromRGB(245, 106, 106))
|
|
1356
1608
|
return
|
|
1357
1609
|
end
|
|
1610
|
+
local downloadedHash = response.Headers and (response.Headers.ETag or response.Headers.etag)
|
|
1611
|
+
if downloadedHash ~= '"' .. state.manifest.hash .. '"' then
|
|
1612
|
+
state.pulling = false
|
|
1613
|
+
setStatus(state, "File changed during download; waiting for the next refresh.", Color3.fromRGB(244, 194, 92))
|
|
1614
|
+
return
|
|
1615
|
+
end
|
|
1358
1616
|
|
|
1359
1617
|
local success, instances = pcall(function()
|
|
1360
1618
|
return SerializationService:DeserializeInstancesAsync(buffer.fromstring(response.Body))
|
|
@@ -1375,24 +1633,70 @@ pull = function(state)
|
|
|
1375
1633
|
|
|
1376
1634
|
local replacement = instances[1]
|
|
1377
1635
|
local expectedName = state.manifest.studioPath[#state.manifest.studioPath]
|
|
1378
|
-
|
|
1379
|
-
if replacement.Name ~= expectedName or scriptInstance then
|
|
1636
|
+
if state.manifest.instanceIds and (replacement:GetAttribute("GoldSyncId") or "") ~= state.manifest.instanceIds[#state.manifest.studioPath] then
|
|
1380
1637
|
replacement:Destroy()
|
|
1381
1638
|
state.pulling = false
|
|
1382
|
-
|
|
1383
|
-
|
|
1639
|
+
setStatus(state, "Blocked: snapshot GoldSyncId does not match its file.", Color3.fromRGB(245, 106, 106))
|
|
1640
|
+
return
|
|
1641
|
+
end
|
|
1642
|
+
if replacement.Name ~= expectedName then
|
|
1643
|
+
replacement:Destroy()
|
|
1644
|
+
state.pulling = false
|
|
1645
|
+
setStatus(state, "Blocked: root must be named " .. expectedName, Color3.fromRGB(245, 106, 106))
|
|
1646
|
+
return
|
|
1647
|
+
end
|
|
1648
|
+
if state.manifest.treeRootId then
|
|
1649
|
+
local isRoot = false
|
|
1650
|
+
for _, tree in serverConfig.treeRoots do
|
|
1651
|
+
if tree.id == state.manifest.treeRootId then
|
|
1652
|
+
isRoot = #tree.studioPath == #state.manifest.studioPath
|
|
1653
|
+
break
|
|
1654
|
+
end
|
|
1655
|
+
end
|
|
1656
|
+
if TreeRoots.isContainer(replacement, isRoot) ~= state.manifest.container then
|
|
1657
|
+
replacement:Destroy()
|
|
1658
|
+
state.pulling = false
|
|
1659
|
+
state.conflict = true
|
|
1660
|
+
setStatus(state, "Blocked: migrate this snapshot to Folder boundaries first.", Color3.fromRGB(245, 106, 106))
|
|
1661
|
+
return
|
|
1662
|
+
end
|
|
1663
|
+
end
|
|
1664
|
+
if state.manifest.container and #replacement:GetChildren() > 0 then
|
|
1665
|
+
replacement:Destroy()
|
|
1666
|
+
state.pulling = false
|
|
1667
|
+
setStatus(state, "Blocked: container snapshot contains children.", Color3.fromRGB(245, 106, 106))
|
|
1668
|
+
return
|
|
1669
|
+
end
|
|
1670
|
+
if adoptOnly and currentRoot then
|
|
1671
|
+
local compared, matches = pcall(TreeRoots.equal, currentRoot, replacement, getSerializedPropertyNames, state.manifest.container)
|
|
1672
|
+
replacement:Destroy()
|
|
1673
|
+
state.pulling = false
|
|
1674
|
+
state.conflict = not compared or not matches
|
|
1675
|
+
if not compared then
|
|
1676
|
+
setStatus(state, "Could not compare existing instance: " .. tostring(matches), Color3.fromRGB(245, 106, 106))
|
|
1677
|
+
return
|
|
1678
|
+
end
|
|
1679
|
+
if matches then
|
|
1680
|
+
rememberHash(state, state.manifest.hash)
|
|
1681
|
+
state.dirty = false
|
|
1682
|
+
setStatus(state, "Synced existing Studio instance", Color3.fromRGB(102, 214, 139))
|
|
1683
|
+
if state.manifest.container and not TreeRoots.needsSnapshot(currentRoot) then upload(state, false) end
|
|
1384
1684
|
else
|
|
1385
|
-
setStatus(state, "
|
|
1685
|
+
setStatus(state, "Studio differs from file. Choose Push or Pull; nothing was replaced.", Color3.fromRGB(244, 194, 92))
|
|
1386
1686
|
end
|
|
1387
1687
|
return
|
|
1388
1688
|
end
|
|
1389
1689
|
|
|
1390
1690
|
state.suppress = true
|
|
1391
1691
|
disconnectRoot(state)
|
|
1392
|
-
local previous = resolveRoot(state.manifest.studioPath)
|
|
1692
|
+
local previous = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
|
|
1393
1693
|
ChangeHistoryService:SetWaypoint("GoldSync before Pull")
|
|
1394
1694
|
local applied, applyError = pcall(function()
|
|
1395
|
-
|
|
1695
|
+
if state.manifest.container and previous then
|
|
1696
|
+
TreeRoots.applyMetadata(previous, replacement, getSerializedPropertyNames)
|
|
1697
|
+
else
|
|
1698
|
+
replacement.Parent = parent
|
|
1699
|
+
end
|
|
1396
1700
|
end)
|
|
1397
1701
|
if not applied then
|
|
1398
1702
|
replacement:Destroy()
|
|
@@ -1404,7 +1708,10 @@ pull = function(state)
|
|
|
1404
1708
|
setStatus(state, "Could not apply snapshot: " .. tostring(applyError), Color3.fromRGB(245, 106, 106))
|
|
1405
1709
|
return
|
|
1406
1710
|
end
|
|
1407
|
-
if previous then
|
|
1711
|
+
if previous and state.manifest.container then
|
|
1712
|
+
replacement:Destroy()
|
|
1713
|
+
replacement = previous
|
|
1714
|
+
elseif previous then
|
|
1408
1715
|
previous:Destroy()
|
|
1409
1716
|
end
|
|
1410
1717
|
ChangeHistoryService:SetWaypoint("GoldSync Pull " .. expectedName)
|
|
@@ -1441,10 +1748,6 @@ local function deserializeConflictStage(state, stage)
|
|
|
1441
1748
|
end
|
|
1442
1749
|
return nil, "snapshot must contain exactly one root"
|
|
1443
1750
|
end
|
|
1444
|
-
if findCode(instances[1]) then
|
|
1445
|
-
instances[1]:Destroy()
|
|
1446
|
-
return nil, "snapshot contains code"
|
|
1447
|
-
end
|
|
1448
1751
|
return instances[1]
|
|
1449
1752
|
end
|
|
1450
1753
|
|
|
@@ -1466,9 +1769,11 @@ local function destroyConflictWorkspace(state)
|
|
|
1466
1769
|
parent:Destroy()
|
|
1467
1770
|
end
|
|
1468
1771
|
end
|
|
1469
|
-
state.
|
|
1470
|
-
|
|
1471
|
-
|
|
1772
|
+
if state.row then
|
|
1773
|
+
state.mergeButton.Text = "Open Merge Workspace"
|
|
1774
|
+
state.mergeButton.Size = UDim2.fromScale(1, 1)
|
|
1775
|
+
state.diffButton.Visible = false
|
|
1776
|
+
end
|
|
1472
1777
|
end
|
|
1473
1778
|
|
|
1474
1779
|
openGitConflict = function(state)
|
|
@@ -1574,8 +1879,12 @@ resolveGitConflict = function(state)
|
|
|
1574
1879
|
end
|
|
1575
1880
|
local resultRoot = resultChildren[1]
|
|
1576
1881
|
local expectedName = state.manifest.studioPath[#state.manifest.studioPath]
|
|
1577
|
-
if
|
|
1578
|
-
setStatus(state, "Result must
|
|
1882
|
+
if state.manifest.instanceIds and (resultRoot:GetAttribute("GoldSyncId") or "") ~= state.manifest.instanceIds[#state.manifest.studioPath] then
|
|
1883
|
+
setStatus(state, "Result GoldSyncId must match its file.", Color3.fromRGB(245, 106, 106))
|
|
1884
|
+
return
|
|
1885
|
+
end
|
|
1886
|
+
if resultRoot.Name ~= expectedName then
|
|
1887
|
+
setStatus(state, "Result must be a root named " .. expectedName .. ".", Color3.fromRGB(245, 106, 106))
|
|
1579
1888
|
return
|
|
1580
1889
|
end
|
|
1581
1890
|
|
|
@@ -1622,28 +1931,28 @@ local function setGroupExpanded(group, expanded)
|
|
|
1622
1931
|
group.name.Text = group.label .. group.summary
|
|
1623
1932
|
end
|
|
1624
1933
|
|
|
1625
|
-
|
|
1934
|
+
pathStartsWith = function(prefix, fullPath, prefixIds, fullIds)
|
|
1626
1935
|
if #prefix > #fullPath then
|
|
1627
1936
|
return false
|
|
1628
1937
|
end
|
|
1629
1938
|
for index, segment in prefix do
|
|
1630
|
-
if fullPath[index] ~= segment then
|
|
1939
|
+
if fullPath[index] ~= segment or (prefixIds and prefixIds[index] or "") ~= (fullIds and fullIds[index] or "") then
|
|
1631
1940
|
return false
|
|
1632
1941
|
end
|
|
1633
1942
|
end
|
|
1634
1943
|
return true
|
|
1635
1944
|
end
|
|
1636
1945
|
|
|
1637
|
-
local function createGroup(path, order)
|
|
1638
|
-
local key =
|
|
1946
|
+
local function createGroup(path, order, instanceIds, attention)
|
|
1947
|
+
local key = (if attention then "attention:" else "") .. TreeRoots.pathKey(path, instanceIds)
|
|
1639
1948
|
if groups[key] then
|
|
1640
1949
|
return groups[key]
|
|
1641
1950
|
end
|
|
1642
1951
|
local parentGroup
|
|
1643
|
-
if #path > 1 then
|
|
1952
|
+
if #path > 1 and not attention then
|
|
1644
1953
|
local parentPath = table.clone(path)
|
|
1645
1954
|
table.remove(parentPath)
|
|
1646
|
-
parentGroup = createGroup(parentPath, order)
|
|
1955
|
+
parentGroup = createGroup(parentPath, order, instanceIds and table.move(instanceIds, 1, #parentPath, 1, {}))
|
|
1647
1956
|
end
|
|
1648
1957
|
|
|
1649
1958
|
local section = Instance.new("Frame")
|
|
@@ -1651,7 +1960,7 @@ local function createGroup(path, order)
|
|
|
1651
1960
|
section.BackgroundTransparency = 1
|
|
1652
1961
|
section.LayoutOrder = order
|
|
1653
1962
|
section.Size = UDim2.new(1, 0, 0, 0)
|
|
1654
|
-
section.Parent = if parentGroup then parentGroup.list else rootsContainer
|
|
1963
|
+
section.Parent = if attention then attentionList elseif parentGroup then parentGroup.list else rootsContainer
|
|
1655
1964
|
|
|
1656
1965
|
local sectionLayout = Instance.new("UIListLayout")
|
|
1657
1966
|
sectionLayout.Padding = UDim.new(0, 2)
|
|
@@ -1687,7 +1996,7 @@ local function createGroup(path, order)
|
|
|
1687
1996
|
icon.Position = UDim2.new(0, 19, 0.5, -8)
|
|
1688
1997
|
icon.Size = UDim2.fromOffset(16, 16)
|
|
1689
1998
|
icon.Parent = header
|
|
1690
|
-
local parentInstance = resolveRoot(path)
|
|
1999
|
+
local parentInstance = resolveRoot(path, instanceIds)
|
|
1691
2000
|
applyClassIcon(icon, if parentInstance then parentInstance.ClassName else "Folder")
|
|
1692
2001
|
|
|
1693
2002
|
local name = Instance.new("TextButton")
|
|
@@ -1770,8 +2079,10 @@ local function createGroup(path, order)
|
|
|
1770
2079
|
|
|
1771
2080
|
local group = {
|
|
1772
2081
|
key = key,
|
|
1773
|
-
label = path[#path],
|
|
2082
|
+
label = if attention then TreeRoots.displayPath(path, instanceIds) else TreeRoots.displayName(path[#path], instanceIds and instanceIds[#path]),
|
|
2083
|
+
attention = attention,
|
|
1774
2084
|
path = table.clone(path),
|
|
2085
|
+
instanceIds = instanceIds,
|
|
1775
2086
|
section = section,
|
|
1776
2087
|
header = header,
|
|
1777
2088
|
disclosure = disclosure,
|
|
@@ -1786,20 +2097,24 @@ local function createGroup(path, order)
|
|
|
1786
2097
|
summary = "",
|
|
1787
2098
|
}
|
|
1788
2099
|
groups[key] = group
|
|
1789
|
-
setGroupExpanded(group, #path <= 2)
|
|
2100
|
+
setGroupExpanded(group, attention or #path <= 2)
|
|
1790
2101
|
disclosure.Activated:Connect(function()
|
|
1791
2102
|
setGroupExpanded(group, not group.expanded)
|
|
2103
|
+
applyTreeFilter()
|
|
1792
2104
|
end)
|
|
1793
2105
|
name.Activated:Connect(function()
|
|
1794
2106
|
setGroupExpanded(group, not group.expanded)
|
|
2107
|
+
applyTreeFilter()
|
|
1795
2108
|
end)
|
|
1796
2109
|
bulkPush.Activated:Connect(function()
|
|
1797
2110
|
if bulkRunning then
|
|
1798
2111
|
return
|
|
1799
2112
|
end
|
|
1800
2113
|
bulkRunning = true
|
|
2114
|
+
while refreshing do task.wait() end
|
|
1801
2115
|
for _, state in states do
|
|
1802
|
-
if
|
|
2116
|
+
if group.attention and state.statusKind ~= "issue" then continue end
|
|
2117
|
+
if pathStartsWith(group.path, state.manifest.studioPath, group.instanceIds, state.manifest.instanceIds) and not state.manifest.gitConflict then
|
|
1803
2118
|
upload(state, true)
|
|
1804
2119
|
end
|
|
1805
2120
|
end
|
|
@@ -1810,11 +2125,15 @@ local function createGroup(path, order)
|
|
|
1810
2125
|
return
|
|
1811
2126
|
end
|
|
1812
2127
|
bulkRunning = true
|
|
2128
|
+
while refreshing do task.wait() end
|
|
2129
|
+
local affected = {}
|
|
1813
2130
|
for _, state in states do
|
|
1814
|
-
if
|
|
1815
|
-
|
|
2131
|
+
if group.attention and state.statusKind ~= "issue" then continue end
|
|
2132
|
+
if pathStartsWith(group.path, state.manifest.studioPath, group.instanceIds, state.manifest.instanceIds) and not state.manifest.gitConflict then
|
|
2133
|
+
table.insert(affected, state)
|
|
1816
2134
|
end
|
|
1817
2135
|
end
|
|
2136
|
+
pullStates(affected)
|
|
1818
2137
|
bulkRunning = false
|
|
1819
2138
|
end)
|
|
1820
2139
|
return group
|
|
@@ -1822,6 +2141,7 @@ end
|
|
|
1822
2141
|
|
|
1823
2142
|
local function setRootExpanded(state, expanded)
|
|
1824
2143
|
state.expanded = expanded
|
|
2144
|
+
if not state.row then return end
|
|
1825
2145
|
state.row.BackgroundTransparency = 1
|
|
1826
2146
|
state.file.Visible = expanded
|
|
1827
2147
|
state.status.Visible = expanded
|
|
@@ -1830,30 +2150,30 @@ local function setRootExpanded(state, expanded)
|
|
|
1830
2150
|
end
|
|
1831
2151
|
|
|
1832
2152
|
updateGroupSummaries = function()
|
|
2153
|
+
local totals = {}
|
|
1833
2154
|
for _, group in groups do
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
issueColor = state.status.TextColor3
|
|
1849
|
-
end
|
|
1850
|
-
elseif state.statusKind == "paused" then
|
|
1851
|
-
paused += 1
|
|
1852
|
-
else
|
|
1853
|
-
pending += 1
|
|
1854
|
-
end
|
|
2155
|
+
totals[group] = { total = 0, synced = 0, issue = 0, paused = 0, pending = 0, issueColor = Color3.fromRGB(244, 194, 92) }
|
|
2156
|
+
end
|
|
2157
|
+
for _, state in states do
|
|
2158
|
+
if state.coveredByBoundary then continue end
|
|
2159
|
+
for depth = 1, #state.manifest.studioPath do
|
|
2160
|
+
local key = TreeRoots.pathKey(state.manifest.studioPath, state.manifest.instanceIds, depth)
|
|
2161
|
+
local group = groups[key]
|
|
2162
|
+
local counts = group and totals[group]
|
|
2163
|
+
if not counts then continue end
|
|
2164
|
+
counts.total += 1
|
|
2165
|
+
local kind = if state.statusKind == "synced" or state.statusKind == "issue" or state.statusKind == "paused" then state.statusKind else "pending"
|
|
2166
|
+
counts[kind] += 1
|
|
2167
|
+
if kind == "issue" and state.statusColor == Color3.fromRGB(245, 106, 106) then
|
|
2168
|
+
counts.issueColor = state.statusColor
|
|
1855
2169
|
end
|
|
1856
2170
|
end
|
|
2171
|
+
end
|
|
2172
|
+
for _, group in groups do
|
|
2173
|
+
local counts = totals[group]
|
|
2174
|
+
if group.attention then continue end
|
|
2175
|
+
local total, synced, issues = counts.total, counts.synced, counts.issue
|
|
2176
|
+
local pending, paused, issueColor = counts.pending, counts.paused, counts.issueColor
|
|
1857
2177
|
group.summary = string.format(" (%d/%d) synced", synced, total)
|
|
1858
2178
|
if issues > 0 then
|
|
1859
2179
|
group.summary ..= string.format(" · %d need attention", issues)
|
|
@@ -1882,7 +2202,7 @@ updateGroupSummaries = function()
|
|
|
1882
2202
|
else Color3.fromRGB(95, 95, 105)
|
|
1883
2203
|
group.bulkPush.TextColor3 = arrowColor
|
|
1884
2204
|
group.bulkPull.TextColor3 = arrowColor
|
|
1885
|
-
setGroupExpanded(group, group.expanded or (issues > 0 and autoExpandConflicts))
|
|
2205
|
+
setGroupExpanded(group, group.expanded or (issues > 0 and autoExpandConflicts and #serverConfig.roots <= 1000))
|
|
1886
2206
|
end
|
|
1887
2207
|
end
|
|
1888
2208
|
if refreshUnsyncedButton then
|
|
@@ -1896,7 +2216,7 @@ end
|
|
|
1896
2216
|
refreshUnsyncedButton = function()
|
|
1897
2217
|
local unsynced = 0
|
|
1898
2218
|
for _, state in states do
|
|
1899
|
-
if state.statusKind ~= "synced" then
|
|
2219
|
+
if not state.coveredByBoundary and state.statusKind ~= "synced" then
|
|
1900
2220
|
unsynced += 1
|
|
1901
2221
|
end
|
|
1902
2222
|
end
|
|
@@ -1912,20 +2232,25 @@ end
|
|
|
1912
2232
|
refreshStatusSummary = function()
|
|
1913
2233
|
local issues = {}
|
|
1914
2234
|
local working = {}
|
|
2235
|
+
for _, group in groups do if group.attention then group.section.Visible = false end end
|
|
1915
2236
|
for _, state in states do
|
|
2237
|
+
if state.coveredByBoundary then continue end
|
|
1916
2238
|
local entry = {
|
|
1917
|
-
path =
|
|
1918
|
-
status = state.
|
|
2239
|
+
path = TreeRoots.displayPath(state.manifest.studioPath, state.manifest.instanceIds),
|
|
2240
|
+
status = state.statusText,
|
|
1919
2241
|
state = state,
|
|
1920
2242
|
}
|
|
1921
2243
|
if state.statusKind == "issue" then
|
|
1922
2244
|
table.insert(issues, entry)
|
|
1923
2245
|
else
|
|
1924
|
-
if state.row
|
|
2246
|
+
if state.row and state.group.attention then
|
|
2247
|
+
local parentPath = table.clone(state.manifest.studioPath)
|
|
2248
|
+
table.remove(parentPath)
|
|
2249
|
+
state.group = createGroup(parentPath, state.treeOrder, state.manifest.instanceIds)
|
|
1925
2250
|
state.row.Parent = state.group.list
|
|
1926
2251
|
state.connector.Visible = true
|
|
1927
2252
|
state.row.LayoutOrder = state.treeOrder
|
|
1928
|
-
state.title.Text = state.manifest.studioPath[#state.manifest.studioPath]
|
|
2253
|
+
state.title.Text = TreeRoots.displayName(state.manifest.studioPath[#state.manifest.studioPath], state.manifest.instanceIds and state.manifest.instanceIds[#state.manifest.studioPath])
|
|
1929
2254
|
setRootExpanded(state, state.expandedBeforeAttention)
|
|
1930
2255
|
end
|
|
1931
2256
|
if state.statusKind == "working" then
|
|
@@ -1955,16 +2280,21 @@ refreshStatusSummary = function()
|
|
|
1955
2280
|
for index, entry in entries do
|
|
1956
2281
|
if #issues > 0 then
|
|
1957
2282
|
local state = entry.state
|
|
1958
|
-
if state.row
|
|
2283
|
+
if not state.row then continue end
|
|
2284
|
+
if not state.group.attention then
|
|
1959
2285
|
state.expandedBeforeAttention = state.expanded
|
|
1960
|
-
state.
|
|
2286
|
+
local parentPath = table.clone(state.manifest.studioPath)
|
|
2287
|
+
table.remove(parentPath)
|
|
2288
|
+
state.group = createGroup(parentPath, state.treeOrder, state.manifest.instanceIds, true)
|
|
2289
|
+
state.row.Parent = state.group.list
|
|
1961
2290
|
state.connector.Visible = false
|
|
1962
2291
|
setRootExpanded(state, false)
|
|
1963
2292
|
end
|
|
2293
|
+
state.group.section.Visible = true
|
|
1964
2294
|
state.row.LayoutOrder = index
|
|
1965
|
-
state.title.Text =
|
|
2295
|
+
state.title.Text = TreeRoots.displayName(state.manifest.studioPath[#state.manifest.studioPath], state.manifest.instanceIds and state.manifest.instanceIds[#state.manifest.studioPath])
|
|
1966
2296
|
else
|
|
1967
|
-
table.insert(lines, entry.path .. " — " .. entry.status)
|
|
2297
|
+
if index <= 10 then table.insert(lines, entry.path .. " — " .. entry.status) end
|
|
1968
2298
|
end
|
|
1969
2299
|
end
|
|
1970
2300
|
statusBanner.BackgroundColor3 = if #issues > 0
|
|
@@ -2089,6 +2419,7 @@ local function createInstanceNode(instance, parent, depth, order)
|
|
|
2089
2419
|
end
|
|
2090
2420
|
|
|
2091
2421
|
local function resetStateContents(state)
|
|
2422
|
+
if not state.row then return end
|
|
2092
2423
|
for _, child in state.contentsList:GetChildren() do
|
|
2093
2424
|
if child ~= state.contentsLayout then
|
|
2094
2425
|
child:Destroy()
|
|
@@ -2098,7 +2429,7 @@ local function resetStateContents(state)
|
|
|
2098
2429
|
state.contentsExpanded = false
|
|
2099
2430
|
state.contents.Visible = false
|
|
2100
2431
|
state.contentDisclosure.Text = "▸"
|
|
2101
|
-
local root = resolveRoot(state.manifest.studioPath)
|
|
2432
|
+
local root = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
|
|
2102
2433
|
state.contentDisclosure.Visible = visualDepth > 0 and root ~= nil and #root:GetChildren() > 0
|
|
2103
2434
|
end
|
|
2104
2435
|
|
|
@@ -2124,7 +2455,7 @@ local function toggleRootContents(state)
|
|
|
2124
2455
|
if not state.contentsExpanded then
|
|
2125
2456
|
return
|
|
2126
2457
|
end
|
|
2127
|
-
local root = resolveRoot(state.manifest.studioPath)
|
|
2458
|
+
local root = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
|
|
2128
2459
|
if not root then
|
|
2129
2460
|
return
|
|
2130
2461
|
end
|
|
@@ -2138,10 +2469,36 @@ local function toggleRootContents(state)
|
|
|
2138
2469
|
state.contentsLoaded = true
|
|
2139
2470
|
end
|
|
2140
2471
|
|
|
2141
|
-
local function
|
|
2472
|
+
local function createRootState(manifest, order)
|
|
2473
|
+
local started = os.clock()
|
|
2474
|
+
local savedHash = savedHashes[manifest.id]
|
|
2475
|
+
if startupProfile then startupProfile.settings += os.clock() - started end
|
|
2476
|
+
local state = {
|
|
2477
|
+
manifest = manifest,
|
|
2478
|
+
treeOrder = order,
|
|
2479
|
+
rootConnections = {},
|
|
2480
|
+
instanceConnections = {},
|
|
2481
|
+
dirty = false,
|
|
2482
|
+
generation = 0,
|
|
2483
|
+
suppress = false,
|
|
2484
|
+
conflict = false,
|
|
2485
|
+
uploading = false,
|
|
2486
|
+
pulling = false,
|
|
2487
|
+
deleting = false,
|
|
2488
|
+
expanded = false,
|
|
2489
|
+
statusText = "Checking...",
|
|
2490
|
+
statusColor = Color3.fromRGB(180, 180, 190),
|
|
2491
|
+
lastHash = if type(savedHash) == "string" then savedHash else nil,
|
|
2492
|
+
}
|
|
2493
|
+
states[manifest.id] = state
|
|
2494
|
+
return state
|
|
2495
|
+
end
|
|
2496
|
+
|
|
2497
|
+
local function createRootRow(state)
|
|
2498
|
+
local manifest, order = state.manifest, state.treeOrder
|
|
2142
2499
|
local parentPath = table.clone(manifest.studioPath)
|
|
2143
2500
|
table.remove(parentPath)
|
|
2144
|
-
local group = createGroup(parentPath, order)
|
|
2501
|
+
local group = createGroup(parentPath, order, manifest.instanceIds and table.move(manifest.instanceIds, 1, #parentPath, 1, {}), state.statusKind == "issue")
|
|
2145
2502
|
group.hasDirectRoots = true
|
|
2146
2503
|
group.bulkPush.Visible = true
|
|
2147
2504
|
group.bulkPull.Visible = true
|
|
@@ -2197,15 +2554,15 @@ local function createRootRow(manifest, order)
|
|
|
2197
2554
|
icon.Position = UDim2.new(0, 19, 0.5, -8)
|
|
2198
2555
|
icon.Size = UDim2.fromOffset(16, 16)
|
|
2199
2556
|
icon.Parent = titleLine
|
|
2200
|
-
local rootInstance = resolveRoot(manifest.studioPath)
|
|
2201
|
-
|
|
2557
|
+
local rootInstance = resolveRoot(manifest.studioPath, manifest.instanceIds)
|
|
2558
|
+
if rootInstance then applyClassIcon(icon, rootInstance.ClassName) end
|
|
2202
2559
|
|
|
2203
2560
|
local title = Instance.new("TextButton")
|
|
2204
2561
|
title.BackgroundTransparency = 1
|
|
2205
2562
|
title.Font = Enum.Font.BuilderSansBold
|
|
2206
2563
|
title.Position = UDim2.new(0, 39, 0, 0)
|
|
2207
2564
|
title.Size = UDim2.new(1, -109, 1, 0)
|
|
2208
|
-
title.Text = manifest.studioPath[#manifest.studioPath]
|
|
2565
|
+
title.Text = TreeRoots.displayName(manifest.studioPath[#manifest.studioPath], manifest.instanceIds and manifest.instanceIds[#manifest.studioPath])
|
|
2209
2566
|
title.TextColor3 = Color3.fromRGB(235, 235, 240)
|
|
2210
2567
|
title.TextSize = 13
|
|
2211
2568
|
title.TextXAlignment = Enum.TextXAlignment.Left
|
|
@@ -2217,7 +2574,7 @@ local function createRootRow(manifest, order)
|
|
|
2217
2574
|
dot.Position = UDim2.new(1, -70, 0, 0)
|
|
2218
2575
|
dot.Size = UDim2.new(0, 12, 1, 0)
|
|
2219
2576
|
dot.Text = "●"
|
|
2220
|
-
dot.TextColor3 =
|
|
2577
|
+
dot.TextColor3 = state.statusColor
|
|
2221
2578
|
dot.TextSize = 9
|
|
2222
2579
|
dot.Parent = titleLine
|
|
2223
2580
|
|
|
@@ -2280,8 +2637,8 @@ local function createRootRow(manifest, order)
|
|
|
2280
2637
|
status.Font = Enum.Font.BuilderSans
|
|
2281
2638
|
status.LayoutOrder = 3
|
|
2282
2639
|
status.Size = UDim2.new(1, 0, 0, 18)
|
|
2283
|
-
status.Text =
|
|
2284
|
-
status.TextColor3 =
|
|
2640
|
+
status.Text = state.statusText
|
|
2641
|
+
status.TextColor3 = state.statusColor
|
|
2285
2642
|
status.TextSize = 12
|
|
2286
2643
|
status.TextWrapped = true
|
|
2287
2644
|
status.TextXAlignment = Enum.TextXAlignment.Left
|
|
@@ -2372,18 +2729,14 @@ local function createRootRow(manifest, order)
|
|
|
2372
2729
|
contentsGuide.Size = UDim2.fromOffset(1, contentsLayout.AbsoluteContentSize.Y)
|
|
2373
2730
|
end)
|
|
2374
2731
|
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
) or plugin:GetSetting(SETTINGS_PREFIX .. serverConfig.projectId .. ":" .. manifest.id .. ":hash")
|
|
2378
|
-
local state = {
|
|
2379
|
-
manifest = manifest,
|
|
2732
|
+
state.rowFields = {}
|
|
2733
|
+
for key, value in {
|
|
2380
2734
|
group = group,
|
|
2381
2735
|
row = row,
|
|
2382
|
-
treeOrder = order,
|
|
2383
2736
|
connector = connector,
|
|
2384
2737
|
title = title,
|
|
2385
2738
|
icon = icon,
|
|
2386
|
-
iconClass =
|
|
2739
|
+
iconClass = rootInstance and rootInstance.ClassName,
|
|
2387
2740
|
contentDisclosure = contentDisclosure,
|
|
2388
2741
|
contents = contents,
|
|
2389
2742
|
contentsList = contentsList,
|
|
@@ -2403,21 +2756,10 @@ local function createRootRow(manifest, order)
|
|
|
2403
2756
|
pullButton = pullButton,
|
|
2404
2757
|
mergeButton = mergeButton,
|
|
2405
2758
|
diffButton = diffButton,
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
generation = 0,
|
|
2411
|
-
suppress = false,
|
|
2412
|
-
conflict = false,
|
|
2413
|
-
uploading = false,
|
|
2414
|
-
pulling = false,
|
|
2415
|
-
deleting = false,
|
|
2416
|
-
conflictWorkspace = nil,
|
|
2417
|
-
expanded = false,
|
|
2418
|
-
lastHash = if type(savedHash) == "string" then savedHash else nil,
|
|
2419
|
-
}
|
|
2420
|
-
states[manifest.id] = state
|
|
2759
|
+
} do
|
|
2760
|
+
state[key] = value
|
|
2761
|
+
table.insert(state.rowFields, key)
|
|
2762
|
+
end
|
|
2421
2763
|
contentDisclosure.Activated:Connect(function()
|
|
2422
2764
|
toggleRootContents(state)
|
|
2423
2765
|
end)
|
|
@@ -2425,7 +2767,7 @@ local function createRootRow(manifest, order)
|
|
|
2425
2767
|
setRootExpanded(state, not state.expanded)
|
|
2426
2768
|
end)
|
|
2427
2769
|
title.Activated:Connect(function()
|
|
2428
|
-
local root = resolveRoot(state.manifest.studioPath)
|
|
2770
|
+
local root = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
|
|
2429
2771
|
if root then
|
|
2430
2772
|
Selection:Set({ root })
|
|
2431
2773
|
end
|
|
@@ -2454,42 +2796,73 @@ local function createRootRow(manifest, order)
|
|
|
2454
2796
|
inspectorWidget.Enabled = true
|
|
2455
2797
|
end
|
|
2456
2798
|
end)
|
|
2799
|
+
setRootExpanded(state, state.expanded)
|
|
2800
|
+
state.status.Text = state.statusText
|
|
2801
|
+
state.status.TextColor3 = state.statusColor
|
|
2802
|
+
state.contentDisclosure.Visible = visualDepth > 0 and rootInstance ~= nil and #rootInstance:GetChildren() > 0
|
|
2803
|
+
for _, button in { pushButton, pullButton, miniPush, miniPull } do
|
|
2804
|
+
button.Visible = not manifest.gitConflict
|
|
2805
|
+
button.Active = serverConfig.rojoConnected
|
|
2806
|
+
button.AutoButtonColor = serverConfig.rojoConnected
|
|
2807
|
+
end
|
|
2808
|
+
mergeButton.Visible = manifest.gitConflict == true
|
|
2457
2809
|
return state
|
|
2458
2810
|
end
|
|
2459
2811
|
|
|
2812
|
+
local function destroyRootRow(state)
|
|
2813
|
+
if not state.row then return end
|
|
2814
|
+
state.row:Destroy()
|
|
2815
|
+
for _, key in state.rowFields do state[key] = nil end
|
|
2816
|
+
state.rowFields = nil
|
|
2817
|
+
end
|
|
2818
|
+
|
|
2460
2819
|
local function reconcile(state, manifest)
|
|
2461
2820
|
state.manifest = manifest
|
|
2821
|
+
if state.coveredByBoundary then
|
|
2822
|
+
disconnectRoot(state)
|
|
2823
|
+
state.statusKind = "covered"
|
|
2824
|
+
return
|
|
2825
|
+
end
|
|
2826
|
+
if state.boundary then
|
|
2827
|
+
state.conflict = true
|
|
2828
|
+
setStatus(state, "Storage boundary changed. Push to convert the saved subtree.", Color3.fromRGB(244, 194, 92))
|
|
2829
|
+
return
|
|
2830
|
+
end
|
|
2462
2831
|
if manifest.gitConflict then
|
|
2463
2832
|
state.conflict = true
|
|
2464
|
-
state.
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2833
|
+
if state.row then
|
|
2834
|
+
state.pushButton.Visible = false
|
|
2835
|
+
state.pullButton.Visible = false
|
|
2836
|
+
state.miniPush.Visible = false
|
|
2837
|
+
state.miniPull.Visible = false
|
|
2838
|
+
state.mergeButton.Visible = true
|
|
2839
|
+
end
|
|
2469
2840
|
setStatus(state, "Git conflict — resolve in VS Code or open the optional merge workspace.", Color3.fromRGB(245, 106, 106))
|
|
2470
|
-
if autoExpandConflicts then
|
|
2841
|
+
if autoExpandConflicts and state.row then
|
|
2471
2842
|
setGroupExpanded(state.group, true)
|
|
2472
2843
|
setRootExpanded(state, true)
|
|
2473
2844
|
end
|
|
2474
2845
|
return
|
|
2475
2846
|
end
|
|
2476
|
-
state.
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2847
|
+
if state.row then
|
|
2848
|
+
state.pushButton.Visible = true
|
|
2849
|
+
state.pullButton.Visible = true
|
|
2850
|
+
state.miniPush.Visible = true
|
|
2851
|
+
state.miniPull.Visible = true
|
|
2852
|
+
state.mergeButton.Visible = false
|
|
2853
|
+
state.pushButton.Active = serverConfig.rojoConnected
|
|
2854
|
+
state.pullButton.Active = serverConfig.rojoConnected
|
|
2855
|
+
state.miniPush.Active = serverConfig.rojoConnected
|
|
2856
|
+
state.miniPull.Active = serverConfig.rojoConnected
|
|
2857
|
+
state.pushButton.AutoButtonColor = serverConfig.rojoConnected
|
|
2858
|
+
state.pullButton.AutoButtonColor = serverConfig.rojoConnected
|
|
2859
|
+
state.miniPush.AutoButtonColor = serverConfig.rojoConnected
|
|
2860
|
+
state.miniPull.AutoButtonColor = serverConfig.rojoConnected
|
|
2861
|
+
state.miniPush.TextColor3 = if serverConfig.rojoConnected
|
|
2862
|
+
then Color3.fromRGB(115, 182, 255)
|
|
2863
|
+
else Color3.fromRGB(95, 95, 105)
|
|
2864
|
+
state.miniPull.TextColor3 = state.miniPush.TextColor3
|
|
2865
|
+
end
|
|
2493
2866
|
if state.conflictWorkspace then
|
|
2494
2867
|
destroyConflictWorkspace(state)
|
|
2495
2868
|
end
|
|
@@ -2497,23 +2870,40 @@ local function reconcile(state, manifest)
|
|
|
2497
2870
|
setStatus(state, "Paused — Rojo offline; conflict viewer remains available.", Color3.fromRGB(244, 194, 92))
|
|
2498
2871
|
return
|
|
2499
2872
|
end
|
|
2500
|
-
local root = resolveRoot(manifest.studioPath)
|
|
2873
|
+
local root = resolveRoot(manifest.studioPath, manifest.instanceIds)
|
|
2501
2874
|
if state.contentRoot ~= root then
|
|
2502
2875
|
state.contentRoot = root
|
|
2503
2876
|
state.searchQuery = nil
|
|
2504
2877
|
resetStateContents(state)
|
|
2505
|
-
elseif root then
|
|
2878
|
+
elseif root and state.row then
|
|
2506
2879
|
state.contentDisclosure.Visible = visualDepth > 0 and #root:GetChildren() > 0
|
|
2507
2880
|
end
|
|
2508
|
-
if root and state.iconClass ~= root.ClassName then
|
|
2881
|
+
if root and state.row and state.iconClass ~= root.ClassName then
|
|
2509
2882
|
state.iconClass = root.ClassName
|
|
2510
2883
|
applyClassIcon(state.icon, root.ClassName)
|
|
2511
2884
|
end
|
|
2512
2885
|
if root and state.root ~= root then
|
|
2513
2886
|
watchRoot(state, root)
|
|
2514
2887
|
end
|
|
2888
|
+
if not root and manifest.treeRootId then
|
|
2889
|
+
if manifest.deleted then
|
|
2890
|
+
state.conflict = false
|
|
2891
|
+
setStatus(state, "Deleted", Color3.fromRGB(102, 214, 139))
|
|
2892
|
+
return
|
|
2893
|
+
end
|
|
2894
|
+
state.conflict = true
|
|
2895
|
+
setStatus(state, "Studio root is missing. Push to remove saved files; Pull to restore it.", Color3.fromRGB(245, 106, 106))
|
|
2896
|
+
return
|
|
2897
|
+
end
|
|
2515
2898
|
|
|
2516
2899
|
if not manifest.exists then
|
|
2900
|
+
if manifest.container and root and not TreeRoots.needsSnapshot(root) then
|
|
2901
|
+
forgetHash(state)
|
|
2902
|
+
state.conflict = false
|
|
2903
|
+
state.dirty = false
|
|
2904
|
+
setStatus(state, "Synced Folder", Color3.fromRGB(102, 214, 139))
|
|
2905
|
+
return
|
|
2906
|
+
end
|
|
2517
2907
|
if manifest.deleted and not root then
|
|
2518
2908
|
state.conflict = false
|
|
2519
2909
|
setStatus(state, "Deleted", Color3.fromRGB(102, 214, 139))
|
|
@@ -2535,6 +2925,12 @@ local function reconcile(state, manifest)
|
|
|
2535
2925
|
return
|
|
2536
2926
|
end
|
|
2537
2927
|
if not state.lastHash then
|
|
2928
|
+
if manifest.treeRootId then
|
|
2929
|
+
if not state.conflict then
|
|
2930
|
+
pull(state, true)
|
|
2931
|
+
end
|
|
2932
|
+
return
|
|
2933
|
+
end
|
|
2538
2934
|
state.conflict = true
|
|
2539
2935
|
setStatus(state, "First sync needs Push or Pull once.", Color3.fromRGB(244, 194, 92))
|
|
2540
2936
|
return
|
|
@@ -2562,15 +2958,32 @@ local function refreshSettingsLabels()
|
|
|
2562
2958
|
end
|
|
2563
2959
|
|
|
2564
2960
|
applyTreeFilter = function()
|
|
2961
|
+
if filteringTree then
|
|
2962
|
+
filterAgain = true
|
|
2963
|
+
return
|
|
2964
|
+
end
|
|
2965
|
+
filteringTree = true
|
|
2565
2966
|
local query = string.lower(searchBox.Text)
|
|
2967
|
+
local visibleGroups = {}
|
|
2968
|
+
local orderedStates = {}
|
|
2969
|
+
local parentsWithChildren = {}
|
|
2566
2970
|
for _, state in states do
|
|
2971
|
+
table.insert(orderedStates, state)
|
|
2972
|
+
local manifest = state.manifest
|
|
2973
|
+
parentsWithChildren[TreeRoots.pathKey(manifest.studioPath, manifest.instanceIds, #manifest.studioPath - 1)] = true
|
|
2974
|
+
end
|
|
2975
|
+
table.sort(orderedStates, function(left, right) return left.treeOrder < right.treeOrder end)
|
|
2976
|
+
local previousBatching = batchingStatusUpdates
|
|
2977
|
+
batchingStatusUpdates = true
|
|
2978
|
+
local sliceStarted = os.clock()
|
|
2979
|
+
for _, state in orderedStates do
|
|
2567
2980
|
if state.searchQuery ~= query then
|
|
2568
2981
|
local searchable = string.lower(
|
|
2569
|
-
table.concat(state.manifest.studioPath, ".") .. " " .. state.manifest.file .. " " .. state.
|
|
2982
|
+
table.concat(state.manifest.studioPath, ".") .. " " .. state.manifest.file .. " " .. state.statusText
|
|
2570
2983
|
)
|
|
2571
2984
|
local matches = query == "" or string.find(searchable, query, 1, true) ~= nil
|
|
2572
|
-
if not matches then
|
|
2573
|
-
local root = resolveRoot(state.manifest.studioPath)
|
|
2985
|
+
if not matches and not state.manifest.container then
|
|
2986
|
+
local root = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
|
|
2574
2987
|
if root then
|
|
2575
2988
|
for _, descendant in root:GetDescendants() do
|
|
2576
2989
|
if string.find(string.lower(descendant.Name .. " " .. descendant.ClassName), query, 1, true) then
|
|
@@ -2583,20 +2996,42 @@ applyTreeFilter = function()
|
|
|
2583
2996
|
state.searchQuery = query
|
|
2584
2997
|
state.searchMatches = matches
|
|
2585
2998
|
end
|
|
2586
|
-
state.
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2999
|
+
local visible = not state.coveredByBoundary and state.searchMatches and (showSynced or state.statusKind ~= "synced")
|
|
3000
|
+
local needsAttention = state.statusKind == "issue"
|
|
3001
|
+
local folderGroup = state.manifest.container and parentsWithChildren[TreeRoots.pathKey(state.manifest.studioPath, state.manifest.instanceIds)]
|
|
3002
|
+
if visible and not needsAttention then
|
|
3003
|
+
for depth = 1, #state.manifest.studioPath - (if folderGroup then 0 else 1) do
|
|
3004
|
+
local parentPath = table.move(state.manifest.studioPath, 1, depth, 1, {})
|
|
3005
|
+
local group = createGroup(parentPath, state.treeOrder, state.manifest.instanceIds and table.move(state.manifest.instanceIds, 1, depth, 1, {}))
|
|
3006
|
+
visibleGroups[group] = true
|
|
3007
|
+
if not group.expanded then
|
|
3008
|
+
visible = false
|
|
3009
|
+
break
|
|
3010
|
+
end
|
|
2594
3011
|
end
|
|
3012
|
+
if folderGroup then visible = false end
|
|
2595
3013
|
end
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
3014
|
+
if visible and not state.row then
|
|
3015
|
+
createRootRow(state)
|
|
3016
|
+
elseif not visible and state.row and not state.conflictWorkspace then
|
|
3017
|
+
destroyRootRow(state)
|
|
2599
3018
|
end
|
|
3019
|
+
if state.row then state.row.Visible = visible end
|
|
3020
|
+
if os.clock() - sliceStarted >= 0.004 then
|
|
3021
|
+
task.wait()
|
|
3022
|
+
sliceStarted = os.clock()
|
|
3023
|
+
end
|
|
3024
|
+
end
|
|
3025
|
+
updateGroupSummaries()
|
|
3026
|
+
for _, group in groups do
|
|
3027
|
+
if not group.attention then group.section.Visible = visibleGroups[group] == true end
|
|
3028
|
+
end
|
|
3029
|
+
batchingStatusUpdates = previousBatching
|
|
3030
|
+
refreshStatusSummary()
|
|
3031
|
+
filteringTree = false
|
|
3032
|
+
if filterAgain then
|
|
3033
|
+
filterAgain = false
|
|
3034
|
+
task.defer(applyTreeFilter)
|
|
2600
3035
|
end
|
|
2601
3036
|
end
|
|
2602
3037
|
|
|
@@ -2667,12 +3102,16 @@ local function manifestChanged(previous, current)
|
|
|
2667
3102
|
or previous.conflictToken ~= current.conflictToken
|
|
2668
3103
|
or previous.file ~= current.file
|
|
2669
3104
|
or previous.splitRootId ~= current.splitRootId
|
|
2670
|
-
or
|
|
3105
|
+
or previous.treeRootId ~= current.treeRootId
|
|
3106
|
+
or previous.container ~= current.container
|
|
3107
|
+
or TreeRoots.pathKey(previous.studioPath, previous.instanceIds) ~= TreeRoots.pathKey(current.studioPath, current.instanceIds)
|
|
2671
3108
|
end
|
|
2672
3109
|
|
|
2673
|
-
|
|
2674
|
-
local
|
|
2675
|
-
if
|
|
3110
|
+
local function refreshConfigNow()
|
|
3111
|
+
local refreshStarted = os.clock()
|
|
3112
|
+
local response, requestError = request("GET", "/v1/config", nil, if configETag then { ["If-None-Match"] = configETag } else nil)
|
|
3113
|
+
if not response or (not response.Success and response.StatusCode ~= 304) then
|
|
3114
|
+
configETag = nil
|
|
2676
3115
|
local reason = requestError or (if response then responseError(response) else nil) or "connect Rojo to start syncing"
|
|
2677
3116
|
if serverConfig then
|
|
2678
3117
|
serverConfig.rojoConnected = false
|
|
@@ -2683,18 +3122,29 @@ refreshConfig = function()
|
|
|
2683
3122
|
return false
|
|
2684
3123
|
end
|
|
2685
3124
|
|
|
2686
|
-
local decoded = HttpService:JSONDecode(response.Body)
|
|
3125
|
+
local decoded = if response.StatusCode == 304 then serverConfig else HttpService:JSONDecode(response.Body)
|
|
3126
|
+
configETag = response.Headers.ETag or response.Headers.etag
|
|
2687
3127
|
local projectChanged = serverConfig
|
|
2688
3128
|
and (serverConfig.projectId ~= decoded.projectId or serverConfig.workspaceId ~= decoded.workspaceId)
|
|
2689
3129
|
local connectionChanged = serverConfig and serverConfig.rojoConnected ~= decoded.rojoConnected
|
|
3130
|
+
local hashesKey = SETTINGS_PREFIX .. decoded.projectId .. ":" .. decoded.workspaceId .. ":hashes"
|
|
3131
|
+
if savedHashesKey ~= hashesKey then
|
|
3132
|
+
saveHashes()
|
|
3133
|
+
local settingsStarted = os.clock()
|
|
3134
|
+
local stored = plugin:GetSetting(hashesKey)
|
|
3135
|
+
if startupProfile then startupProfile.settings += os.clock() - settingsStarted end
|
|
3136
|
+
savedHashes = if type(stored) == "table" then stored else {}
|
|
3137
|
+
savedHashesKey = hashesKey
|
|
3138
|
+
end
|
|
2690
3139
|
if projectChanged then
|
|
2691
3140
|
for _, state in states do
|
|
2692
3141
|
disconnectRoot(state)
|
|
2693
3142
|
destroyConflictWorkspace(state)
|
|
2694
|
-
state
|
|
3143
|
+
destroyRootRow(state)
|
|
2695
3144
|
end
|
|
2696
3145
|
disconnectSplitRoots()
|
|
2697
3146
|
table.clear(states)
|
|
3147
|
+
table.clear(treeDiscovery)
|
|
2698
3148
|
for _, child in rootsContainer:GetChildren() do
|
|
2699
3149
|
if child ~= rootsLayout then
|
|
2700
3150
|
child:Destroy()
|
|
@@ -2703,6 +3153,56 @@ refreshConfig = function()
|
|
|
2703
3153
|
table.clear(groups)
|
|
2704
3154
|
end
|
|
2705
3155
|
serverConfig = decoded
|
|
3156
|
+
if decoded.restore and decoded.restore.status == "pending" then
|
|
3157
|
+
bulkRunning = true
|
|
3158
|
+
for _, state in states do
|
|
3159
|
+
state.generation += 1
|
|
3160
|
+
state.suppress = true
|
|
3161
|
+
end
|
|
3162
|
+
local prepared
|
|
3163
|
+
local success, failure = pcall(function()
|
|
3164
|
+
local base = "/v1/restore/" .. decoded.restore.id
|
|
3165
|
+
local response, failure = request("POST", base .. "/claim")
|
|
3166
|
+
assert(response and response.Success, failure or (response and responseError(response)) or "Restore download failed")
|
|
3167
|
+
connectionStatus.Text = "Restoring Studio from local files..."
|
|
3168
|
+
prepared = StudioRestore.prepare(response.Body)
|
|
3169
|
+
local authorized = request("POST", base .. "/commit")
|
|
3170
|
+
assert(authorized and authorized.Success, "Restore expired or Rojo disconnected; Studio was not replaced")
|
|
3171
|
+
for _, state in states do disconnectRoot(state) end
|
|
3172
|
+
disconnectSplitRoots()
|
|
3173
|
+
ChangeHistoryService:SetWaypoint("GoldSync before authoritative restore")
|
|
3174
|
+
StudioRestore.commit(prepared)
|
|
3175
|
+
ChangeHistoryService:SetWaypoint("GoldSync restore from local files")
|
|
3176
|
+
for _, state in states do
|
|
3177
|
+
destroyConflictWorkspace(state)
|
|
3178
|
+
destroyRootRow(state)
|
|
3179
|
+
end
|
|
3180
|
+
table.clear(states)
|
|
3181
|
+
table.clear(treeDiscovery)
|
|
3182
|
+
table.clear(savedHashes)
|
|
3183
|
+
for _, manifest in prepared.manifests do savedHashes[manifest.id] = manifest.hash end
|
|
3184
|
+
hashesDirty = true
|
|
3185
|
+
saveHashes()
|
|
3186
|
+
end)
|
|
3187
|
+
if not success and prepared then prepared.staging:Destroy() end
|
|
3188
|
+
local acknowledged = request("POST", "/v1/restore/" .. decoded.restore.id .. "/result", HttpService:JSONEncode({ success = success, error = if success then nil else tostring(failure) }), { ["Content-Type"] = "application/json" })
|
|
3189
|
+
bulkRunning = false
|
|
3190
|
+
configETag = nil
|
|
3191
|
+
if not success then
|
|
3192
|
+
for _, state in states do
|
|
3193
|
+
state.suppress = false
|
|
3194
|
+
state.root = nil
|
|
3195
|
+
state.observedRoot = nil
|
|
3196
|
+
end
|
|
3197
|
+
connectionStatus.Text = "Restore failed: " .. tostring(failure)
|
|
3198
|
+
elseif not acknowledged or not acknowledged.Success then
|
|
3199
|
+
connectionStatus.Text = "Studio restored, but the service did not acknowledge completion."
|
|
3200
|
+
else
|
|
3201
|
+
connectionStatus.Text = "Restored Studio from local files"
|
|
3202
|
+
end
|
|
3203
|
+
return success
|
|
3204
|
+
end
|
|
3205
|
+
if decoded.restore and (decoded.restore.status == "preparing" or decoded.restore.status == "running") then return true end
|
|
2706
3206
|
if decoded.rojoConnected then
|
|
2707
3207
|
connectionStatus.Text = "Connected · " .. decoded.name
|
|
2708
3208
|
connectionStatus.TextColor3 = Color3.fromRGB(102, 214, 139)
|
|
@@ -2711,6 +3211,49 @@ refreshConfig = function()
|
|
|
2711
3211
|
connectionStatus.TextColor3 = Color3.fromRGB(244, 194, 92)
|
|
2712
3212
|
end
|
|
2713
3213
|
connectionDot.TextColor3 = connectionStatus.TextColor3
|
|
3214
|
+
if decoded.rojoConnected then
|
|
3215
|
+
for _, tree in decoded.treeRoots or {} do
|
|
3216
|
+
local root = resolveRoot(tree.studioPath)
|
|
3217
|
+
if not root then
|
|
3218
|
+
continue
|
|
3219
|
+
end
|
|
3220
|
+
local discovery = treeDiscovery[tree.id]
|
|
3221
|
+
if not discovery or discovery.root ~= root then
|
|
3222
|
+
discovery = { root = root, dirty = true, nextScan = 0 }
|
|
3223
|
+
treeDiscovery[tree.id] = discovery
|
|
3224
|
+
end
|
|
3225
|
+
if not discovery.dirty and os.clock() < discovery.nextScan then
|
|
3226
|
+
continue
|
|
3227
|
+
end
|
|
3228
|
+
discovery.dirty = false
|
|
3229
|
+
local instancesById = {}
|
|
3230
|
+
for id, state in states do instancesById[id] = state.root end
|
|
3231
|
+
local discovered, entries = pcall(TreeRoots.discover, tree, root, decoded.roots, decoded.maxPathDepth, instancesById)
|
|
3232
|
+
if not discovered then
|
|
3233
|
+
discovery.dirty = true
|
|
3234
|
+
connectionStatus.Text = "Tree discovery paused: " .. tostring(entries)
|
|
3235
|
+
return false
|
|
3236
|
+
end
|
|
3237
|
+
discovery.boundaries = {}
|
|
3238
|
+
local additions = {}
|
|
3239
|
+
for _, entry in entries do
|
|
3240
|
+
if entry.boundaryId then discovery.boundaries[entry.boundaryId] = true else table.insert(additions, entry) end
|
|
3241
|
+
end
|
|
3242
|
+
entries = additions
|
|
3243
|
+
for first = 1, #entries, 1000 do
|
|
3244
|
+
local batch = table.move(entries, first, math.min(first + 999, #entries), 1, {})
|
|
3245
|
+
local discoveryResponse, discoveryError = request("POST", "/v1/trees/" .. tree.id .. "/discover", HttpService:JSONEncode({ entries = batch }), {
|
|
3246
|
+
["Content-Type"] = "application/json",
|
|
3247
|
+
})
|
|
3248
|
+
if not discoveryResponse or not discoveryResponse.Success then
|
|
3249
|
+
discovery.dirty = true
|
|
3250
|
+
connectionStatus.Text = "Tree discovery paused: " .. (discoveryError or responseError(discoveryResponse))
|
|
3251
|
+
return false
|
|
3252
|
+
end
|
|
3253
|
+
end
|
|
3254
|
+
discovery.nextScan = os.clock() + 30
|
|
3255
|
+
end
|
|
3256
|
+
end
|
|
2714
3257
|
|
|
2715
3258
|
local currentSplitRootIds = {}
|
|
2716
3259
|
for _, splitRoot in decoded.splitRoots do
|
|
@@ -2727,25 +3270,87 @@ refreshConfig = function()
|
|
|
2727
3270
|
disconnectSplitRoot(id)
|
|
2728
3271
|
end
|
|
2729
3272
|
|
|
3273
|
+
local presentRoots = {}
|
|
3274
|
+
for _, manifest in decoded.roots do
|
|
3275
|
+
if manifest.treeRootId and not manifest.container and not manifest.exists and not manifest.gitConflict
|
|
3276
|
+
and not resolveRoot(manifest.studioPath, manifest.instanceIds) then
|
|
3277
|
+
continue
|
|
3278
|
+
end
|
|
3279
|
+
table.insert(presentRoots, manifest)
|
|
3280
|
+
end
|
|
3281
|
+
decoded.roots = presentRoots
|
|
3282
|
+
|
|
3283
|
+
local boundaryPaths = {}
|
|
3284
|
+
for _, manifest in decoded.roots do
|
|
3285
|
+
local discovery = treeDiscovery[manifest.treeRootId]
|
|
3286
|
+
if discovery and discovery.boundaries and discovery.boundaries[manifest.id] then boundaryPaths[manifest.id] = manifest end
|
|
3287
|
+
end
|
|
3288
|
+
local affectedBoundaries = {}
|
|
3289
|
+
for _, manifest in decoded.roots do
|
|
3290
|
+
for id, boundary in boundaryPaths do
|
|
3291
|
+
if pathStartsWith(boundary.studioPath, manifest.studioPath, boundary.instanceIds, manifest.instanceIds) then affectedBoundaries[manifest.id] = id break end
|
|
3292
|
+
end
|
|
3293
|
+
end
|
|
2730
3294
|
local treeChanged = projectChanged == true or connectionChanged == true
|
|
2731
3295
|
batchingStatusUpdates = true
|
|
3296
|
+
local sliceStarted = os.clock()
|
|
2732
3297
|
for order, manifest in decoded.roots do
|
|
3298
|
+
if decoded.version == 2 and (order - 1) % 50 == 0 then
|
|
3299
|
+
table.clear(snapshotBatch)
|
|
3300
|
+
local ids = {}
|
|
3301
|
+
for index = order, math.min(order + 49, #decoded.roots) do
|
|
3302
|
+
local candidate = decoded.roots[index]
|
|
3303
|
+
local previous = states[candidate.id]
|
|
3304
|
+
local knownHash = if previous then previous.lastHash else savedHashes[candidate.id]
|
|
3305
|
+
if not affectedBoundaries[candidate.id] and candidate.exists and not candidate.gitConflict and knownHash ~= candidate.hash and (not previous or (not previous.dirty and not previous.conflict)) then
|
|
3306
|
+
table.insert(ids, candidate.id)
|
|
3307
|
+
end
|
|
3308
|
+
end
|
|
3309
|
+
if #ids > 0 and decoded.rojoConnected then
|
|
3310
|
+
local downloadStarted = os.clock()
|
|
3311
|
+
local response = request("POST", "/v1/snapshots", HttpService:JSONEncode(ids), { ["Content-Type"] = "application/json" })
|
|
3312
|
+
if startupProfile then startupProfile.downloads += os.clock() - downloadStarted end
|
|
3313
|
+
if response and response.Success then
|
|
3314
|
+
local bytes = buffer.fromstring(response.Body)
|
|
3315
|
+
local offset = 0
|
|
3316
|
+
while offset < buffer.len(bytes) do
|
|
3317
|
+
local length = buffer.readu32(bytes, offset)
|
|
3318
|
+
offset += 4
|
|
3319
|
+
local header = HttpService:JSONDecode(buffer.readstring(bytes, offset, length))
|
|
3320
|
+
offset += length
|
|
3321
|
+
snapshotBatch[header.id] = { Success = true, Body = buffer.readstring(bytes, offset, header.size), Headers = { ETag = '"' .. header.hash .. '"' } }
|
|
3322
|
+
offset += header.size
|
|
3323
|
+
end
|
|
3324
|
+
end
|
|
3325
|
+
end
|
|
3326
|
+
end
|
|
2733
3327
|
local state = states[manifest.id]
|
|
2734
3328
|
local created = state == nil
|
|
2735
3329
|
if created then
|
|
2736
|
-
state =
|
|
3330
|
+
state = createRootState(manifest, order)
|
|
2737
3331
|
end
|
|
2738
|
-
local
|
|
3332
|
+
local owner = affectedBoundaries[manifest.id]
|
|
3333
|
+
local boundaryChanged = state.boundary ~= (owner == manifest.id) or state.coveredByBoundary ~= (if owner ~= manifest.id then owner else nil)
|
|
3334
|
+
state.boundary = owner == manifest.id
|
|
3335
|
+
state.coveredByBoundary = if owner ~= manifest.id then owner else nil
|
|
3336
|
+
local resolvedRoot = resolveRoot(manifest.studioPath, manifest.instanceIds)
|
|
2739
3337
|
local rootChanged = state.observedRoot ~= resolvedRoot
|
|
2740
3338
|
state.observedRoot = resolvedRoot
|
|
2741
|
-
if created or connectionChanged or rootChanged or manifestChanged(state.manifest, manifest) then
|
|
3339
|
+
if created or connectionChanged or rootChanged or boundaryChanged or manifestChanged(state.manifest, manifest) then
|
|
3340
|
+
local reconcileStarted = os.clock()
|
|
2742
3341
|
reconcile(state, manifest)
|
|
3342
|
+
if startupProfile then startupProfile.reconcile += os.clock() - reconcileStarted end
|
|
2743
3343
|
treeChanged = true
|
|
2744
3344
|
else
|
|
2745
3345
|
state.manifest = manifest
|
|
2746
3346
|
end
|
|
3347
|
+
if os.clock() - sliceStarted >= 0.004 then
|
|
3348
|
+
task.wait()
|
|
3349
|
+
sliceStarted = os.clock()
|
|
3350
|
+
end
|
|
2747
3351
|
end
|
|
2748
3352
|
local currentIds = {}
|
|
3353
|
+
table.clear(snapshotBatch)
|
|
2749
3354
|
for _, manifest in decoded.roots do
|
|
2750
3355
|
currentIds[manifest.id] = true
|
|
2751
3356
|
end
|
|
@@ -2759,7 +3364,7 @@ refreshConfig = function()
|
|
|
2759
3364
|
local state = states[id]
|
|
2760
3365
|
disconnectRoot(state)
|
|
2761
3366
|
destroyConflictWorkspace(state)
|
|
2762
|
-
state
|
|
3367
|
+
destroyRootRow(state)
|
|
2763
3368
|
states[id] = nil
|
|
2764
3369
|
treeChanged = true
|
|
2765
3370
|
end
|
|
@@ -2768,9 +3373,27 @@ refreshConfig = function()
|
|
|
2768
3373
|
updateGroupSummaries()
|
|
2769
3374
|
applyTreeFilter()
|
|
2770
3375
|
end
|
|
3376
|
+
saveHashes()
|
|
3377
|
+
if startupProfile and decoded.rojoConnected and #decoded.roots > 0 then
|
|
3378
|
+
print(string.format("[GoldSync startup] total=%.3fs settings=%.3fs watchers=%.3fs reconciliationIncludingWatchers=%.3fs batchDownloads=%.3fs roots=%d", os.clock() - refreshStarted, startupProfile.settings, startupProfile.watchers, startupProfile.reconcile, startupProfile.downloads, #decoded.roots))
|
|
3379
|
+
startupProfile = nil
|
|
3380
|
+
end
|
|
2771
3381
|
return true
|
|
2772
3382
|
end
|
|
2773
3383
|
|
|
3384
|
+
refreshConfig = function()
|
|
3385
|
+
if bulkRunning or refreshing then return false end
|
|
3386
|
+
refreshing = true
|
|
3387
|
+
local success, result = pcall(refreshConfigNow)
|
|
3388
|
+
refreshing = false
|
|
3389
|
+
if not success then
|
|
3390
|
+
batchingStatusUpdates = false
|
|
3391
|
+
warn("GoldSync refresh failed: " .. tostring(result))
|
|
3392
|
+
return false
|
|
3393
|
+
end
|
|
3394
|
+
return result
|
|
3395
|
+
end
|
|
3396
|
+
|
|
2774
3397
|
toolbarButton.Click:Connect(function()
|
|
2775
3398
|
widget.Enabled = not widget.Enabled
|
|
2776
3399
|
if widget.Enabled then
|
|
@@ -2783,6 +3406,7 @@ end)
|
|
|
2783
3406
|
|
|
2784
3407
|
plugin.Unloading:Connect(function()
|
|
2785
3408
|
running = false
|
|
3409
|
+
saveHashes()
|
|
2786
3410
|
disconnectSplitRoots()
|
|
2787
3411
|
for _, state in states do
|
|
2788
3412
|
disconnectRoot(state)
|
|
@@ -2796,6 +3420,384 @@ task.spawn(function()
|
|
|
2796
3420
|
task.wait((serverConfig and serverConfig.pollIntervalMs or 750) / 1000)
|
|
2797
3421
|
end
|
|
2798
3422
|
end)
|
|
3423
|
+
]]></string>
|
|
3424
|
+
</Properties>
|
|
3425
|
+
</Item>
|
|
3426
|
+
<Item class="ModuleScript" referent="2">
|
|
3427
|
+
<Properties>
|
|
3428
|
+
<string name="Name">StudioRestore</string>
|
|
3429
|
+
<string name="Source"><![CDATA[local HttpService = game:GetService("HttpService")
|
|
3430
|
+
local SerializationService = game:GetService("SerializationService")
|
|
3431
|
+
local TreeRoots = require(script.Parent.TreeRoots)
|
|
3432
|
+
|
|
3433
|
+
local StudioRestore = {}
|
|
3434
|
+
|
|
3435
|
+
function StudioRestore.prepare(body)
|
|
3436
|
+
local staging = Instance.new("Folder")
|
|
3437
|
+
local success, result = pcall(function()
|
|
3438
|
+
local bytes = buffer.fromstring(body)
|
|
3439
|
+
local headerLength = buffer.readu32(bytes, 0)
|
|
3440
|
+
local packet = HttpService:JSONDecode(buffer.readstring(bytes, 4, headerLength))
|
|
3441
|
+
local offset = 4 + headerLength
|
|
3442
|
+
local instances, roots = {}, {}
|
|
3443
|
+
local trees = {}
|
|
3444
|
+
for _, tree in packet.trees do
|
|
3445
|
+
trees[tree.id] = TreeRoots.pathKey(tree.studioPath)
|
|
3446
|
+
end
|
|
3447
|
+
for _, manifest in packet.roots do
|
|
3448
|
+
local key = TreeRoots.pathKey(manifest.studioPath, manifest.instanceIds)
|
|
3449
|
+
assert(not instances[key], "Duplicate restore path")
|
|
3450
|
+
local isRoot = trees[manifest.treeRootId] == key
|
|
3451
|
+
local instance
|
|
3452
|
+
if manifest.exists then
|
|
3453
|
+
local restored = SerializationService:DeserializeInstancesAsync(buffer.fromstring(buffer.readstring(bytes, offset, manifest.size)))
|
|
3454
|
+
for _, candidate in restored do candidate.Parent = staging end
|
|
3455
|
+
assert(#restored == 1, "Snapshot must contain one root: " .. manifest.file)
|
|
3456
|
+
instance = restored[1]
|
|
3457
|
+
else
|
|
3458
|
+
assert(manifest.container and manifest.size == 0, "Missing asset snapshot: " .. manifest.file)
|
|
3459
|
+
instance = Instance.new("Folder")
|
|
3460
|
+
instance.Name = manifest.studioPath[#manifest.studioPath]
|
|
3461
|
+
local id = manifest.instanceIds[#manifest.studioPath]
|
|
3462
|
+
if id ~= "" then instance:SetAttribute("GoldSyncId", id) end
|
|
3463
|
+
instance.Parent = staging
|
|
3464
|
+
end
|
|
3465
|
+
offset += manifest.size
|
|
3466
|
+
assert(instance.Name == manifest.studioPath[#manifest.studioPath], "Snapshot name does not match " .. manifest.file)
|
|
3467
|
+
assert((instance:GetAttribute("GoldSyncId") or "") == manifest.instanceIds[#manifest.studioPath], "Snapshot identity does not match " .. manifest.file)
|
|
3468
|
+
assert(TreeRoots.isContainer(instance, isRoot) == manifest.container, "Snapshot class does not match " .. manifest.file)
|
|
3469
|
+
assert(not manifest.container or #instance:GetChildren() == 0, "Container snapshot contains children: " .. manifest.file)
|
|
3470
|
+
if isRoot then
|
|
3471
|
+
table.insert(roots, { instance = instance, path = manifest.studioPath })
|
|
3472
|
+
else
|
|
3473
|
+
local parentKey = TreeRoots.pathKey(manifest.studioPath, manifest.instanceIds, #manifest.studioPath - 1)
|
|
3474
|
+
assert(instances[parentKey], "Restore parent is missing: " .. manifest.file)
|
|
3475
|
+
instance.Parent = instances[parentKey]
|
|
3476
|
+
end
|
|
3477
|
+
instances[key] = instance
|
|
3478
|
+
end
|
|
3479
|
+
assert(offset == buffer.len(bytes), "Unexpected restore packet data")
|
|
3480
|
+
assert(#roots == #packet.trees, "Restore is missing a configured root")
|
|
3481
|
+
return { staging = staging, roots = roots, manifests = packet.roots }
|
|
3482
|
+
end)
|
|
3483
|
+
if not success then
|
|
3484
|
+
staging:Destroy()
|
|
3485
|
+
error(result)
|
|
3486
|
+
end
|
|
3487
|
+
return result
|
|
3488
|
+
end
|
|
3489
|
+
|
|
3490
|
+
function StudioRestore.commit(prepared)
|
|
3491
|
+
local previous = {}
|
|
3492
|
+
for _, root in prepared.roots do
|
|
3493
|
+
local parent = game
|
|
3494
|
+
for depth = 1, #root.path - 1 do
|
|
3495
|
+
parent = parent:FindFirstChild(root.path[depth])
|
|
3496
|
+
assert(parent, "Configured Studio parent is missing: " .. table.concat(root.path, "."))
|
|
3497
|
+
end
|
|
3498
|
+
root.parent = parent
|
|
3499
|
+
for _, child in parent:GetChildren() do
|
|
3500
|
+
if child.Name == root.instance.Name then table.insert(previous, { instance = child, parent = parent }) end
|
|
3501
|
+
end
|
|
3502
|
+
end
|
|
3503
|
+
local success, failure = pcall(function()
|
|
3504
|
+
for _, root in previous do root.instance.Parent = nil end
|
|
3505
|
+
for _, root in prepared.roots do root.instance.Parent = root.parent end
|
|
3506
|
+
end)
|
|
3507
|
+
if not success then
|
|
3508
|
+
for _, root in prepared.roots do root.instance.Parent = prepared.staging end
|
|
3509
|
+
for _, root in previous do root.instance.Parent = root.parent end
|
|
3510
|
+
error(failure)
|
|
3511
|
+
end
|
|
3512
|
+
for _, root in previous do root.instance:Destroy() end
|
|
3513
|
+
prepared.staging:Destroy()
|
|
3514
|
+
end
|
|
3515
|
+
|
|
3516
|
+
return StudioRestore
|
|
3517
|
+
]]></string>
|
|
3518
|
+
</Properties>
|
|
3519
|
+
</Item>
|
|
3520
|
+
<Item class="ModuleScript" referent="3">
|
|
3521
|
+
<Properties>
|
|
3522
|
+
<string name="Name">TreeRoots</string>
|
|
3523
|
+
<string name="Source"><![CDATA[local TreeRoots = {}
|
|
3524
|
+
|
|
3525
|
+
local function getInstanceId(instance)
|
|
3526
|
+
local id = instance:GetAttribute("GoldSyncId")
|
|
3527
|
+
if id ~= nil and not (type(id) == "string" and ((#id <= 9 and string.match(id, "^[1-9]%d*$")) or (#id == 32 and string.match(id, "^[0-9a-f]+$")))) then
|
|
3528
|
+
error("Invalid GoldSyncId on " .. instance:GetFullName())
|
|
3529
|
+
end
|
|
3530
|
+
return id or ""
|
|
3531
|
+
end
|
|
3532
|
+
|
|
3533
|
+
function TreeRoots.pathKey(path, instanceIds, depth)
|
|
3534
|
+
local segments = {}
|
|
3535
|
+
for index = 1, depth or #path do
|
|
3536
|
+
local name = path[index]
|
|
3537
|
+
segments[index] = #name .. ":" .. name .. ":" .. (instanceIds and instanceIds[index] or "")
|
|
3538
|
+
end
|
|
3539
|
+
return table.concat(segments, "/")
|
|
3540
|
+
end
|
|
3541
|
+
|
|
3542
|
+
function TreeRoots.displayName(name, id)
|
|
3543
|
+
return if id and id ~= "" then name .. " (" .. id .. ")" else name
|
|
3544
|
+
end
|
|
3545
|
+
|
|
3546
|
+
function TreeRoots.displayPath(path, instanceIds)
|
|
3547
|
+
local names = {}
|
|
3548
|
+
for index, name in path do
|
|
3549
|
+
names[index] = TreeRoots.displayName(name, instanceIds and instanceIds[index])
|
|
3550
|
+
end
|
|
3551
|
+
return table.concat(names, ".")
|
|
3552
|
+
end
|
|
3553
|
+
|
|
3554
|
+
function TreeRoots.findChild(parent, name, id)
|
|
3555
|
+
local first = parent:FindFirstChild(name)
|
|
3556
|
+
if not first or (first:GetAttribute("GoldSyncId") or "") == id then return first end
|
|
3557
|
+
for _, child in parent:GetChildren() do
|
|
3558
|
+
if child.Name == name and (child:GetAttribute("GoldSyncId") or "") == id then return child end
|
|
3559
|
+
end
|
|
3560
|
+
return nil
|
|
3561
|
+
end
|
|
3562
|
+
|
|
3563
|
+
function TreeRoots.isContainer(instance, isRoot)
|
|
3564
|
+
return isRoot or instance.ClassName == "Folder"
|
|
3565
|
+
end
|
|
3566
|
+
|
|
3567
|
+
function TreeRoots.needsSnapshot(instance)
|
|
3568
|
+
return instance.ClassName ~= "Folder" or not instance.Archivable
|
|
3569
|
+
or next(instance:GetAttributes()) ~= nil or #instance:GetTags() > 0
|
|
3570
|
+
or #instance:GetChildren() == 0
|
|
3571
|
+
end
|
|
3572
|
+
|
|
3573
|
+
function TreeRoots.discover(tree, root, manifests, maxDepth, instancesById)
|
|
3574
|
+
local known = {}
|
|
3575
|
+
local reservedNumbers = {}
|
|
3576
|
+
for _, manifest in manifests do
|
|
3577
|
+
if manifest.treeRootId == tree.id then
|
|
3578
|
+
known[TreeRoots.pathKey(manifest.studioPath, manifest.instanceIds)] = manifest
|
|
3579
|
+
for depth = #tree.studioPath + 1, #manifest.studioPath do
|
|
3580
|
+
local id = manifest.instanceIds and manifest.instanceIds[depth] or ""
|
|
3581
|
+
if #id > 9 or not string.match(id, "^[1-9]%d*$") then continue end
|
|
3582
|
+
local parentKey = TreeRoots.pathKey(manifest.studioPath, manifest.instanceIds, depth - 1)
|
|
3583
|
+
local numbers = reservedNumbers[parentKey] or {}
|
|
3584
|
+
reservedNumbers[parentKey] = numbers
|
|
3585
|
+
local name = string.lower(manifest.studioPath[depth])
|
|
3586
|
+
numbers[name] = math.max(numbers[name] or 1, tonumber(id) + 1)
|
|
3587
|
+
end
|
|
3588
|
+
end
|
|
3589
|
+
end
|
|
3590
|
+
local entries = {}
|
|
3591
|
+
local function visit(instance, relativePath, relativeIds)
|
|
3592
|
+
assert(#tree.studioPath + #relativePath <= maxDepth, "Tree exceeds maxPathDepth")
|
|
3593
|
+
local fullPath = table.clone(tree.studioPath)
|
|
3594
|
+
local fullIds = table.create(#tree.studioPath, "")
|
|
3595
|
+
for _, segment in relativePath do
|
|
3596
|
+
table.insert(fullPath, segment)
|
|
3597
|
+
end
|
|
3598
|
+
for _, id in relativeIds do table.insert(fullIds, id) end
|
|
3599
|
+
local existing = known[TreeRoots.pathKey(fullPath, fullIds)]
|
|
3600
|
+
local container = TreeRoots.isContainer(instance, #relativePath == 0)
|
|
3601
|
+
if existing and existing.container ~= container then
|
|
3602
|
+
table.insert(entries, { path = relativePath, instanceIds = relativeIds, container = container, boundaryId = existing.id })
|
|
3603
|
+
return
|
|
3604
|
+
end
|
|
3605
|
+
if not existing then
|
|
3606
|
+
table.insert(entries, { path = relativePath, instanceIds = relativeIds, container = container })
|
|
3607
|
+
end
|
|
3608
|
+
if not container then
|
|
3609
|
+
return
|
|
3610
|
+
end
|
|
3611
|
+
local children = instance:GetChildren()
|
|
3612
|
+
local owners = {}
|
|
3613
|
+
local nextNumbers = table.clone(reservedNumbers[TreeRoots.pathKey(fullPath, fullIds)] or {})
|
|
3614
|
+
for _, child in children do
|
|
3615
|
+
local id = getInstanceId(child)
|
|
3616
|
+
local name = string.lower(child.Name)
|
|
3617
|
+
local number = if #id <= 9 and string.match(id, "^[1-9]%d*$") then tonumber(id) else nil
|
|
3618
|
+
nextNumbers[name] = math.max(nextNumbers[name] or 1, if number then number + 1 else 1)
|
|
3619
|
+
local key = string.lower(child.Name) .. "\0" .. id
|
|
3620
|
+
local childPath, childIds = table.clone(fullPath), table.clone(fullIds)
|
|
3621
|
+
table.insert(childPath, child.Name)
|
|
3622
|
+
table.insert(childIds, id)
|
|
3623
|
+
local manifest = known[TreeRoots.pathKey(childPath, childIds)]
|
|
3624
|
+
if not owners[key] or (manifest and instancesById and instancesById[manifest.id] == child) then
|
|
3625
|
+
owners[key] = child
|
|
3626
|
+
end
|
|
3627
|
+
end
|
|
3628
|
+
for _, child in children do
|
|
3629
|
+
local id = getInstanceId(child)
|
|
3630
|
+
if owners[string.lower(child.Name) .. "\0" .. id] ~= child then
|
|
3631
|
+
local name = string.lower(child.Name)
|
|
3632
|
+
assert(nextNumbers[name] <= 999999999, "Duplicate number limit reached: " .. child:GetFullName())
|
|
3633
|
+
id = tostring(nextNumbers[name])
|
|
3634
|
+
nextNumbers[name] += 1
|
|
3635
|
+
child:SetAttribute("GoldSyncId", id)
|
|
3636
|
+
end
|
|
3637
|
+
local childPath = table.clone(relativePath)
|
|
3638
|
+
local childIds = table.clone(relativeIds)
|
|
3639
|
+
table.insert(childPath, child.Name)
|
|
3640
|
+
table.insert(childIds, id)
|
|
3641
|
+
visit(child, childPath, childIds)
|
|
3642
|
+
end
|
|
3643
|
+
end
|
|
3644
|
+
visit(root, {}, {})
|
|
3645
|
+
return entries
|
|
3646
|
+
end
|
|
3647
|
+
|
|
3648
|
+
local function propertiesEqual(left, right, propertyNames, counterparts)
|
|
3649
|
+
local leftAttributes, rightAttributes = left:GetAttributes(), right:GetAttributes()
|
|
3650
|
+
for key, value in leftAttributes do
|
|
3651
|
+
if rightAttributes[key] ~= value then
|
|
3652
|
+
return false
|
|
3653
|
+
end
|
|
3654
|
+
end
|
|
3655
|
+
for key in rightAttributes do
|
|
3656
|
+
if leftAttributes[key] == nil then
|
|
3657
|
+
return false
|
|
3658
|
+
end
|
|
3659
|
+
end
|
|
3660
|
+
if #left:GetTags() ~= #right:GetTags() then
|
|
3661
|
+
return false
|
|
3662
|
+
end
|
|
3663
|
+
for _, tag in left:GetTags() do
|
|
3664
|
+
if not right:HasTag(tag) then
|
|
3665
|
+
return false
|
|
3666
|
+
end
|
|
3667
|
+
end
|
|
3668
|
+
for _, name in propertyNames(left.ClassName) do
|
|
3669
|
+
if name == "Parent" then
|
|
3670
|
+
continue
|
|
3671
|
+
end
|
|
3672
|
+
local leftReadable, leftValue = pcall(function() return left[name] end)
|
|
3673
|
+
local rightReadable, rightValue = pcall(function() return right[name] end)
|
|
3674
|
+
if leftReadable ~= rightReadable then
|
|
3675
|
+
return false
|
|
3676
|
+
end
|
|
3677
|
+
if not leftReadable then
|
|
3678
|
+
continue
|
|
3679
|
+
end
|
|
3680
|
+
if typeof(leftValue) == "Instance" or typeof(rightValue) == "Instance" then
|
|
3681
|
+
if not counterparts then continue end
|
|
3682
|
+
leftValue = counterparts[leftValue] or leftValue
|
|
3683
|
+
elseif typeof(leftValue) == "buffer" and typeof(rightValue) == "buffer" then
|
|
3684
|
+
leftValue, rightValue = buffer.tostring(leftValue), buffer.tostring(rightValue)
|
|
3685
|
+
end
|
|
3686
|
+
if leftValue ~= rightValue then
|
|
3687
|
+
if typeof(leftValue) == "CFrame" and typeof(rightValue) == "CFrame" and leftValue.Position == rightValue.Position then
|
|
3688
|
+
local leftComponents = { leftValue:GetComponents() }
|
|
3689
|
+
local rightComponents = { rightValue:GetComponents() }
|
|
3690
|
+
for index = 4, 12 do
|
|
3691
|
+
if not (math.abs(leftComponents[index] - rightComponents[index]) <= 2 ^ -23) then return false end
|
|
3692
|
+
end
|
|
3693
|
+
continue
|
|
3694
|
+
end
|
|
3695
|
+
return false
|
|
3696
|
+
end
|
|
3697
|
+
end
|
|
3698
|
+
return true
|
|
3699
|
+
end
|
|
3700
|
+
|
|
3701
|
+
function TreeRoots.equal(first, second, propertyNames, container)
|
|
3702
|
+
local pairsToCompare = {}
|
|
3703
|
+
local counterparts = {}
|
|
3704
|
+
local function pair(left, right)
|
|
3705
|
+
if left.ClassName ~= right.ClassName or left.Name ~= right.Name then return false end
|
|
3706
|
+
if not propertiesEqual(left, right, propertyNames) then return false end
|
|
3707
|
+
counterparts[left] = right
|
|
3708
|
+
table.insert(pairsToCompare, { left, right })
|
|
3709
|
+
if container then return true end
|
|
3710
|
+
local childrenByName = {}
|
|
3711
|
+
local remaining = 0
|
|
3712
|
+
for _, child in right:GetChildren() do
|
|
3713
|
+
if child.ClassName == "TouchTransmitter" then continue end
|
|
3714
|
+
remaining += 1
|
|
3715
|
+
local key = child.ClassName .. "\0" .. child.Name
|
|
3716
|
+
local bucket = childrenByName[key] or {}
|
|
3717
|
+
childrenByName[key] = bucket
|
|
3718
|
+
table.insert(bucket, child)
|
|
3719
|
+
end
|
|
3720
|
+
for _, child in left:GetChildren() do
|
|
3721
|
+
if child.ClassName == "TouchTransmitter" then continue end
|
|
3722
|
+
local bucket = childrenByName[child.ClassName .. "\0" .. child.Name]
|
|
3723
|
+
if not bucket then return false end
|
|
3724
|
+
local matched = false
|
|
3725
|
+
for index, candidate in bucket do
|
|
3726
|
+
local previousCount = #pairsToCompare
|
|
3727
|
+
if pair(child, candidate) then
|
|
3728
|
+
table.remove(bucket, index)
|
|
3729
|
+
remaining -= 1
|
|
3730
|
+
matched = true
|
|
3731
|
+
break
|
|
3732
|
+
end
|
|
3733
|
+
for rollback = #pairsToCompare, previousCount + 1, -1 do
|
|
3734
|
+
counterparts[pairsToCompare[rollback][1]] = nil
|
|
3735
|
+
pairsToCompare[rollback] = nil
|
|
3736
|
+
end
|
|
3737
|
+
end
|
|
3738
|
+
if not matched then return false end
|
|
3739
|
+
end
|
|
3740
|
+
return remaining == 0
|
|
3741
|
+
end
|
|
3742
|
+
if not pair(first, second) then return false end
|
|
3743
|
+
for _, instances in pairsToCompare do
|
|
3744
|
+
if not propertiesEqual(instances[1], instances[2], propertyNames, counterparts) then return false end
|
|
3745
|
+
end
|
|
3746
|
+
return true
|
|
3747
|
+
end
|
|
3748
|
+
|
|
3749
|
+
local function copyMetadata(target, snapshot, propertyNames)
|
|
3750
|
+
local previous = {}
|
|
3751
|
+
local applied, failure = pcall(function()
|
|
3752
|
+
for _, name in propertyNames(target.ClassName) do
|
|
3753
|
+
if name == "Parent" or name == "Name" then
|
|
3754
|
+
continue
|
|
3755
|
+
end
|
|
3756
|
+
local readable, value, original = pcall(function() return snapshot[name], target[name] end)
|
|
3757
|
+
if readable and value ~= original then
|
|
3758
|
+
previous[name] = { value = original }
|
|
3759
|
+
target[name] = value
|
|
3760
|
+
end
|
|
3761
|
+
end
|
|
3762
|
+
end)
|
|
3763
|
+
if not applied then
|
|
3764
|
+
for name, original in previous do
|
|
3765
|
+
pcall(function() target[name] = original.value end)
|
|
3766
|
+
end
|
|
3767
|
+
error(failure)
|
|
3768
|
+
end
|
|
3769
|
+
for name in target:GetAttributes() do
|
|
3770
|
+
target:SetAttribute(name, nil)
|
|
3771
|
+
end
|
|
3772
|
+
for name, value in snapshot:GetAttributes() do
|
|
3773
|
+
target:SetAttribute(name, value)
|
|
3774
|
+
end
|
|
3775
|
+
for _, tag in target:GetTags() do
|
|
3776
|
+
target:RemoveTag(tag)
|
|
3777
|
+
end
|
|
3778
|
+
for _, tag in snapshot:GetTags() do
|
|
3779
|
+
target:AddTag(tag)
|
|
3780
|
+
end
|
|
3781
|
+
end
|
|
3782
|
+
|
|
3783
|
+
function TreeRoots.snapshot(root, propertyNames)
|
|
3784
|
+
local snapshot = Instance.new(root.ClassName)
|
|
3785
|
+
snapshot.Name = root.Name
|
|
3786
|
+
local success, failure = pcall(copyMetadata, snapshot, root, propertyNames)
|
|
3787
|
+
if not success then
|
|
3788
|
+
snapshot:Destroy()
|
|
3789
|
+
error(failure)
|
|
3790
|
+
end
|
|
3791
|
+
return snapshot
|
|
3792
|
+
end
|
|
3793
|
+
|
|
3794
|
+
function TreeRoots.applyMetadata(target, snapshot, propertyNames)
|
|
3795
|
+
assert(target.ClassName == snapshot.ClassName, "Container class changed; resolve manually to preserve children")
|
|
3796
|
+
assert(#snapshot:GetChildren() == 0, "Container snapshot must not contain children")
|
|
3797
|
+
copyMetadata(target, snapshot, propertyNames)
|
|
3798
|
+
end
|
|
3799
|
+
|
|
3800
|
+
return TreeRoots
|
|
2799
3801
|
]]></string>
|
|
2800
3802
|
</Properties>
|
|
2801
3803
|
</Item>
|