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.
@@ -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.16"
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
- for _, state in affected do
424
- if states[state.manifest.id] ~= state or state.manifest.gitConflict then
425
- continue
426
- end
427
- if index == 1 then
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.status.Text == text and state.status.TextColor3 == statusColor and state.statusKind == statusKind then
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.status.Text = text
751
- state.status.TextColor3 = statusColor
752
- state.dot.TextColor3 = statusColor
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 = table.concat(state.manifest.studioPath, ".")
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
- plugin:SetSetting(
1170
- SETTINGS_PREFIX .. serverConfig.projectId .. ":" .. serverConfig.workspaceId .. ":" .. state.manifest.id .. ":hash",
1171
- hash
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
- plugin:SetSetting(
1178
- SETTINGS_PREFIX .. serverConfig.projectId .. ":" .. serverConfig.workspaceId .. ":" .. state.manifest.id .. ":hash",
1179
- nil
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
- local scriptInstance = findCode(root)
1254
- if scriptInstance then
1255
- state.conflict = true
1256
- setStatus(state, "Blocked: contains code at " .. scriptInstance:GetFullName(), Color3.fromRGB(245, 106, 106))
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 SerializationService:SerializeInstancesAsync({ root })
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, requestError = request("GET", "/v1/roots/" .. state.manifest.id .. "/snapshot")
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
- local scriptInstance = findCode(replacement)
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
- if scriptInstance then
1383
- setStatus(state, "Blocked: file snapshot contains code.", Color3.fromRGB(245, 106, 106))
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, "Blocked: root must be named " .. expectedName, Color3.fromRGB(245, 106, 106))
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
- replacement.Parent = parent
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.mergeButton.Text = "Open Merge Workspace"
1470
- state.mergeButton.Size = UDim2.fromScale(1, 1)
1471
- state.diffButton.Visible = false
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 resultRoot.Name ~= expectedName or findCode(resultRoot) then
1578
- setStatus(state, "Result must be a code-free root named " .. expectedName .. ".", Color3.fromRGB(245, 106, 106))
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
- local function pathStartsWith(prefix, fullPath)
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 = table.concat(path, "\0")
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 pathStartsWith(group.path, state.manifest.studioPath) and not state.manifest.gitConflict then
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 pathStartsWith(group.path, state.manifest.studioPath) and not state.manifest.gitConflict then
1815
- pull(state)
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
- local total = 0
1835
- local synced = 0
1836
- local issues = 0
1837
- local issueColor = Color3.fromRGB(244, 194, 92)
1838
- local pending = 0
1839
- local paused = 0
1840
- for _, state in states do
1841
- if pathStartsWith(group.path, state.manifest.studioPath) then
1842
- total += 1
1843
- if state.statusKind == "synced" then
1844
- synced += 1
1845
- elseif state.statusKind == "issue" then
1846
- issues += 1
1847
- if state.status.TextColor3 == Color3.fromRGB(245, 106, 106) then
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 = table.concat(state.manifest.studioPath, "."),
1918
- status = state.status.Text,
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.Parent == attentionList then
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.Parent ~= attentionList then
2283
+ if not state.row then continue end
2284
+ if not state.group.attention then
1959
2285
  state.expandedBeforeAttention = state.expanded
1960
- state.row.Parent = attentionList
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 = entry.path
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 createRootRow(manifest, order)
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
- applyClassIcon(icon, if rootInstance then rootInstance.ClassName else "Model")
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 = Color3.fromRGB(180, 180, 190)
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 = "Checking..."
2284
- status.TextColor3 = Color3.fromRGB(180, 180, 190)
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
- local savedHash = plugin:GetSetting(
2376
- SETTINGS_PREFIX .. serverConfig.projectId .. ":" .. serverConfig.workspaceId .. ":" .. manifest.id .. ":hash"
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 = if rootInstance then rootInstance.ClassName else "Model",
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
- root = nil,
2407
- rootConnections = {},
2408
- instanceConnections = {},
2409
- dirty = false,
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.pushButton.Visible = false
2465
- state.pullButton.Visible = false
2466
- state.miniPush.Visible = false
2467
- state.miniPull.Visible = false
2468
- state.mergeButton.Visible = true
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.pushButton.Visible = true
2477
- state.pullButton.Visible = true
2478
- state.miniPush.Visible = true
2479
- state.miniPull.Visible = true
2480
- state.mergeButton.Visible = false
2481
- state.pushButton.Active = serverConfig.rojoConnected
2482
- state.pullButton.Active = serverConfig.rojoConnected
2483
- state.miniPush.Active = serverConfig.rojoConnected
2484
- state.miniPull.Active = serverConfig.rojoConnected
2485
- state.pushButton.AutoButtonColor = serverConfig.rojoConnected
2486
- state.pullButton.AutoButtonColor = serverConfig.rojoConnected
2487
- state.miniPush.AutoButtonColor = serverConfig.rojoConnected
2488
- state.miniPull.AutoButtonColor = serverConfig.rojoConnected
2489
- state.miniPush.TextColor3 = if serverConfig.rojoConnected
2490
- then Color3.fromRGB(115, 182, 255)
2491
- else Color3.fromRGB(95, 95, 105)
2492
- state.miniPull.TextColor3 = state.miniPush.TextColor3
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.status.Text
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.row.Visible = state.searchMatches and (showSynced or state.statusKind ~= "synced")
2587
- end
2588
- for _, group in groups do
2589
- local hasVisibleDescendant = false
2590
- for _, state in states do
2591
- if state.row.Visible and pathStartsWith(group.path, state.manifest.studioPath) then
2592
- hasVisibleDescendant = true
2593
- break
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
- group.section.Visible = hasVisibleDescendant
2597
- if query ~= "" and hasVisibleDescendant then
2598
- setGroupExpanded(group, true)
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 table.concat(previous.studioPath, "\0") ~= table.concat(current.studioPath, "\0")
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
- refreshConfig = function()
2674
- local response, requestError = request("GET", "/v1/config")
2675
- if not response or not response.Success then
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.row:Destroy()
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 = createRootRow(manifest, order)
3330
+ state = createRootState(manifest, order)
2737
3331
  end
2738
- local resolvedRoot = resolveRoot(manifest.studioPath)
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.row:Destroy()
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>