goldsync 0.1.15 → 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.15"
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
 
@@ -185,13 +191,13 @@ local searchBox = Instance.new("TextBox")
185
191
  searchBox.BackgroundColor3 = Color3.fromRGB(40, 40, 46)
186
192
  searchBox.BorderSizePixel = 0
187
193
  searchBox.ClearTextOnFocus = false
188
- searchBox.Font = Enum.Font.BuilderSans
189
- searchBox.PlaceholderColor3 = Color3.fromRGB(130, 130, 140)
194
+ searchBox.FontFace = Font.new("rbxasset://fonts/families/BuilderSans.json", Enum.FontWeight.Medium)
195
+ searchBox.PlaceholderColor3 = Color3.fromRGB(195, 195, 205)
190
196
  searchBox.PlaceholderText = "Search files and instances..."
191
197
  searchBox.Size = UDim2.new(1, -132, 1, 0)
192
198
  searchBox.Text = ""
193
199
  searchBox.TextColor3 = Color3.fromRGB(235, 235, 240)
194
- searchBox.TextSize = 12
200
+ searchBox.TextSize = 13
195
201
  searchBox.TextXAlignment = Enum.TextXAlignment.Left
196
202
  searchBox.Parent = controls
197
203
  Instance.new("UICorner", searchBox).CornerRadius = UDim.new(0, 4)
@@ -202,12 +208,12 @@ searchPadding.PaddingRight = UDim.new(0, 8)
202
208
  local unsyncedButton = Instance.new("TextButton")
203
209
  unsyncedButton.BackgroundColor3 = Color3.fromRGB(40, 40, 46)
204
210
  unsyncedButton.BorderSizePixel = 0
205
- unsyncedButton.Font = Enum.Font.BuilderSansBold
211
+ unsyncedButton.FontFace = Font.new("rbxasset://fonts/families/BuilderSans.json", Enum.FontWeight.Medium)
206
212
  unsyncedButton.Position = UDim2.new(1, -126, 0, 0)
207
213
  unsyncedButton.Size = UDim2.fromOffset(92, 28)
208
214
  unsyncedButton.Text = "Unsynced · 0"
209
215
  unsyncedButton.TextColor3 = Color3.fromRGB(190, 190, 198)
210
- unsyncedButton.TextSize = 11
216
+ unsyncedButton.TextSize = 13
211
217
  unsyncedButton.Parent = controls
212
218
  Instance.new("UICorner", unsyncedButton).CornerRadius = UDim.new(0, 4)
213
219
 
@@ -292,12 +298,12 @@ local statusBanner = Instance.new("TextLabel")
292
298
  statusBanner.AutomaticSize = Enum.AutomaticSize.Y
293
299
  statusBanner.BackgroundColor3 = Color3.fromRGB(66, 38, 40)
294
300
  statusBanner.BorderSizePixel = 0
295
- statusBanner.Font = Enum.Font.BuilderSans
301
+ statusBanner.FontFace = Font.new("rbxasset://fonts/families/BuilderSans.json", Enum.FontWeight.Medium)
296
302
  statusBanner.LayoutOrder = 4
297
303
  statusBanner.Size = UDim2.new(1, 0, 0, 0)
298
304
  statusBanner.Text = ""
299
305
  statusBanner.TextColor3 = Color3.fromRGB(255, 205, 205)
300
- statusBanner.TextSize = 11
306
+ statusBanner.TextSize = 14
301
307
  statusBanner.TextWrapped = true
302
308
  statusBanner.TextXAlignment = Enum.TextXAlignment.Left
303
309
  statusBanner.Visible = false
@@ -309,13 +315,66 @@ statusBannerPadding.PaddingLeft = UDim.new(0, 8)
309
315
  statusBannerPadding.PaddingRight = UDim.new(0, 8)
310
316
  statusBannerPadding.PaddingTop = UDim.new(0, 7)
311
317
 
318
+ local attentionBox = Instance.new("Frame")
319
+ attentionBox.Name = "AttentionBox"
320
+ attentionBox.AutomaticSize = Enum.AutomaticSize.Y
321
+ attentionBox.BackgroundColor3 = Color3.fromRGB(43, 42, 38)
322
+ attentionBox.BorderSizePixel = 0
323
+ attentionBox.LayoutOrder = 5
324
+ attentionBox.Size = UDim2.fromScale(1, 0)
325
+ attentionBox.Visible = false
326
+ attentionBox.Parent = content
327
+ Instance.new("UICorner", attentionBox).CornerRadius = UDim.new(0.04, 0)
328
+ local attentionBoxLayout = Instance.new("UIListLayout")
329
+ attentionBoxLayout.SortOrder = Enum.SortOrder.LayoutOrder
330
+ attentionBoxLayout.Parent = attentionBox
331
+
332
+ local attentionActions = Instance.new("Frame")
333
+ attentionActions.BackgroundTransparency = 1
334
+ attentionActions.LayoutOrder = 1
335
+ attentionActions.Size = UDim2.new(1, 0, 0, 30)
336
+ attentionActions.Parent = attentionBox
337
+ local attentionBulkButtons = {}
338
+ for index, text in { "Push All →", "← Pull All" } do
339
+ local button = Instance.new("ImageButton")
340
+ button.AnchorPoint = Vector2.new(0.5, 0.5)
341
+ button.BackgroundColor3 = Color3.fromRGB(57, 57, 67)
342
+ button.BorderSizePixel = 0
343
+ button.Position = UDim2.fromScale(0.25 + (index - 1) * 0.5, 0.5)
344
+ button.Size = UDim2.fromScale(0.47, 0.8)
345
+ button.Parent = attentionActions
346
+ Instance.new("UICorner", button).CornerRadius = UDim.new(0.22, 0)
347
+ local label = Instance.new("TextLabel")
348
+ label.BackgroundTransparency = 1
349
+ label.Position = UDim2.fromScale(0.12, 0.18)
350
+ label.Size = UDim2.fromScale(0.76, 0.64)
351
+ label.Font = Enum.Font.BuilderSansBold
352
+ label.Text = text
353
+ label.TextColor3 = Color3.fromRGB(215, 225, 240)
354
+ label.TextScaled = true
355
+ label.Parent = button
356
+ attentionBulkButtons[index] = button
357
+ end
358
+
359
+ local attentionList = Instance.new("Frame")
360
+ attentionList.Name = "AttentionList"
361
+ attentionList.AutomaticSize = Enum.AutomaticSize.Y
362
+ attentionList.BackgroundTransparency = 1
363
+ attentionList.LayoutOrder = 5
364
+ attentionList.Size = UDim2.fromScale(1, 0)
365
+ attentionList.Visible = false
366
+ attentionList.Parent = attentionBox
367
+ local attentionLayout = Instance.new("UIListLayout")
368
+ attentionLayout.SortOrder = Enum.SortOrder.LayoutOrder
369
+ attentionLayout.Parent = attentionList
370
+
312
371
  local directionLegend = Instance.new("TextLabel")
