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.
- package/README.md +60 -79
- package/assets/GoldSync.rbxmx +1130 -208
- package/bin/install-companion.mjs +29 -3
- package/media/configuration-assets.png +0 -0
- package/media/file-boundaries.png +0 -0
- package/package.json +2 -2
- package/src/companion.mjs +1 -1
- package/src/config.mjs +39 -6
- package/src/git-conflicts.mjs +1 -1
- package/src/server.mjs +108 -13
- package/src/store.mjs +231 -5
package/assets/GoldSync.rbxmx
CHANGED
|
@@ -19,10 +19,16 @@ local ENDPOINT = "http://127.0.0.1:34873"
|
|
|
19
19
|
local SESSION_MARKER_NAME = "__GoldSyncSession"
|
|
20
20
|
local CLIENT_HEADERS = {
|
|
21
21
|
["X-GoldSync-Client"] = "studio-plugin",
|
|
22
|
+
["X-GoldSync-Tree-Roots"] = "4",
|
|
22
23
|
}
|
|
23
24
|
local SETTINGS_PREFIX = "GoldSync:v2:"
|
|
24
25
|
local UI_SETTINGS_PREFIX = "GoldSync:ui:v1:"
|
|
25
|
-
local VERSION = "0.1.
|
|
26
|
+
local VERSION = "0.1.31"
|
|
27
|
+
local startupProfile = { settings = 0, watchers = 0, reconcile = 0, downloads = 0 }
|
|
28
|
+
local savedHashes = {}
|
|
29
|
+
local savedHashesKey
|
|
30
|
+
local hashesDirty = false
|
|
31
|
+
local TreeRoots = require(script.Parent.TreeRoots)
|
|
26
32
|
local REQUEST_INTERVAL_SECONDS = 0.15
|
|
27
33
|
local REQUEST_RETRY_DELAYS = { 1, 2, 4 }
|
|
28
34
|
|
|
@@ -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.
|
|
189
|
-
searchBox.PlaceholderColor3 = Color3.fromRGB(
|
|
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 =
|
|
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.
|
|
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 =
|
|
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.
|
|
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 =
|
|
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.
|
|
315
|
-
directionLegend.LayoutOrder =
|
|
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(
|
|
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 =
|
|
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.
|
|
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.
|
|
670
|
-
state.
|
|
671
|
-
state.
|
|
751
|
+
state.statusText = text
|
|
752
|
+
state.statusColor = statusColor
|
|
753
|
+
if state.row then
|
|
754
|
+
state.status.Text = text
|
|
755
|
+
state.status.TextColor3 = statusColor
|
|
756
|
+
state.dot.TextColor3 = statusColor
|
|
757
|
+
end
|
|
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 =
|
|
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
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1302
|
-
|
|
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, "
|
|
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
|
-
|
|
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.
|
|
1389
|
-
|
|
1390
|
-
|
|
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
|
|
1497
|
-
setStatus(state, "Result must
|
|
1863
|
+
if state.manifest.instanceIds and (resultRoot:GetAttribute("GoldSyncId") or "") ~= state.manifest.instanceIds[#state.manifest.studioPath] then
|
|
1864
|
+
setStatus(state, "Result GoldSyncId must match its file.", Color3.fromRGB(245, 106, 106))
|
|
1865
|
+
return
|
|
1866
|
+
end
|
|
1867
|
+
if resultRoot.Name ~= expectedName then
|
|
1868
|
+
setStatus(state, "Result must be a root named " .. expectedName .. ".", Color3.fromRGB(245, 106, 106))
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
1833
|
-
status = state.
|
|
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
|
-
|
|
1838
|
-
|
|
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
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
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(
|
|
1863
|
-
else Color3.fromRGB(
|
|
1864
|
-
statusBanner.TextColor3 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
2177
|
-
status.TextColor3 =
|
|
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
|
-
|
|
2269
|
-
|
|
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 =
|
|
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
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
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.
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
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.
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
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.
|
|
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.
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
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
|
-
|
|
2488
|
-
if
|
|
2489
|
-
|
|
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
|
|
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
|
|
2566
|
-
if
|
|
3088
|
+
local refreshStarted = os.clock()
|
|
3089
|
+
local response, requestError = request("GET", "/v1/config", nil, if configETag then { ["If-None-Match"] = configETag } else nil)
|
|
3090
|
+
if not response or (not response.Success and response.StatusCode ~= 304) then
|
|
3091
|
+
configETag = nil
|
|
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 =
|
|
3247
|
+
state = createRootState(manifest, order)
|
|
2627
3248
|
end
|
|
2628
|
-
local
|
|
3249
|
+
local owner = affectedBoundaries[manifest.id]
|
|
3250
|
+
local boundaryChanged = state.boundary ~= (owner == manifest.id) or state.coveredByBoundary ~= (if owner ~= manifest.id then owner else nil)
|
|
3251
|
+
state.boundary = owner == manifest.id
|
|
3252
|
+
state.coveredByBoundary = if owner ~= manifest.id then owner else nil
|
|
3253
|
+
local resolvedRoot = resolveRoot(manifest.studioPath, manifest.instanceIds)
|
|
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
|
|
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>
|