goldsync 0.1.16 → 0.1.31
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 +60 -79
- package/assets/GoldSync.rbxmx +1006 -194
- package/bin/install-companion.mjs +29 -3
- package/media/configuration-assets.png +0 -0
- package/media/file-boundaries.png +0 -0
- package/package.json +2 -2
- package/src/companion.mjs +1 -1
- package/src/config.mjs +39 -6
- package/src/git-conflicts.mjs +1 -1
- package/src/server.mjs +108 -13
- package/src/store.mjs +231 -5
package/assets/GoldSync.rbxmx
CHANGED
|
@@ -19,10 +19,16 @@ 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",
|
|
22
23
|
}
|
|
23
24
|
local SETTINGS_PREFIX = "GoldSync:v2:"
|
|
24
25
|
local UI_SETTINGS_PREFIX = "GoldSync:ui:v1:"
|
|
25
|
-
local VERSION = "0.1.
|
|
26
|
+
local VERSION = "0.1.31"
|
|
27
|
+
local startupProfile = { settings = 0, watchers = 0, reconcile = 0, downloads = 0 }
|
|
28
|
+
local savedHashes = {}
|
|
29
|
+
local savedHashesKey
|
|
30
|
+
local hashesDirty = false
|
|
31
|
+
local TreeRoots = require(script.Parent.TreeRoots)
|
|
26
32
|
local REQUEST_INTERVAL_SECONDS = 0.15
|
|
27
33
|
local REQUEST_RETRY_DELAYS = { 1, 2, 4 }
|
|
28
34
|
|
|
@@ -392,6 +398,12 @@ local requestWaiters = {}
|
|
|
392
398
|
local lastRequestAt = 0
|
|
393
399
|
local serverConfig
|
|
394
400
|
local states = {}
|
|
401
|
+
local treeDiscovery = {}
|
|
402
|
+
local snapshotBatch = {}
|
|
403
|
+
local configETag
|
|
404
|
+
local filteringTree = false
|
|
405
|
+
local filterScheduled = false
|
|
406
|
+
local filterAgain = false
|
|
395
407
|
local groups = {}
|
|
396
408
|
local batchingStatusUpdates = false
|
|
397
409
|
local splitRootConnections = {}
|
|
@@ -402,6 +414,7 @@ local refreshUnsyncedButton
|
|
|
402
414
|
local upload
|
|
403
415
|
local pull
|
|
404
416
|
local deleteSnapshot
|
|
417
|
+
local pathStartsWith
|
|
405
418
|
local openGitConflict
|
|
406
419
|
local resolveGitConflict
|
|
407
420
|
local applyTreeFilter
|
|
@@ -550,10 +563,10 @@ local function responseError(response)
|
|
|
550
563
|
return string.format("HTTP %d · %s", response.StatusCode, tostring(response.StatusMessage))
|
|
551
564
|
end
|
|
552
565
|
|
|
553
|
-
local function resolveRoot(studioPath)
|
|
566
|
+
local function resolveRoot(studioPath, instanceIds)
|
|
554
567
|
local current = game:GetService(studioPath[1])
|
|
555
568
|
for index = 2, #studioPath do
|
|
556
|
-
current = current:FindFirstChild(studioPath[index])
|
|
569
|
+
current = if instanceIds then TreeRoots.findChild(current, studioPath[index], instanceIds[index]) else current:FindFirstChild(studioPath[index])
|
|
557
570
|
if not current then
|
|
558
571
|
return nil
|
|
559
572
|
end
|
|
@@ -561,10 +574,10 @@ local function resolveRoot(studioPath)
|
|
|
561
574
|
return current
|
|
562
575
|
end
|
|
563
576
|
|
|
564
|
-
local function resolveParent(studioPath)
|
|
577
|
+
local function resolveParent(studioPath, instanceIds)
|
|
565
578
|
local current = game:GetService(studioPath[1])
|
|
566
579
|
for index = 2, #studioPath - 1 do
|
|
567
|
-
current = current:FindFirstChild(studioPath[index])
|
|
580
|
+
current = if instanceIds then TreeRoots.findChild(current, studioPath[index], instanceIds[index]) else current:FindFirstChild(studioPath[index])
|
|
568
581
|
if not current then
|
|
569
582
|
return nil
|
|
570
583
|
end
|
|
@@ -719,21 +732,9 @@ local function addButtonHover(button)
|
|
|
719
732
|
Instance.new("UICorner", button).CornerRadius = UDim.new(0, 3)
|
|
720
733
|
end
|
|
721
734
|
|
|
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
735
|
local function setStatus(state, text, color)
|
|
735
736
|
local statusColor = color or Color3.fromRGB(180, 180, 190)
|
|
736
|
-
local statusKind = if string.sub(text, 1, 6) == "Synced"
|
|
737
|
+
local statusKind = if string.sub(text, 1, 6) == "Synced" or string.sub(text, 1, 7) == "Deleted"
|
|
737
738
|
then "synced"
|
|
738
739
|
elseif string.find(text, "syncing")
|
|
739
740
|
or string.find(text, "Serializing")
|
|
@@ -743,18 +744,29 @@ local function setStatus(state, text, color)
|
|
|
743
744
|
then "working"
|
|
744
745
|
elseif string.find(text, "Rojo") and string.find(text, "offline") then "paused"
|
|
745
746
|
else "issue"
|
|
746
|
-
if state.
|
|
747
|
+
if state.statusText == text and state.statusColor == statusColor and state.statusKind == statusKind then
|
|
747
748
|
return
|
|
748
749
|
end
|
|
749
750
|
local previousStatusKind = state.statusKind
|
|
750
|
-
state.
|
|
751
|
-
state.
|
|
752
|
-
state.
|
|
751
|
+
state.statusText = text
|
|
752
|
+
state.statusColor = statusColor
|
|
753
|
+
if state.row then
|
|
754
|
+
state.status.Text = text
|
|
755
|
+
state.status.TextColor3 = statusColor
|
|
756
|
+
state.dot.TextColor3 = statusColor
|
|
757
|
+
end
|
|
753
758
|
state.searchQuery = nil
|
|
754
759
|
state.statusKind = statusKind
|
|
755
760
|
if updateGroupSummaries and not batchingStatusUpdates then
|
|
756
761
|
if previousStatusKind ~= state.statusKind then
|
|
757
762
|
updateGroupSummaries()
|
|
763
|
+
if applyTreeFilter and not filterScheduled then
|
|
764
|
+
filterScheduled = true
|
|
765
|
+
task.defer(function()
|
|
766
|
+
filterScheduled = false
|
|
767
|
+
if running then applyTreeFilter() end
|
|
768
|
+
end)
|
|
769
|
+
end
|
|
758
770
|
elseif refreshStatusSummary then
|
|
759
771
|
refreshStatusSummary()
|
|
760
772
|
end
|
|
@@ -813,6 +825,9 @@ local function getSerializedPropertyNames(className)
|
|
|
813
825
|
table.insert(names, property.Name)
|
|
814
826
|
end
|
|
815
827
|
end
|
|
828
|
+
if (className == "ModuleScript" or className == "Script" or className == "LocalScript") and not table.find(names, "Source") then
|
|
829
|
+
table.insert(names, "Source")
|
|
830
|
+
end
|
|
816
831
|
table.sort(names)
|
|
817
832
|
end
|
|
818
833
|
propertyNamesByClass[className] = names
|
|
@@ -850,7 +865,7 @@ local function compareConflictWorkspace(state, versions, resultRoot)
|
|
|
850
865
|
local generation = inspectorGeneration
|
|
851
866
|
table.clear(inspectorEntries)
|
|
852
867
|
inspectorRendered = 0
|
|
853
|
-
inspectorTitle.Text =
|
|
868
|
+
inspectorTitle.Text = TreeRoots.displayPath(state.manifest.studioPath, state.manifest.instanceIds)
|
|
854
869
|
inspectorSummary.Text = "Scanning serialized properties..."
|
|
855
870
|
inspectorWidget.Enabled = true
|
|
856
871
|
|
|
@@ -1110,10 +1125,20 @@ local function watchInstance(state, instance)
|
|
|
1110
1125
|
return
|
|
1111
1126
|
end
|
|
1112
1127
|
local connections = {
|
|
1113
|
-
instance.AttributeChanged:Connect(function()
|
|
1128
|
+
instance.AttributeChanged:Connect(function(attribute)
|
|
1114
1129
|
markDirty(state)
|
|
1130
|
+
if attribute == "GoldSyncId" and instance == state.root and state.manifest.treeRootId then
|
|
1131
|
+
local tree = treeDiscovery[state.manifest.treeRootId]
|
|
1132
|
+
if tree then tree.dirty = true end
|
|
1133
|
+
end
|
|
1115
1134
|
end),
|
|
1116
1135
|
}
|
|
1136
|
+
if instance == state.root and state.manifest.treeRootId then
|
|
1137
|
+
table.insert(connections, instance:GetPropertyChangedSignal("Name"):Connect(function()
|
|
1138
|
+
local tree = treeDiscovery[state.manifest.treeRootId]
|
|
1139
|
+
if tree then tree.dirty = true end
|
|
1140
|
+
end))
|
|
1141
|
+
end
|
|
1117
1142
|
if instance:IsA("ValueBase") then
|
|
1118
1143
|
for _, propertyName in getSerializedPropertyNames(instance.ClassName) do
|
|
1119
1144
|
local connected, connection = pcall(function()
|
|
@@ -1137,9 +1162,23 @@ local function watchInstance(state, instance)
|
|
|
1137
1162
|
end
|
|
1138
1163
|
|
|
1139
1164
|
local function watchRoot(state, root)
|
|
1165
|
+
local started = os.clock()
|
|
1140
1166
|
disconnectRoot(state)
|
|
1141
1167
|
state.root = root
|
|
1142
1168
|
watchInstance(state, root)
|
|
1169
|
+
if state.manifest.container then
|
|
1170
|
+
local tree = treeDiscovery[state.manifest.treeRootId]
|
|
1171
|
+
if tree then
|
|
1172
|
+
for _, signal in { root.ChildAdded, root.ChildRemoved } do
|
|
1173
|
+
table.insert(state.rootConnections, signal:Connect(function()
|
|
1174
|
+
tree.dirty = true
|
|
1175
|
+
markDirty(state)
|
|
1176
|
+
end))
|
|
1177
|
+
end
|
|
1178
|
+
end
|
|
1179
|
+
if startupProfile then startupProfile.watchers += os.clock() - started end
|
|
1180
|
+
return
|
|
1181
|
+
end
|
|
1143
1182
|
for _, descendant in root:GetDescendants() do
|
|
1144
1183
|
watchInstance(state, descendant)
|
|
1145
1184
|
end
|
|
@@ -1158,34 +1197,86 @@ local function watchRoot(state, root)
|
|
|
1158
1197
|
markDirty(state)
|
|
1159
1198
|
end))
|
|
1160
1199
|
table.insert(state.rootConnections, root.AncestryChanged:Connect(function()
|
|
1161
|
-
if not state.suppress and resolveRoot(state.manifest.studioPath) ~= root then
|
|
1200
|
+
if not state.suppress and resolveRoot(state.manifest.studioPath, state.manifest.instanceIds) ~= root then
|
|
1162
1201
|
deleteSnapshot(state)
|
|
1163
1202
|
end
|
|
1164
1203
|
end))
|
|
1204
|
+
if startupProfile then startupProfile.watchers += os.clock() - started end
|
|
1205
|
+
end
|
|
1206
|
+
|
|
1207
|
+
local function saveHashes()
|
|
1208
|
+
if not hashesDirty or not savedHashesKey then return end
|
|
1209
|
+
plugin:SetSetting(savedHashesKey, savedHashes)
|
|
1210
|
+
hashesDirty = false
|
|
1165
1211
|
end
|
|
1166
1212
|
|
|
1167
1213
|
local function rememberHash(state, hash)
|
|
1168
1214
|
state.lastHash = hash
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
)
|
|
1215
|
+
if savedHashes[state.manifest.id] == hash then return end
|
|
1216
|
+
savedHashes[state.manifest.id] = hash
|
|
1217
|
+
hashesDirty = true
|
|
1173
1218
|
end
|
|
1174
1219
|
|
|
1175
1220
|
local function forgetHash(state)
|
|
1176
1221
|
state.lastHash = nil
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
)
|
|
1222
|
+
if savedHashes[state.manifest.id] == nil then return end
|
|
1223
|
+
savedHashes[state.manifest.id] = nil
|
|
1224
|
+
hashesDirty = true
|
|
1181
1225
|
end
|
|
1182
1226
|
|
|
1183
|
-
deleteSnapshot = function(state)
|
|
1227
|
+
deleteSnapshot = function(state, explicit)
|
|
1228
|
+
if state.boundary or state.coveredByBoundary then return end
|
|
1229
|
+
if state.manifest.container then
|
|
1230
|
+
state.conflict = true
|
|
1231
|
+
setStatus(state, "Folder removed. Push to remove its saved subtree; Pull to restore it.", Color3.fromRGB(245, 106, 106))
|
|
1232
|
+
if not explicit or state.deleting or not serverConfig.rojoConnected then return end
|
|
1233
|
+
local expected, affected = {}, {}
|
|
1234
|
+
for _, candidate in states do
|
|
1235
|
+
local manifest = candidate.manifest
|
|
1236
|
+
if manifest.treeRootId ~= state.manifest.treeRootId or not pathStartsWith(state.manifest.studioPath, manifest.studioPath, state.manifest.instanceIds, manifest.instanceIds) then continue end
|
|
1237
|
+
if manifest.gitConflict or resolveRoot(manifest.studioPath, manifest.instanceIds) then
|
|
1238
|
+
setStatus(state, "Deletion blocked: a saved descendant still exists in Studio or has a Git conflict.", Color3.fromRGB(245, 106, 106))
|
|
1239
|
+
return
|
|
1240
|
+
end
|
|
1241
|
+
expected[manifest.id] = manifest.hash or false
|
|
1242
|
+
table.insert(affected, candidate)
|
|
1243
|
+
end
|
|
1244
|
+
local header = HttpService:JSONEncode({ entries = {}, expected = expected })
|
|
1245
|
+
local length = buffer.create(4)
|
|
1246
|
+
buffer.writeu32(length, 0, #header)
|
|
1247
|
+
state.deleting = true
|
|
1248
|
+
local response, failure = request("PUT", "/v1/roots/" .. state.manifest.id .. "/boundary", buffer.tostring(length) .. header, { ["Content-Type"] = "application/octet-stream" })
|
|
1249
|
+
state.deleting = false
|
|
1250
|
+
if not response or not response.Success then
|
|
1251
|
+
setStatus(state, "Deletion failed: " .. (failure or responseError(response)), Color3.fromRGB(245, 106, 106))
|
|
1252
|
+
return
|
|
1253
|
+
end
|
|
1254
|
+
for _, candidate in affected do
|
|
1255
|
+
disconnectRoot(candidate)
|
|
1256
|
+
forgetHash(candidate)
|
|
1257
|
+
candidate.coveredByBoundary = state.manifest.id
|
|
1258
|
+
candidate.conflict = false
|
|
1259
|
+
setStatus(candidate, "Deleted Studio → file", Color3.fromRGB(102, 214, 139))
|
|
1260
|
+
end
|
|
1261
|
+
configETag = nil
|
|
1262
|
+
treeDiscovery[state.manifest.treeRootId].dirty = true
|
|
1263
|
+
return
|
|
1264
|
+
end
|
|
1265
|
+
if not explicit and state.manifest.treeRootId then
|
|
1266
|
+
local parentPath = table.clone(state.manifest.studioPath)
|
|
1267
|
+
table.remove(parentPath)
|
|
1268
|
+
if not resolveRoot(parentPath, state.manifest.instanceIds) then
|
|
1269
|
+
state.conflict = true
|
|
1270
|
+
setStatus(state, "Parent removed. Push to remove its saved subtree; Pull to restore it.", Color3.fromRGB(245, 106, 106))
|
|
1271
|
+
return
|
|
1272
|
+
end
|
|
1273
|
+
end
|
|
1184
1274
|
if state.uploading or state.pulling or state.deleting then
|
|
1185
1275
|
return
|
|
1186
1276
|
end
|
|
1187
1277
|
state.generation += 1
|
|
1188
1278
|
state.dirty = false
|
|
1279
|
+
if explicit then state.lastHash = state.manifest.hash end
|
|
1189
1280
|
if state.manifest.gitConflict or not state.lastHash then
|
|
1190
1281
|
state.conflict = true
|
|
1191
1282
|
setStatus(state, "Root removed; resolve the existing file conflict manually.", Color3.fromRGB(245, 106, 106))
|
|
@@ -1233,6 +1324,7 @@ deleteSnapshot = function(state)
|
|
|
1233
1324
|
end
|
|
1234
1325
|
|
|
1235
1326
|
upload = function(state, overwrite)
|
|
1327
|
+
if state.coveredByBoundary then return end
|
|
1236
1328
|
if state.uploading or state.pulling or state.deleting then
|
|
1237
1329
|
return
|
|
1238
1330
|
end
|
|
@@ -1244,25 +1336,132 @@ upload = function(state, overwrite)
|
|
|
1244
1336
|
setStatus(state, "Paused — Rojo is offline.", Color3.fromRGB(244, 194, 92))
|
|
1245
1337
|
return
|
|
1246
1338
|
end
|
|
1247
|
-
local root = resolveRoot(state.manifest.studioPath)
|
|
1339
|
+
local root = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
|
|
1248
1340
|
if not root then
|
|
1341
|
+
if state.manifest.deleted and not state.manifest.exists then
|
|
1342
|
+
state.conflict = false
|
|
1343
|
+
setStatus(state, "Deleted", Color3.fromRGB(102, 214, 139))
|
|
1344
|
+
return
|
|
1345
|
+
end
|
|
1346
|
+
if overwrite then
|
|
1347
|
+
local owner = state
|
|
1348
|
+
for _, candidate in states do
|
|
1349
|
+
if candidate.manifest.treeRootId == state.manifest.treeRootId and candidate.manifest.container
|
|
1350
|
+
and #candidate.manifest.studioPath < #owner.manifest.studioPath
|
|
1351
|
+
and pathStartsWith(candidate.manifest.studioPath, state.manifest.studioPath, candidate.manifest.instanceIds, state.manifest.instanceIds)
|
|
1352
|
+
and not resolveRoot(candidate.manifest.studioPath, candidate.manifest.instanceIds) then owner = candidate end
|
|
1353
|
+
end
|
|
1354
|
+
deleteSnapshot(owner, true)
|
|
1355
|
+
return
|
|
1356
|
+
end
|
|
1249
1357
|
state.conflict = true
|
|
1250
1358
|
setStatus(state, "Studio root is missing. Pull to restore it.", Color3.fromRGB(245, 106, 106))
|
|
1251
1359
|
return
|
|
1252
1360
|
end
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1361
|
+
if state.manifest.treeRootId and not state.boundary then
|
|
1362
|
+
for _, tree in serverConfig.treeRoots do
|
|
1363
|
+
if tree.id ~= state.manifest.treeRootId then continue end
|
|
1364
|
+
if TreeRoots.isContainer(root, #tree.studioPath == #state.manifest.studioPath) ~= state.manifest.container then
|
|
1365
|
+
state.boundary = true
|
|
1366
|
+
treeDiscovery[tree.id].dirty = true
|
|
1367
|
+
setStatus(state, "Storage boundary changed. Refreshing conversion details...", Color3.fromRGB(244, 194, 92))
|
|
1368
|
+
return
|
|
1369
|
+
end
|
|
1370
|
+
end
|
|
1371
|
+
end
|
|
1372
|
+
if state.boundary then
|
|
1373
|
+
if not overwrite then
|
|
1374
|
+
setStatus(state, "Storage boundary changed. Push to convert the saved subtree.", Color3.fromRGB(244, 194, 92))
|
|
1375
|
+
return
|
|
1376
|
+
end
|
|
1377
|
+
state.uploading = true
|
|
1378
|
+
setStatus(state, "Serializing replacement boundary...", Color3.fromRGB(115, 182, 255))
|
|
1379
|
+
local success, packet = pcall(function()
|
|
1380
|
+
local tree
|
|
1381
|
+
for _, candidate in serverConfig.treeRoots do if candidate.id == state.manifest.treeRootId then tree = candidate break end end
|
|
1382
|
+
local entries, snapshots = {}, {}
|
|
1383
|
+
if root.ClassName == "Folder" then TreeRoots.discover({ id = tree.id, studioPath = state.manifest.studioPath }, root, {}, serverConfig.maxPathDepth) end
|
|
1384
|
+
local expected = {}
|
|
1385
|
+
for _, candidate in serverConfig.roots do
|
|
1386
|
+
if candidate.id == state.manifest.id or (states[candidate.id] and states[candidate.id].coveredByBoundary == state.manifest.id) then
|
|
1387
|
+
expected[candidate.id] = candidate.hash or false
|
|
1388
|
+
end
|
|
1389
|
+
end
|
|
1390
|
+
local function visit(instance, path, ids)
|
|
1391
|
+
local container = instance.ClassName == "Folder"
|
|
1392
|
+
local snapshot = if container then TreeRoots.snapshot(instance, getSerializedPropertyNames) else instance
|
|
1393
|
+
local bytes = SerializationService:SerializeInstancesAsync({ snapshot })
|
|
1394
|
+
local restored = SerializationService:DeserializeInstancesAsync(bytes)
|
|
1395
|
+
assert(#restored == 1 and TreeRoots.equal(snapshot, restored[1], getSerializedPropertyNames, container), "Replacement snapshot failed verification")
|
|
1396
|
+
restored[1]:Destroy()
|
|
1397
|
+
if snapshot ~= instance then snapshot:Destroy() end
|
|
1398
|
+
if container and not TreeRoots.needsSnapshot(instance) then bytes = buffer.create(0) end
|
|
1399
|
+
table.insert(entries, { path = path, instanceIds = ids, container = container, size = buffer.len(bytes) })
|
|
1400
|
+
table.insert(snapshots, buffer.tostring(bytes))
|
|
1401
|
+
if not container then return end
|
|
1402
|
+
for _, child in instance:GetChildren() do
|
|
1403
|
+
local childPath, childIds = table.clone(path), table.clone(ids)
|
|
1404
|
+
table.insert(childPath, child.Name)
|
|
1405
|
+
table.insert(childIds, child:GetAttribute("GoldSyncId") or "")
|
|
1406
|
+
visit(child, childPath, childIds)
|
|
1407
|
+
end
|
|
1408
|
+
end
|
|
1409
|
+
local relativePath, relativeIds = {}, {}
|
|
1410
|
+
for depth = #tree.studioPath + 1, #state.manifest.studioPath do
|
|
1411
|
+
table.insert(relativePath, state.manifest.studioPath[depth])
|
|
1412
|
+
table.insert(relativeIds, state.manifest.instanceIds[depth])
|
|
1413
|
+
end
|
|
1414
|
+
visit(root, relativePath, relativeIds)
|
|
1415
|
+
local header = HttpService:JSONEncode({ entries = entries, expected = expected })
|
|
1416
|
+
local length = buffer.create(4)
|
|
1417
|
+
buffer.writeu32(length, 0, #header)
|
|
1418
|
+
return buffer.tostring(length) .. header .. table.concat(snapshots)
|
|
1419
|
+
end)
|
|
1420
|
+
if not success then
|
|
1421
|
+
state.uploading = false
|
|
1422
|
+
setStatus(state, "Conversion failed: " .. tostring(packet), Color3.fromRGB(245, 106, 106))
|
|
1423
|
+
return
|
|
1424
|
+
end
|
|
1425
|
+
local response, failure = request("PUT", "/v1/roots/" .. state.manifest.id .. "/boundary", packet, { ["Content-Type"] = "application/octet-stream" })
|
|
1426
|
+
state.uploading = false
|
|
1427
|
+
if not response or not response.Success then
|
|
1428
|
+
setStatus(state, "Conversion failed: " .. (failure or responseError(response)), Color3.fromRGB(245, 106, 106))
|
|
1429
|
+
return
|
|
1430
|
+
end
|
|
1431
|
+
for _, manifest in HttpService:JSONDecode(response.Body).roots do
|
|
1432
|
+
if manifest.id == state.manifest.id then state.manifest = manifest end
|
|
1433
|
+
end
|
|
1434
|
+
state.boundary = nil
|
|
1435
|
+
state.conflict = false
|
|
1436
|
+
state.dirty = false
|
|
1437
|
+
configETag = nil
|
|
1438
|
+
treeDiscovery[state.manifest.treeRootId].dirty = true
|
|
1439
|
+
setStatus(state, "Synced converted boundary", Color3.fromRGB(102, 214, 139))
|
|
1440
|
+
return
|
|
1441
|
+
end
|
|
1442
|
+
|
|
1443
|
+
local plainFolder = state.manifest.container and not TreeRoots.needsSnapshot(root)
|
|
1444
|
+
if plainFolder and not state.manifest.exists then
|
|
1445
|
+
state.dirty = false
|
|
1446
|
+
state.conflict = false
|
|
1447
|
+
forgetHash(state)
|
|
1448
|
+
watchRoot(state, root)
|
|
1449
|
+
setStatus(state, "Synced Folder", Color3.fromRGB(102, 214, 139))
|
|
1257
1450
|
return
|
|
1258
1451
|
end
|
|
1259
1452
|
|
|
1260
1453
|
state.uploading = true
|
|
1261
1454
|
setStatus(state, "Serializing Studio root...", Color3.fromRGB(115, 182, 255))
|
|
1262
1455
|
local serialized, serializationError
|
|
1456
|
+
local snapshot
|
|
1263
1457
|
local success, result = pcall(function()
|
|
1264
|
-
return
|
|
1458
|
+
if plainFolder then return buffer.create(0) end
|
|
1459
|
+
snapshot = if state.manifest.container then TreeRoots.snapshot(root, getSerializedPropertyNames) else root
|
|
1460
|
+
return SerializationService:SerializeInstancesAsync({ snapshot })
|
|
1265
1461
|
end)
|
|
1462
|
+
if snapshot and snapshot ~= root then
|
|
1463
|
+
snapshot:Destroy()
|
|
1464
|
+
end
|
|
1266
1465
|
if success then
|
|
1267
1466
|
serialized = result
|
|
1268
1467
|
else
|
|
@@ -1280,9 +1479,10 @@ upload = function(state, overwrite)
|
|
|
1280
1479
|
elseif state.lastHash then
|
|
1281
1480
|
expectedHash = state.lastHash
|
|
1282
1481
|
end
|
|
1283
|
-
local response, requestError = request("PUT", "/v1/roots/" .. state.manifest.id .. "/snapshot", buffer.tostring(serialized), {
|
|
1482
|
+
local response, requestError = request(if plainFolder then "DELETE" else "PUT", "/v1/roots/" .. state.manifest.id .. "/snapshot", buffer.tostring(serialized), {
|
|
1284
1483
|
["Content-Type"] = "application/octet-stream",
|
|
1285
1484
|
["If-Match"] = expectedHash,
|
|
1485
|
+
["X-GoldSync-Plain-Folder"] = if plainFolder then "1" else "0",
|
|
1286
1486
|
})
|
|
1287
1487
|
state.uploading = false
|
|
1288
1488
|
if not response then
|
|
@@ -1308,12 +1508,13 @@ upload = function(state, overwrite)
|
|
|
1308
1508
|
state.manifest = manifest
|
|
1309
1509
|
state.dirty = false
|
|
1310
1510
|
state.conflict = false
|
|
1311
|
-
rememberHash(state, manifest.hash)
|
|
1511
|
+
if manifest.hash then rememberHash(state, manifest.hash) else forgetHash(state) end
|
|
1312
1512
|
watchRoot(state, root)
|
|
1313
1513
|
setStatus(state, "Synced Studio → file", Color3.fromRGB(102, 214, 139))
|
|
1314
1514
|
end
|
|
1315
1515
|
|
|
1316
|
-
pull = function(state)
|
|
1516
|
+
pull = function(state, adoptOnly)
|
|
1517
|
+
if state.coveredByBoundary then return end
|
|
1317
1518
|
if state.uploading or state.pulling or state.deleting then
|
|
1318
1519
|
return
|
|
1319
1520
|
end
|
|
@@ -1326,25 +1527,57 @@ pull = function(state)
|
|
|
1326
1527
|
return
|
|
1327
1528
|
end
|
|
1328
1529
|
if not state.manifest.exists then
|
|
1530
|
+
if state.manifest.container then
|
|
1531
|
+
local parent = resolveParent(state.manifest.studioPath, state.manifest.instanceIds)
|
|
1532
|
+
if not parent then
|
|
1533
|
+
setStatus(state, "Studio parent path is missing.", Color3.fromRGB(245, 106, 106))
|
|
1534
|
+
return
|
|
1535
|
+
end
|
|
1536
|
+
local root = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
|
|
1537
|
+
if root and root.ClassName ~= "Folder" then
|
|
1538
|
+
setStatus(state, "Missing metadata represents a Folder. Push to keep this class.", Color3.fromRGB(245, 106, 106))
|
|
1539
|
+
return
|
|
1540
|
+
end
|
|
1541
|
+
local snapshot = Instance.new("Folder")
|
|
1542
|
+
snapshot.Name = state.manifest.studioPath[#state.manifest.studioPath]
|
|
1543
|
+
local id = state.manifest.instanceIds[#state.manifest.studioPath]
|
|
1544
|
+
if id ~= "" then snapshot:SetAttribute("GoldSyncId", id) end
|
|
1545
|
+
if root then
|
|
1546
|
+
local applied, failure = pcall(TreeRoots.applyMetadata, root, snapshot, getSerializedPropertyNames)
|
|
1547
|
+
snapshot:Destroy()
|
|
1548
|
+
if not applied then
|
|
1549
|
+
setStatus(state, tostring(failure), Color3.fromRGB(245, 106, 106))
|
|
1550
|
+
return
|
|
1551
|
+
end
|
|
1552
|
+
else
|
|
1553
|
+
root = snapshot
|
|
1554
|
+
root.Parent = parent
|
|
1555
|
+
end
|
|
1556
|
+
forgetHash(state)
|
|
1557
|
+
watchRoot(state, root)
|
|
1558
|
+
state.dirty = false
|
|
1559
|
+
state.conflict = false
|
|
1560
|
+
setStatus(state, "Synced Folder", Color3.fromRGB(102, 214, 139))
|
|
1561
|
+
return
|
|
1562
|
+
end
|
|
1329
1563
|
setStatus(state, "No file snapshot exists. Push Studio first.", Color3.fromRGB(245, 106, 106))
|
|
1330
1564
|
return
|
|
1331
1565
|
end
|
|
1332
|
-
local parent = resolveParent(state.manifest.studioPath)
|
|
1566
|
+
local parent = resolveParent(state.manifest.studioPath, state.manifest.instanceIds)
|
|
1333
1567
|
if not parent then
|
|
1334
1568
|
setStatus(state, "Studio parent path is missing.", Color3.fromRGB(245, 106, 106))
|
|
1335
1569
|
return
|
|
1336
1570
|
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
|
|
1571
|
+
local currentRoot = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
|
|
1344
1572
|
|
|
1345
1573
|
state.pulling = true
|
|
1346
1574
|
setStatus(state, "Loading file snapshot...", Color3.fromRGB(115, 182, 255))
|
|
1347
|
-
local response
|
|
1575
|
+
local response = snapshotBatch[state.manifest.id]
|
|
1576
|
+
snapshotBatch[state.manifest.id] = nil
|
|
1577
|
+
local requestError
|
|
1578
|
+
if not response then
|
|
1579
|
+
response, requestError = request("GET", "/v1/roots/" .. state.manifest.id .. "/snapshot")
|
|
1580
|
+
end
|
|
1348
1581
|
if not response then
|
|
1349
1582
|
state.pulling = false
|
|
1350
1583
|
setStatus(state, "Daemon unavailable: " .. requestError, Color3.fromRGB(245, 106, 106))
|
|
@@ -1355,6 +1588,12 @@ pull = function(state)
|
|
|
1355
1588
|
setStatus(state, "Download failed (HTTP " .. response.StatusCode .. ")", Color3.fromRGB(245, 106, 106))
|
|
1356
1589
|
return
|
|
1357
1590
|
end
|
|
1591
|
+
local downloadedHash = response.Headers and (response.Headers.ETag or response.Headers.etag)
|
|
1592
|
+
if downloadedHash ~= '"' .. state.manifest.hash .. '"' then
|
|
1593
|
+
state.pulling = false
|
|
1594
|
+
setStatus(state, "File changed during download; waiting for the next refresh.", Color3.fromRGB(244, 194, 92))
|
|
1595
|
+
return
|
|
1596
|
+
end
|
|
1358
1597
|
|
|
1359
1598
|
local success, instances = pcall(function()
|
|
1360
1599
|
return SerializationService:DeserializeInstancesAsync(buffer.fromstring(response.Body))
|
|
@@ -1375,24 +1614,70 @@ pull = function(state)
|
|
|
1375
1614
|
|
|
1376
1615
|
local replacement = instances[1]
|
|
1377
1616
|
local expectedName = state.manifest.studioPath[#state.manifest.studioPath]
|
|
1378
|
-
|
|
1379
|
-
|
|
1617
|
+
if state.manifest.instanceIds and (replacement:GetAttribute("GoldSyncId") or "") ~= state.manifest.instanceIds[#state.manifest.studioPath] then
|
|
1618
|
+
replacement:Destroy()
|
|
1619
|
+
state.pulling = false
|
|
1620
|
+
setStatus(state, "Blocked: snapshot GoldSyncId does not match its file.", Color3.fromRGB(245, 106, 106))
|
|
1621
|
+
return
|
|
1622
|
+
end
|
|
1623
|
+
if replacement.Name ~= expectedName then
|
|
1380
1624
|
replacement:Destroy()
|
|
1381
1625
|
state.pulling = false
|
|
1382
|
-
|
|
1383
|
-
|
|
1626
|
+
setStatus(state, "Blocked: root must be named " .. expectedName, Color3.fromRGB(245, 106, 106))
|
|
1627
|
+
return
|
|
1628
|
+
end
|
|
1629
|
+
if state.manifest.treeRootId then
|
|
1630
|
+
local isRoot = false
|
|
1631
|
+
for _, tree in serverConfig.treeRoots do
|
|
1632
|
+
if tree.id == state.manifest.treeRootId then
|
|
1633
|
+
isRoot = #tree.studioPath == #state.manifest.studioPath
|
|
1634
|
+
break
|
|
1635
|
+
end
|
|
1636
|
+
end
|
|
1637
|
+
if TreeRoots.isContainer(replacement, isRoot) ~= state.manifest.container then
|
|
1638
|
+
replacement:Destroy()
|
|
1639
|
+
state.pulling = false
|
|
1640
|
+
state.conflict = true
|
|
1641
|
+
setStatus(state, "Blocked: migrate this snapshot to Folder boundaries first.", Color3.fromRGB(245, 106, 106))
|
|
1642
|
+
return
|
|
1643
|
+
end
|
|
1644
|
+
end
|
|
1645
|
+
if state.manifest.container and #replacement:GetChildren() > 0 then
|
|
1646
|
+
replacement:Destroy()
|
|
1647
|
+
state.pulling = false
|
|
1648
|
+
setStatus(state, "Blocked: container snapshot contains children.", Color3.fromRGB(245, 106, 106))
|
|
1649
|
+
return
|
|
1650
|
+
end
|
|
1651
|
+
if adoptOnly and currentRoot then
|
|
1652
|
+
local compared, matches = pcall(TreeRoots.equal, currentRoot, replacement, getSerializedPropertyNames, state.manifest.container)
|
|
1653
|
+
replacement:Destroy()
|
|
1654
|
+
state.pulling = false
|
|
1655
|
+
state.conflict = not compared or not matches
|
|
1656
|
+
if not compared then
|
|
1657
|
+
setStatus(state, "Could not compare existing instance: " .. tostring(matches), Color3.fromRGB(245, 106, 106))
|
|
1658
|
+
return
|
|
1659
|
+
end
|
|
1660
|
+
if matches then
|
|
1661
|
+
rememberHash(state, state.manifest.hash)
|
|
1662
|
+
state.dirty = false
|
|
1663
|
+
setStatus(state, "Synced existing Studio instance", Color3.fromRGB(102, 214, 139))
|
|
1664
|
+
if state.manifest.container and not TreeRoots.needsSnapshot(currentRoot) then upload(state, false) end
|
|
1384
1665
|
else
|
|
1385
|
-
setStatus(state, "
|
|
1666
|
+
setStatus(state, "Studio differs from file. Choose Push or Pull; nothing was replaced.", Color3.fromRGB(244, 194, 92))
|
|
1386
1667
|
end
|
|
1387
1668
|
return
|
|
1388
1669
|
end
|
|
1389
1670
|
|
|
1390
1671
|
state.suppress = true
|
|
1391
1672
|
disconnectRoot(state)
|
|
1392
|
-
local previous = resolveRoot(state.manifest.studioPath)
|
|
1673
|
+
local previous = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
|
|
1393
1674
|
ChangeHistoryService:SetWaypoint("GoldSync before Pull")
|
|
1394
1675
|
local applied, applyError = pcall(function()
|
|
1395
|
-
|
|
1676
|
+
if state.manifest.container and previous then
|
|
1677
|
+
TreeRoots.applyMetadata(previous, replacement, getSerializedPropertyNames)
|
|
1678
|
+
else
|
|
1679
|
+
replacement.Parent = parent
|
|
1680
|
+
end
|
|
1396
1681
|
end)
|
|
1397
1682
|
if not applied then
|
|
1398
1683
|
replacement:Destroy()
|
|
@@ -1404,7 +1689,10 @@ pull = function(state)
|
|
|
1404
1689
|
setStatus(state, "Could not apply snapshot: " .. tostring(applyError), Color3.fromRGB(245, 106, 106))
|
|
1405
1690
|
return
|
|
1406
1691
|
end
|
|
1407
|
-
if previous then
|
|
1692
|
+
if previous and state.manifest.container then
|
|
1693
|
+
replacement:Destroy()
|
|
1694
|
+
replacement = previous
|
|
1695
|
+
elseif previous then
|
|
1408
1696
|
previous:Destroy()
|
|
1409
1697
|
end
|
|
1410
1698
|
ChangeHistoryService:SetWaypoint("GoldSync Pull " .. expectedName)
|
|
@@ -1441,10 +1729,6 @@ local function deserializeConflictStage(state, stage)
|
|
|
1441
1729
|
end
|
|
1442
1730
|
return nil, "snapshot must contain exactly one root"
|
|
1443
1731
|
end
|
|
1444
|
-
if findCode(instances[1]) then
|
|
1445
|
-
instances[1]:Destroy()
|
|
1446
|
-
return nil, "snapshot contains code"
|
|
1447
|
-
end
|
|
1448
1732
|
return instances[1]
|
|
1449
1733
|
end
|
|
1450
1734
|
|
|
@@ -1466,9 +1750,11 @@ local function destroyConflictWorkspace(state)
|
|
|
1466
1750
|
parent:Destroy()
|
|
1467
1751
|
end
|
|
1468
1752
|
end
|
|
1469
|
-
state.
|
|
1470
|
-
|
|
1471
|
-
|
|
1753
|
+
if state.row then
|
|
1754
|
+
state.mergeButton.Text = "Open Merge Workspace"
|
|
1755
|
+
state.mergeButton.Size = UDim2.fromScale(1, 1)
|
|
1756
|
+
state.diffButton.Visible = false
|
|
1757
|
+
end
|
|
1472
1758
|
end
|
|
1473
1759
|
|
|
1474
1760
|
openGitConflict = function(state)
|
|
@@ -1574,8 +1860,12 @@ resolveGitConflict = function(state)
|
|
|
1574
1860
|
end
|
|
1575
1861
|
local resultRoot = resultChildren[1]
|
|
1576
1862
|
local expectedName = state.manifest.studioPath[#state.manifest.studioPath]
|
|
1577
|
-
if
|
|
1578
|
-
setStatus(state, "Result must
|
|
1863
|
+
if state.manifest.instanceIds and (resultRoot:GetAttribute("GoldSyncId") or "") ~= state.manifest.instanceIds[#state.manifest.studioPath] then
|
|
1864
|
+
setStatus(state, "Result GoldSyncId must match its file.", Color3.fromRGB(245, 106, 106))
|
|
1865
|
+
return
|
|
1866
|
+
end
|
|
1867
|
+
if resultRoot.Name ~= expectedName then
|
|
1868
|
+
setStatus(state, "Result must be a root named " .. expectedName .. ".", Color3.fromRGB(245, 106, 106))
|
|
1579
1869
|
return
|
|
1580
1870
|
end
|
|
1581
1871
|
|
|
@@ -1622,28 +1912,28 @@ local function setGroupExpanded(group, expanded)
|
|
|
1622
1912
|
group.name.Text = group.label .. group.summary
|
|
1623
1913
|
end
|
|
1624
1914
|
|
|
1625
|
-
|
|
1915
|
+
pathStartsWith = function(prefix, fullPath, prefixIds, fullIds)
|
|
1626
1916
|
if #prefix > #fullPath then
|
|
1627
1917
|
return false
|
|
1628
1918
|
end
|
|
1629
1919
|
for index, segment in prefix do
|
|
1630
|
-
if fullPath[index] ~= segment then
|
|
1920
|
+
if fullPath[index] ~= segment or (prefixIds and prefixIds[index] or "") ~= (fullIds and fullIds[index] or "") then
|
|
1631
1921
|
return false
|
|
1632
1922
|
end
|
|
1633
1923
|
end
|
|
1634
1924
|
return true
|
|
1635
1925
|
end
|
|
1636
1926
|
|
|
1637
|
-
local function createGroup(path, order)
|
|
1638
|
-
local key =
|
|
1927
|
+
local function createGroup(path, order, instanceIds, attention)
|
|
1928
|
+
local key = (if attention then "attention:" else "") .. TreeRoots.pathKey(path, instanceIds)
|
|
1639
1929
|
if groups[key] then
|
|
1640
1930
|
return groups[key]
|
|
1641
1931
|
end
|
|
1642
1932
|
local parentGroup
|
|
1643
|
-
if #path > 1 then
|
|
1933
|
+
if #path > 1 and not attention then
|
|
1644
1934
|
local parentPath = table.clone(path)
|
|
1645
1935
|
table.remove(parentPath)
|
|
1646
|
-
parentGroup = createGroup(parentPath, order)
|
|
1936
|
+
parentGroup = createGroup(parentPath, order, instanceIds and table.move(instanceIds, 1, #parentPath, 1, {}))
|
|
1647
1937
|
end
|
|
1648
1938
|
|
|
1649
1939
|
local section = Instance.new("Frame")
|
|
@@ -1651,7 +1941,7 @@ local function createGroup(path, order)
|
|
|
1651
1941
|
section.BackgroundTransparency = 1
|
|
1652
1942
|
section.LayoutOrder = order
|
|
1653
1943
|
section.Size = UDim2.new(1, 0, 0, 0)
|
|
1654
|
-
section.Parent = if parentGroup then parentGroup.list else rootsContainer
|
|
1944
|
+
section.Parent = if attention then attentionList elseif parentGroup then parentGroup.list else rootsContainer
|
|
1655
1945
|
|
|
1656
1946
|
local sectionLayout = Instance.new("UIListLayout")
|
|
1657
1947
|
sectionLayout.Padding = UDim.new(0, 2)
|
|
@@ -1687,7 +1977,7 @@ local function createGroup(path, order)
|
|
|
1687
1977
|
icon.Position = UDim2.new(0, 19, 0.5, -8)
|
|
1688
1978
|
icon.Size = UDim2.fromOffset(16, 16)
|
|
1689
1979
|
icon.Parent = header
|
|
1690
|
-
local parentInstance = resolveRoot(path)
|
|
1980
|
+
local parentInstance = resolveRoot(path, instanceIds)
|
|
1691
1981
|
applyClassIcon(icon, if parentInstance then parentInstance.ClassName else "Folder")
|
|
1692
1982
|
|
|
1693
1983
|
local name = Instance.new("TextButton")
|
|
@@ -1770,8 +2060,10 @@ local function createGroup(path, order)
|
|
|
1770
2060
|
|
|
1771
2061
|
local group = {
|
|
1772
2062
|
key = key,
|
|
1773
|
-
label = path[#path],
|
|
2063
|
+
label = if attention then TreeRoots.displayPath(path, instanceIds) else TreeRoots.displayName(path[#path], instanceIds and instanceIds[#path]),
|
|
2064
|
+
attention = attention,
|
|
1774
2065
|
path = table.clone(path),
|
|
2066
|
+
instanceIds = instanceIds,
|
|
1775
2067
|
section = section,
|
|
1776
2068
|
header = header,
|
|
1777
2069
|
disclosure = disclosure,
|
|
@@ -1786,12 +2078,14 @@ local function createGroup(path, order)
|
|
|
1786
2078
|
summary = "",
|
|
1787
2079
|
}
|
|
1788
2080
|
groups[key] = group
|
|
1789
|
-
setGroupExpanded(group, #path <= 2)
|
|
2081
|
+
setGroupExpanded(group, attention or #path <= 2)
|
|
1790
2082
|
disclosure.Activated:Connect(function()
|
|
1791
2083
|
setGroupExpanded(group, not group.expanded)
|
|
2084
|
+
applyTreeFilter()
|
|
1792
2085
|
end)
|
|
1793
2086
|
name.Activated:Connect(function()
|
|
1794
2087
|
setGroupExpanded(group, not group.expanded)
|
|
2088
|
+
applyTreeFilter()
|
|
1795
2089
|
end)
|
|
1796
2090
|
bulkPush.Activated:Connect(function()
|
|
1797
2091
|
if bulkRunning then
|
|
@@ -1799,7 +2093,8 @@ local function createGroup(path, order)
|
|
|
1799
2093
|
end
|
|
1800
2094
|
bulkRunning = true
|
|
1801
2095
|
for _, state in states do
|
|
1802
|
-
if
|
|
2096
|
+
if group.attention and state.statusKind ~= "issue" then continue end
|
|
2097
|
+
if pathStartsWith(group.path, state.manifest.studioPath, group.instanceIds, state.manifest.instanceIds) and not state.manifest.gitConflict then
|
|
1803
2098
|
upload(state, true)
|
|
1804
2099
|
end
|
|
1805
2100
|
end
|
|
@@ -1811,7 +2106,8 @@ local function createGroup(path, order)
|
|
|
1811
2106
|
end
|
|
1812
2107
|
bulkRunning = true
|
|
1813
2108
|
for _, state in states do
|
|
1814
|
-
if
|
|
2109
|
+
if group.attention and state.statusKind ~= "issue" then continue end
|
|
2110
|
+
if pathStartsWith(group.path, state.manifest.studioPath, group.instanceIds, state.manifest.instanceIds) and not state.manifest.gitConflict then
|
|
1815
2111
|
pull(state)
|
|
1816
2112
|
end
|
|
1817
2113
|
end
|
|
@@ -1822,6 +2118,7 @@ end
|
|
|
1822
2118
|
|
|
1823
2119
|
local function setRootExpanded(state, expanded)
|
|
1824
2120
|
state.expanded = expanded
|
|
2121
|
+
if not state.row then return end
|
|
1825
2122
|
state.row.BackgroundTransparency = 1
|
|
1826
2123
|
state.file.Visible = expanded
|
|
1827
2124
|
state.status.Visible = expanded
|
|
@@ -1830,30 +2127,30 @@ local function setRootExpanded(state, expanded)
|
|
|
1830
2127
|
end
|
|
1831
2128
|
|
|
1832
2129
|
updateGroupSummaries = function()
|
|
2130
|
+
local totals = {}
|
|
1833
2131
|
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
|
|
2132
|
+
totals[group] = { total = 0, synced = 0, issue = 0, paused = 0, pending = 0, issueColor = Color3.fromRGB(244, 194, 92) }
|
|
2133
|
+
end
|
|
2134
|
+
for _, state in states do
|
|
2135
|
+
if state.coveredByBoundary then continue end
|
|
2136
|
+
for depth = 1, #state.manifest.studioPath do
|
|
2137
|
+
local key = TreeRoots.pathKey(state.manifest.studioPath, state.manifest.instanceIds, depth)
|
|
2138
|
+
local group = groups[key]
|
|
2139
|
+
local counts = group and totals[group]
|
|
2140
|
+
if not counts then continue end
|
|
2141
|
+
counts.total += 1
|
|
2142
|
+
local kind = if state.statusKind == "synced" or state.statusKind == "issue" or state.statusKind == "paused" then state.statusKind else "pending"
|
|
2143
|
+
counts[kind] += 1
|
|
2144
|
+
if kind == "issue" and state.statusColor == Color3.fromRGB(245, 106, 106) then
|
|
2145
|
+
counts.issueColor = state.statusColor
|
|
1855
2146
|
end
|
|
1856
2147
|
end
|
|
2148
|
+
end
|
|
2149
|
+
for _, group in groups do
|
|
2150
|
+
local counts = totals[group]
|
|
2151
|
+
if group.attention then continue end
|
|
2152
|
+
local total, synced, issues = counts.total, counts.synced, counts.issue
|
|
2153
|
+
local pending, paused, issueColor = counts.pending, counts.paused, counts.issueColor
|
|
1857
2154
|
group.summary = string.format(" (%d/%d) synced", synced, total)
|
|
1858
2155
|
if issues > 0 then
|
|
1859
2156
|
group.summary ..= string.format(" · %d need attention", issues)
|
|
@@ -1882,7 +2179,7 @@ updateGroupSummaries = function()
|
|
|
1882
2179
|
else Color3.fromRGB(95, 95, 105)
|
|
1883
2180
|
group.bulkPush.TextColor3 = arrowColor
|
|
1884
2181
|
group.bulkPull.TextColor3 = arrowColor
|
|
1885
|
-
setGroupExpanded(group, group.expanded or (issues > 0 and autoExpandConflicts))
|
|
2182
|
+
setGroupExpanded(group, group.expanded or (issues > 0 and autoExpandConflicts and #serverConfig.roots <= 1000))
|
|
1886
2183
|
end
|
|
1887
2184
|
end
|
|
1888
2185
|
if refreshUnsyncedButton then
|
|
@@ -1896,7 +2193,7 @@ end
|
|
|
1896
2193
|
refreshUnsyncedButton = function()
|
|
1897
2194
|
local unsynced = 0
|
|
1898
2195
|
for _, state in states do
|
|
1899
|
-
if state.statusKind ~= "synced" then
|
|
2196
|
+
if not state.coveredByBoundary and state.statusKind ~= "synced" then
|
|
1900
2197
|
unsynced += 1
|
|
1901
2198
|
end
|
|
1902
2199
|
end
|
|
@@ -1912,20 +2209,25 @@ end
|
|
|
1912
2209
|
refreshStatusSummary = function()
|
|
1913
2210
|
local issues = {}
|
|
1914
2211
|
local working = {}
|
|
2212
|
+
for _, group in groups do if group.attention then group.section.Visible = false end end
|
|
1915
2213
|
for _, state in states do
|
|
2214
|
+
if state.coveredByBoundary then continue end
|
|
1916
2215
|
local entry = {
|
|
1917
|
-
path =
|
|
1918
|
-
status = state.
|
|
2216
|
+
path = TreeRoots.displayPath(state.manifest.studioPath, state.manifest.instanceIds),
|
|
2217
|
+
status = state.statusText,
|
|
1919
2218
|
state = state,
|
|
1920
2219
|
}
|
|
1921
2220
|
if state.statusKind == "issue" then
|
|
1922
2221
|
table.insert(issues, entry)
|
|
1923
2222
|
else
|
|
1924
|
-
if state.row
|
|
2223
|
+
if state.row and state.group.attention then
|
|
2224
|
+
local parentPath = table.clone(state.manifest.studioPath)
|
|
2225
|
+
table.remove(parentPath)
|
|
2226
|
+
state.group = createGroup(parentPath, state.treeOrder, state.manifest.instanceIds)
|
|
1925
2227
|
state.row.Parent = state.group.list
|
|
1926
2228
|
state.connector.Visible = true
|
|
1927
2229
|
state.row.LayoutOrder = state.treeOrder
|
|
1928
|
-
state.title.Text = state.manifest.studioPath[#state.manifest.studioPath]
|
|
2230
|
+
state.title.Text = TreeRoots.displayName(state.manifest.studioPath[#state.manifest.studioPath], state.manifest.instanceIds and state.manifest.instanceIds[#state.manifest.studioPath])
|
|
1929
2231
|
setRootExpanded(state, state.expandedBeforeAttention)
|
|
1930
2232
|
end
|
|
1931
2233
|
if state.statusKind == "working" then
|
|
@@ -1955,16 +2257,21 @@ refreshStatusSummary = function()
|
|
|
1955
2257
|
for index, entry in entries do
|
|
1956
2258
|
if #issues > 0 then
|
|
1957
2259
|
local state = entry.state
|
|
1958
|
-
if state.row
|
|
2260
|
+
if not state.row then continue end
|
|
2261
|
+
if not state.group.attention then
|
|
1959
2262
|
state.expandedBeforeAttention = state.expanded
|
|
1960
|
-
state.
|
|
2263
|
+
local parentPath = table.clone(state.manifest.studioPath)
|
|
2264
|
+
table.remove(parentPath)
|
|
2265
|
+
state.group = createGroup(parentPath, state.treeOrder, state.manifest.instanceIds, true)
|
|
2266
|
+
state.row.Parent = state.group.list
|
|
1961
2267
|
state.connector.Visible = false
|
|
1962
2268
|
setRootExpanded(state, false)
|
|
1963
2269
|
end
|
|
2270
|
+
state.group.section.Visible = true
|
|
1964
2271
|
state.row.LayoutOrder = index
|
|
1965
|
-
state.title.Text =
|
|
2272
|
+
state.title.Text = TreeRoots.displayName(state.manifest.studioPath[#state.manifest.studioPath], state.manifest.instanceIds and state.manifest.instanceIds[#state.manifest.studioPath])
|
|
1966
2273
|
else
|
|
1967
|
-
table.insert(lines, entry.path .. " — " .. entry.status)
|
|
2274
|
+
if index <= 10 then table.insert(lines, entry.path .. " — " .. entry.status) end
|
|
1968
2275
|
end
|
|
1969
2276
|
end
|
|
1970
2277
|
statusBanner.BackgroundColor3 = if #issues > 0
|
|
@@ -2089,6 +2396,7 @@ local function createInstanceNode(instance, parent, depth, order)
|
|
|
2089
2396
|
end
|
|
2090
2397
|
|
|
2091
2398
|
local function resetStateContents(state)
|
|
2399
|
+
if not state.row then return end
|
|
2092
2400
|
for _, child in state.contentsList:GetChildren() do
|
|
2093
2401
|
if child ~= state.contentsLayout then
|
|
2094
2402
|
child:Destroy()
|
|
@@ -2098,7 +2406,7 @@ local function resetStateContents(state)
|
|
|
2098
2406
|
state.contentsExpanded = false
|
|
2099
2407
|
state.contents.Visible = false
|
|
2100
2408
|
state.contentDisclosure.Text = "▸"
|
|
2101
|
-
local root = resolveRoot(state.manifest.studioPath)
|
|
2409
|
+
local root = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
|
|
2102
2410
|
state.contentDisclosure.Visible = visualDepth > 0 and root ~= nil and #root:GetChildren() > 0
|
|
2103
2411
|
end
|
|
2104
2412
|
|
|
@@ -2124,7 +2432,7 @@ local function toggleRootContents(state)
|
|
|
2124
2432
|
if not state.contentsExpanded then
|
|
2125
2433
|
return
|
|
2126
2434
|
end
|
|
2127
|
-
local root = resolveRoot(state.manifest.studioPath)
|
|
2435
|
+
local root = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
|
|
2128
2436
|
if not root then
|
|
2129
2437
|
return
|
|
2130
2438
|
end
|
|
@@ -2138,10 +2446,36 @@ local function toggleRootContents(state)
|
|
|
2138
2446
|
state.contentsLoaded = true
|
|
2139
2447
|
end
|
|
2140
2448
|
|
|
2141
|
-
local function
|
|
2449
|
+
local function createRootState(manifest, order)
|
|
2450
|
+
local started = os.clock()
|
|
2451
|
+
local savedHash = savedHashes[manifest.id]
|
|
2452
|
+
if startupProfile then startupProfile.settings += os.clock() - started end
|
|
2453
|
+
local state = {
|
|
2454
|
+
manifest = manifest,
|
|
2455
|
+
treeOrder = order,
|
|
2456
|
+
rootConnections = {},
|
|
2457
|
+
instanceConnections = {},
|
|
2458
|
+
dirty = false,
|
|
2459
|
+
generation = 0,
|
|
2460
|
+
suppress = false,
|
|
2461
|
+
conflict = false,
|
|
2462
|
+
uploading = false,
|
|
2463
|
+
pulling = false,
|
|
2464
|
+
deleting = false,
|
|
2465
|
+
expanded = false,
|
|
2466
|
+
statusText = "Checking...",
|
|
2467
|
+
statusColor = Color3.fromRGB(180, 180, 190),
|
|
2468
|
+
lastHash = if type(savedHash) == "string" then savedHash else nil,
|
|
2469
|
+
}
|
|
2470
|
+
states[manifest.id] = state
|
|
2471
|
+
return state
|
|
2472
|
+
end
|
|
2473
|
+
|
|
2474
|
+
local function createRootRow(state)
|
|
2475
|
+
local manifest, order = state.manifest, state.treeOrder
|
|
2142
2476
|
local parentPath = table.clone(manifest.studioPath)
|
|
2143
2477
|
table.remove(parentPath)
|
|
2144
|
-
local group = createGroup(parentPath, order)
|
|
2478
|
+
local group = createGroup(parentPath, order, manifest.instanceIds and table.move(manifest.instanceIds, 1, #parentPath, 1, {}), state.statusKind == "issue")
|
|
2145
2479
|
group.hasDirectRoots = true
|
|
2146
2480
|
group.bulkPush.Visible = true
|
|
2147
2481
|
group.bulkPull.Visible = true
|
|
@@ -2197,15 +2531,15 @@ local function createRootRow(manifest, order)
|
|
|
2197
2531
|
icon.Position = UDim2.new(0, 19, 0.5, -8)
|
|
2198
2532
|
icon.Size = UDim2.fromOffset(16, 16)
|
|
2199
2533
|
icon.Parent = titleLine
|
|
2200
|
-
local rootInstance = resolveRoot(manifest.studioPath)
|
|
2201
|
-
|
|
2534
|
+
local rootInstance = resolveRoot(manifest.studioPath, manifest.instanceIds)
|
|
2535
|
+
if rootInstance then applyClassIcon(icon, rootInstance.ClassName) end
|
|
2202
2536
|
|
|
2203
2537
|
local title = Instance.new("TextButton")
|
|
2204
2538
|
title.BackgroundTransparency = 1
|
|
2205
2539
|
title.Font = Enum.Font.BuilderSansBold
|
|
2206
2540
|
title.Position = UDim2.new(0, 39, 0, 0)
|
|
2207
2541
|
title.Size = UDim2.new(1, -109, 1, 0)
|
|
2208
|
-
title.Text = manifest.studioPath[#manifest.studioPath]
|
|
2542
|
+
title.Text = TreeRoots.displayName(manifest.studioPath[#manifest.studioPath], manifest.instanceIds and manifest.instanceIds[#manifest.studioPath])
|
|
2209
2543
|
title.TextColor3 = Color3.fromRGB(235, 235, 240)
|
|
2210
2544
|
title.TextSize = 13
|
|
2211
2545
|
title.TextXAlignment = Enum.TextXAlignment.Left
|
|
@@ -2217,7 +2551,7 @@ local function createRootRow(manifest, order)
|
|
|
2217
2551
|
dot.Position = UDim2.new(1, -70, 0, 0)
|
|
2218
2552
|
dot.Size = UDim2.new(0, 12, 1, 0)
|
|
2219
2553
|
dot.Text = "●"
|
|
2220
|
-
dot.TextColor3 =
|
|
2554
|
+
dot.TextColor3 = state.statusColor
|
|
2221
2555
|
dot.TextSize = 9
|
|
2222
2556
|
dot.Parent = titleLine
|
|
2223
2557
|
|
|
@@ -2280,8 +2614,8 @@ local function createRootRow(manifest, order)
|
|
|
2280
2614
|
status.Font = Enum.Font.BuilderSans
|
|
2281
2615
|
status.LayoutOrder = 3
|
|
2282
2616
|
status.Size = UDim2.new(1, 0, 0, 18)
|
|
2283
|
-
status.Text =
|
|
2284
|
-
status.TextColor3 =
|
|
2617
|
+
status.Text = state.statusText
|
|
2618
|
+
status.TextColor3 = state.statusColor
|
|
2285
2619
|
status.TextSize = 12
|
|
2286
2620
|
status.TextWrapped = true
|
|
2287
2621
|
status.TextXAlignment = Enum.TextXAlignment.Left
|
|
@@ -2372,18 +2706,14 @@ local function createRootRow(manifest, order)
|
|
|
2372
2706
|
contentsGuide.Size = UDim2.fromOffset(1, contentsLayout.AbsoluteContentSize.Y)
|
|
2373
2707
|
end)
|
|
2374
2708
|
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
) or plugin:GetSetting(SETTINGS_PREFIX .. serverConfig.projectId .. ":" .. manifest.id .. ":hash")
|
|
2378
|
-
local state = {
|
|
2379
|
-
manifest = manifest,
|
|
2709
|
+
state.rowFields = {}
|
|
2710
|
+
for key, value in {
|
|
2380
2711
|
group = group,
|
|
2381
2712
|
row = row,
|
|
2382
|
-
treeOrder = order,
|
|
2383
2713
|
connector = connector,
|
|
2384
2714
|
title = title,
|
|
2385
2715
|
icon = icon,
|
|
2386
|
-
iconClass =
|
|
2716
|
+
iconClass = rootInstance and rootInstance.ClassName,
|
|
2387
2717
|
contentDisclosure = contentDisclosure,
|
|
2388
2718
|
contents = contents,
|
|
2389
2719
|
contentsList = contentsList,
|
|
@@ -2403,21 +2733,10 @@ local function createRootRow(manifest, order)
|
|
|
2403
2733
|
pullButton = pullButton,
|
|
2404
2734
|
mergeButton = mergeButton,
|
|
2405
2735
|
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
|
|
2736
|
+
} do
|
|
2737
|
+
state[key] = value
|
|
2738
|
+
table.insert(state.rowFields, key)
|
|
2739
|
+
end
|
|
2421
2740
|
contentDisclosure.Activated:Connect(function()
|
|
2422
2741
|
toggleRootContents(state)
|
|
2423
2742
|
end)
|
|
@@ -2425,7 +2744,7 @@ local function createRootRow(manifest, order)
|
|
|
2425
2744
|
setRootExpanded(state, not state.expanded)
|
|
2426
2745
|
end)
|
|
2427
2746
|
title.Activated:Connect(function()
|
|
2428
|
-
local root = resolveRoot(state.manifest.studioPath)
|
|
2747
|
+
local root = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
|
|
2429
2748
|
if root then
|
|
2430
2749
|
Selection:Set({ root })
|
|
2431
2750
|
end
|
|
@@ -2454,42 +2773,73 @@ local function createRootRow(manifest, order)
|
|
|
2454
2773
|
inspectorWidget.Enabled = true
|
|
2455
2774
|
end
|
|
2456
2775
|
end)
|
|
2776
|
+
setRootExpanded(state, state.expanded)
|
|
2777
|
+
state.status.Text = state.statusText
|
|
2778
|
+
state.status.TextColor3 = state.statusColor
|
|
2779
|
+
state.contentDisclosure.Visible = visualDepth > 0 and rootInstance ~= nil and #rootInstance:GetChildren() > 0
|
|
2780
|
+
for _, button in { pushButton, pullButton, miniPush, miniPull } do
|
|
2781
|
+
button.Visible = not manifest.gitConflict
|
|
2782
|
+
button.Active = serverConfig.rojoConnected
|
|
2783
|
+
button.AutoButtonColor = serverConfig.rojoConnected
|
|
2784
|
+
end
|
|
2785
|
+
mergeButton.Visible = manifest.gitConflict == true
|
|
2457
2786
|
return state
|
|
2458
2787
|
end
|
|
2459
2788
|
|
|
2789
|
+
local function destroyRootRow(state)
|
|
2790
|
+
if not state.row then return end
|
|
2791
|
+
state.row:Destroy()
|
|
2792
|
+
for _, key in state.rowFields do state[key] = nil end
|
|
2793
|
+
state.rowFields = nil
|
|
2794
|
+
end
|
|
2795
|
+
|
|
2460
2796
|
local function reconcile(state, manifest)
|
|
2461
2797
|
state.manifest = manifest
|
|
2798
|
+
if state.coveredByBoundary then
|
|
2799
|
+
disconnectRoot(state)
|
|
2800
|
+
state.statusKind = "covered"
|
|
2801
|
+
return
|
|
2802
|
+
end
|
|
2803
|
+
if state.boundary then
|
|
2804
|
+
state.conflict = true
|
|
2805
|
+
setStatus(state, "Storage boundary changed. Push to convert the saved subtree.", Color3.fromRGB(244, 194, 92))
|
|
2806
|
+
return
|
|
2807
|
+
end
|
|
2462
2808
|
if manifest.gitConflict then
|
|
2463
2809
|
state.conflict = true
|
|
2464
|
-
state.
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2810
|
+
if state.row then
|
|
2811
|
+
state.pushButton.Visible = false
|
|
2812
|
+
state.pullButton.Visible = false
|
|
2813
|
+
state.miniPush.Visible = false
|
|
2814
|
+
state.miniPull.Visible = false
|
|
2815
|
+
state.mergeButton.Visible = true
|
|
2816
|
+
end
|
|
2469
2817
|
setStatus(state, "Git conflict — resolve in VS Code or open the optional merge workspace.", Color3.fromRGB(245, 106, 106))
|
|
2470
|
-
if autoExpandConflicts then
|
|
2818
|
+
if autoExpandConflicts and state.row then
|
|
2471
2819
|
setGroupExpanded(state.group, true)
|
|
2472
2820
|
setRootExpanded(state, true)
|
|
2473
2821
|
end
|
|
2474
2822
|
return
|
|
2475
2823
|
end
|
|
2476
|
-
state.
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2824
|
+
if state.row then
|
|
2825
|
+
state.pushButton.Visible = true
|
|
2826
|
+
state.pullButton.Visible = true
|
|
2827
|
+
state.miniPush.Visible = true
|
|
2828
|
+
state.miniPull.Visible = true
|
|
2829
|
+
state.mergeButton.Visible = false
|
|
2830
|
+
state.pushButton.Active = serverConfig.rojoConnected
|
|
2831
|
+
state.pullButton.Active = serverConfig.rojoConnected
|
|
2832
|
+
state.miniPush.Active = serverConfig.rojoConnected
|
|
2833
|
+
state.miniPull.Active = serverConfig.rojoConnected
|
|
2834
|
+
state.pushButton.AutoButtonColor = serverConfig.rojoConnected
|
|
2835
|
+
state.pullButton.AutoButtonColor = serverConfig.rojoConnected
|
|
2836
|
+
state.miniPush.AutoButtonColor = serverConfig.rojoConnected
|
|
2837
|
+
state.miniPull.AutoButtonColor = serverConfig.rojoConnected
|
|
2838
|
+
state.miniPush.TextColor3 = if serverConfig.rojoConnected
|
|
2839
|
+
then Color3.fromRGB(115, 182, 255)
|
|
2840
|
+
else Color3.fromRGB(95, 95, 105)
|
|
2841
|
+
state.miniPull.TextColor3 = state.miniPush.TextColor3
|
|
2842
|
+
end
|
|
2493
2843
|
if state.conflictWorkspace then
|
|
2494
2844
|
destroyConflictWorkspace(state)
|
|
2495
2845
|
end
|
|
@@ -2497,23 +2847,40 @@ local function reconcile(state, manifest)
|
|
|
2497
2847
|
setStatus(state, "Paused — Rojo offline; conflict viewer remains available.", Color3.fromRGB(244, 194, 92))
|
|
2498
2848
|
return
|
|
2499
2849
|
end
|
|
2500
|
-
local root = resolveRoot(manifest.studioPath)
|
|
2850
|
+
local root = resolveRoot(manifest.studioPath, manifest.instanceIds)
|
|
2501
2851
|
if state.contentRoot ~= root then
|
|
2502
2852
|
state.contentRoot = root
|
|
2503
2853
|
state.searchQuery = nil
|
|
2504
2854
|
resetStateContents(state)
|
|
2505
|
-
elseif root then
|
|
2855
|
+
elseif root and state.row then
|
|
2506
2856
|
state.contentDisclosure.Visible = visualDepth > 0 and #root:GetChildren() > 0
|
|
2507
2857
|
end
|
|
2508
|
-
if root and state.iconClass ~= root.ClassName then
|
|
2858
|
+
if root and state.row and state.iconClass ~= root.ClassName then
|
|
2509
2859
|
state.iconClass = root.ClassName
|
|
2510
2860
|
applyClassIcon(state.icon, root.ClassName)
|
|
2511
2861
|
end
|
|
2512
2862
|
if root and state.root ~= root then
|
|
2513
2863
|
watchRoot(state, root)
|
|
2514
2864
|
end
|
|
2865
|
+
if not root and manifest.treeRootId then
|
|
2866
|
+
if manifest.deleted then
|
|
2867
|
+
state.conflict = false
|
|
2868
|
+
setStatus(state, "Deleted", Color3.fromRGB(102, 214, 139))
|
|
2869
|
+
return
|
|
2870
|
+
end
|
|
2871
|
+
state.conflict = true
|
|
2872
|
+
setStatus(state, "Studio root is missing. Push to remove saved files; Pull to restore it.", Color3.fromRGB(245, 106, 106))
|
|
2873
|
+
return
|
|
2874
|
+
end
|
|
2515
2875
|
|
|
2516
2876
|
if not manifest.exists then
|
|
2877
|
+
if manifest.container and root and not TreeRoots.needsSnapshot(root) then
|
|
2878
|
+
forgetHash(state)
|
|
2879
|
+
state.conflict = false
|
|
2880
|
+
state.dirty = false
|
|
2881
|
+
setStatus(state, "Synced Folder", Color3.fromRGB(102, 214, 139))
|
|
2882
|
+
return
|
|
2883
|
+
end
|
|
2517
2884
|
if manifest.deleted and not root then
|
|
2518
2885
|
state.conflict = false
|
|
2519
2886
|
setStatus(state, "Deleted", Color3.fromRGB(102, 214, 139))
|
|
@@ -2535,6 +2902,12 @@ local function reconcile(state, manifest)
|
|
|
2535
2902
|
return
|
|
2536
2903
|
end
|
|
2537
2904
|
if not state.lastHash then
|
|
2905
|
+
if manifest.treeRootId then
|
|
2906
|
+
if not state.conflict then
|
|
2907
|
+
pull(state, true)
|
|
2908
|
+
end
|
|
2909
|
+
return
|
|
2910
|
+
end
|
|
2538
2911
|
state.conflict = true
|
|
2539
2912
|
setStatus(state, "First sync needs Push or Pull once.", Color3.fromRGB(244, 194, 92))
|
|
2540
2913
|
return
|
|
@@ -2562,15 +2935,32 @@ local function refreshSettingsLabels()
|
|
|
2562
2935
|
end
|
|
2563
2936
|
|
|
2564
2937
|
applyTreeFilter = function()
|
|
2938
|
+
if filteringTree then
|
|
2939
|
+
filterAgain = true
|
|
2940
|
+
return
|
|
2941
|
+
end
|
|
2942
|
+
filteringTree = true
|
|
2565
2943
|
local query = string.lower(searchBox.Text)
|
|
2944
|
+
local visibleGroups = {}
|
|
2945
|
+
local orderedStates = {}
|
|
2946
|
+
local parentsWithChildren = {}
|
|
2566
2947
|
for _, state in states do
|
|
2948
|
+
table.insert(orderedStates, state)
|
|
2949
|
+
local manifest = state.manifest
|
|
2950
|
+
parentsWithChildren[TreeRoots.pathKey(manifest.studioPath, manifest.instanceIds, #manifest.studioPath - 1)] = true
|
|
2951
|
+
end
|
|
2952
|
+
table.sort(orderedStates, function(left, right) return left.treeOrder < right.treeOrder end)
|
|
2953
|
+
local previousBatching = batchingStatusUpdates
|
|
2954
|
+
batchingStatusUpdates = true
|
|
2955
|
+
local sliceStarted = os.clock()
|
|
2956
|
+
for _, state in orderedStates do
|
|
2567
2957
|
if state.searchQuery ~= query then
|
|
2568
2958
|
local searchable = string.lower(
|
|
2569
|
-
table.concat(state.manifest.studioPath, ".") .. " " .. state.manifest.file .. " " .. state.
|
|
2959
|
+
table.concat(state.manifest.studioPath, ".") .. " " .. state.manifest.file .. " " .. state.statusText
|
|
2570
2960
|
)
|
|
2571
2961
|
local matches = query == "" or string.find(searchable, query, 1, true) ~= nil
|
|
2572
|
-
if not matches then
|
|
2573
|
-
local root = resolveRoot(state.manifest.studioPath)
|
|
2962
|
+
if not matches and not state.manifest.container then
|
|
2963
|
+
local root = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
|
|
2574
2964
|
if root then
|
|
2575
2965
|
for _, descendant in root:GetDescendants() do
|
|
2576
2966
|
if string.find(string.lower(descendant.Name .. " " .. descendant.ClassName), query, 1, true) then
|
|
@@ -2583,21 +2973,43 @@ applyTreeFilter = function()
|
|
|
2583
2973
|
state.searchQuery = query
|
|
2584
2974
|
state.searchMatches = matches
|
|
2585
2975
|
end
|
|
2586
|
-
state.
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2976
|
+
local visible = not state.coveredByBoundary and state.searchMatches and (showSynced or state.statusKind ~= "synced")
|
|
2977
|
+
local needsAttention = state.statusKind == "issue"
|
|
2978
|
+
local folderGroup = state.manifest.container and parentsWithChildren[TreeRoots.pathKey(state.manifest.studioPath, state.manifest.instanceIds)]
|
|
2979
|
+
if visible and not needsAttention then
|
|
2980
|
+
for depth = 1, #state.manifest.studioPath - (if folderGroup then 0 else 1) do
|
|
2981
|
+
local parentPath = table.move(state.manifest.studioPath, 1, depth, 1, {})
|
|
2982
|
+
local group = createGroup(parentPath, state.treeOrder, state.manifest.instanceIds and table.move(state.manifest.instanceIds, 1, depth, 1, {}))
|
|
2983
|
+
visibleGroups[group] = true
|
|
2984
|
+
if not group.expanded then
|
|
2985
|
+
visible = false
|
|
2986
|
+
break
|
|
2987
|
+
end
|
|
2594
2988
|
end
|
|
2989
|
+
if folderGroup then visible = false end
|
|
2595
2990
|
end
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2991
|
+
if visible and not state.row then
|
|
2992
|
+
createRootRow(state)
|
|
2993
|
+
elseif not visible and state.row and not state.conflictWorkspace then
|
|
2994
|
+
destroyRootRow(state)
|
|
2995
|
+
end
|
|
2996
|
+
if state.row then state.row.Visible = visible end
|
|
2997
|
+
if os.clock() - sliceStarted >= 0.004 then
|
|
2998
|
+
task.wait()
|
|
2999
|
+
sliceStarted = os.clock()
|
|
2599
3000
|
end
|
|
2600
3001
|
end
|
|
3002
|
+
updateGroupSummaries()
|
|
3003
|
+
for _, group in groups do
|
|
3004
|
+
if not group.attention then group.section.Visible = visibleGroups[group] == true end
|
|
3005
|
+
end
|
|
3006
|
+
batchingStatusUpdates = previousBatching
|
|
3007
|
+
refreshStatusSummary()
|
|
3008
|
+
filteringTree = false
|
|
3009
|
+
if filterAgain then
|
|
3010
|
+
filterAgain = false
|
|
3011
|
+
task.defer(applyTreeFilter)
|
|
3012
|
+
end
|
|
2601
3013
|
end
|
|
2602
3014
|
|
|
2603
3015
|
addButtonHover(settingsButton)
|
|
@@ -2667,12 +3079,16 @@ local function manifestChanged(previous, current)
|
|
|
2667
3079
|
or previous.conflictToken ~= current.conflictToken
|
|
2668
3080
|
or previous.file ~= current.file
|
|
2669
3081
|
or previous.splitRootId ~= current.splitRootId
|
|
2670
|
-
or
|
|
3082
|
+
or previous.treeRootId ~= current.treeRootId
|
|
3083
|
+
or previous.container ~= current.container
|
|
3084
|
+
or TreeRoots.pathKey(previous.studioPath, previous.instanceIds) ~= TreeRoots.pathKey(current.studioPath, current.instanceIds)
|
|
2671
3085
|
end
|
|
2672
3086
|
|
|
2673
3087
|
refreshConfig = function()
|
|
2674
|
-
local
|
|
2675
|
-
if
|
|
3088
|
+
local refreshStarted = os.clock()
|
|
3089
|
+
local response, requestError = request("GET", "/v1/config", nil, if configETag then { ["If-None-Match"] = configETag } else nil)
|
|
3090
|
+
if not response or (not response.Success and response.StatusCode ~= 304) then
|
|
3091
|
+
configETag = nil
|
|
2676
3092
|
local reason = requestError or (if response then responseError(response) else nil) or "connect Rojo to start syncing"
|
|
2677
3093
|
if serverConfig then
|
|
2678
3094
|
serverConfig.rojoConnected = false
|
|
@@ -2683,18 +3099,29 @@ refreshConfig = function()
|
|
|
2683
3099
|
return false
|
|
2684
3100
|
end
|
|
2685
3101
|
|
|
2686
|
-
local decoded = HttpService:JSONDecode(response.Body)
|
|
3102
|
+
local decoded = if response.StatusCode == 304 then serverConfig else HttpService:JSONDecode(response.Body)
|
|
3103
|
+
configETag = response.Headers.ETag or response.Headers.etag
|
|
2687
3104
|
local projectChanged = serverConfig
|
|
2688
3105
|
and (serverConfig.projectId ~= decoded.projectId or serverConfig.workspaceId ~= decoded.workspaceId)
|
|
2689
3106
|
local connectionChanged = serverConfig and serverConfig.rojoConnected ~= decoded.rojoConnected
|
|
3107
|
+
local hashesKey = SETTINGS_PREFIX .. decoded.projectId .. ":" .. decoded.workspaceId .. ":hashes"
|
|
3108
|
+
if savedHashesKey ~= hashesKey then
|
|
3109
|
+
saveHashes()
|
|
3110
|
+
local settingsStarted = os.clock()
|
|
3111
|
+
local stored = plugin:GetSetting(hashesKey)
|
|
3112
|
+
if startupProfile then startupProfile.settings += os.clock() - settingsStarted end
|
|
3113
|
+
savedHashes = if type(stored) == "table" then stored else {}
|
|
3114
|
+
savedHashesKey = hashesKey
|
|
3115
|
+
end
|
|
2690
3116
|
if projectChanged then
|
|
2691
3117
|
for _, state in states do
|
|
2692
3118
|
disconnectRoot(state)
|
|
2693
3119
|
destroyConflictWorkspace(state)
|
|
2694
|
-
state
|
|
3120
|
+
destroyRootRow(state)
|
|
2695
3121
|
end
|
|
2696
3122
|
disconnectSplitRoots()
|
|
2697
3123
|
table.clear(states)
|
|
3124
|
+
table.clear(treeDiscovery)
|
|
2698
3125
|
for _, child in rootsContainer:GetChildren() do
|
|
2699
3126
|
if child ~= rootsLayout then
|
|
2700
3127
|
child:Destroy()
|
|
@@ -2711,6 +3138,49 @@ refreshConfig = function()
|
|
|
2711
3138
|
connectionStatus.TextColor3 = Color3.fromRGB(244, 194, 92)
|
|
2712
3139
|
end
|
|
2713
3140
|
connectionDot.TextColor3 = connectionStatus.TextColor3
|
|
3141
|
+
if decoded.rojoConnected then
|
|
3142
|
+
for _, tree in decoded.treeRoots or {} do
|
|
3143
|
+
local root = resolveRoot(tree.studioPath)
|
|
3144
|
+
if not root then
|
|
3145
|
+
continue
|
|
3146
|
+
end
|
|
3147
|
+
local discovery = treeDiscovery[tree.id]
|
|
3148
|
+
if not discovery or discovery.root ~= root then
|
|
3149
|
+
discovery = { root = root, dirty = true, nextScan = 0 }
|
|
3150
|
+
treeDiscovery[tree.id] = discovery
|
|
3151
|
+
end
|
|
3152
|
+
if not discovery.dirty and os.clock() < discovery.nextScan then
|
|
3153
|
+
continue
|
|
3154
|
+
end
|
|
3155
|
+
discovery.dirty = false
|
|
3156
|
+
local instancesById = {}
|
|
3157
|
+
for id, state in states do instancesById[id] = state.root end
|
|
3158
|
+
local discovered, entries = pcall(TreeRoots.discover, tree, root, decoded.roots, decoded.maxPathDepth, instancesById)
|
|
3159
|
+
if not discovered then
|
|
3160
|
+
discovery.dirty = true
|
|
3161
|
+
connectionStatus.Text = "Tree discovery paused: " .. tostring(entries)
|
|
3162
|
+
return false
|
|
3163
|
+
end
|
|
3164
|
+
discovery.boundaries = {}
|
|
3165
|
+
local additions = {}
|
|
3166
|
+
for _, entry in entries do
|
|
3167
|
+
if entry.boundaryId then discovery.boundaries[entry.boundaryId] = true else table.insert(additions, entry) end
|
|
3168
|
+
end
|
|
3169
|
+
entries = additions
|
|
3170
|
+
for first = 1, #entries, 1000 do
|
|
3171
|
+
local batch = table.move(entries, first, math.min(first + 999, #entries), 1, {})
|
|
3172
|
+
local discoveryResponse, discoveryError = request("POST", "/v1/trees/" .. tree.id .. "/discover", HttpService:JSONEncode({ entries = batch }), {
|
|
3173
|
+
["Content-Type"] = "application/json",
|
|
3174
|
+
})
|
|
3175
|
+
if not discoveryResponse or not discoveryResponse.Success then
|
|
3176
|
+
discovery.dirty = true
|
|
3177
|
+
connectionStatus.Text = "Tree discovery paused: " .. (discoveryError or responseError(discoveryResponse))
|
|
3178
|
+
return false
|
|
3179
|
+
end
|
|
3180
|
+
end
|
|
3181
|
+
discovery.nextScan = os.clock() + 30
|
|
3182
|
+
end
|
|
3183
|
+
end
|
|
2714
3184
|
|
|
2715
3185
|
local currentSplitRootIds = {}
|
|
2716
3186
|
for _, splitRoot in decoded.splitRoots do
|
|
@@ -2727,25 +3197,77 @@ refreshConfig = function()
|
|
|
2727
3197
|
disconnectSplitRoot(id)
|
|
2728
3198
|
end
|
|
2729
3199
|
|
|
3200
|
+
local boundaryPaths = {}
|
|
3201
|
+
for _, manifest in decoded.roots do
|
|
3202
|
+
local discovery = treeDiscovery[manifest.treeRootId]
|
|
3203
|
+
if discovery and discovery.boundaries and discovery.boundaries[manifest.id] then boundaryPaths[manifest.id] = manifest end
|
|
3204
|
+
end
|
|
3205
|
+
local affectedBoundaries = {}
|
|
3206
|
+
for _, manifest in decoded.roots do
|
|
3207
|
+
for id, boundary in boundaryPaths do
|
|
3208
|
+
if pathStartsWith(boundary.studioPath, manifest.studioPath, boundary.instanceIds, manifest.instanceIds) then affectedBoundaries[manifest.id] = id break end
|
|
3209
|
+
end
|
|
3210
|
+
end
|
|
2730
3211
|
local treeChanged = projectChanged == true or connectionChanged == true
|
|
2731
3212
|
batchingStatusUpdates = true
|
|
3213
|
+
local sliceStarted = os.clock()
|
|
2732
3214
|
for order, manifest in decoded.roots do
|
|
3215
|
+
if decoded.version == 2 and (order - 1) % 50 == 0 then
|
|
3216
|
+
table.clear(snapshotBatch)
|
|
3217
|
+
local ids = {}
|
|
3218
|
+
for index = order, math.min(order + 49, #decoded.roots) do
|
|
3219
|
+
local candidate = decoded.roots[index]
|
|
3220
|
+
local previous = states[candidate.id]
|
|
3221
|
+
local knownHash = if previous then previous.lastHash else savedHashes[candidate.id]
|
|
3222
|
+
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
|
|
3223
|
+
table.insert(ids, candidate.id)
|
|
3224
|
+
end
|
|
3225
|
+
end
|
|
3226
|
+
if #ids > 0 and decoded.rojoConnected then
|
|
3227
|
+
local downloadStarted = os.clock()
|
|
3228
|
+
local response = request("POST", "/v1/snapshots", HttpService:JSONEncode(ids), { ["Content-Type"] = "application/json" })
|
|
3229
|
+
if startupProfile then startupProfile.downloads += os.clock() - downloadStarted end
|
|
3230
|
+
if response and response.Success then
|
|
3231
|
+
local bytes = buffer.fromstring(response.Body)
|
|
3232
|
+
local offset = 0
|
|
3233
|
+
while offset < buffer.len(bytes) do
|
|
3234
|
+
local length = buffer.readu32(bytes, offset)
|
|
3235
|
+
offset += 4
|
|
3236
|
+
local header = HttpService:JSONDecode(buffer.readstring(bytes, offset, length))
|
|
3237
|
+
offset += length
|
|
3238
|
+
snapshotBatch[header.id] = { Success = true, Body = buffer.readstring(bytes, offset, header.size), Headers = { ETag = '"' .. header.hash .. '"' } }
|
|
3239
|
+
offset += header.size
|
|
3240
|
+
end
|
|
3241
|
+
end
|
|
3242
|
+
end
|
|
3243
|
+
end
|
|
2733
3244
|
local state = states[manifest.id]
|
|
2734
3245
|
local created = state == nil
|
|
2735
3246
|
if created then
|
|
2736
|
-
state =
|
|
3247
|
+
state = createRootState(manifest, order)
|
|
2737
3248
|
end
|
|
2738
|
-
local
|
|
3249
|
+
local owner = affectedBoundaries[manifest.id]
|
|
3250
|
+
local boundaryChanged = state.boundary ~= (owner == manifest.id) or state.coveredByBoundary ~= (if owner ~= manifest.id then owner else nil)
|
|
3251
|
+
state.boundary = owner == manifest.id
|
|
3252
|
+
state.coveredByBoundary = if owner ~= manifest.id then owner else nil
|
|
3253
|
+
local resolvedRoot = resolveRoot(manifest.studioPath, manifest.instanceIds)
|
|
2739
3254
|
local rootChanged = state.observedRoot ~= resolvedRoot
|
|
2740
3255
|
state.observedRoot = resolvedRoot
|
|
2741
|
-
if created or connectionChanged or rootChanged or manifestChanged(state.manifest, manifest) then
|
|
3256
|
+
if created or connectionChanged or rootChanged or boundaryChanged or manifestChanged(state.manifest, manifest) then
|
|
3257
|
+
local reconcileStarted = os.clock()
|
|
2742
3258
|
reconcile(state, manifest)
|
|
3259
|
+
if startupProfile then startupProfile.reconcile += os.clock() - reconcileStarted end
|
|
2743
3260
|
treeChanged = true
|
|
2744
3261
|
else
|
|
2745
3262
|
state.manifest = manifest
|
|
2746
3263
|
end
|
|
3264
|
+
if os.clock() - sliceStarted >= 0.004 then
|
|
3265
|
+
task.wait()
|
|
3266
|
+
sliceStarted = os.clock()
|
|
3267
|
+
end
|
|
2747
3268
|
end
|
|
2748
3269
|
local currentIds = {}
|
|
3270
|
+
table.clear(snapshotBatch)
|
|
2749
3271
|
for _, manifest in decoded.roots do
|
|
2750
3272
|
currentIds[manifest.id] = true
|
|
2751
3273
|
end
|
|
@@ -2759,7 +3281,7 @@ refreshConfig = function()
|
|
|
2759
3281
|
local state = states[id]
|
|
2760
3282
|
disconnectRoot(state)
|
|
2761
3283
|
destroyConflictWorkspace(state)
|
|
2762
|
-
state
|
|
3284
|
+
destroyRootRow(state)
|
|
2763
3285
|
states[id] = nil
|
|
2764
3286
|
treeChanged = true
|
|
2765
3287
|
end
|
|
@@ -2768,6 +3290,11 @@ refreshConfig = function()
|
|
|
2768
3290
|
updateGroupSummaries()
|
|
2769
3291
|
applyTreeFilter()
|
|
2770
3292
|
end
|
|
3293
|
+
saveHashes()
|
|
3294
|
+
if startupProfile and decoded.rojoConnected and #decoded.roots > 0 then
|
|
3295
|
+
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))
|
|
3296
|
+
startupProfile = nil
|
|
3297
|
+
end
|
|
2771
3298
|
return true
|
|
2772
3299
|
end
|
|
2773
3300
|
|
|
@@ -2783,6 +3310,7 @@ end)
|
|
|
2783
3310
|
|
|
2784
3311
|
plugin.Unloading:Connect(function()
|
|
2785
3312
|
running = false
|
|
3313
|
+
saveHashes()
|
|
2786
3314
|
disconnectSplitRoots()
|
|
2787
3315
|
for _, state in states do
|
|
2788
3316
|
disconnectRoot(state)
|
|
@@ -2796,6 +3324,290 @@ task.spawn(function()
|
|
|
2796
3324
|
task.wait((serverConfig and serverConfig.pollIntervalMs or 750) / 1000)
|
|
2797
3325
|
end
|
|
2798
3326
|
end)
|
|
3327
|
+
]]></string>
|
|
3328
|
+
</Properties>
|
|
3329
|
+
</Item>
|
|
3330
|
+
<Item class="ModuleScript" referent="2">
|
|
3331
|
+
<Properties>
|
|
3332
|
+
<string name="Name">TreeRoots</string>
|
|
3333
|
+
<string name="Source"><![CDATA[local TreeRoots = {}
|
|
3334
|
+
|
|
3335
|
+
local function getInstanceId(instance)
|
|
3336
|
+
local id = instance:GetAttribute("GoldSyncId")
|
|
3337
|
+
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
|
|
3338
|
+
error("Invalid GoldSyncId on " .. instance:GetFullName())
|
|
3339
|
+
end
|
|
3340
|
+
return id or ""
|
|
3341
|
+
end
|
|
3342
|
+
|
|
3343
|
+
function TreeRoots.pathKey(path, instanceIds, depth)
|
|
3344
|
+
local segments = {}
|
|
3345
|
+
for index = 1, depth or #path do
|
|
3346
|
+
local name = path[index]
|
|
3347
|
+
segments[index] = #name .. ":" .. name .. ":" .. (instanceIds and instanceIds[index] or "")
|
|
3348
|
+
end
|
|
3349
|
+
return table.concat(segments, "/")
|
|
3350
|
+
end
|
|
3351
|
+
|
|
3352
|
+
function TreeRoots.displayName(name, id)
|
|
3353
|
+
return if id and id ~= "" then name .. " (" .. id .. ")" else name
|
|
3354
|
+
end
|
|
3355
|
+
|
|
3356
|
+
function TreeRoots.displayPath(path, instanceIds)
|
|
3357
|
+
local names = {}
|
|
3358
|
+
for index, name in path do
|
|
3359
|
+
names[index] = TreeRoots.displayName(name, instanceIds and instanceIds[index])
|
|
3360
|
+
end
|
|
3361
|
+
return table.concat(names, ".")
|
|
3362
|
+
end
|
|
3363
|
+
|
|
3364
|
+
function TreeRoots.findChild(parent, name, id)
|
|
3365
|
+
local first = parent:FindFirstChild(name)
|
|
3366
|
+
if not first or (first:GetAttribute("GoldSyncId") or "") == id then return first end
|
|
3367
|
+
for _, child in parent:GetChildren() do
|
|
3368
|
+
if child.Name == name and (child:GetAttribute("GoldSyncId") or "") == id then return child end
|
|
3369
|
+
end
|
|
3370
|
+
return nil
|
|
3371
|
+
end
|
|
3372
|
+
|
|
3373
|
+
function TreeRoots.isContainer(instance, isRoot)
|
|
3374
|
+
return isRoot or instance.ClassName == "Folder"
|
|
3375
|
+
end
|
|
3376
|
+
|
|
3377
|
+
function TreeRoots.needsSnapshot(instance)
|
|
3378
|
+
return instance.ClassName ~= "Folder" or not instance.Archivable
|
|
3379
|
+
or next(instance:GetAttributes()) ~= nil or #instance:GetTags() > 0
|
|
3380
|
+
or #instance:GetChildren() == 0
|
|
3381
|
+
end
|
|
3382
|
+
|
|
3383
|
+
function TreeRoots.discover(tree, root, manifests, maxDepth, instancesById)
|
|
3384
|
+
local known = {}
|
|
3385
|
+
local reservedNumbers = {}
|
|
3386
|
+
for _, manifest in manifests do
|
|
3387
|
+
if manifest.treeRootId == tree.id then
|
|
3388
|
+
known[TreeRoots.pathKey(manifest.studioPath, manifest.instanceIds)] = manifest
|
|
3389
|
+
for depth = #tree.studioPath + 1, #manifest.studioPath do
|
|
3390
|
+
local id = manifest.instanceIds and manifest.instanceIds[depth] or ""
|
|
3391
|
+
if #id > 9 or not string.match(id, "^[1-9]%d*$") then continue end
|
|
3392
|
+
local parentKey = TreeRoots.pathKey(manifest.studioPath, manifest.instanceIds, depth - 1)
|
|
3393
|
+
local numbers = reservedNumbers[parentKey] or {}
|
|
3394
|
+
reservedNumbers[parentKey] = numbers
|
|
3395
|
+
local name = string.lower(manifest.studioPath[depth])
|
|
3396
|
+
numbers[name] = math.max(numbers[name] or 1, tonumber(id) + 1)
|
|
3397
|
+
end
|
|
3398
|
+
end
|
|
3399
|
+
end
|
|
3400
|
+
local entries = {}
|
|
3401
|
+
local function visit(instance, relativePath, relativeIds)
|
|
3402
|
+
assert(#tree.studioPath + #relativePath <= maxDepth, "Tree exceeds maxPathDepth")
|
|
3403
|
+
local fullPath = table.clone(tree.studioPath)
|
|
3404
|
+
local fullIds = table.create(#tree.studioPath, "")
|
|
3405
|
+
for _, segment in relativePath do
|
|
3406
|
+
table.insert(fullPath, segment)
|
|
3407
|
+
end
|
|
3408
|
+
for _, id in relativeIds do table.insert(fullIds, id) end
|
|
3409
|
+
local existing = known[TreeRoots.pathKey(fullPath, fullIds)]
|
|
3410
|
+
local container = TreeRoots.isContainer(instance, #relativePath == 0)
|
|
3411
|
+
if existing and existing.container ~= container then
|
|
3412
|
+
table.insert(entries, { path = relativePath, instanceIds = relativeIds, container = container, boundaryId = existing.id })
|
|
3413
|
+
return
|
|
3414
|
+
end
|
|
3415
|
+
if not existing then
|
|
3416
|
+
table.insert(entries, { path = relativePath, instanceIds = relativeIds, container = container })
|
|
3417
|
+
end
|
|
3418
|
+
if not container then
|
|
3419
|
+
return
|
|
3420
|
+
end
|
|
3421
|
+
local children = instance:GetChildren()
|
|
3422
|
+
local owners = {}
|
|
3423
|
+
local nextNumbers = table.clone(reservedNumbers[TreeRoots.pathKey(fullPath, fullIds)] or {})
|
|
3424
|
+
for _, child in children do
|
|
3425
|
+
local id = getInstanceId(child)
|
|
3426
|
+
local name = string.lower(child.Name)
|
|
3427
|
+
local number = if #id <= 9 and string.match(id, "^[1-9]%d*$") then tonumber(id) else nil
|
|
3428
|
+
nextNumbers[name] = math.max(nextNumbers[name] or 1, if number then number + 1 else 1)
|
|
3429
|
+
local key = string.lower(child.Name) .. "\0" .. id
|
|
3430
|
+
local childPath, childIds = table.clone(fullPath), table.clone(fullIds)
|
|
3431
|
+
table.insert(childPath, child.Name)
|
|
3432
|
+
table.insert(childIds, id)
|
|
3433
|
+
local manifest = known[TreeRoots.pathKey(childPath, childIds)]
|
|
3434
|
+
if not owners[key] or (manifest and instancesById and instancesById[manifest.id] == child) then
|
|
3435
|
+
owners[key] = child
|
|
3436
|
+
end
|
|
3437
|
+
end
|
|
3438
|
+
for _, child in children do
|
|
3439
|
+
local id = getInstanceId(child)
|
|
3440
|
+
if owners[string.lower(child.Name) .. "\0" .. id] ~= child then
|
|
3441
|
+
local name = string.lower(child.Name)
|
|
3442
|
+
assert(nextNumbers[name] <= 999999999, "Duplicate number limit reached: " .. child:GetFullName())
|
|
3443
|
+
id = tostring(nextNumbers[name])
|
|
3444
|
+
nextNumbers[name] += 1
|
|
3445
|
+
child:SetAttribute("GoldSyncId", id)
|
|
3446
|
+
end
|
|
3447
|
+
local childPath = table.clone(relativePath)
|
|
3448
|
+
local childIds = table.clone(relativeIds)
|
|
3449
|
+
table.insert(childPath, child.Name)
|
|
3450
|
+
table.insert(childIds, id)
|
|
3451
|
+
visit(child, childPath, childIds)
|
|
3452
|
+
end
|
|
3453
|
+
end
|
|
3454
|
+
visit(root, {}, {})
|
|
3455
|
+
return entries
|
|
3456
|
+
end
|
|
3457
|
+
|
|
3458
|
+
local function propertiesEqual(left, right, propertyNames, counterparts)
|
|
3459
|
+
local leftAttributes, rightAttributes = left:GetAttributes(), right:GetAttributes()
|
|
3460
|
+
for key, value in leftAttributes do
|
|
3461
|
+
if rightAttributes[key] ~= value then
|
|
3462
|
+
return false
|
|
3463
|
+
end
|
|
3464
|
+
end
|
|
3465
|
+
for key in rightAttributes do
|
|
3466
|
+
if leftAttributes[key] == nil then
|
|
3467
|
+
return false
|
|
3468
|
+
end
|
|
3469
|
+
end
|
|
3470
|
+
if #left:GetTags() ~= #right:GetTags() then
|
|
3471
|
+
return false
|
|
3472
|
+
end
|
|
3473
|
+
for _, tag in left:GetTags() do
|
|
3474
|
+
if not right:HasTag(tag) then
|
|
3475
|
+
return false
|
|
3476
|
+
end
|
|
3477
|
+
end
|
|
3478
|
+
for _, name in propertyNames(left.ClassName) do
|
|
3479
|
+
if name == "Parent" then
|
|
3480
|
+
continue
|
|
3481
|
+
end
|
|
3482
|
+
local leftReadable, leftValue = pcall(function() return left[name] end)
|
|
3483
|
+
local rightReadable, rightValue = pcall(function() return right[name] end)
|
|
3484
|
+
if leftReadable ~= rightReadable then
|
|
3485
|
+
return false
|
|
3486
|
+
end
|
|
3487
|
+
if not leftReadable then
|
|
3488
|
+
continue
|
|
3489
|
+
end
|
|
3490
|
+
if typeof(leftValue) == "Instance" or typeof(rightValue) == "Instance" then
|
|
3491
|
+
if not counterparts then continue end
|
|
3492
|
+
leftValue = counterparts[leftValue] or leftValue
|
|
3493
|
+
elseif typeof(leftValue) == "buffer" and typeof(rightValue) == "buffer" then
|
|
3494
|
+
leftValue, rightValue = buffer.tostring(leftValue), buffer.tostring(rightValue)
|
|
3495
|
+
end
|
|
3496
|
+
if leftValue ~= rightValue then
|
|
3497
|
+
if typeof(leftValue) == "CFrame" and typeof(rightValue) == "CFrame" and leftValue.Position == rightValue.Position then
|
|
3498
|
+
local leftComponents = { leftValue:GetComponents() }
|
|
3499
|
+
local rightComponents = { rightValue:GetComponents() }
|
|
3500
|
+
for index = 4, 12 do
|
|
3501
|
+
if not (math.abs(leftComponents[index] - rightComponents[index]) <= 2 ^ -23) then return false end
|
|
3502
|
+
end
|
|
3503
|
+
continue
|
|
3504
|
+
end
|
|
3505
|
+
return false
|
|
3506
|
+
end
|
|
3507
|
+
end
|
|
3508
|
+
return true
|
|
3509
|
+
end
|
|
3510
|
+
|
|
3511
|
+
function TreeRoots.equal(first, second, propertyNames, container)
|
|
3512
|
+
local pairsToCompare = {}
|
|
3513
|
+
local counterparts = {}
|
|
3514
|
+
local function pair(left, right)
|
|
3515
|
+
if left.ClassName ~= right.ClassName or left.Name ~= right.Name then return false end
|
|
3516
|
+
if not propertiesEqual(left, right, propertyNames) then return false end
|
|
3517
|
+
counterparts[left] = right
|
|
3518
|
+
table.insert(pairsToCompare, { left, right })
|
|
3519
|
+
if container then return true end
|
|
3520
|
+
local childrenByName = {}
|
|
3521
|
+
local remaining = 0
|
|
3522
|
+
for _, child in right:GetChildren() do
|
|
3523
|
+
if child.ClassName == "TouchTransmitter" then continue end
|
|
3524
|
+
remaining += 1
|
|
3525
|
+
local key = child.ClassName .. "\0" .. child.Name
|
|
3526
|
+
local bucket = childrenByName[key] or {}
|
|
3527
|
+
childrenByName[key] = bucket
|
|
3528
|
+
table.insert(bucket, child)
|
|
3529
|
+
end
|
|
3530
|
+
for _, child in left:GetChildren() do
|
|
3531
|
+
if child.ClassName == "TouchTransmitter" then continue end
|
|
3532
|
+
local bucket = childrenByName[child.ClassName .. "\0" .. child.Name]
|
|
3533
|
+
if not bucket then return false end
|
|
3534
|
+
local matched = false
|
|
3535
|
+
for index, candidate in bucket do
|
|
3536
|
+
local previousCount = #pairsToCompare
|
|
3537
|
+
if pair(child, candidate) then
|
|
3538
|
+
table.remove(bucket, index)
|
|
3539
|
+
remaining -= 1
|
|
3540
|
+
matched = true
|
|
3541
|
+
break
|
|
3542
|
+
end
|
|
3543
|
+
for rollback = #pairsToCompare, previousCount + 1, -1 do
|
|
3544
|
+
counterparts[pairsToCompare[rollback][1]] = nil
|
|
3545
|
+
pairsToCompare[rollback] = nil
|
|
3546
|
+
end
|
|
3547
|
+
end
|
|
3548
|
+
if not matched then return false end
|
|
3549
|
+
end
|
|
3550
|
+
return remaining == 0
|
|
3551
|
+
end
|
|
3552
|
+
if not pair(first, second) then return false end
|
|
3553
|
+
for _, instances in pairsToCompare do
|
|
3554
|
+
if not propertiesEqual(instances[1], instances[2], propertyNames, counterparts) then return false end
|
|
3555
|
+
end
|
|
3556
|
+
return true
|
|
3557
|
+
end
|
|
3558
|
+
|
|
3559
|
+
local function copyMetadata(target, snapshot, propertyNames)
|
|
3560
|
+
local previous = {}
|
|
3561
|
+
local applied, failure = pcall(function()
|
|
3562
|
+
for _, name in propertyNames(target.ClassName) do
|
|
3563
|
+
if name == "Parent" or name == "Name" then
|
|
3564
|
+
continue
|
|
3565
|
+
end
|
|
3566
|
+
local readable, value, original = pcall(function() return snapshot[name], target[name] end)
|
|
3567
|
+
if readable and value ~= original then
|
|
3568
|
+
previous[name] = { value = original }
|
|
3569
|
+
target[name] = value
|
|
3570
|
+
end
|
|
3571
|
+
end
|
|
3572
|
+
end)
|
|
3573
|
+
if not applied then
|
|
3574
|
+
for name, original in previous do
|
|
3575
|
+
pcall(function() target[name] = original.value end)
|
|
3576
|
+
end
|
|
3577
|
+
error(failure)
|
|
3578
|
+
end
|
|
3579
|
+
for name in target:GetAttributes() do
|
|
3580
|
+
target:SetAttribute(name, nil)
|
|
3581
|
+
end
|
|
3582
|
+
for name, value in snapshot:GetAttributes() do
|
|
3583
|
+
target:SetAttribute(name, value)
|
|
3584
|
+
end
|
|
3585
|
+
for _, tag in target:GetTags() do
|
|
3586
|
+
target:RemoveTag(tag)
|
|
3587
|
+
end
|
|
3588
|
+
for _, tag in snapshot:GetTags() do
|
|
3589
|
+
target:AddTag(tag)
|
|
3590
|
+
end
|
|
3591
|
+
end
|
|
3592
|
+
|
|
3593
|
+
function TreeRoots.snapshot(root, propertyNames)
|
|
3594
|
+
local snapshot = Instance.new(root.ClassName)
|
|
3595
|
+
snapshot.Name = root.Name
|
|
3596
|
+
local success, failure = pcall(copyMetadata, snapshot, root, propertyNames)
|
|
3597
|
+
if not success then
|
|
3598
|
+
snapshot:Destroy()
|
|
3599
|
+
error(failure)
|
|
3600
|
+
end
|
|
3601
|
+
return snapshot
|
|
3602
|
+
end
|
|
3603
|
+
|
|
3604
|
+
function TreeRoots.applyMetadata(target, snapshot, propertyNames)
|
|
3605
|
+
assert(target.ClassName == snapshot.ClassName, "Container class changed; resolve manually to preserve children")
|
|
3606
|
+
assert(#snapshot:GetChildren() == 0, "Container snapshot must not contain children")
|
|
3607
|
+
copyMetadata(target, snapshot, propertyNames)
|
|
3608
|
+
end
|
|
3609
|
+
|
|
3610
|
+
return TreeRoots
|
|
2799
3611
|
]]></string>
|
|
2800
3612
|
</Properties>
|
|
2801
3613
|
</Item>
|