313
372
  directionLegend.BackgroundTransparency = 1
314
- directionLegend.Font = Enum.Font.BuilderSans
315
- directionLegend.LayoutOrder = 5
373
+ directionLegend.FontFace = Font.new("rbxasset://fonts/families/BuilderSans.json", Enum.FontWeight.Medium)
374
+ directionLegend.LayoutOrder = 6
316
375
  directionLegend.Size = UDim2.new(1, 0, 0, 18)
317
376
  directionLegend.Text = "→ Studio to file ← File to Studio"
318
- directionLegend.TextColor3 = Color3.fromRGB(145, 145, 155)
377
+ directionLegend.TextColor3 = Color3.fromRGB(195, 195, 205)
319
378
  directionLegend.TextSize = 11
320
379
  directionLegend.TextXAlignment = Enum.TextXAlignment.Right
321
380
  directionLegend.Parent = content
@@ -323,7 +382,7 @@ directionLegend.Parent = content
323
382
  local rootsContainer = Instance.new("Frame")
324
383
  rootsContainer.AutomaticSize = Enum.AutomaticSize.Y
325
384
  rootsContainer.BackgroundTransparency = 1
326
- rootsContainer.LayoutOrder = 6
385
+ rootsContainer.LayoutOrder = 7
327
386
  rootsContainer.Size = UDim2.new(1, 0, 0, 0)
328
387
  rootsContainer.Parent = content
329
388
 
@@ -339,6 +398,12 @@ local requestWaiters = {}
339
398
  local lastRequestAt = 0
340
399
  local serverConfig
341
400
  local states = {}
401
+ local treeDiscovery = {}
402
+ local snapshotBatch = {}
403
+ local configETag
404
+ local filteringTree = false
405
+ local filterScheduled = false
406
+ local filterAgain = false
342
407
  local groups = {}
343
408
  local batchingStatusUpdates = false
344
409
  local splitRootConnections = {}
@@ -349,11 +414,40 @@ local refreshUnsyncedButton
349
414
  local upload
350
415
  local pull
351
416
  local deleteSnapshot
417
+ local pathStartsWith
352
418
  local openGitConflict
353
419
  local resolveGitConflict
354
420
  local applyTreeFilter
355
421
  local resetInstanceTrees
356
422
 
423
+ for index, button in attentionBulkButtons do
424
+ button.Activated:Connect(function()
425
+ if bulkRunning or not serverConfig or not serverConfig.rojoConnected then
426
+ return
427
+ end
428
+ local affected = {}
429
+ for _, state in states do
430
+ if state.statusKind == "issue" and not state.manifest.gitConflict then
431
+ table.insert(affected, state)
432
+ end
433
+ end
434
+ bulkRunning = true
435
+ refreshStatusSummary()
436
+ for _, state in affected do
437
+ if states[state.manifest.id] ~= state or state.manifest.gitConflict then
438
+ continue
439
+ end
440
+ if index == 1 then
441
+ upload(state, true)
442
+ else
443
+ pull(state)
444
+ end
445
+ end
446
+ bulkRunning = false
447
+ refreshStatusSummary()
448
+ end)
449
+ end
450
+
357
451
  local function collectSplitPaths(root, depth)
358
452
  local paths = {}
359
453
  local function visit(instance, remainingDepth, relativePath)
@@ -469,10 +563,10 @@ local function responseError(response)
469
563
  return string.format("HTTP %d · %s", response.StatusCode, tostring(response.StatusMessage))
470
564
  end
471
565
 
472
- local function resolveRoot(studioPath)
566
+ local function resolveRoot(studioPath, instanceIds)
473
567
  local current = game:GetService(studioPath[1])
474
568
  for index = 2, #studioPath do
475
- current = current:FindFirstChild(studioPath[index])
569
+ current = if instanceIds then TreeRoots.findChild(current, studioPath[index], instanceIds[index]) else current:FindFirstChild(studioPath[index])
476
570
  if not current then
477
571
  return nil
478
572
  end
@@ -480,10 +574,10 @@ local function resolveRoot(studioPath)
480
574
  return current
481
575
  end
482
576
 
483
- local function resolveParent(studioPath)
577
+ local function resolveParent(studioPath, instanceIds)
484
578
  local current = game:GetService(studioPath[1])
485
579
  for index = 2, #studioPath - 1 do
486
- current = current:FindFirstChild(studioPath[index])
580
+ current = if instanceIds then TreeRoots.findChild(current, studioPath[index], instanceIds[index]) else current:FindFirstChild(studioPath[index])
487
581
  if not current then
488
582
  return nil
489
583
  end
@@ -638,21 +732,9 @@ local function addButtonHover(button)
638
732
  Instance.new("UICorner", button).CornerRadius = UDim.new(0, 3)
639
733
  end
640
734
 
641
- local function findCode(root)
642
- if root:IsA("BaseScript") then
643
- return root
644
- end
645
- for _, descendant in root:GetDescendants() do
646
- if descendant:IsA("BaseScript") then
647
- return descendant
648
- end
649
- end
650
- return nil
651
- end
652
-
653
735
  local function setStatus(state, text, color)
654
736
  local statusColor = color or Color3.fromRGB(180, 180, 190)
655
- 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"
656
738
  then "synced"
657
739
  elseif string.find(text, "syncing")
658
740
  or string.find(text, "Serializing")
@@ -662,18 +744,29 @@ local function setStatus(state, text, color)
662
744
  then "working"
663
745
  elseif string.find(text, "Rojo") and string.find(text, "offline") then "paused"
664
746
  else "issue"
665
- 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
666
748
  return
667
749
  end
668
750
  local previousStatusKind = state.statusKind
669
- state.status.Text = text
670
- state.status.TextColor3 = statusColor
671
- 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
672
758
  state.searchQuery = nil
673
759
  state.statusKind = statusKind
674
760
  if updateGroupSummaries and not batchingStatusUpdates then
675
761
  if previousStatusKind ~= state.statusKind then
676
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
677
770
  elseif refreshStatusSummary then
678
771
  refreshStatusSummary()
679
772
  end
@@ -732,6 +825,9 @@ local function getSerializedPropertyNames(className)
732
825
  table.insert(names, property.Name)
733
826
  end
734
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
735
831
  table.sort(names)
736
832
  end
737
833
  propertyNamesByClass[className] = names
@@ -769,7 +865,7 @@ local function compareConflictWorkspace(state, versions, resultRoot)
769
865
  local generation = inspectorGeneration
770
866
  table.clear(inspectorEntries)
771
867
  inspectorRendered = 0
