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.
@@ -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.16"
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.status.Text == text and state.status.TextColor3 == statusColor and state.statusKind == statusKind then
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.status.Text = text
751
- state.status.TextColor3 = statusColor
752
- state.dot.TextColor3 = statusColor
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 = table.concat(state.manifest.studioPath, ".")
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
- plugin:SetSetting(
1170
- SETTINGS_PREFIX .. serverConfig.projectId .. ":" .. serverConfig.workspaceId .. ":" .. state.manifest.id .. ":hash",
1171
- hash
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
- plugin:SetSetting(
1178
- SETTINGS_PREFIX .. serverConfig.projectId .. ":" .. serverConfig.workspaceId .. ":" .. state.manifest.id .. ":hash",
1179
- nil
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
- 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))
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 SerializationService:SerializeInstancesAsync({ root })
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, requestError = request("GET", "/v1/roots/" .. state.manifest.id .. "/snapshot")
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
- local scriptInstance = findCode(replacement)
1379
- if replacement.Name ~= expectedName or scriptInstance then
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
- if scriptInstance then
1383
- setStatus(state, "Blocked: file snapshot contains code.", Color3.fromRGB(245, 106, 106))
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, "Blocked: root must be named " .. expectedName, Color3.fromRGB(245, 106, 106))
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
- replacement.Parent = parent
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.mergeButton.Text = "Open Merge Workspace"
1470
- state.mergeButton.Size = UDim2.fromScale(1, 1)
1471
- state.diffButton.Visible = false
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 resultRoot.Name ~= expectedName or findCode(resultRoot) then
1578
- setStatus(state, "Result must be a code-free root named " .. expectedName .. ".", Color3.fromRGB(245, 106, 106))
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
- local function pathStartsWith(prefix, fullPath)
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 = table.concat(path, "\0")
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 pathStartsWith(group.path, state.manifest.studioPath) and not state.manifest.gitConflict then
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 pathStartsWith(group.path, state.manifest.studioPath) and not state.manifest.gitConflict then
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
- 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
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 = table.concat(state.manifest.studioPath, "."),
1918
- status = state.status.Text,
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.Parent == attentionList then
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.Parent ~= attentionList then
2260
+ if not state.row then continue end
2261
+ if not state.group.attention then
1959
2262
  state.expandedBeforeAttention = state.expanded
1960
- state.row.Parent = attentionList
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 = entry.path
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 createRootRow(manifest, order)
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
- applyClassIcon(icon, if rootInstance then rootInstance.ClassName else "Model")
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 = Color3.fromRGB(180, 180, 190)
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 = "Checking..."
2284
- status.TextColor3 = Color3.fromRGB(180, 180, 190)
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
- 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,
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 = if rootInstance then rootInstance.ClassName else "Model",
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
- 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
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.pushButton.Visible = false
2465
- state.pullButton.Visible = false
2466
- state.miniPush.Visible = false
2467
- state.miniPull.Visible = false
2468
- state.mergeButton.Visible = true
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.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
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.status.Text
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.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
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
- group.section.Visible = hasVisibleDescendant
2597
- if query ~= "" and hasVisibleDescendant then
2598
- setGroupExpanded(group, true)
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 table.concat(previous.studioPath, "\0") ~= table.concat(current.studioPath, "\0")
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 response, requestError = request("GET", "/v1/config")
2675
- if not response or not response.Success then
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.row:Destroy()
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 = createRootRow(manifest, order)
3247
+ state = createRootState(manifest, order)
2737
3248
  end
2738
- local resolvedRoot = resolveRoot(manifest.studioPath)
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.row:Destroy()
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>