772
- inspectorTitle.Text = table.concat(state.manifest.studioPath, ".")
868
+ inspectorTitle.Text = TreeRoots.displayPath(state.manifest.studioPath, state.manifest.instanceIds)
773
869
  inspectorSummary.Text = "Scanning serialized properties..."
774
870
  inspectorWidget.Enabled = true
775
871
 
@@ -1029,10 +1125,20 @@ local function watchInstance(state, instance)
1029
1125
  return
1030
1126
  end
1031
1127
  local connections = {
1032
- instance.AttributeChanged:Connect(function()
1128
+ instance.AttributeChanged:Connect(function(attribute)
1033
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
1034
1134
  end),
1035
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
1036
1142
  if instance:IsA("ValueBase") then
1037
1143
  for _, propertyName in getSerializedPropertyNames(instance.ClassName) do
1038
1144
  local connected, connection = pcall(function()
@@ -1056,9 +1162,23 @@ local function watchInstance(state, instance)
1056
1162
  end
1057
1163
 
1058
1164
  local function watchRoot(state, root)
1165
+ local started = os.clock()
1059
1166
  disconnectRoot(state)
1060
1167
  state.root = root
1061
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
1062
1182
  for _, descendant in root:GetDescendants() do
1063
1183
  watchInstance(state, descendant)
1064
1184
  end
@@ -1077,34 +1197,86 @@ local function watchRoot(state, root)
1077
1197
  markDirty(state)
1078
1198
  end))
1079
1199
  table.insert(state.rootConnections, root.AncestryChanged:Connect(function()
1080
- 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
1081
1201
  deleteSnapshot(state)
1082
1202
  end
1083
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
1084
1211
  end
1085
1212
 
1086
1213
  local function rememberHash(state, hash)
1087
1214
  state.lastHash = hash
1088
- plugin:SetSetting(
1089
- SETTINGS_PREFIX .. serverConfig.projectId .. ":" .. serverConfig.workspaceId .. ":" .. state.manifest.id .. ":hash",
1090
- hash
1091
- )
1215
+ if savedHashes[state.manifest.id] == hash then return end
1216
+ savedHashes[state.manifest.id] = hash
1217
+ hashesDirty = true
1092
1218
  end
1093
1219
 
1094
1220
  local function forgetHash(state)
1095
1221
  state.lastHash = nil
1096
- plugin:SetSetting(
1097
- SETTINGS_PREFIX .. serverConfig.projectId .. ":" .. serverConfig.workspaceId .. ":" .. state.manifest.id .. ":hash",
1098
- nil
1099
- )
1222
+ if savedHashes[state.manifest.id] == nil then return end
1223
+ savedHashes[state.manifest.id] = nil
1224
+ hashesDirty = true
1100
1225
  end
1101
1226
 
1102
- 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
1103
1274
  if state.uploading or state.pulling or state.deleting then
1104
1275
  return
1105
1276
  end
1106
1277
  state.generation += 1
1107
1278
  state.dirty = false
1279
+ if explicit then state.lastHash = state.manifest.hash end
1108
1280
  if state.manifest.gitConflict or not state.lastHash then
1109
1281
  state.conflict = true
1110
1282
  setStatus(state, "Root removed; resolve the existing file conflict manually.", Color3.fromRGB(245, 106, 106))
@@ -1152,6 +1324,7 @@ deleteSnapshot = function(state)
1152
1324
  end
1153
1325
 
1154
1326
  upload = function(state, overwrite)
1327
+ if state.coveredByBoundary then return end
1155
1328
  if state.uploading or state.pulling or state.deleting then
1156
1329
  return
1157
1330
  end
@@ -1163,25 +1336,132 @@ upload = function(state, overwrite)
1163
1336
  setStatus(state, "Paused — Rojo is offline.", Color3.fromRGB(244, 194, 92))
1164
1337
  return
1165
1338
  end
1166
- local root = resolveRoot(state.manifest.studioPath)
1339
+ local root = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
1167
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
1168
1357
  state.conflict = true
1169
1358
  setStatus(state, "Studio root is missing. Pull to restore it.", Color3.fromRGB(245, 106, 106))
1170
1359
  return
1171
1360
  end
1172
- local scriptInstance = findCode(root)
1173
- if scriptInstance then
1174
- state.conflict = true
1175
- 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))
1176
1450
  return
1177
1451
  end
1178
1452
 
1179
1453
  state.uploading = true
1180
1454
  setStatus(state, "Serializing Studio root...", Color3.fromRGB(115, 182, 255))
1181
1455
  local serialized, serializationError
1456
+ local snapshot
1182
1457
  local success, result = pcall(function()
1183
- 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 })
1184
1461
  end)
1462
+ if snapshot and snapshot ~= root then
1463
+ snapshot:Destroy()
1464
+ end
1185
1465
  if success then
1186
1466
  serialized = result
1187
1467
  else
@@ -1199,9 +1479,10 @@ upload = function(state, overwrite)
1199
1479
  elseif state.lastHash then
1200
1480
  expectedHash = state.lastHash
1201
1481
  end
1202
- 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), {
1203
1483
  ["Content-Type"] = "application/octet-stream",
1204
1484
  ["If-Match"] = expectedHash,
1485
+ ["X-GoldSync-Plain-Folder"] = if plainFolder then "1" else "0",
1205
1486
  })
1206
1487
  state.uploading = false
1207
1488
  if not response then
@@ -1227,12 +1508,13 @@ upload = function(state, overwrite)
1227
1508
  state.manifest = manifest
1228
1509
  state.dirty = false
1229
1510
  state.conflict = false
1230
- rememberHash(state, manifest.hash)
1511
+ if manifest.hash then rememberHash(state, manifest.hash) else forgetHash(state) end
1231
1512
  watchRoot(state, root)
1232
1513
  setStatus(state, "Synced Studio → file", Color3.fromRGB(102, 214, 139))
1233
1514
  end
1234
1515
 
1235
- pull = function(state)
1516
+ pull = function(state, adoptOnly)
1517
+ if state.coveredByBoundary then return end
1236
1518
  if state.uploading or state.pulling or state.deleting then
1237
1519
  return
1238
1520
  end
@@ -1245,25 +1527,57 @@ pull = function(state)
1245
1527
  return
1246
1528
  end
1247
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
1248
1563
  setStatus(state, "No file snapshot exists. Push Studio first.", Color3.fromRGB(245, 106, 106))
1249
1564
  return
1250
1565
  end
1251
- local parent = resolveParent(state.manifest.studioPath)
1566
+ local parent = resolveParent(state.manifest.studioPath, state.manifest.instanceIds)
1252
1567
  if not parent then
1253
1568
  setStatus(state, "Studio parent path is missing.", Color3.fromRGB(245, 106, 106))
1254
1569
  return
1255
1570
  end
1256
- local currentRoot = resolveRoot(state.manifest.studioPath)
1257
- local currentScript = currentRoot and findCode(currentRoot)
1258
- if currentScript then
1259
- state.conflict = true
1260
- setStatus(state, "Blocked: Studio root contains code at " .. currentScript:GetFullName(), Color3.fromRGB(245, 106, 106))
1261
- return
1262
- end
1571
+ local currentRoot = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
1263
1572
 
1264
1573
  state.pulling = true
1265
1574
  setStatus(state, "Loading file snapshot...", Color3.fromRGB(115, 182, 255))
1266
- 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
1267
1581
  if not response then
1268
1582
  state.pulling = false
1269
1583
  setStatus(state, "Daemon unavailable: " .. requestError, Color3.fromRGB(245, 106, 106))
@@ -1274,6 +1588,12 @@ pull = function(state)
1274
1588
  setStatus(state, "Download failed (HTTP " .. response.StatusCode .. ")", Color3.fromRGB(245, 106, 106))
1275
1589
  return
1276
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
1277
1597
 
1278
1598
  local success, instances = pcall(function()
1279
1599
  return SerializationService:DeserializeInstancesAsync(buffer.fromstring(response.Body))
@@ -1294,24 +1614,70 @@ pull = function(state)
1294
1614
 
1295
1615
  local replacement = instances[1]
1296
1616
  local expectedName = state.manifest.studioPath[#state.manifest.studioPath]
1297
- local scriptInstance = findCode(replacement)
1298
- if replacement.Name ~= expectedName or scriptInstance then
1617
+ if state.manifest.instanceIds and (replacement:GetAttribute("GoldSyncId") or "") ~= state.manifest.instanceIds[#state.manifest.studioPath] then
1299
1618
  replacement:Destroy()
1300
1619
  state.pulling = false
1301
- if scriptInstance then
1302
- setStatus(state, "Blocked: file snapshot contains code.", Color3.fromRGB(245, 106, 106))
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
1624
+ replacement:Destroy()
1625
+ state.pulling = false
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
1303
1665
  else
1304
- 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))
1305
1667
  end
1306
1668
  return
1307
1669
  end
1308
1670
 
1309
1671
  state.suppress = true
1310
1672
  disconnectRoot(state)
1311
- local previous = resolveRoot(state.manifest.studioPath)
1673
+ local previous = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
1312
1674
  ChangeHistoryService:SetWaypoint("GoldSync before Pull")
1313
1675
  local applied, applyError = pcall(function()
1314
- 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
1315
1681
  end)
1316
1682
  if not applied then
1317
1683
  replacement:Destroy()
@@ -1323,7 +1689,10 @@ pull = function(state)
1323
1689
  setStatus(state, "Could not apply snapshot: " .. tostring(applyError), Color3.fromRGB(245, 106, 106))
1324
1690
  return
1325
1691
  end
1326
- if previous then
1692
+ if previous and state.manifest.container then
1693
+ replacement:Destroy()
1694
+ replacement = previous
1695
+ elseif previous then
1327
1696
  previous:Destroy()
1328
1697
  end
1329
1698
  ChangeHistoryService:SetWaypoint("GoldSync Pull " .. expectedName)
@@ -1360,10 +1729,6 @@ local function deserializeConflictStage(state, stage)
1360
1729
  end
1361
1730
  return nil, "snapshot must contain exactly one root"
1362
1731
  end
1363
- if findCode(instances[1]) then
1364
- instances[1]:Destroy()
1365
- return nil, "snapshot contains code"
1366
- end
1367
1732
  return instances[1]
1368
1733
  end
1369
1734
 
@@ -1385,9 +1750,11 @@ local function destroyConflictWorkspace(state)
1385
1750
  parent:Destroy()
1386
1751
  end
1387
1752
  end
1388
- state.mergeButton.Text = "Open Merge Workspace"
1389
- state.mergeButton.Size = UDim2.fromScale(1, 1)
1390
- 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
1391
1758
  end
1392
1759
 
1393
1760
  openGitConflict = function(state)
@@ -1493,8 +1860,12 @@ resolveGitConflict = function(state)
1493
1860
  end
1494
1861
  local resultRoot = resultChildren[1]
1495
1862
  local expectedName = state.manifest.studioPath[#state.manifest.studioPath]
1496
- if resultRoot.Name ~= expectedName or findCode(resultRoot) then
1497
- 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))
1498
1869
  return
1499
1870
  end
1500
1871
 
@@ -1541,28 +1912,28 @@ local function setGroupExpanded(group, expanded)
1541
1912
  group.name.Text = group.label .. group.summary
1542
1913
  end
1543
1914
 
1544
- local function pathStartsWith(prefix, fullPath)
1915
+ pathStartsWith = function(prefix, fullPath, prefixIds, fullIds)
1545
1916
  if #prefix > #fullPath then
1546
1917
  return false
1547
1918
  end
1548
1919
  for index, segment in prefix do
1549
- if fullPath[index] ~= segment then
1920
+ if fullPath[index] ~= segment or (prefixIds and prefixIds[index] or "") ~= (fullIds and fullIds[index] or "") then
1550
1921
  return false
1551
1922
  end
1552
1923
  end
1553
1924
  return true
1554
1925
  end
1555
1926
 
1556
- local function createGroup(path, order)
1557
- 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)
1558
1929
  if groups[key] then
1559
1930
  return groups[key]
1560
1931
  end
1561
1932
  local parentGroup
1562
- if #path > 1 then
1933
+ if #path > 1 and not attention then
1563
1934
  local parentPath = table.clone(path)
1564
1935
  table.remove(parentPath)
1565
- parentGroup = createGroup(parentPath, order)
1936
+ parentGroup = createGroup(parentPath, order, instanceIds and table.move(instanceIds, 1, #parentPath, 1, {}))
1566
1937
  end
1567
1938
 
1568
1939
  local section = Instance.new("Frame")
@@ -1570,7 +1941,7 @@ local function createGroup(path, order)
1570
1941
  section.BackgroundTransparency = 1
1571
1942
  section.LayoutOrder = order
1572
1943
  section.Size = UDim2.new(1, 0, 0, 0)
1573
- section.Parent = if parentGroup then parentGroup.list else rootsContainer
1944
+ section.Parent = if attention then attentionList elseif parentGroup then parentGroup.list else rootsContainer
1574
1945
 
1575
1946
  local sectionLayout = Instance.new("UIListLayout")
1576
1947
  sectionLayout.Padding = UDim.new(0, 2)
@@ -1606,7 +1977,7 @@ local function createGroup(path, order)
1606
1977
  icon.Position = UDim2.new(0, 19, 0.5, -8)
1607
1978
  icon.Size = UDim2.fromOffset(16, 16)
1608
1979
  icon.Parent = header
1609
- local parentInstance = resolveRoot(path)
1980
+ local parentInstance = resolveRoot(path, instanceIds)
1610
1981
  applyClassIcon(icon, if parentInstance then parentInstance.ClassName else "Folder")
1611
1982
 
1612
1983
  local name = Instance.new("TextButton")
@@ -1689,8 +2060,10 @@ local function createGroup(path, order)
1689
2060
 
1690
2061
  local group = {
1691
2062
  key = key,
1692
- label = path[#path],
2063
+ label = if attention then TreeRoots.displayPath(path, instanceIds) else TreeRoots.displayName(path[#path], instanceIds and instanceIds[#path]),
2064
+ attention = attention,
1693
2065
  path = table.clone(path),
2066
+ instanceIds = instanceIds,
1694
2067
  section = section,
1695
2068
  header = header,
1696
2069
  disclosure = disclosure,
@@ -1705,12 +2078,14 @@ local function createGroup(path, order)
1705
2078
  summary = "",
1706
2079
  }
1707
2080
  groups[key] = group
1708
- setGroupExpanded(group, #path <= 2)
2081
+ setGroupExpanded(group, attention or #path <= 2)
1709
2082
  disclosure.Activated:Connect(function()
1710
2083
  setGroupExpanded(group, not group.expanded)
2084
+ applyTreeFilter()
1711
2085
  end)
1712
2086
  name.Activated:Connect(function()
1713
2087
  setGroupExpanded(group, not group.expanded)
2088
+ applyTreeFilter()
1714
2089
  end)
1715
2090
  bulkPush.Activated:Connect(function()
1716
2091
  if bulkRunning then
@@ -1718,7 +2093,8 @@ local function createGroup(path, order)
1718
2093
  end
1719
2094
  bulkRunning = true
1720
2095
  for _, state in states do
1721
- 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
1722
2098
  upload(state, true)
1723
2099
  end
1724
2100
  end
@@ -1730,7 +2106,8 @@ local function createGroup(path, order)
1730
2106
  end
1731
2107
  bulkRunning = true
1732
2108
  for _, state in states do
1733
- 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
1734
2111
  pull(state)
1735
2112
  end
1736
2113
  end
@@ -1741,6 +2118,7 @@ end
1741
2118
 
1742
2119
  local function setRootExpanded(state, expanded)
1743
2120
  state.expanded = expanded
2121
+ if not state.row then return end
1744
2122
  state.row.BackgroundTransparency = 1
1745
2123
  state.file.Visible = expanded
1746
2124
  state.status.Visible = expanded
@@ -1749,26 +2127,30 @@ local function setRootExpanded(state, expanded)
1749
2127
  end
1750
2128
 
1751
2129
  updateGroupSummaries = function()
2130
+ local totals = {}
1752
2131
  for _, group in groups do
1753
- local total = 0
1754
- local synced = 0
1755
- local issues = 0
1756
- local pending = 0
1757
- local paused = 0
1758
- for _, state in states do
1759
- if pathStartsWith(group.path, state.manifest.studioPath) then
1760
- total += 1
1761
- if state.statusKind == "synced" then
1762
- synced += 1
1763
- elseif state.statusKind == "issue" then
1764
- issues += 1
1765
- elseif state.statusKind == "paused" then
1766
- paused += 1
1767
- else
1768
- pending += 1
1769
- 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
1770
2146
  end
1771
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
1772
2154
  group.summary = string.format(" (%d/%d) synced", synced, total)
1773
2155
  if issues > 0 then
1774
2156
  group.summary ..= string.format(" · %d need attention", issues)
@@ -1784,7 +2166,7 @@ updateGroupSummaries = function()
1784
2166
  group.bulkPush.Visible = true
1785
2167
  group.bulkPull.Visible = true
1786
2168
  group.statusDot.TextColor3 = if issues > 0
1787
- then Color3.fromRGB(245, 106, 106)
2169
+ then issueColor
1788
2170
  elseif synced == total then Color3.fromRGB(102, 214, 139)
1789
2171
  elseif pending > 0 or paused > 0 then Color3.fromRGB(244, 194, 92)
1790
2172
  else Color3.fromRGB(180, 180, 190)
@@ -1797,7 +2179,7 @@ updateGroupSummaries = function()
1797
2179
  else Color3.fromRGB(95, 95, 105)
1798
2180
  group.bulkPush.TextColor3 = arrowColor
1799
2181
  group.bulkPull.TextColor3 = arrowColor
1800
- setGroupExpanded(group, group.expanded or (issues > 0 and autoExpandConflicts))
2182
+ setGroupExpanded(group, group.expanded or (issues > 0 and autoExpandConflicts and #serverConfig.roots <= 1000))
1801
2183
  end
1802
2184
  end
1803
2185
  if refreshUnsyncedButton then
@@ -1811,7 +2193,7 @@ end
1811
2193
  refreshUnsyncedButton = function()
1812
2194
  local unsynced = 0
1813
2195
  for _, state in states do
1814
- if state.statusKind ~= "synced" then
2196
+ if not state.coveredByBoundary and state.statusKind ~= "synced" then
1815
2197
  unsynced += 1
1816
2198
  end
1817
2199
  end
@@ -1820,24 +2202,45 @@ refreshUnsyncedButton = function()
1820
2202
  then Color3.fromRGB(40, 40, 46)
1821
2203
  else Color3.fromRGB(78, 66, 38)
1822
2204
  unsyncedButton.TextColor3 = if showSynced
1823
- then Color3.fromRGB(190, 190, 198)
2205
+ then Color3.fromRGB(220, 220, 230)
1824
2206
  else Color3.fromRGB(255, 218, 126)
1825
2207
  end
1826
2208
 
1827
2209
  refreshStatusSummary = function()
1828
2210
  local issues = {}
1829
2211
  local working = {}
2212
+ for _, group in groups do if group.attention then group.section.Visible = false end end
1830
2213
  for _, state in states do
2214
+ if state.coveredByBoundary then continue end
1831
2215
  local entry = {
1832
- path = table.concat(state.manifest.studioPath, "."),
1833
- status = state.status.Text,
2216
+ path = TreeRoots.displayPath(state.manifest.studioPath, state.manifest.instanceIds),
2217
+ status = state.statusText,
2218
+ state = state,
1834
2219
  }
1835
2220
  if state.statusKind == "issue" then
1836
2221
  table.insert(issues, entry)
1837
- elseif state.statusKind == "working" then
1838
- table.insert(working, entry)
2222
+ else
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)
2227
+ state.row.Parent = state.group.list
2228
+ state.connector.Visible = true
2229
+ state.row.LayoutOrder = state.treeOrder
2230
+ state.title.Text = TreeRoots.displayName(state.manifest.studioPath[#state.manifest.studioPath], state.manifest.instanceIds and state.manifest.instanceIds[#state.manifest.studioPath])
2231
+ setRootExpanded(state, state.expandedBeforeAttention)
2232
+ end
2233
+ if state.statusKind == "working" then
2234
+ table.insert(working, entry)
2235
+ end
1839
2236
  end
1840
2237
  end
2238
+ attentionList.Visible = #issues > 0
2239
+ attentionBox.Visible = #issues > 0
2240
+ for _, button in attentionBulkButtons do
2241
+ button.Active = serverConfig.rojoConnected and not bulkRunning
2242
+ button.AutoButtonColor = button.Active
2243
+ end
1841
2244
  local entries = if #issues > 0 then issues else working
1842
2245
  if #entries == 0 then
1843
2246
  statusBanner.Visible = false
@@ -1851,19 +2254,30 @@ refreshStatusSummary = function()
1851
2254
  then string.format("%d need attention", #issues)
1852
2255
  else string.format("%d still syncing", #working),
1853
2256
  }
1854
- for index = 1, math.min(#entries, 3) do
1855
- local entry = entries[index]
1856
- table.insert(lines, entry.path .. " — " .. entry.status)
1857
- end
1858
- if #entries > 3 then
1859
- table.insert(lines, string.format("+%d more", #entries - 3))
2257
+ for index, entry in entries do
2258
+ if #issues > 0 then
2259
+ local state = entry.state
2260
+ if not state.row then continue end
2261
+ if not state.group.attention then
2262
+ state.expandedBeforeAttention = state.expanded
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
2267
+ state.connector.Visible = false
2268
+ setRootExpanded(state, false)
2269
+ end
2270
+ state.group.section.Visible = true
2271
+ state.row.LayoutOrder = index
2272
+ state.title.Text = TreeRoots.displayName(state.manifest.studioPath[#state.manifest.studioPath], state.manifest.instanceIds and state.manifest.instanceIds[#state.manifest.studioPath])
2273
+ else
2274
+ if index <= 10 then table.insert(lines, entry.path .. " — " .. entry.status) end
2275
+ end
1860
2276
  end
1861
2277
  statusBanner.BackgroundColor3 = if #issues > 0
1862
- then Color3.fromRGB(66, 38, 40)
1863
- else Color3.fromRGB(66, 56, 34)
1864
- statusBanner.TextColor3 = if #issues > 0
1865
- then Color3.fromRGB(255, 205, 205)
1866
- else Color3.fromRGB(255, 224, 145)
2278
+ then Color3.fromRGB(49, 46, 38)
2279
+ else Color3.fromRGB(43, 42, 38)
2280
+ statusBanner.TextColor3 = Color3.fromRGB(230, 222, 200)
1867
2281
  statusBanner.Text = table.concat(lines, "\n")
1868
2282
  statusBanner.Visible = true
1869
2283
  end
@@ -1982,6 +2396,7 @@ local function createInstanceNode(instance, parent, depth, order)
1982
2396
  end
1983
2397
 
1984
2398
  local function resetStateContents(state)
2399
+ if not state.row then return end
1985
2400
  for _, child in state.contentsList:GetChildren() do
1986
2401
  if child ~= state.contentsLayout then
1987
2402
  child:Destroy()
@@ -1991,7 +2406,7 @@ local function resetStateContents(state)
1991
2406
  state.contentsExpanded = false
1992
2407
  state.contents.Visible = false
1993
2408
  state.contentDisclosure.Text = "▸"
1994
- local root = resolveRoot(state.manifest.studioPath)
2409
+ local root = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
1995
2410
  state.contentDisclosure.Visible = visualDepth > 0 and root ~= nil and #root:GetChildren() > 0
1996
2411
  end
1997
2412
 
@@ -2017,7 +2432,7 @@ local function toggleRootContents(state)
2017
2432
  if not state.contentsExpanded then
2018
2433
  return
2019
2434
  end
2020
- local root = resolveRoot(state.manifest.studioPath)
2435
+ local root = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
2021
2436
  if not root then
2022
2437
  return
2023
2438
  end
@@ -2031,10 +2446,36 @@ local function toggleRootContents(state)
2031
2446
  state.contentsLoaded = true
2032
2447
  end
2033
2448
 
2034
- 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
2035
2476
  local parentPath = table.clone(manifest.studioPath)
2036
2477
  table.remove(parentPath)
2037
- local group = createGroup(parentPath, order)
2478
+ local group = createGroup(parentPath, order, manifest.instanceIds and table.move(manifest.instanceIds, 1, #parentPath, 1, {}), state.statusKind == "issue")
2038
2479
  group.hasDirectRoots = true
2039
2480
  group.bulkPush.Visible = true
2040
2481
  group.bulkPull.Visible = true
@@ -2090,15 +2531,15 @@ local function createRootRow(manifest, order)
2090
2531
  icon.Position = UDim2.new(0, 19, 0.5, -8)
2091
2532
  icon.Size = UDim2.fromOffset(16, 16)
2092
2533
  icon.Parent = titleLine
2093
- local rootInstance = resolveRoot(manifest.studioPath)
2094
- 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
2095
2536
 
2096
2537
  local title = Instance.new("TextButton")
2097
2538
  title.BackgroundTransparency = 1
2098
2539
  title.Font = Enum.Font.BuilderSansBold
2099
2540
  title.Position = UDim2.new(0, 39, 0, 0)
2100
2541
  title.Size = UDim2.new(1, -109, 1, 0)
2101
- title.Text = manifest.studioPath[#manifest.studioPath]
2542
+ title.Text = TreeRoots.displayName(manifest.studioPath[#manifest.studioPath], manifest.instanceIds and manifest.instanceIds[#manifest.studioPath])
2102
2543
  title.TextColor3 = Color3.fromRGB(235, 235, 240)
2103
2544
  title.TextSize = 13
2104
2545
  title.TextXAlignment = Enum.TextXAlignment.Left
@@ -2110,7 +2551,7 @@ local function createRootRow(manifest, order)
2110
2551
  dot.Position = UDim2.new(1, -70, 0, 0)
2111
2552
  dot.Size = UDim2.new(0, 12, 1, 0)
2112
2553
  dot.Text = "●"
2113
- dot.TextColor3 = Color3.fromRGB(180, 180, 190)
2554
+ dot.TextColor3 = state.statusColor
2114
2555
  dot.TextSize = 9
2115
2556
  dot.Parent = titleLine
2116
2557
 
@@ -2173,8 +2614,8 @@ local function createRootRow(manifest, order)
2173
2614
  status.Font = Enum.Font.BuilderSans
2174
2615
  status.LayoutOrder = 3
2175
2616
  status.Size = UDim2.new(1, 0, 0, 18)
2176
- status.Text = "Checking..."
2177
- status.TextColor3 = Color3.fromRGB(180, 180, 190)
2617
+ status.Text = state.statusText
2618
+ status.TextColor3 = state.statusColor
2178
2619
  status.TextSize = 12
2179
2620
  status.TextWrapped = true
2180
2621
  status.TextXAlignment = Enum.TextXAlignment.Left
@@ -2265,16 +2706,14 @@ local function createRootRow(manifest, order)
2265
2706
  contentsGuide.Size = UDim2.fromOffset(1, contentsLayout.AbsoluteContentSize.Y)
2266
2707
  end)
2267
2708
 
2268
- local savedHash = plugin:GetSetting(
2269
- SETTINGS_PREFIX .. serverConfig.projectId .. ":" .. serverConfig.workspaceId .. ":" .. manifest.id .. ":hash"
2270
- ) or plugin:GetSetting(SETTINGS_PREFIX .. serverConfig.projectId .. ":" .. manifest.id .. ":hash")
2271
- local state = {
2272
- manifest = manifest,
2709
+ state.rowFields = {}
2710
+ for key, value in {
2273
2711
  group = group,
2274
2712
  row = row,
2713
+ connector = connector,
2275
2714
  title = title,
2276
2715
  icon = icon,
2277
- iconClass = if rootInstance then rootInstance.ClassName else "Model",
2716
+ iconClass = rootInstance and rootInstance.ClassName,
2278
2717
  contentDisclosure = contentDisclosure,
2279
2718
  contents = contents,
2280
2719
  contentsList = contentsList,
@@ -2294,21 +2733,10 @@ local function createRootRow(manifest, order)
2294
2733
  pullButton = pullButton,
2295
2734
  mergeButton = mergeButton,
2296
2735
  diffButton = diffButton,
2297
- root = nil,
2298
- rootConnections = {},
2299
- instanceConnections = {},
2300
- dirty = false,
2301
- generation = 0,
2302
- suppress = false,
2303
- conflict = false,
2304
- uploading = false,
2305
- pulling = false,
2306
- deleting = false,
2307
- conflictWorkspace = nil,
2308
- expanded = false,
2309
- lastHash = if type(savedHash) == "string" then savedHash else nil,
2310
- }
2311
- states[manifest.id] = state
2736
+ } do
2737
+ state[key] = value
2738
+ table.insert(state.rowFields, key)
2739
+ end
2312
2740
  contentDisclosure.Activated:Connect(function()
2313
2741
  toggleRootContents(state)
2314
2742
  end)
@@ -2316,7 +2744,7 @@ local function createRootRow(manifest, order)
2316
2744
  setRootExpanded(state, not state.expanded)
2317
2745
  end)
2318
2746
  title.Activated:Connect(function()
2319
- local root = resolveRoot(state.manifest.studioPath)
2747
+ local root = resolveRoot(state.manifest.studioPath, state.manifest.instanceIds)
2320
2748
  if root then
2321
2749
  Selection:Set({ root })
2322
2750
  end
@@ -2345,42 +2773,73 @@ local function createRootRow(manifest, order)
2345
2773
  inspectorWidget.Enabled = true
2346
2774
  end
2347
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
2348
2786
  return state
2349
2787
  end
2350
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
+
2351
2796
  local function reconcile(state, manifest)
2352
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
2353
2808
  if manifest.gitConflict then
2354
2809
  state.conflict = true
2355
- state.pushButton.Visible = false
2356
- state.pullButton.Visible = false
2357
- state.miniPush.Visible = false
2358
- state.miniPull.Visible = false
2359
- 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
2360
2817
  setStatus(state, "Git conflict — resolve in VS Code or open the optional merge workspace.", Color3.fromRGB(245, 106, 106))
2361
- if autoExpandConflicts then
2818
+ if autoExpandConflicts and state.row then
2362
2819
  setGroupExpanded(state.group, true)
2363
2820
  setRootExpanded(state, true)
2364
2821
  end
2365
2822
  return
2366
2823
  end
2367
- state.pushButton.Visible = true
2368
- state.pullButton.Visible = true
2369
- state.miniPush.Visible = true
2370
- state.miniPull.Visible = true
2371
- state.mergeButton.Visible = false
2372
- state.pushButton.Active = serverConfig.rojoConnected
2373
- state.pullButton.Active = serverConfig.rojoConnected
2374
- state.miniPush.Active = serverConfig.rojoConnected
2375
- state.miniPull.Active = serverConfig.rojoConnected
2376
- state.pushButton.AutoButtonColor = serverConfig.rojoConnected
2377
- state.pullButton.AutoButtonColor = serverConfig.rojoConnected
2378
- state.miniPush.AutoButtonColor = serverConfig.rojoConnected
2379
- state.miniPull.AutoButtonColor = serverConfig.rojoConnected
2380
- state.miniPush.TextColor3 = if serverConfig.rojoConnected
2381
- then Color3.fromRGB(115, 182, 255)
2382
- else Color3.fromRGB(95, 95, 105)
2383
- 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
2384
2843
  if state.conflictWorkspace then
2385
2844
  destroyConflictWorkspace(state)
2386
2845
  end
@@ -2388,23 +2847,40 @@ local function reconcile(state, manifest)
2388
2847
  setStatus(state, "Paused — Rojo offline; conflict viewer remains available.", Color3.fromRGB(244, 194, 92))
2389
2848
  return
2390
2849
  end
2391
- local root = resolveRoot(manifest.studioPath)
2850
+ local root = resolveRoot(manifest.studioPath, manifest.instanceIds)
2392
2851
  if state.contentRoot ~= root then
2393
2852
  state.contentRoot = root
2394
2853
  state.searchQuery = nil
2395
2854
  resetStateContents(state)
2396
- elseif root then
2855
+ elseif root and state.row then
2397
2856
  state.contentDisclosure.Visible = visualDepth > 0 and #root:GetChildren() > 0
2398
2857
  end
2399
- if root and state.iconClass ~= root.ClassName then
2858
+ if root and state.row and state.iconClass ~= root.ClassName then
2400
2859
  state.iconClass = root.ClassName
2401
2860
  applyClassIcon(state.icon, root.ClassName)
2402
2861
  end
2403
2862
  if root and state.root ~= root then
2404
2863
  watchRoot(state, root)
2405
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
2406
2875
 
2407
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
2408
2884
  if manifest.deleted and not root then
2409
2885
  state.conflict = false
2410
2886
  setStatus(state, "Deleted", Color3.fromRGB(102, 214, 139))
@@ -2426,6 +2902,12 @@ local function reconcile(state, manifest)
2426
2902
  return
2427
2903
  end
2428
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
2429
2911
  state.conflict = true
2430
2912
  setStatus(state, "First sync needs Push or Pull once.", Color3.fromRGB(244, 194, 92))
2431
2913
  return
@@ -2453,15 +2935,32 @@ local function refreshSettingsLabels()
2453
2935
  end
2454
2936
 
2455
2937
  applyTreeFilter = function()
2938
+ if filteringTree then
2939
+ filterAgain = true
2940
+ return
2941
+ end
2942
+ filteringTree = true
2456
2943
  local query = string.lower(searchBox.Text)
2944
+ local visibleGroups = {}
2945
+ local orderedStates = {}
2946
+ local parentsWithChildren = {}
2457
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
2458
2957
  if state.searchQuery ~= query then
2459
2958
  local searchable = string.lower(
2460
- table.concat(state.manifest.studioPath, ".") .. " " .. state.manifest.file .. " " .. state.status.Text
2959
+ table.concat(state.manifest.studioPath, ".") .. " " .. state.manifest.file .. " " .. state.statusText
2461
2960
  )
2462
2961
  local matches = query == "" or string.find(searchable, query, 1, true) ~= nil
2463
- if not matches then
2464
- 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)
2465
2964
  if root then
2466
2965
  for _, descendant in root:GetDescendants() do
2467
2966
  if string.find(string.lower(descendant.Name .. " " .. descendant.ClassName), query, 1, true) then
@@ -2474,21 +2973,43 @@ applyTreeFilter = function()
2474
2973
  state.searchQuery = query
2475
2974
  state.searchMatches = matches
2476
2975
  end
2477
- state.row.Visible = state.searchMatches and (showSynced or state.statusKind ~= "synced")
2478
- end
2479
- for _, group in groups do
2480
- local hasVisibleDescendant = false
2481
- for _, state in states do
2482
- if state.row.Visible and pathStartsWith(group.path, state.manifest.studioPath) then
2483
- hasVisibleDescendant = true
2484
- 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
2485
2988
  end
2989
+ if folderGroup then visible = false end
2990
+ end
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)
2486
2995
  end
2487
- group.section.Visible = hasVisibleDescendant
2488
- if query ~= "" and hasVisibleDescendant then
2489
- setGroupExpanded(group, true)
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()
2490
3000
  end
2491
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
2492
3013
  end
2493
3014
 
2494
3015
  addButtonHover(settingsButton)
@@ -2558,12 +3079,16 @@ local function manifestChanged(previous, current)
2558
3079
  or previous.conflictToken ~= current.conflictToken
2559
3080
  or previous.file ~= current.file
2560
3081
  or previous.splitRootId ~= current.splitRootId
2561
- 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)
2562
3085
  end
2563
3086
 
2564
3087
  refreshConfig = function()
2565
- local response, requestError = request("GET", "/v1/config")
2566
- 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
2567
3092
  local reason = requestError or (if response then responseError(response) else nil) or "connect Rojo to start syncing"
2568
3093
  if serverConfig then
2569
3094
  serverConfig.rojoConnected = false
@@ -2574,17 +3099,29 @@ refreshConfig = function()
2574
3099
  return false
2575
3100
  end
2576
3101
 
2577
- 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
2578
3104
  local projectChanged = serverConfig
2579
3105
  and (serverConfig.projectId ~= decoded.projectId or serverConfig.workspaceId ~= decoded.workspaceId)
2580
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
2581
3116
  if projectChanged then
2582
3117
  for _, state in states do
2583
3118
  disconnectRoot(state)
2584
3119
  destroyConflictWorkspace(state)
3120
+ destroyRootRow(state)
2585
3121
  end
2586
3122
  disconnectSplitRoots()
2587
3123
  table.clear(states)
3124
+ table.clear(treeDiscovery)
2588
3125
  for _, child in rootsContainer:GetChildren() do
2589
3126
  if child ~= rootsLayout then
2590
3127
  child:Destroy()
@@ -2601,6 +3138,49 @@ refreshConfig = function()
2601
3138
  connectionStatus.TextColor3 = Color3.fromRGB(244, 194, 92)
2602
3139
  end
2603
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
2604
3184
 
2605
3185
  local currentSplitRootIds = {}
2606
3186
  for _, splitRoot in decoded.splitRoots do
@@ -2617,25 +3197,77 @@ refreshConfig = function()
2617
3197
  disconnectSplitRoot(id)
2618
3198
  end
2619
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
2620
3211
  local treeChanged = projectChanged == true or connectionChanged == true
2621
3212
  batchingStatusUpdates = true
3213
+ local sliceStarted = os.clock()
2622
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
2623
3244
  local state = states[manifest.id]
2624
3245
  local created = state == nil
2625
3246
  if created then
2626
- state = createRootRow(manifest, order)
3247
+ state = createRootState(manifest, order)
2627
3248
  end
2628
- 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)
2629
3254
  local rootChanged = state.observedRoot ~= resolvedRoot
2630
3255
  state.observedRoot = resolvedRoot
2631
- 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()
2632
3258
  reconcile(state, manifest)
3259
+ if startupProfile then startupProfile.reconcile += os.clock() - reconcileStarted end
2633
3260
  treeChanged = true
2634
3261
  else
2635
3262
  state.manifest = manifest
2636
3263
  end
3264
+ if os.clock() - sliceStarted >= 0.004 then
3265
+ task.wait()
3266
+ sliceStarted = os.clock()
3267
+ end
2637
3268
  end
2638
3269
  local currentIds = {}
3270
+ table.clear(snapshotBatch)
2639
3271
  for _, manifest in decoded.roots do
2640
3272
  currentIds[manifest.id] = true
2641
3273
  end
@@ -2649,7 +3281,7 @@ refreshConfig = function()
2649
3281
  local state = states[id]
2650
3282
  disconnectRoot(state)
2651
3283
  destroyConflictWorkspace(state)
2652
- state.row:Destroy()
3284
+ destroyRootRow(state)
2653
3285
  states[id] = nil
2654
3286
  treeChanged = true
2655
3287
  end
@@ -2658,6 +3290,11 @@ refreshConfig = function()
2658
3290
  updateGroupSummaries()
2659
3291
  applyTreeFilter()
2660
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
2661
3298
  return true
2662
3299
  end
2663
3300
 
@@ -2673,6 +3310,7 @@ end)
2673
3310
 
2674
3311
  plugin.Unloading:Connect(function()
2675
3312
  running = false
3313
+ saveHashes()
2676
3314
  disconnectSplitRoots()
2677
3315
  for _, state in states do
2678
3316
  disconnectRoot(state)
@@ -2686,6 +3324,290 @@ task.spawn(function()
2686
3324
  task.wait((serverConfig and serverConfig.pollIntervalMs or 750) / 1000)
2687
3325
  end
2688
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
2689
3611
  ]]></string>
2690
3612
  </Properties>
2691
3613
  </Item>