goldsync 0.1.11

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.
@@ -0,0 +1,2516 @@
1
+ <roblox version="4">
2
+ <Item class="Folder" referent="0">
3
+ <Properties>
4
+ <string name="Name">GoldSync</string>
5
+ </Properties>
6
+ <Item class="Script" referent="1">
7
+ <Properties>
8
+ <string name="Name">GoldSync</string>
9
+ <token name="RunContext">3</token>
10
+ <string name="Source"><![CDATA[local ChangeHistoryService = game:GetService("ChangeHistoryService")
11
+ local HttpService = game:GetService("HttpService")
12
+ local ReflectionService = game:GetService("ReflectionService")
13
+ local SerializationService = game:GetService("SerializationService")
14
+ local ServerStorage = game:GetService("ServerStorage")
15
+ local Selection = game:GetService("Selection")
16
+ local StudioService = game:GetService("StudioService")
17
+
18
+ local ENDPOINT = "http://127.0.0.1:34873"
19
+ local SESSION_MARKER_NAME = "__GoldSyncSession"
20
+ local CLIENT_HEADERS = {
21
+ ["X-GoldSync-Client"] = "studio-plugin",
22
+ }
23
+ local SETTINGS_PREFIX = "GoldSync:v2:"
24
+ local UI_SETTINGS_PREFIX = "GoldSync:ui:v1:"
25
+ local VERSION = "0.1.11"
26
+ local REQUEST_INTERVAL_SECONDS = 0.15
27
+ local REQUEST_RETRY_DELAYS = { 1, 2, 4 }
28
+
29
+ local showSynced = plugin:GetSetting(UI_SETTINGS_PREFIX .. "showSynced") ~= false
30
+ local autoExpandConflicts = plugin:GetSetting(UI_SETTINGS_PREFIX .. "autoExpandConflicts") ~= false
31
+ local visualDepth = math.clamp(tonumber(plugin:GetSetting(UI_SETTINGS_PREFIX .. "visualDepth")) or 3, 0, 5)
32
+
33
+ local toolbar = plugin:CreateToolbar("GoldSync")
34
+ local toolbarButton = toolbar:CreateButton(
35
+ "GoldSync",
36
+ "GoldSync v" .. VERSION .. " · Two-way native RBXM asset sync",
37
+ "rbxassetid://127943421516268"
38
+ )
39
+ toolbarButton.ClickableWhenViewportHidden = true
40
+
41
+ local widgetInfo = DockWidgetPluginGuiInfo.new(
42
+ Enum.InitialDockState.Right,
43
+ false,
44
+ false,
45
+ 360,
46
+ 300,
47
+ 280,
48
+ 180
49
+ )
50
+ local widget = plugin:CreateDockWidgetPluginGui("GoldSyncWidget", widgetInfo)
51
+ widget.Title = "GoldSync v" .. VERSION
52
+
53
+ local inspectorInfo = DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Right, false, false, 620, 500, 420, 280)
54
+ local inspectorWidget = plugin:CreateDockWidgetPluginGui("GoldSyncConflictInspector", inspectorInfo)
55
+ inspectorWidget.Title = "GoldSync Conflict Inspector"
56
+
57
+ local inspectorContent = Instance.new("ScrollingFrame")
58
+ inspectorContent.AutomaticCanvasSize = Enum.AutomaticSize.Y
59
+ inspectorContent.BackgroundColor3 = Color3.fromRGB(30, 30, 34)
60
+ inspectorContent.BorderSizePixel = 0
61
+ inspectorContent.CanvasSize = UDim2.new()
62
+ inspectorContent.ScrollBarThickness = 7
63
+ inspectorContent.Size = UDim2.fromScale(1, 1)
64
+ inspectorContent.Parent = inspectorWidget
65
+
66
+ local inspectorPadding = Instance.new("UIPadding")
67
+ inspectorPadding.PaddingBottom = UDim.new(0, 10)
68
+ inspectorPadding.PaddingLeft = UDim.new(0, 10)
69
+ inspectorPadding.PaddingRight = UDim.new(0, 10)
70
+ inspectorPadding.PaddingTop = UDim.new(0, 10)
71
+ inspectorPadding.Parent = inspectorContent
72
+
73
+ local inspectorLayout = Instance.new("UIListLayout")
74
+ inspectorLayout.Padding = UDim.new(0, 7)
75
+ inspectorLayout.SortOrder = Enum.SortOrder.LayoutOrder
76
+ inspectorLayout.Parent = inspectorContent
77
+
78
+ local inspectorTitle = Instance.new("TextLabel")
79
+ inspectorTitle.BackgroundTransparency = 1
80
+ inspectorTitle.Font = Enum.Font.BuilderSansBold
81
+ inspectorTitle.LayoutOrder = 1
82
+ inspectorTitle.Size = UDim2.new(1, 0, 0, 28)
83
+ inspectorTitle.Text = "No conflict workspace open"
84
+ inspectorTitle.TextColor3 = Color3.fromRGB(240, 240, 245)
85
+ inspectorTitle.TextSize = 18
86
+ inspectorTitle.TextXAlignment = Enum.TextXAlignment.Left
87
+ inspectorTitle.Parent = inspectorContent
88
+
89
+ local inspectorSummary = Instance.new("TextLabel")
90
+ inspectorSummary.AutomaticSize = Enum.AutomaticSize.Y
91
+ inspectorSummary.BackgroundTransparency = 1
92
+ inspectorSummary.Font = Enum.Font.BuilderSans
93
+ inspectorSummary.LayoutOrder = 2
94
+ inspectorSummary.Size = UDim2.new(1, 0, 0, 18)
95
+ inspectorSummary.Text = "Open Merge Workspace from a conflicted root."
96
+ inspectorSummary.TextColor3 = Color3.fromRGB(180, 180, 190)
97
+ inspectorSummary.TextSize = 13
98
+ inspectorSummary.TextWrapped = true
99
+ inspectorSummary.TextXAlignment = Enum.TextXAlignment.Left
100
+ inspectorSummary.Parent = inspectorContent
101
+
102
+ local inspectorEntries = {}
103
+ local inspectorRendered = 0
104
+ local inspectorGeneration = 0
105
+ local inspectorState
106
+ local renderInspector
107
+
108
+ local content = Instance.new("ScrollingFrame")
109
+ content.Name = "Content"
110
+ content.AutomaticCanvasSize = Enum.AutomaticSize.Y
111
+ content.BackgroundColor3 = Color3.fromRGB(30, 30, 34)
112
+ content.BorderSizePixel = 0
113
+ content.CanvasSize = UDim2.new()
114
+ content.ScrollBarThickness = 6
115
+ content.Size = UDim2.fromScale(1, 1)
116
+ content.Parent = widget
117
+
118
+ local padding = Instance.new("UIPadding")
119
+ padding.PaddingBottom = UDim.new(0, 10)
120
+ padding.PaddingLeft = UDim.new(0, 10)
121
+ padding.PaddingRight = UDim.new(0, 10)
122
+ padding.PaddingTop = UDim.new(0, 10)
123
+ padding.Parent = content
124
+
125
+ local layout = Instance.new("UIListLayout")
126
+ layout.Padding = UDim.new(0, 8)
127
+ layout.SortOrder = Enum.SortOrder.LayoutOrder
128
+ layout.Parent = content
129
+
130
+ local header = Instance.new("Frame")
131
+ header.BackgroundColor3 = Color3.fromRGB(40, 40, 46)
132
+ header.BorderSizePixel = 0
133
+ header.LayoutOrder = 1
134
+ header.Size = UDim2.new(1, 0, 0, 58)
135
+ header.Parent = content
136
+ Instance.new("UICorner", header).CornerRadius = UDim.new(0, 6)
137
+
138
+ local pluginIcon = Instance.new("ImageLabel")
139
+ pluginIcon.BackgroundTransparency = 1
140
+ pluginIcon.Image = "rbxassetid://127943421516268"
141
+ pluginIcon.Position = UDim2.new(0, 8, 0.5, -20)
142
+ pluginIcon.Size = UDim2.fromOffset(40, 40)
143
+ pluginIcon.Parent = header
144
+
145
+ local heading = Instance.new("TextLabel")
146
+ heading.BackgroundTransparency = 1
147
+ heading.Font = Enum.Font.BuilderSansBold
148
+ heading.Position = UDim2.new(0, 56, 0, 7)
149
+ heading.Size = UDim2.new(1, -64, 0, 22)
150
+ heading.Text = "GoldSync · v" .. VERSION
151
+ heading.TextColor3 = Color3.fromRGB(245, 245, 248)
152
+ heading.TextSize = 18
153
+ heading.TextXAlignment = Enum.TextXAlignment.Left
154
+ heading.Parent = header
155
+
156
+ local connectionDot = Instance.new("TextLabel")
157
+ connectionDot.BackgroundTransparency = 1
158
+ connectionDot.Font = Enum.Font.BuilderSansBold
159
+ connectionDot.Position = UDim2.new(0, 56, 0, 30)
160
+ connectionDot.Size = UDim2.fromOffset(12, 18)
161
+ connectionDot.Text = "●"
162
+ connectionDot.TextColor3 = Color3.fromRGB(180, 180, 190)
163
+ connectionDot.TextSize = 9
164
+ connectionDot.Parent = header
165
+
166
+ local connectionStatus = Instance.new("TextLabel")
167
+ connectionStatus.BackgroundTransparency = 1
168
+ connectionStatus.Font = Enum.Font.BuilderSansBold
169
+ connectionStatus.Position = UDim2.new(0, 70, 0, 29)
170
+ connectionStatus.Size = UDim2.new(1, -78, 0, 20)
171
+ connectionStatus.Text = "Waiting for daemon"
172
+ connectionStatus.TextColor3 = Color3.fromRGB(180, 180, 190)
173
+ connectionStatus.TextSize = 12
174
+ connectionStatus.TextTruncate = Enum.TextTruncate.AtEnd
175
+ connectionStatus.TextXAlignment = Enum.TextXAlignment.Left
176
+ connectionStatus.Parent = header
177
+
178
+ local controls = Instance.new("Frame")
179
+ controls.BackgroundTransparency = 1
180
+ controls.LayoutOrder = 2
181
+ controls.Size = UDim2.new(1, 0, 0, 28)
182
+ controls.Parent = content
183
+
184
+ local searchBox = Instance.new("TextBox")
185
+ searchBox.BackgroundColor3 = Color3.fromRGB(40, 40, 46)
186
+ searchBox.BorderSizePixel = 0
187
+ searchBox.ClearTextOnFocus = false
188
+ searchBox.Font = Enum.Font.BuilderSans
189
+ searchBox.PlaceholderColor3 = Color3.fromRGB(130, 130, 140)
190
+ searchBox.PlaceholderText = "Search files and instances..."
191
+ searchBox.Size = UDim2.new(1, -132, 1, 0)
192
+ searchBox.Text = ""
193
+ searchBox.TextColor3 = Color3.fromRGB(235, 235, 240)
194
+ searchBox.TextSize = 12
195
+ searchBox.TextXAlignment = Enum.TextXAlignment.Left
196
+ searchBox.Parent = controls
197
+ Instance.new("UICorner", searchBox).CornerRadius = UDim.new(0, 4)
198
+ local searchPadding = Instance.new("UIPadding", searchBox)
199
+ searchPadding.PaddingLeft = UDim.new(0, 8)
200
+ searchPadding.PaddingRight = UDim.new(0, 8)
201
+
202
+ local unsyncedButton = Instance.new("TextButton")
203
+ unsyncedButton.BackgroundColor3 = Color3.fromRGB(40, 40, 46)
204
+ unsyncedButton.BorderSizePixel = 0
205
+ unsyncedButton.Font = Enum.Font.BuilderSansBold
206
+ unsyncedButton.Position = UDim2.new(1, -126, 0, 0)
207
+ unsyncedButton.Size = UDim2.fromOffset(92, 28)
208
+ unsyncedButton.Text = "Unsynced · 0"
209
+ unsyncedButton.TextColor3 = Color3.fromRGB(190, 190, 198)
210
+ unsyncedButton.TextSize = 11
211
+ unsyncedButton.Parent = controls
212
+ Instance.new("UICorner", unsyncedButton).CornerRadius = UDim.new(0, 4)
213
+
214
+ local settingsButton = Instance.new("TextButton")
215
+ settingsButton.BackgroundColor3 = Color3.fromRGB(40, 40, 46)
216
+ settingsButton.BorderSizePixel = 0
217
+ settingsButton.Font = Enum.Font.BuilderSansBold
218
+ settingsButton.Position = UDim2.new(1, -28, 0, 0)
219
+ settingsButton.Size = UDim2.fromOffset(28, 28)
220
+ settingsButton.Text = "⚙"
221
+ settingsButton.TextColor3 = Color3.fromRGB(210, 210, 218)
222
+ settingsButton.TextSize = 15
223
+ settingsButton.Parent = controls
224
+ Instance.new("UICorner", settingsButton).CornerRadius = UDim.new(0, 4)
225
+
226
+ local settingsPanel = Instance.new("Frame")
227
+ settingsPanel.AutomaticSize = Enum.AutomaticSize.Y
228
+ settingsPanel.BackgroundColor3 = Color3.fromRGB(36, 36, 42)
229
+ settingsPanel.BorderSizePixel = 0
230
+ settingsPanel.LayoutOrder = 3
231
+ settingsPanel.Size = UDim2.new(1, 0, 0, 0)
232
+ settingsPanel.Visible = false
233
+ settingsPanel.Parent = content
234
+ Instance.new("UICorner", settingsPanel).CornerRadius = UDim.new(0, 5)
235
+ local settingsPadding = Instance.new("UIPadding", settingsPanel)
236
+ settingsPadding.PaddingBottom = UDim.new(0, 6)
237
+ settingsPadding.PaddingLeft = UDim.new(0, 6)
238
+ settingsPadding.PaddingRight = UDim.new(0, 6)
239
+ settingsPadding.PaddingTop = UDim.new(0, 6)
240
+ local settingsLayout = Instance.new("UIListLayout", settingsPanel)
241
+ settingsLayout.Padding = UDim.new(0, 4)
242
+
243
+ local showSyncedButton = Instance.new("TextButton")
244
+ showSyncedButton.BackgroundColor3 = Color3.fromRGB(48, 48, 56)
245
+ showSyncedButton.BorderSizePixel = 0
246
+ showSyncedButton.Font = Enum.Font.BuilderSans
247
+ showSyncedButton.Size = UDim2.new(1, 0, 0, 26)
248
+ showSyncedButton.TextColor3 = Color3.fromRGB(230, 230, 235)
249
+ showSyncedButton.TextSize = 12
250
+ showSyncedButton.TextXAlignment = Enum.TextXAlignment.Left
251
+ showSyncedButton.Parent = settingsPanel
252
+ Instance.new("UICorner", showSyncedButton).CornerRadius = UDim.new(0, 4)
253
+ local showSyncedPadding = Instance.new("UIPadding", showSyncedButton)
254
+ showSyncedPadding.PaddingLeft = UDim.new(0, 8)
255
+
256
+ local autoExpandButton = showSyncedButton:Clone()
257
+ autoExpandButton.Parent = settingsPanel
258
+
259
+ local depthRow = Instance.new("Frame")
260
+ depthRow.BackgroundColor3 = Color3.fromRGB(48, 48, 56)
261
+ depthRow.BorderSizePixel = 0
262
+ depthRow.Size = UDim2.new(1, 0, 0, 26)
263
+ depthRow.Parent = settingsPanel
264
+ Instance.new("UICorner", depthRow).CornerRadius = UDim.new(0, 4)
265
+
266
+ local depthLabel = Instance.new("TextLabel")
267
+ depthLabel.BackgroundTransparency = 1
268
+ depthLabel.Font = Enum.Font.BuilderSans
269
+ depthLabel.Position = UDim2.new(0, 8, 0, 0)
270
+ depthLabel.Size = UDim2.new(1, -64, 1, 0)
271
+ depthLabel.TextColor3 = Color3.fromRGB(230, 230, 235)
272
+ depthLabel.TextSize = 12
273
+ depthLabel.TextXAlignment = Enum.TextXAlignment.Left
274
+ depthLabel.Parent = depthRow
275
+
276
+ local depthMinus = Instance.new("TextButton")
277
+ depthMinus.BackgroundTransparency = 1
278
+ depthMinus.Font = Enum.Font.BuilderSansBold
279
+ depthMinus.Position = UDim2.new(1, -52, 0, 0)
280
+ depthMinus.Size = UDim2.new(0, 26, 1, 0)
281
+ depthMinus.Text = "−"
282
+ depthMinus.TextColor3 = Color3.fromRGB(180, 180, 190)
283
+ depthMinus.TextSize = 16
284
+ depthMinus.Parent = depthRow
285
+
286
+ local depthPlus = depthMinus:Clone()
287
+ depthPlus.Position = UDim2.new(1, -26, 0, 0)
288
+ depthPlus.Text = "+"
289
+ depthPlus.Parent = depthRow
290
+
291
+ local statusBanner = Instance.new("TextLabel")
292
+ statusBanner.AutomaticSize = Enum.AutomaticSize.Y
293
+ statusBanner.BackgroundColor3 = Color3.fromRGB(66, 38, 40)
294
+ statusBanner.BorderSizePixel = 0
295
+ statusBanner.Font = Enum.Font.BuilderSans
296
+ statusBanner.LayoutOrder = 4
297
+ statusBanner.Size = UDim2.new(1, 0, 0, 0)
298
+ statusBanner.Text = ""
299
+ statusBanner.TextColor3 = Color3.fromRGB(255, 205, 205)
300
+ statusBanner.TextSize = 11
301
+ statusBanner.TextWrapped = true
302
+ statusBanner.TextXAlignment = Enum.TextXAlignment.Left
303
+ statusBanner.Visible = false
304
+ statusBanner.Parent = content
305
+ Instance.new("UICorner", statusBanner).CornerRadius = UDim.new(0, 5)
306
+ local statusBannerPadding = Instance.new("UIPadding", statusBanner)
307
+ statusBannerPadding.PaddingBottom = UDim.new(0, 7)
308
+ statusBannerPadding.PaddingLeft = UDim.new(0, 8)
309
+ statusBannerPadding.PaddingRight = UDim.new(0, 8)
310
+ statusBannerPadding.PaddingTop = UDim.new(0, 7)
311
+
312
+ local directionLegend = Instance.new("TextLabel")
313
+ directionLegend.BackgroundTransparency = 1
314
+ directionLegend.Font = Enum.Font.BuilderSans
315
+ directionLegend.LayoutOrder = 5
316
+ directionLegend.Size = UDim2.new(1, 0, 0, 18)
317
+ directionLegend.Text = "→ Studio to file ← File to Studio"
318
+ directionLegend.TextColor3 = Color3.fromRGB(145, 145, 155)
319
+ directionLegend.TextSize = 11
320
+ directionLegend.TextXAlignment = Enum.TextXAlignment.Right
321
+ directionLegend.Parent = content
322
+
323
+ local rootsContainer = Instance.new("Frame")
324
+ rootsContainer.AutomaticSize = Enum.AutomaticSize.Y
325
+ rootsContainer.BackgroundTransparency = 1
326
+ rootsContainer.LayoutOrder = 6
327
+ rootsContainer.Size = UDim2.new(1, 0, 0, 0)
328
+ rootsContainer.Parent = content
329
+
330
+ local rootsLayout = Instance.new("UIListLayout")
331
+ rootsLayout.Padding = UDim.new(0, 8)
332
+ rootsLayout.SortOrder = Enum.SortOrder.LayoutOrder
333
+ rootsLayout.Parent = rootsContainer
334
+
335
+ local running = true
336
+ local bulkRunning = false
337
+ local requestLocked = false
338
+ local requestWaiters = {}
339
+ local lastRequestAt = 0
340
+ local serverConfig
341
+ local states = {}
342
+ local groups = {}
343
+ local refreshConfig
344
+ local updateGroupSummaries
345
+ local refreshStatusSummary
346
+ local refreshUnsyncedButton
347
+ local upload
348
+ local pull
349
+ local deleteSnapshot
350
+ local openGitConflict
351
+ local resolveGitConflict
352
+ local applyTreeFilter
353
+ local resetInstanceTrees
354
+
355
+ local function collectSplitPaths(root, depth)
356
+ local paths = {}
357
+ local function visit(instance, remainingDepth, relativePath)
358
+ if remainingDepth == 0 then
359
+ table.insert(paths, relativePath)
360
+ return
361
+ end
362
+ for _, child in instance:GetChildren() do
363
+ local childPath = table.clone(relativePath)
364
+ table.insert(childPath, child.Name)
365
+ visit(child, remainingDepth - 1, childPath)
366
+ end
367
+ end
368
+ visit(root, depth, {})
369
+ return paths
370
+ end
371
+
372
+ local function acquireRequestLock()
373
+ if requestLocked then
374
+ local waiter = Instance.new("BindableEvent")
375
+ table.insert(requestWaiters, waiter)
376
+ waiter.Event:Wait()
377
+ waiter:Destroy()
378
+ end
379
+ requestLocked = true
380
+ end
381
+
382
+ local function releaseRequestLock()
383
+ local waiter = table.remove(requestWaiters, 1)
384
+ if waiter then
385
+ waiter:Fire()
386
+ else
387
+ requestLocked = false
388
+ end
389
+ end
390
+
391
+ local function request(method, path, body, headers)
392
+ local requestHeaders = table.clone(CLIENT_HEADERS)
393
+ local sessionMarker = ServerStorage:FindFirstChild(SESSION_MARKER_NAME)
394
+ if sessionMarker and sessionMarker.ClassName == "StringValue" and sessionMarker.Value ~= "" then
395
+ requestHeaders["X-GoldSync-Session"] = sessionMarker.Value
396
+ end
397
+ if headers then
398
+ for key, value in headers do
399
+ requestHeaders[key] = value
400
+ end
401
+ end
402
+
403
+ acquireRequestLock()
404
+ local requestError
405
+ for attempt = 1, #REQUEST_RETRY_DELAYS + 1 do
406
+ local delayUntilRequest = REQUEST_INTERVAL_SECONDS - (os.clock() - lastRequestAt)
407
+ if delayUntilRequest > 0 then
408
+ task.wait(delayUntilRequest)
409
+ end
410
+ local success, response = pcall(function()
411
+ return HttpService:RequestAsync({
412
+ Url = ENDPOINT .. path,
413
+ Method = method,
414
+ Headers = requestHeaders,
415
+ Body = body,
416
+ })
417
+ end)
418
+ lastRequestAt = os.clock()
419
+ if success then
420
+ releaseRequestLock()
421
+ return response, nil
422
+ end
423
+ requestError = tostring(response)
424
+ if not string.find(requestError, "Number of requests exceeded limit", 1, true)
425
+ or attempt > #REQUEST_RETRY_DELAYS
426
+ then
427
+ break
428
+ end
429
+ task.wait(REQUEST_RETRY_DELAYS[attempt])
430
+ end
431
+ releaseRequestLock()
432
+ return nil, requestError
433
+ end
434
+
435
+ local function responseError(response)
436
+ if response.Body ~= "" then
437
+ local success, decoded = pcall(HttpService.JSONDecode, HttpService, response.Body)
438
+ if success and type(decoded) == "table" and type(decoded.error) == "string" then
439
+ return decoded.error
440
+ end
441
+ end
442
+ return string.format("HTTP %d · %s", response.StatusCode, tostring(response.StatusMessage))
443
+ end
444
+
445
+ local function resolveRoot(studioPath)
446
+ local current = game:GetService(studioPath[1])
447
+ for index = 2, #studioPath do
448
+ current = current:FindFirstChild(studioPath[index])
449
+ if not current then
450
+ return nil
451
+ end
452
+ end
453
+ return current
454
+ end
455
+
456
+ local function resolveParent(studioPath)
457
+ local current = game:GetService(studioPath[1])
458
+ for index = 2, #studioPath - 1 do
459
+ current = current:FindFirstChild(studioPath[index])
460
+ if not current then
461
+ return nil
462
+ end
463
+ end
464
+ return current
465
+ end
466
+
467
+ local function applyClassIcon(image, className)
468
+ local success, icon = pcall(function()
469
+ return StudioService:GetClassIcon(className)
470
+ end)
471
+ if success then
472
+ for property, value in icon do
473
+ image[property] = value
474
+ end
475
+ end
476
+ end
477
+
478
+ local function addButtonHover(button)
479
+ button.MouseEnter:Connect(function()
480
+ button.BackgroundColor3 = Color3.fromRGB(58, 58, 68)
481
+ button.BackgroundTransparency = 0
482
+ end)
483
+ button.MouseLeave:Connect(function()
484
+ button.BackgroundTransparency = 1
485
+ end)
486
+ Instance.new("UICorner", button).CornerRadius = UDim.new(0, 3)
487
+ end
488
+
489
+ local function findCode(root)
490
+ if root:IsA("BaseScript") then
491
+ return root
492
+ end
493
+ for _, descendant in root:GetDescendants() do
494
+ if descendant:IsA("BaseScript") then
495
+ return descendant
496
+ end
497
+ end
498
+ return nil
499
+ end
500
+
501
+ local function setStatus(state, text, color)
502
+ local previousStatusKind = state.statusKind
503
+ state.status.Text = text
504
+ state.status.TextColor3 = color or Color3.fromRGB(180, 180, 190)
505
+ state.dot.TextColor3 = color or Color3.fromRGB(180, 180, 190)
506
+ state.searchQuery = nil
507
+ state.statusKind = if string.sub(text, 1, 6) == "Synced"
508
+ then "synced"
509
+ elseif string.find(text, "syncing")
510
+ or string.find(text, "Serializing")
511
+ or string.find(text, "Loading")
512
+ or string.find(text, "Saving")
513
+ or string.find(text, "applying")
514
+ then "working"
515
+ elseif string.find(text, "Rojo") and string.find(text, "offline") then "paused"
516
+ else "issue"
517
+ if updateGroupSummaries then
518
+ if previousStatusKind ~= state.statusKind then
519
+ updateGroupSummaries()
520
+ elseif refreshStatusSummary then
521
+ refreshStatusSummary()
522
+ end
523
+ end
524
+ end
525
+
526
+ local propertyNamesByClass = {}
527
+ local propertySetsByClass = {}
528
+
529
+ local function buildInstanceMap(root)
530
+ local byKey = { [""] = root }
531
+ local keyByInstance = { [root] = "" }
532
+ local displayByKey = { [""] = root.Name }
533
+ local function visit(parent, parentKey, displayPath)
534
+ local occurrences = {}
535
+ for _, child in parent:GetChildren() do
536
+ local identity = child.ClassName .. "\0" .. child.Name
537
+ occurrences[identity] = (occurrences[identity] or 0) + 1
538
+ local key = parentKey .. "\1" .. identity .. "\0" .. occurrences[identity]
539
+ local childDisplayPath = displayPath .. "." .. child.Name
540
+ byKey[key] = child
541
+ keyByInstance[child] = key
542
+ displayByKey[key] = childDisplayPath
543
+ visit(child, key, childDisplayPath)
544
+ end
545
+ end
546
+ visit(root, "", root.Name)
547
+ return byKey, keyByInstance, displayByKey
548
+ end
549
+
550
+ local function comparableValue(value, keyByInstance)
551
+ if typeof(value) == "Instance" then
552
+ return "Instance:" .. (keyByInstance[value] or value:GetFullName())
553
+ end
554
+ return typeof(value) .. ":" .. tostring(value)
555
+ end
556
+
557
+ local function displayValue(value)
558
+ if #value <= 240 then
559
+ return value
560
+ end
561
+ return string.sub(value, 1, 237) .. "..."
562
+ end
563
+
564
+ local function getSerializedPropertyNames(className)
565
+ if propertyNamesByClass[className] then
566
+ return propertyNamesByClass[className]
567
+ end
568
+ local names = {}
569
+ local success, properties = pcall(function()
570
+ return ReflectionService:GetPropertiesOfClass(className)
571
+ end)
572
+ if success then
573
+ for _, property in properties do
574
+ if property.Serialized and property.Name ~= "UniqueId" and property.Name ~= "HistoryId" then
575
+ table.insert(names, property.Name)
576
+ end
577
+ end
578
+ table.sort(names)
579
+ end
580
+ propertyNamesByClass[className] = names
581
+ return names
582
+ end
583
+
584
+ local function getSerializedPropertySet(className)
585
+ if propertySetsByClass[className] then
586
+ return propertySetsByClass[className]
587
+ end
588
+ local properties = {}
589
+ for _, propertyName in getSerializedPropertyNames(className) do
590
+ properties[propertyName] = true
591
+ end
592
+ propertySetsByClass[className] = properties
593
+ return properties
594
+ end
595
+
596
+ local function readProperty(instance, propertyName)
597
+ if not instance then
598
+ return false, nil
599
+ end
600
+ return pcall(function()
601
+ return instance[propertyName]
602
+ end)
603
+ end
604
+
605
+ local function addInspectorEntry(entry)
606
+ table.insert(inspectorEntries, entry)
607
+ end
608
+
609
+ local function compareConflictWorkspace(state, versions, resultRoot)
610
+ inspectorGeneration += 1
611
+ inspectorState = state
612
+ local generation = inspectorGeneration
613
+ table.clear(inspectorEntries)
614
+ inspectorRendered = 0
615
+ inspectorTitle.Text = table.concat(state.manifest.studioPath, ".")
616
+ inspectorSummary.Text = "Scanning serialized properties..."
617
+ inspectorWidget.Enabled = true
618
+
619
+ local maps = {}
620
+ for _, stage in { "base", "ours", "theirs" } do
621
+ if versions[stage] then
622
+ local byKey, keyByInstance, displayByKey = buildInstanceMap(versions[stage])
623
+ maps[stage] = { byKey = byKey, keyByInstance = keyByInstance, displayByKey = displayByKey }
624
+ end
625
+ end
626
+ local resultByKey, resultKeyByInstance, resultDisplayByKey = buildInstanceMap(resultRoot)
627
+ maps.result = { byKey = resultByKey, keyByInstance = resultKeyByInstance, displayByKey = resultDisplayByKey }
628
+
629
+ local allKeys = {}
630
+ for _, stage in { "base", "ours", "theirs" } do
631
+ if maps[stage] then
632
+ for key in maps[stage].byKey do
633
+ allKeys[key] = true
634
+ end
635
+ end
636
+ end
637
+ local sortedKeys = {}
638
+ for key in allKeys do
639
+ table.insert(sortedKeys, key)
640
+ end
641
+ table.sort(sortedKeys)
642
+
643
+ local scanned = 0
644
+ for _, key in sortedKeys do
645
+ if generation ~= inspectorGeneration or not state.conflictWorkspace then
646
+ return
647
+ end
648
+ local base = maps.base and maps.base.byKey[key]
649
+ local ours = maps.ours and maps.ours.byKey[key]
650
+ local theirs = maps.theirs and maps.theirs.byKey[key]
651
+ local result = maps.result.byKey[key]
652
+ local displayPath = (maps.ours and maps.ours.displayByKey[key])
653
+ or (maps.theirs and maps.theirs.displayByKey[key])
654
+ or (maps.base and maps.base.displayByKey[key])
655
+ or resultDisplayByKey[key]
656
+ local basePresence = base and base.ClassName or "<deleted>"
657
+ local oursPresence = ours and ours.ClassName or "<deleted>"
658
+ local theirsPresence = theirs and theirs.ClassName or "<deleted>"
659
+ if basePresence ~= oursPresence or basePresence ~= theirsPresence then
660
+ local status = if oursPresence ~= basePresence and theirsPresence ~= basePresence
661
+ then (if oursPresence == theirsPresence then "same" else "conflict")
662
+ elseif oursPresence ~= basePresence then "ours"
663
+ else "theirs"
664
+ addInspectorEntry({
665
+ path = displayPath,
666
+ property = "(instance)",
667
+ baseText = basePresence,
668
+ oursText = oursPresence,
669
+ theirsText = theirsPresence,
670
+ status = status,
671
+ resultInstance = result,
672
+ })
673
+ elseif base and ours and theirs and base.ClassName == ours.ClassName and base.ClassName == theirs.ClassName then
674
+ for _, propertyName in getSerializedPropertyNames(base.ClassName) do
675
+ local baseReadable, baseValue = readProperty(base, propertyName)
676
+ local oursReadable, oursValue = readProperty(ours, propertyName)
677
+ local theirsReadable, theirsValue = readProperty(theirs, propertyName)
678
+ if baseReadable and oursReadable and theirsReadable then
679
+ local baseText = comparableValue(baseValue, maps.base.keyByInstance)
680
+ local oursText = comparableValue(oursValue, maps.ours.keyByInstance)
681
+ local theirsText = comparableValue(theirsValue, maps.theirs.keyByInstance)
682
+ local oursChanged = oursText ~= baseText
683
+ local theirsChanged = theirsText ~= baseText
684
+ if oursChanged or theirsChanged then
685
+ local status = if oursChanged and theirsChanged
686
+ then (if oursText == theirsText then "same" else "conflict")
687
+ elseif oursChanged then "ours"
688
+ else "theirs"
689
+ addInspectorEntry({
690
+ path = displayPath,
691
+ property = propertyName,
692
+ baseText = baseText,
693
+ oursText = oursText,
694
+ theirsText = theirsText,
695
+ status = status,
696
+ oursValue = oursValue,
697
+ theirsValue = theirsValue,
698
+ oursKeyByInstance = maps.ours.keyByInstance,
699
+ theirsKeyByInstance = maps.theirs.keyByInstance,
700
+ resultByKey = resultByKey,
701
+ resultInstance = result,
702
+ })
703
+ end
704
+ end
705
+ end
706
+ end
707
+ scanned += 1
708
+ if scanned % 100 == 0 then
709
+ inspectorSummary.Text = string.format("Scanning... %d instances, %d changes", scanned, #inspectorEntries)
710
+ task.wait()
711
+ end
712
+ end
713
+
714
+ local conflicts = 0
715
+ for _, entry in inspectorEntries do
716
+ if entry.status == "conflict" then
717
+ conflicts += 1
718
+ end
719
+ end
720
+ inspectorSummary.Text = string.format(
721
+ "%d changed properties/instances; %d true conflicts. Blue = yours, yellow = theirs, red = both, green = same.",
722
+ #inspectorEntries,
723
+ conflicts
724
+ )
725
+ renderInspector()
726
+ end
727
+
728
+ renderInspector = function()
729
+ for _, child in inspectorContent:GetChildren() do
730
+ if child ~= inspectorPadding and child ~= inspectorLayout and child ~= inspectorTitle and child ~= inspectorSummary then
731
+ child:Destroy()
732
+ end
733
+ end
734
+ local count = math.min(#inspectorEntries, inspectorRendered + 100)
735
+ inspectorRendered = count
736
+ for index = 1, count do
737
+ local entry = inspectorEntries[index]
738
+ local colors = {
739
+ ours = Color3.fromRGB(48, 91, 132),
740
+ theirs = Color3.fromRGB(123, 96, 38),
741
+ conflict = Color3.fromRGB(126, 52, 57),
742
+ same = Color3.fromRGB(45, 104, 67),
743
+ }
744
+ local row = Instance.new("Frame")
745
+ row.AutomaticSize = Enum.AutomaticSize.Y
746
+ row.BackgroundColor3 = colors[entry.status]
747
+ row.BorderSizePixel = 0
748
+ row.LayoutOrder = 10 + index
749
+ row.Size = UDim2.new(1, 0, 0, 0)
750
+ row.Parent = inspectorContent
751
+ Instance.new("UICorner", row).CornerRadius = UDim.new(0, 5)
752
+ local rowPadding = Instance.new("UIPadding", row)
753
+ rowPadding.PaddingBottom = UDim.new(0, 7)
754
+ rowPadding.PaddingLeft = UDim.new(0, 8)
755
+ rowPadding.PaddingRight = UDim.new(0, 8)
756
+ rowPadding.PaddingTop = UDim.new(0, 7)
757
+ local rowLayout = Instance.new("UIListLayout", row)
758
+ rowLayout.Padding = UDim.new(0, 4)
759
+ local pathButton = Instance.new("TextButton")
760
+ pathButton.AutomaticSize = Enum.AutomaticSize.Y
761
+ pathButton.BackgroundTransparency = 1
762
+ pathButton.Font = Enum.Font.BuilderSansBold
763
+ pathButton.Size = UDim2.new(1, 0, 0, 18)
764
+ pathButton.Text = entry.path .. " • " .. entry.property
765
+ pathButton.TextColor3 = Color3.fromRGB(250, 250, 252)
766
+ pathButton.TextSize = 13
767
+ pathButton.TextWrapped = true
768
+ pathButton.TextXAlignment = Enum.TextXAlignment.Left
769
+ pathButton.Parent = row
770
+ pathButton.Activated:Connect(function()
771
+ if entry.resultInstance then
772
+ Selection:Set({ entry.resultInstance })
773
+ end
774
+ end)
775
+ local values = Instance.new("TextLabel")
776
+ values.AutomaticSize = Enum.AutomaticSize.Y
777
+ values.BackgroundTransparency = 1
778
+ values.Font = Enum.Font.Code
779
+ values.Size = UDim2.new(1, 0, 0, 18)
780
+ values.Text = "Base: "
781
+ .. displayValue(entry.baseText)
782
+ .. "\nYours: "
783
+ .. displayValue(entry.oursText)
784
+ .. "\nTheirs: "
785
+ .. displayValue(entry.theirsText)
786
+ values.TextColor3 = Color3.fromRGB(232, 232, 238)
787
+ values.TextSize = 11
788
+ values.TextWrapped = true
789
+ values.TextXAlignment = Enum.TextXAlignment.Left
790
+ values.Parent = row
791
+ if entry.property ~= "(instance)" and entry.resultInstance then
792
+ local actions = Instance.new("Frame")
793
+ actions.BackgroundTransparency = 1
794
+ actions.Size = UDim2.new(1, 0, 0, 24)
795
+ actions.Parent = row
796
+ local actionsLayout = Instance.new("UIListLayout", actions)
797
+ actionsLayout.FillDirection = Enum.FillDirection.Horizontal
798
+ actionsLayout.Padding = UDim.new(0, 6)
799
+ for buttonIndex, side in { "ours", "theirs" } do
800
+ local button = Instance.new("TextButton")
801
+ button.BackgroundColor3 = Color3.fromRGB(50, 50, 58)
802
+ button.BorderSizePixel = 0
803
+ button.Font = Enum.Font.BuilderSansBold
804
+ button.Size = UDim2.new(0.5, -3, 1, 0)
805
+ button.Text = if side == "ours" then "Use Yours" else "Use Theirs"
806
+ button.TextColor3 = Color3.fromRGB(245, 245, 248)
807
+ button.TextSize = 11
808
+ button.LayoutOrder = buttonIndex
809
+ button.Parent = actions
810
+ Instance.new("UICorner", button).CornerRadius = UDim.new(0, 4)
811
+ button.Activated:Connect(function()
812
+ local value = entry[side .. "Value"]
813
+ if typeof(value) == "Instance" then
814
+ value = entry.resultByKey[entry[side .. "KeyByInstance"][value]]
815
+ end
816
+ local applied = pcall(function()
817
+ entry.resultInstance[entry.property] = value
818
+ end)
819
+ if applied then
820
+ Selection:Set({ entry.resultInstance })
821
+ end
822
+ end)
823
+ end
824
+ end
825
+ end
826
+ if inspectorRendered < #inspectorEntries then
827
+ local more = Instance.new("TextButton")
828
+ more.BackgroundColor3 = Color3.fromRGB(70, 70, 80)
829
+ more.BorderSizePixel = 0
830
+ more.Font = Enum.Font.BuilderSansBold
831
+ more.LayoutOrder = 1000000
832
+ more.Size = UDim2.new(1, 0, 0, 32)
833
+ more.Text = string.format("Load 100 more (%d remaining)", #inspectorEntries - inspectorRendered)
834
+ more.TextColor3 = Color3.fromRGB(240, 240, 245)
835
+ more.TextSize = 12
836
+ more.Parent = inspectorContent
837
+ Instance.new("UICorner", more).CornerRadius = UDim.new(0, 5)
838
+ more.Activated:Connect(renderInspector)
839
+ end
840
+ end
841
+
842
+ local function disconnectRoot(state)
843
+ for _, connection in state.rootConnections do
844
+ connection:Disconnect()
845
+ end
846
+ table.clear(state.rootConnections)
847
+ for instance, connections in state.instanceConnections do
848
+ for _, connection in connections do
849
+ connection:Disconnect()
850
+ end
851
+ state.instanceConnections[instance] = nil
852
+ end
853
+ end
854
+
855
+ local function markDirty(state)
856
+ if state.suppress or state.conflict or state.deleting or not state.root then
857
+ return
858
+ end
859
+ state.dirty = true
860
+ state.generation += 1
861
+ local generation = state.generation
862
+ setStatus(state, "Studio changed; syncing...", Color3.fromRGB(244, 194, 92))
863
+ task.delay((serverConfig and serverConfig.debounceMs or 600) / 1000, function()
864
+ if running and state.generation == generation and state.dirty and not state.conflict and not state.deleting then
865
+ upload(state, false)
866
+ end
867
+ end)
868
+ end
869
+
870
+ local function watchInstance(state, instance)
871
+ if state.instanceConnections[instance] then
872
+ return
873
+ end
874
+ local connections = {
875
+ instance.AttributeChanged:Connect(function()
876
+ markDirty(state)
877
+ end),
878
+ }
879
+ if instance:IsA("ValueBase") then
880
+ for _, propertyName in getSerializedPropertyNames(instance.ClassName) do
881
+ local connected, connection = pcall(function()
882
+ return instance:GetPropertyChangedSignal(propertyName):Connect(function()
883
+ markDirty(state)
884
+ end)
885
+ end)
886
+ if connected then
887
+ table.insert(connections, connection)
888
+ end
889
+ end
890
+ else
891
+ local serializedProperties = getSerializedPropertySet(instance.ClassName)
892
+ table.insert(connections, instance.Changed:Connect(function(propertyName)
893
+ if serializedProperties[propertyName] then
894
+ markDirty(state)
895
+ end
896
+ end))
897
+ end
898
+ state.instanceConnections[instance] = connections
899
+ end
900
+
901
+ local function watchRoot(state, root)
902
+ disconnectRoot(state)
903
+ state.root = root
904
+ watchInstance(state, root)
905
+ for _, descendant in root:GetDescendants() do
906
+ watchInstance(state, descendant)
907
+ end
908
+ table.insert(state.rootConnections, root.DescendantAdded:Connect(function(descendant)
909
+ watchInstance(state, descendant)
910
+ markDirty(state)
911
+ end))
912
+ table.insert(state.rootConnections, root.DescendantRemoving:Connect(function(descendant)
913
+ local connections = state.instanceConnections[descendant]
914
+ if connections then
915
+ for _, connection in connections do
916
+ connection:Disconnect()
917
+ end
918
+ state.instanceConnections[descendant] = nil
919
+ end
920
+ markDirty(state)
921
+ end))
922
+ table.insert(state.rootConnections, root.AncestryChanged:Connect(function()
923
+ if not state.suppress and resolveRoot(state.manifest.studioPath) ~= root then
924
+ deleteSnapshot(state)
925
+ end
926
+ end))
927
+ end
928
+
929
+ local function rememberHash(state, hash)
930
+ state.lastHash = hash
931
+ plugin:SetSetting(
932
+ SETTINGS_PREFIX .. serverConfig.projectId .. ":" .. serverConfig.workspaceId .. ":" .. state.manifest.id .. ":hash",
933
+ hash
934
+ )
935
+ end
936
+
937
+ local function forgetHash(state)
938
+ state.lastHash = nil
939
+ plugin:SetSetting(
940
+ SETTINGS_PREFIX .. serverConfig.projectId .. ":" .. serverConfig.workspaceId .. ":" .. state.manifest.id .. ":hash",
941
+ nil
942
+ )
943
+ end
944
+
945
+ deleteSnapshot = function(state)
946
+ if state.uploading or state.pulling or state.deleting then
947
+ return
948
+ end
949
+ state.generation += 1
950
+ state.dirty = false
951
+ if state.manifest.gitConflict or not state.lastHash then
952
+ state.conflict = true
953
+ setStatus(state, "Root removed; resolve the existing file conflict manually.", Color3.fromRGB(245, 106, 106))
954
+ return
955
+ end
956
+ if not serverConfig.rojoConnected then
957
+ state.conflict = true
958
+ setStatus(state, "Root removed while Rojo is offline.", Color3.fromRGB(244, 194, 92))
959
+ return
960
+ end
961
+
962
+ state.deleting = true
963
+ setStatus(state, "Deleting file snapshot...", Color3.fromRGB(115, 182, 255))
964
+ local response, requestError = request("DELETE", "/v1/roots/" .. state.manifest.id .. "/snapshot", nil, {
965
+ ["If-Match"] = state.lastHash,
966
+ })
967
+ state.deleting = false
968
+ if not response then
969
+ state.conflict = true
970
+ setStatus(state, "Daemon unavailable: " .. requestError, Color3.fromRGB(245, 106, 106))
971
+ return
972
+ end
973
+ if response.StatusCode == 409 then
974
+ local reason = responseError(response)
975
+ state.conflict = string.find(reason, "snapshot changed", 1, true) ~= nil
976
+ setStatus(
977
+ state,
978
+ if state.conflict then "Conflict: file changed before Studio deleted it." else "Paused — " .. reason,
979
+ if state.conflict then Color3.fromRGB(245, 106, 106) else Color3.fromRGB(244, 194, 92)
980
+ )
981
+ return
982
+ end
983
+ if not response.Success then
984
+ state.conflict = true
985
+ setStatus(state, "Delete failed (HTTP " .. response.StatusCode .. ")", Color3.fromRGB(245, 106, 106))
986
+ return
987
+ end
988
+
989
+ disconnectRoot(state)
990
+ state.root = nil
991
+ state.manifest = HttpService:JSONDecode(response.Body)
992
+ state.conflict = false
993
+ forgetHash(state)
994
+ setStatus(state, "Deleted Studio → file", Color3.fromRGB(102, 214, 139))
995
+ end
996
+
997
+ upload = function(state, overwrite)
998
+ if state.uploading or state.pulling or state.deleting then
999
+ return
1000
+ end
1001
+ if state.manifest.gitConflict then
1002
+ setStatus(state, "Resolve the Git conflict before syncing.", Color3.fromRGB(245, 106, 106))
1003
+ return
1004
+ end
1005
+ if not serverConfig.rojoConnected then
1006
+ setStatus(state, "Paused — Rojo is offline.", Color3.fromRGB(244, 194, 92))
1007
+ return
1008
+ end
1009
+ local root = resolveRoot(state.manifest.studioPath)
1010
+ if not root then
1011
+ state.conflict = true
1012
+ setStatus(state, "Studio root is missing. Pull to restore it.", Color3.fromRGB(245, 106, 106))
1013
+ return
1014
+ end
1015
+ local scriptInstance = findCode(root)
1016
+ if scriptInstance then
1017
+ state.conflict = true
1018
+ setStatus(state, "Blocked: contains code at " .. scriptInstance:GetFullName(), Color3.fromRGB(245, 106, 106))
1019
+ return
1020
+ end
1021
+
1022
+ state.uploading = true
1023
+ setStatus(state, "Serializing Studio root...", Color3.fromRGB(115, 182, 255))
1024
+ local serialized, serializationError
1025
+ local success, result = pcall(function()
1026
+ return SerializationService:SerializeInstancesAsync({ root })
1027
+ end)
1028
+ if success then
1029
+ serialized = result
1030
+ else
1031
+ serializationError = tostring(result)
1032
+ end
1033
+ if not serialized then
1034
+ state.uploading = false
1035
+ setStatus(state, "Serialization failed: " .. serializationError, Color3.fromRGB(245, 106, 106))
1036
+ return
1037
+ end
1038
+
1039
+ local expectedHash = "*"
1040
+ if overwrite then
1041
+ expectedHash = state.manifest.exists and state.manifest.hash or "*"
1042
+ elseif state.lastHash then
1043
+ expectedHash = state.lastHash
1044
+ end
1045
+ local response, requestError = request("PUT", "/v1/roots/" .. state.manifest.id .. "/snapshot", buffer.tostring(serialized), {
1046
+ ["Content-Type"] = "application/octet-stream",
1047
+ ["If-Match"] = expectedHash,
1048
+ })
1049
+ state.uploading = false
1050
+ if not response then
1051
+ setStatus(state, "Daemon unavailable: " .. requestError, Color3.fromRGB(245, 106, 106))
1052
+ return
1053
+ end
1054
+ if response.StatusCode == 409 then
1055
+ local reason = responseError(response)
1056
+ state.conflict = string.find(reason, "snapshot changed", 1, true) ~= nil
1057
+ setStatus(
1058
+ state,
1059
+ if state.conflict then "Conflict: disk changed too. Push or Pull." else "Paused — " .. reason,
1060
+ if state.conflict then Color3.fromRGB(245, 106, 106) else Color3.fromRGB(244, 194, 92)
1061
+ )
1062
+ return
1063
+ end
1064
+ if not response.Success then
1065
+ setStatus(state, "Upload failed (HTTP " .. response.StatusCode .. ")", Color3.fromRGB(245, 106, 106))
1066
+ return
1067
+ end
1068
+
1069
+ local manifest = HttpService:JSONDecode(response.Body)
1070
+ state.manifest = manifest
1071
+ state.dirty = false
1072
+ state.conflict = false
1073
+ rememberHash(state, manifest.hash)
1074
+ watchRoot(state, root)
1075
+ setStatus(state, "Synced Studio → file", Color3.fromRGB(102, 214, 139))
1076
+ end
1077
+
1078
+ pull = function(state)
1079
+ if state.uploading or state.pulling or state.deleting then
1080
+ return
1081
+ end
1082
+ if state.manifest.gitConflict then
1083
+ setStatus(state, "Resolve the Git conflict before syncing.", Color3.fromRGB(245, 106, 106))
1084
+ return
1085
+ end
1086
+ if not serverConfig.rojoConnected then
1087
+ setStatus(state, "Paused — Rojo is offline.", Color3.fromRGB(244, 194, 92))
1088
+ return
1089
+ end
1090
+ if not state.manifest.exists then
1091
+ setStatus(state, "No file snapshot exists. Push Studio first.", Color3.fromRGB(245, 106, 106))
1092
+ return
1093
+ end
1094
+ local parent = resolveParent(state.manifest.studioPath)
1095
+ if not parent then
1096
+ setStatus(state, "Studio parent path is missing.", Color3.fromRGB(245, 106, 106))
1097
+ return
1098
+ end
1099
+ local currentRoot = resolveRoot(state.manifest.studioPath)
1100
+ local currentScript = currentRoot and findCode(currentRoot)
1101
+ if currentScript then
1102
+ state.conflict = true
1103
+ setStatus(state, "Blocked: Studio root contains code at " .. currentScript:GetFullName(), Color3.fromRGB(245, 106, 106))
1104
+ return
1105
+ end
1106
+
1107
+ state.pulling = true
1108
+ setStatus(state, "Loading file snapshot...", Color3.fromRGB(115, 182, 255))
1109
+ local response, requestError = request("GET", "/v1/roots/" .. state.manifest.id .. "/snapshot")
1110
+ if not response then
1111
+ state.pulling = false
1112
+ setStatus(state, "Daemon unavailable: " .. requestError, Color3.fromRGB(245, 106, 106))
1113
+ return
1114
+ end
1115
+ if not response.Success then
1116
+ state.pulling = false
1117
+ setStatus(state, "Download failed (HTTP " .. response.StatusCode .. ")", Color3.fromRGB(245, 106, 106))
1118
+ return
1119
+ end
1120
+
1121
+ local success, instances = pcall(function()
1122
+ return SerializationService:DeserializeInstancesAsync(buffer.fromstring(response.Body))
1123
+ end)
1124
+ if not success then
1125
+ state.pulling = false
1126
+ setStatus(state, "Invalid RBXM: " .. tostring(instances), Color3.fromRGB(245, 106, 106))
1127
+ return
1128
+ end
1129
+ if #instances ~= 1 then
1130
+ for _, instance in instances do
1131
+ instance:Destroy()
1132
+ end
1133
+ state.pulling = false
1134
+ setStatus(state, "Blocked: snapshot must contain exactly one root.", Color3.fromRGB(245, 106, 106))
1135
+ return
1136
+ end
1137
+
1138
+ local replacement = instances[1]
1139
+ local expectedName = state.manifest.studioPath[#state.manifest.studioPath]
1140
+ local scriptInstance = findCode(replacement)
1141
+ if replacement.Name ~= expectedName or scriptInstance then
1142
+ replacement:Destroy()
1143
+ state.pulling = false
1144
+ if scriptInstance then
1145
+ setStatus(state, "Blocked: file snapshot contains code.", Color3.fromRGB(245, 106, 106))
1146
+ else
1147
+ setStatus(state, "Blocked: root must be named " .. expectedName, Color3.fromRGB(245, 106, 106))
1148
+ end
1149
+ return
1150
+ end
1151
+
1152
+ state.suppress = true
1153
+ disconnectRoot(state)
1154
+ local previous = resolveRoot(state.manifest.studioPath)
1155
+ ChangeHistoryService:SetWaypoint("GoldSync before Pull")
1156
+ local applied, applyError = pcall(function()
1157
+ replacement.Parent = parent
1158
+ end)
1159
+ if not applied then
1160
+ replacement:Destroy()
1161
+ state.suppress = false
1162
+ state.pulling = false
1163
+ if previous then
1164
+ watchRoot(state, previous)
1165
+ end
1166
+ setStatus(state, "Could not apply snapshot: " .. tostring(applyError), Color3.fromRGB(245, 106, 106))
1167
+ return
1168
+ end
1169
+ if previous then
1170
+ previous:Destroy()
1171
+ end
1172
+ ChangeHistoryService:SetWaypoint("GoldSync Pull " .. expectedName)
1173
+ state.root = replacement
1174
+ state.dirty = false
1175
+ state.conflict = false
1176
+ state.pulling = false
1177
+ rememberHash(state, state.manifest.hash)
1178
+ watchRoot(state, replacement)
1179
+ state.suppress = false
1180
+ setStatus(state, "Synced file → Studio", Color3.fromRGB(102, 214, 139))
1181
+ end
1182
+
1183
+ local function deserializeConflictStage(state, stage)
1184
+ if not state.manifest.conflictStages[stage] then
1185
+ return nil
1186
+ end
1187
+ local response, requestError = request("GET", "/v1/roots/" .. state.manifest.id .. "/conflict/" .. stage)
1188
+ if not response then
1189
+ return nil, requestError
1190
+ end
1191
+ if not response.Success then
1192
+ return nil, "HTTP " .. response.StatusCode
1193
+ end
1194
+ local success, instances = pcall(function()
1195
+ return SerializationService:DeserializeInstancesAsync(buffer.fromstring(response.Body))
1196
+ end)
1197
+ if not success then
1198
+ return nil, tostring(instances)
1199
+ end
1200
+ if #instances ~= 1 then
1201
+ for _, instance in instances do
1202
+ instance:Destroy()
1203
+ end
1204
+ return nil, "snapshot must contain exactly one root"
1205
+ end
1206
+ if findCode(instances[1]) then
1207
+ instances[1]:Destroy()
1208
+ return nil, "snapshot contains code"
1209
+ end
1210
+ return instances[1]
1211
+ end
1212
+
1213
+ local function destroyConflictWorkspace(state)
1214
+ if inspectorState == state then
1215
+ inspectorGeneration += 1
1216
+ inspectorState = nil
1217
+ table.clear(inspectorEntries)
1218
+ inspectorRendered = 0
1219
+ inspectorTitle.Text = "No conflict workspace open"
1220
+ inspectorSummary.Text = "Open Merge Workspace from a conflicted root."
1221
+ renderInspector()
1222
+ end
1223
+ if state.conflictWorkspace then
1224
+ local parent = state.conflictWorkspace.Parent
1225
+ state.conflictWorkspace:Destroy()
1226
+ state.conflictWorkspace = nil
1227
+ if parent and #parent:GetChildren() == 0 then
1228
+ parent:Destroy()
1229
+ end
1230
+ end
1231
+ state.mergeButton.Text = "Open Merge Workspace"
1232
+ state.mergeButton.Size = UDim2.fromScale(1, 1)
1233
+ state.diffButton.Visible = false
1234
+ end
1235
+
1236
+ openGitConflict = function(state)
1237
+ if state.uploading or state.pulling or not state.manifest.gitConflict then
1238
+ return
1239
+ end
1240
+ if state.conflictWorkspace and state.conflictWorkspace.Parent then
1241
+ Selection:Set({ state.conflictWorkspace })
1242
+ setStatus(state, "Edit ServerStorage.GoldSync Conflicts." .. state.manifest.id .. ".Result, then click Resolve Result.", Color3.fromRGB(244, 194, 92))
1243
+ return
1244
+ elseif state.conflictWorkspace then
1245
+ state.conflictWorkspace = nil
1246
+ end
1247
+
1248
+ state.pulling = true
1249
+ setStatus(state, "Loading Git conflict versions...", Color3.fromRGB(115, 182, 255))
1250
+ local versions = {}
1251
+ for _, stage in { "base", "ours", "theirs" } do
1252
+ local instance, stageError = deserializeConflictStage(state, stage)
1253
+ if stageError then
1254
+ for _, loaded in versions do
1255
+ loaded:Destroy()
1256
+ end
1257
+ state.pulling = false
1258
+ setStatus(state, "Could not load " .. stage .. ": " .. stageError, Color3.fromRGB(245, 106, 106))
1259
+ return
1260
+ end
1261
+ if instance then
1262
+ versions[stage] = instance
1263
+ end
1264
+ end
1265
+
1266
+ local resultStage = if versions.ours then "ours" else "theirs"
1267
+ if not versions[resultStage] then
1268
+ for _, loaded in versions do
1269
+ loaded:Destroy()
1270
+ end
1271
+ state.pulling = false
1272
+ setStatus(state, "Conflict has no usable version.", Color3.fromRGB(245, 106, 106))
1273
+ return
1274
+ end
1275
+ local resultRoot, resultError = deserializeConflictStage(state, resultStage)
1276
+ if not resultRoot then
1277
+ for _, loaded in versions do
1278
+ loaded:Destroy()
1279
+ end
1280
+ state.pulling = false
1281
+ setStatus(state, "Could not create Result: " .. resultError, Color3.fromRGB(245, 106, 106))
1282
+ return
1283
+ end
1284
+
1285
+ local workspaces = ServerStorage:FindFirstChild("GoldSync Conflicts")
1286
+ if not workspaces then
1287
+ workspaces = Instance.new("Folder")
1288
+ workspaces.Name = "GoldSync Conflicts"
1289
+ workspaces.Archivable = false
1290
+ workspaces.Parent = ServerStorage
1291
+ end
1292
+ local existing = workspaces:FindFirstChild(state.manifest.id)
1293
+ if existing then
1294
+ existing:Destroy()
1295
+ end
1296
+
1297
+ local workspace = Instance.new("Folder")
1298
+ workspace.Name = state.manifest.id
1299
+ workspace.Archivable = false
1300
+ workspace:SetAttribute("GoldSyncWorkspaceId", serverConfig.workspaceId)
1301
+ workspace.Parent = workspaces
1302
+ for _, stage in { "base", "ours", "theirs" } do
1303
+ local versionFolder = Instance.new("Folder")
1304
+ versionFolder.Name = if stage == "base" then "Base" elseif stage == "ours" then "Yours" else "Theirs"
1305
+ versionFolder.Parent = workspace
1306
+ if versions[stage] then
1307
+ versions[stage].Parent = versionFolder
1308
+ else
1309
+ versionFolder:SetAttribute("Deleted", true)
1310
+ end
1311
+ end
1312
+ local resultFolder = Instance.new("Folder")
1313
+ resultFolder.Name = "Result"
1314
+ resultFolder.Parent = workspace
1315
+ resultRoot.Parent = resultFolder
1316
+
1317
+ state.conflictWorkspace = workspace
1318
+ state.pulling = false
1319
+ state.mergeButton.Text = "Resolve Result"
1320
+ state.mergeButton.Size = UDim2.new(0.5, -3, 1, 0)
1321
+ state.diffButton.Visible = true
1322
+ Selection:Set({ workspace })
1323
+ setStatus(state, "Edit Result in ServerStorage.GoldSync Conflicts, then click Resolve Result.", Color3.fromRGB(244, 194, 92))
1324
+ task.spawn(compareConflictWorkspace, state, versions, resultRoot)
1325
+ end
1326
+
1327
+ resolveGitConflict = function(state)
1328
+ local workspace = state.conflictWorkspace
1329
+ if not workspace or state.uploading or state.pulling then
1330
+ return
1331
+ end
1332
+ local resultChildren = workspace.Result:GetChildren()
1333
+ if #resultChildren ~= 1 then
1334
+ setStatus(state, "Result must contain exactly one root.", Color3.fromRGB(245, 106, 106))
1335
+ return
1336
+ end
1337
+ local resultRoot = resultChildren[1]
1338
+ local expectedName = state.manifest.studioPath[#state.manifest.studioPath]
1339
+ if resultRoot.Name ~= expectedName or findCode(resultRoot) then
1340
+ setStatus(state, "Result must be a code-free root named " .. expectedName .. ".", Color3.fromRGB(245, 106, 106))
1341
+ return
1342
+ end
1343
+
1344
+ state.uploading = true
1345
+ setStatus(state, "Saving merged result...", Color3.fromRGB(115, 182, 255))
1346
+ local success, serialized = pcall(function()
1347
+ return SerializationService:SerializeInstancesAsync({ resultRoot })
1348
+ end)
1349
+ if not success then
1350
+ state.uploading = false
1351
+ setStatus(state, "Serialization failed: " .. tostring(serialized), Color3.fromRGB(245, 106, 106))
1352
+ return
1353
+ end
1354
+
1355
+ local response, requestError = request(
1356
+ "PUT",
1357
+ "/v1/roots/" .. state.manifest.id .. "/conflict/resolve",
1358
+ buffer.tostring(serialized),
1359
+ {
1360
+ ["Content-Type"] = "application/octet-stream",
1361
+ ["X-GoldSync-Conflict-Token"] = state.manifest.conflictToken,
1362
+ }
1363
+ )
1364
+ state.uploading = false
1365
+ if not response then
1366
+ setStatus(state, "Daemon unavailable: " .. requestError, Color3.fromRGB(245, 106, 106))
1367
+ return
1368
+ end
1369
+ if not response.Success then
1370
+ setStatus(state, "Resolve failed (HTTP " .. response.StatusCode .. ")", Color3.fromRGB(245, 106, 106))
1371
+ return
1372
+ end
1373
+
1374
+ destroyConflictWorkspace(state)
1375
+ state.conflict = false
1376
+ setStatus(state, "Git conflict resolved; applying result...", Color3.fromRGB(102, 214, 139))
1377
+ refreshConfig()
1378
+ end
1379
+
1380
+ local function setGroupExpanded(group, expanded)
1381
+ group.expanded = expanded
1382
+ group.children.Visible = expanded
1383
+ group.disclosure.Text = if expanded then "▾" else "▸"
1384
+ group.name.Text = group.label .. group.summary
1385
+ end
1386
+
1387
+ local function pathStartsWith(prefix, fullPath)
1388
+ if #prefix > #fullPath then
1389
+ return false
1390
+ end
1391
+ for index, segment in prefix do
1392
+ if fullPath[index] ~= segment then
1393
+ return false
1394
+ end
1395
+ end
1396
+ return true
1397
+ end
1398
+
1399
+ local function createGroup(path, order)
1400
+ local key = table.concat(path, "\0")
1401
+ if groups[key] then
1402
+ return groups[key]
1403
+ end
1404
+ local parentGroup
1405
+ if #path > 1 then
1406
+ local parentPath = table.clone(path)
1407
+ table.remove(parentPath)
1408
+ parentGroup = createGroup(parentPath, order)
1409
+ end
1410
+
1411
+ local section = Instance.new("Frame")
1412
+ section.AutomaticSize = Enum.AutomaticSize.Y
1413
+ section.BackgroundTransparency = 1
1414
+ section.LayoutOrder = order
1415
+ section.Size = UDim2.new(1, 0, 0, 0)
1416
+ section.Parent = if parentGroup then parentGroup.list else rootsContainer
1417
+ if parentGroup then
1418
+ local connector = Instance.new("Frame")
1419
+ connector.BackgroundColor3 = Color3.fromRGB(72, 72, 82)
1420
+ connector.BorderSizePixel = 0
1421
+ connector.Position = UDim2.new(0, -8, 0, 11)
1422
+ connector.Size = UDim2.fromOffset(35, 1)
1423
+ connector.Parent = section
1424
+ end
1425
+
1426
+ local sectionLayout = Instance.new("UIListLayout")
1427
+ sectionLayout.Padding = UDim.new(0, 2)
1428
+ sectionLayout.Parent = section
1429
+
1430
+ local header = Instance.new("Frame")
1431
+ header.BackgroundColor3 = Color3.fromRGB(40, 40, 46)
1432
+ header.BackgroundTransparency = 1
1433
+ header.BorderSizePixel = 0
1434
+ header.LayoutOrder = 1
1435
+ header.Size = UDim2.new(1, 0, 0, 24)
1436
+ header.Parent = section
1437
+
1438
+ local disclosure = Instance.new("TextButton")
1439
+ disclosure.BackgroundTransparency = 1
1440
+ disclosure.Font = Enum.Font.BuilderSansBold
1441
+ disclosure.Size = UDim2.new(0, 18, 1, 0)
1442
+ disclosure.TextColor3 = Color3.fromRGB(180, 180, 188)
1443
+ disclosure.TextSize = 12
1444
+ disclosure.Parent = header
1445
+ addButtonHover(disclosure)
1446
+
1447
+ local icon = Instance.new("ImageLabel")
1448
+ icon.BackgroundTransparency = 1
1449
+ icon.Position = UDim2.new(0, 19, 0.5, -8)
1450
+ icon.Size = UDim2.fromOffset(16, 16)
1451
+ icon.Parent = header
1452
+ local parentInstance = resolveRoot(path)
1453
+ applyClassIcon(icon, if parentInstance then parentInstance.ClassName else "Folder")
1454
+
1455
+ local name = Instance.new("TextButton")
1456
+ name.BackgroundTransparency = 1
1457
+ name.Font = Enum.Font.BuilderSansBold
1458
+ name.Position = UDim2.new(0, 39, 0, 0)
1459
+ name.Size = UDim2.new(1, -101, 1, 0)
1460
+ name.TextColor3 = Color3.fromRGB(226, 226, 232)
1461
+ name.TextSize = 13
1462
+ name.TextXAlignment = Enum.TextXAlignment.Left
1463
+ name.Parent = header
1464
+
1465
+ local statusDot = Instance.new("TextLabel")
1466
+ statusDot.BackgroundTransparency = 1
1467
+ statusDot.Font = Enum.Font.BuilderSansBold
1468
+ statusDot.Position = UDim2.new(1, -60, 0, 0)
1469
+ statusDot.Size = UDim2.new(0, 12, 1, 0)
1470
+ statusDot.Text = "●"
1471
+ statusDot.TextColor3 = Color3.fromRGB(180, 180, 190)
1472
+ statusDot.TextSize = 9
1473
+ statusDot.Parent = header
1474
+
1475
+ local bulkPush = Instance.new("TextButton")
1476
+ bulkPush.BackgroundTransparency = 1
1477
+ bulkPush.Font = Enum.Font.BuilderSansBold
1478
+ bulkPush.Position = UDim2.new(1, -48, 0, 0)
1479
+ bulkPush.Size = UDim2.new(0, 24, 1, 0)
1480
+ bulkPush.Text = "→"
1481
+ bulkPush.TextColor3 = Color3.fromRGB(115, 182, 255)
1482
+ bulkPush.TextSize = 16
1483
+ bulkPush.Visible = false
1484
+ bulkPush.Parent = header
1485
+ addButtonHover(bulkPush)
1486
+
1487
+ local bulkPull = Instance.new("TextButton")
1488
+ bulkPull.BackgroundTransparency = 1
1489
+ bulkPull.Font = Enum.Font.BuilderSansBold
1490
+ bulkPull.Position = UDim2.new(1, -24, 0, 0)
1491
+ bulkPull.Size = UDim2.new(0, 24, 1, 0)
1492
+ bulkPull.Text = "←"
1493
+ bulkPull.TextColor3 = Color3.fromRGB(115, 182, 255)
1494
+ bulkPull.TextSize = 16
1495
+ bulkPull.Visible = false
1496
+ bulkPull.Parent = header
1497
+ addButtonHover(bulkPull)
1498
+
1499
+ header.MouseEnter:Connect(function()
1500
+ header.BackgroundTransparency = 0
1501
+ end)
1502
+ header.MouseLeave:Connect(function()
1503
+ header.BackgroundTransparency = 1
1504
+ end)
1505
+
1506
+ local children = Instance.new("Frame")
1507
+ children.AutomaticSize = Enum.AutomaticSize.Y
1508
+ children.BackgroundTransparency = 1
1509
+ children.LayoutOrder = 2
1510
+ children.Size = UDim2.new(1, 0, 0, 0)
1511
+ children.Visible = false
1512
+ children.Parent = section
1513
+ local guide = Instance.new("Frame")
1514
+ guide.BackgroundColor3 = Color3.fromRGB(72, 72, 82)
1515
+ guide.BorderSizePixel = 0
1516
+ guide.Position = UDim2.new(0, 6, 0, 0)
1517
+ guide.Size = UDim2.fromOffset(1, 0)
1518
+ guide.Parent = children
1519
+
1520
+ local childList = Instance.new("Frame")
1521
+ childList.AutomaticSize = Enum.AutomaticSize.Y
1522
+ childList.BackgroundTransparency = 1
1523
+ childList.Position = UDim2.new(0, 14, 0, 0)
1524
+ childList.Size = UDim2.new(1, -14, 0, 0)
1525
+ childList.Parent = children
1526
+ local childrenLayout = Instance.new("UIListLayout", childList)
1527
+ childrenLayout.Padding = UDim.new(0, 2)
1528
+ childrenLayout.SortOrder = Enum.SortOrder.LayoutOrder
1529
+ childrenLayout:GetPropertyChangedSignal("AbsoluteContentSize"):Connect(function()
1530
+ guide.Size = UDim2.fromOffset(1, childrenLayout.AbsoluteContentSize.Y)
1531
+ end)
1532
+
1533
+ local group = {
1534
+ key = key,
1535
+ label = path[#path],
1536
+ path = table.clone(path),
1537
+ section = section,
1538
+ header = header,
1539
+ disclosure = disclosure,
1540
+ name = name,
1541
+ statusDot = statusDot,
1542
+ bulkPush = bulkPush,
1543
+ bulkPull = bulkPull,
1544
+ children = children,
1545
+ list = childList,
1546
+ expanded = false,
1547
+ hasDirectRoots = false,
1548
+ summary = "",
1549
+ }
1550
+ groups[key] = group
1551
+ setGroupExpanded(group, #path <= 2)
1552
+ disclosure.Activated:Connect(function()
1553
+ setGroupExpanded(group, not group.expanded)
1554
+ end)
1555
+ name.Activated:Connect(function()
1556
+ setGroupExpanded(group, not group.expanded)
1557
+ end)
1558
+ bulkPush.Activated:Connect(function()
1559
+ if bulkRunning then
1560
+ return
1561
+ end
1562
+ bulkRunning = true
1563
+ for _, state in states do
1564
+ if pathStartsWith(group.path, state.manifest.studioPath) and not state.manifest.gitConflict then
1565
+ upload(state, true)
1566
+ end
1567
+ end
1568
+ bulkRunning = false
1569
+ end)
1570
+ bulkPull.Activated:Connect(function()
1571
+ if bulkRunning then
1572
+ return
1573
+ end
1574
+ bulkRunning = true
1575
+ for _, state in states do
1576
+ if pathStartsWith(group.path, state.manifest.studioPath) and not state.manifest.gitConflict then
1577
+ pull(state)
1578
+ end
1579
+ end
1580
+ bulkRunning = false
1581
+ end)
1582
+ return group
1583
+ end
1584
+
1585
+ local function setRootExpanded(state, expanded)
1586
+ state.expanded = expanded
1587
+ state.row.BackgroundTransparency = 1
1588
+ state.file.Visible = expanded
1589
+ state.status.Visible = expanded
1590
+ state.buttons.Visible = expanded
1591
+ state.infoButton.TextColor3 = if expanded then Color3.fromRGB(235, 235, 240) else Color3.fromRGB(150, 150, 160)
1592
+ end
1593
+
1594
+ updateGroupSummaries = function()
1595
+ for _, group in groups do
1596
+ local total = 0
1597
+ local synced = 0
1598
+ local issues = 0
1599
+ local pending = 0
1600
+ local paused = 0
1601
+ for _, state in states do
1602
+ if pathStartsWith(group.path, state.manifest.studioPath) then
1603
+ total += 1
1604
+ if state.statusKind == "synced" then
1605
+ synced += 1
1606
+ elseif state.statusKind == "issue" then
1607
+ issues += 1
1608
+ elseif state.statusKind == "paused" then
1609
+ paused += 1
1610
+ else
1611
+ pending += 1
1612
+ end
1613
+ end
1614
+ end
1615
+ group.summary = string.format(" (%d/%d) synced", synced, total)
1616
+ if issues > 0 then
1617
+ group.summary ..= string.format(" · %d need attention", issues)
1618
+ elseif pending > 0 then
1619
+ group.summary ..= string.format(" · %d pending", pending)
1620
+ elseif paused > 0 then
1621
+ group.summary ..= string.format(" · %d paused", paused)
1622
+ end
1623
+ if total == 0 then
1624
+ group.section.Visible = false
1625
+ else
1626
+ group.section.Visible = true
1627
+ group.bulkPush.Visible = true
1628
+ group.bulkPull.Visible = true
1629
+ group.statusDot.TextColor3 = if issues > 0
1630
+ then Color3.fromRGB(245, 106, 106)
1631
+ elseif synced == total then Color3.fromRGB(102, 214, 139)
1632
+ elseif pending > 0 or paused > 0 then Color3.fromRGB(244, 194, 92)
1633
+ else Color3.fromRGB(180, 180, 190)
1634
+ group.bulkPush.Active = serverConfig.rojoConnected
1635
+ group.bulkPull.Active = serverConfig.rojoConnected
1636
+ group.bulkPush.AutoButtonColor = serverConfig.rojoConnected
1637
+ group.bulkPull.AutoButtonColor = serverConfig.rojoConnected
1638
+ local arrowColor = if serverConfig.rojoConnected
1639
+ then Color3.fromRGB(115, 182, 255)
1640
+ else Color3.fromRGB(95, 95, 105)
1641
+ group.bulkPush.TextColor3 = arrowColor
1642
+ group.bulkPull.TextColor3 = arrowColor
1643
+ setGroupExpanded(group, group.expanded or (issues > 0 and autoExpandConflicts))
1644
+ end
1645
+ end
1646
+ if refreshUnsyncedButton then
1647
+ refreshUnsyncedButton()
1648
+ end
1649
+ if refreshStatusSummary then
1650
+ refreshStatusSummary()
1651
+ end
1652
+ end
1653
+
1654
+ refreshUnsyncedButton = function()
1655
+ local unsynced = 0
1656
+ for _, state in states do
1657
+ if state.statusKind ~= "synced" then
1658
+ unsynced += 1
1659
+ end
1660
+ end
1661
+ unsyncedButton.Text = (if showSynced then "" else "✓ ") .. string.format("Unsynced · %d", unsynced)
1662
+ unsyncedButton.BackgroundColor3 = if showSynced
1663
+ then Color3.fromRGB(40, 40, 46)
1664
+ else Color3.fromRGB(78, 66, 38)
1665
+ unsyncedButton.TextColor3 = if showSynced
1666
+ then Color3.fromRGB(190, 190, 198)
1667
+ else Color3.fromRGB(255, 218, 126)
1668
+ end
1669
+
1670
+ refreshStatusSummary = function()
1671
+ local issues = {}
1672
+ local working = {}
1673
+ for _, state in states do
1674
+ local entry = {
1675
+ path = table.concat(state.manifest.studioPath, "."),
1676
+ status = state.status.Text,
1677
+ }
1678
+ if state.statusKind == "issue" then
1679
+ table.insert(issues, entry)
1680
+ elseif state.statusKind == "working" then
1681
+ table.insert(working, entry)
1682
+ end
1683
+ end
1684
+ local entries = if #issues > 0 then issues else working
1685
+ if #entries == 0 then
1686
+ statusBanner.Visible = false
1687
+ return
1688
+ end
1689
+ table.sort(entries, function(left, right)
1690
+ return left.path < right.path
1691
+ end)
1692
+ local lines = {
1693
+ if #issues > 0
1694
+ then string.format("%d need attention", #issues)
1695
+ else string.format("%d still syncing", #working),
1696
+ }
1697
+ for index = 1, math.min(#entries, 3) do
1698
+ local entry = entries[index]
1699
+ table.insert(lines, entry.path .. " — " .. entry.status)
1700
+ end
1701
+ if #entries > 3 then
1702
+ table.insert(lines, string.format("+%d more", #entries - 3))
1703
+ end
1704
+ statusBanner.BackgroundColor3 = if #issues > 0
1705
+ then Color3.fromRGB(66, 38, 40)
1706
+ else Color3.fromRGB(66, 56, 34)
1707
+ statusBanner.TextColor3 = if #issues > 0
1708
+ then Color3.fromRGB(255, 205, 205)
1709
+ else Color3.fromRGB(255, 224, 145)
1710
+ statusBanner.Text = table.concat(lines, "\n")
1711
+ statusBanner.Visible = true
1712
+ end
1713
+
1714
+ local function createInstanceNode(instance, parent, depth, order)
1715
+ local section = Instance.new("Frame")
1716
+ section.AutomaticSize = Enum.AutomaticSize.Y
1717
+ section.BackgroundTransparency = 1
1718
+ section.LayoutOrder = order
1719
+ section.Size = UDim2.new(1, 0, 0, 0)
1720
+ section.Parent = parent
1721
+ local sectionLayout = Instance.new("UIListLayout", section)
1722
+ sectionLayout.Padding = UDim.new(0, 2)
1723
+
1724
+ local connector = Instance.new("Frame")
1725
+ connector.BackgroundColor3 = Color3.fromRGB(72, 72, 82)
1726
+ connector.BorderSizePixel = 0
1727
+ connector.Position = UDim2.new(0, -8, 0, 11)
1728
+ connector.Size = UDim2.fromOffset(35, 1)
1729
+ connector.Parent = section
1730
+
1731
+ local header = Instance.new("Frame")
1732
+ header.BackgroundColor3 = Color3.fromRGB(40, 40, 46)
1733
+ header.BackgroundTransparency = 1
1734
+ header.LayoutOrder = 1
1735
+ header.Size = UDim2.new(1, 0, 0, 22)
1736
+ header.Parent = section
1737
+ header.MouseEnter:Connect(function()
1738
+ header.BackgroundTransparency = 0
1739
+ end)
1740
+ header.MouseLeave:Connect(function()
1741
+ header.BackgroundTransparency = 1
1742
+ end)
1743
+
1744
+ local disclosure = Instance.new("TextButton")
1745
+ disclosure.BackgroundTransparency = 1
1746
+ disclosure.Font = Enum.Font.BuilderSansBold
1747
+ disclosure.Size = UDim2.new(0, 18, 1, 0)
1748
+ disclosure.Text = "▸"
1749
+ disclosure.TextColor3 = Color3.fromRGB(170, 170, 180)
1750
+ disclosure.TextSize = 11
1751
+ disclosure.Visible = depth < visualDepth and #instance:GetChildren() > 0
1752
+ disclosure.Parent = header
1753
+ addButtonHover(disclosure)
1754
+
1755
+ local icon = Instance.new("ImageLabel")
1756
+ icon.BackgroundTransparency = 1
1757
+ icon.Position = UDim2.new(0, 19, 0.5, -8)
1758
+ icon.Size = UDim2.fromOffset(16, 16)
1759
+ icon.Parent = header
1760
+ applyClassIcon(icon, instance.ClassName)
1761
+
1762
+ local name = Instance.new("TextButton")
1763
+ name.BackgroundTransparency = 1
1764
+ name.Font = Enum.Font.BuilderSans
1765
+ name.Position = UDim2.new(0, 39, 0, 0)
1766
+ name.Size = UDim2.new(1, -39, 1, 0)
1767
+ name.Text = instance.Name
1768
+ name.TextColor3 = Color3.fromRGB(218, 218, 225)
1769
+ name.TextSize = 12
1770
+ name.TextTruncate = Enum.TextTruncate.AtEnd
1771
+ name.TextXAlignment = Enum.TextXAlignment.Left
1772
+ name.Parent = header
1773
+ name.Activated:Connect(function()
1774
+ if instance.Parent then
1775
+ Selection:Set({ instance })
1776
+ end
1777
+ end)
1778
+
1779
+ local children = Instance.new("Frame")
1780
+ children.AutomaticSize = Enum.AutomaticSize.Y
1781
+ children.BackgroundTransparency = 1
1782
+ children.LayoutOrder = 2
1783
+ children.Size = UDim2.new(1, 0, 0, 0)
1784
+ children.Visible = false
1785
+ children.Parent = section
1786
+ local guide = Instance.new("Frame")
1787
+ guide.BackgroundColor3 = Color3.fromRGB(72, 72, 82)
1788
+ guide.BorderSizePixel = 0
1789
+ guide.Position = UDim2.new(0, 6, 0, 0)
1790
+ guide.Size = UDim2.fromOffset(1, 0)
1791
+ guide.Parent = children
1792
+ local childList = Instance.new("Frame")
1793
+ childList.AutomaticSize = Enum.AutomaticSize.Y
1794
+ childList.BackgroundTransparency = 1
1795
+ childList.Position = UDim2.new(0, 14, 0, 0)
1796
+ childList.Size = UDim2.new(1, -14, 0, 0)
1797
+ childList.Parent = children
1798
+ local childLayout = Instance.new("UIListLayout", childList)
1799
+ childLayout.Padding = UDim.new(0, 2)
1800
+ childLayout.SortOrder = Enum.SortOrder.LayoutOrder
1801
+ childLayout:GetPropertyChangedSignal("AbsoluteContentSize"):Connect(function()
1802
+ guide.Size = UDim2.fromOffset(1, childLayout.AbsoluteContentSize.Y)
1803
+ end)
1804
+
1805
+ local expanded = false
1806
+ disclosure.Activated:Connect(function()
1807
+ expanded = not expanded
1808
+ disclosure.Text = if expanded then "▾" else "▸"
1809
+ children.Visible = expanded
1810
+ for _, child in childList:GetChildren() do
1811
+ if child ~= childLayout then
1812
+ child:Destroy()
1813
+ end
1814
+ end
1815
+ if expanded then
1816
+ local childInstances = instance:GetChildren()
1817
+ table.sort(childInstances, function(left, right)
1818
+ return string.lower(left.Name) < string.lower(right.Name)
1819
+ end)
1820
+ for childOrder, child in childInstances do
1821
+ createInstanceNode(child, childList, depth + 1, childOrder)
1822
+ end
1823
+ end
1824
+ end)
1825
+ end
1826
+
1827
+ local function resetStateContents(state)
1828
+ for _, child in state.contentsList:GetChildren() do
1829
+ if child ~= state.contentsLayout then
1830
+ child:Destroy()
1831
+ end
1832
+ end
1833
+ state.contentsLoaded = false
1834
+ state.contentsExpanded = false
1835
+ state.contents.Visible = false
1836
+ state.contentDisclosure.Text = "▸"
1837
+ local root = resolveRoot(state.manifest.studioPath)
1838
+ state.contentDisclosure.Visible = visualDepth > 0 and root ~= nil and #root:GetChildren() > 0
1839
+ end
1840
+
1841
+ resetInstanceTrees = function()
1842
+ for _, state in states do
1843
+ resetStateContents(state)
1844
+ end
1845
+ end
1846
+
1847
+ local function toggleRootContents(state)
1848
+ if visualDepth == 0 then
1849
+ return
1850
+ end
1851
+ state.contentsExpanded = not state.contentsExpanded
1852
+ state.contents.Visible = state.contentsExpanded
1853
+ state.contentDisclosure.Text = if state.contentsExpanded then "▾" else "▸"
1854
+ for _, child in state.contentsList:GetChildren() do
1855
+ if child ~= state.contentsLayout then
1856
+ child:Destroy()
1857
+ end
1858
+ end
1859
+ state.contentsLoaded = false
1860
+ if not state.contentsExpanded then
1861
+ return
1862
+ end
1863
+ local root = resolveRoot(state.manifest.studioPath)
1864
+ if not root then
1865
+ return
1866
+ end
1867
+ local children = root:GetChildren()
1868
+ table.sort(children, function(left, right)
1869
+ return string.lower(left.Name) < string.lower(right.Name)
1870
+ end)
1871
+ for order, child in children do
1872
+ createInstanceNode(child, state.contentsList, 1, order)
1873
+ end
1874
+ state.contentsLoaded = true
1875
+ end
1876
+
1877
+ local function createRootRow(manifest, order)
1878
+ local parentPath = table.clone(manifest.studioPath)
1879
+ table.remove(parentPath)
1880
+ local group = createGroup(parentPath, order)
1881
+ group.hasDirectRoots = true
1882
+ group.bulkPush.Visible = true
1883
+ group.bulkPull.Visible = true
1884
+ local row = Instance.new("Frame")
1885
+ row.AutomaticSize = Enum.AutomaticSize.Y
1886
+ row.BackgroundColor3 = Color3.fromRGB(40, 40, 46)
1887
+ row.BackgroundTransparency = 1
1888
+ row.BorderSizePixel = 0
1889
+ row.LayoutOrder = order
1890
+ row.Size = UDim2.new(1, 0, 0, 0)
1891
+ row.Parent = group.list
1892
+ local connector = Instance.new("Frame")
1893
+ connector.BackgroundColor3 = Color3.fromRGB(72, 72, 82)
1894
+ connector.BorderSizePixel = 0
1895
+ connector.Position = UDim2.new(0, -8, 0, 11)
1896
+ connector.Size = UDim2.fromOffset(35, 1)
1897
+ connector.Parent = row
1898
+
1899
+ local corner = Instance.new("UICorner")
1900
+ corner.CornerRadius = UDim.new(0, 6)
1901
+ corner.Parent = row
1902
+
1903
+ local rowPadding = Instance.new("UIPadding")
1904
+ rowPadding.PaddingBottom = UDim.new(0, 5)
1905
+ rowPadding.PaddingLeft = UDim.new(0, 6)
1906
+ rowPadding.PaddingRight = UDim.new(0, 6)
1907
+ rowPadding.PaddingTop = UDim.new(0, 5)
1908
+ rowPadding.Parent = row
1909
+
1910
+ local rowLayout = Instance.new("UIListLayout")
1911
+ rowLayout.Padding = UDim.new(0, 5)
1912
+ rowLayout.SortOrder = Enum.SortOrder.LayoutOrder
1913
+ rowLayout.Parent = row
1914
+
1915
+ local titleLine = Instance.new("Frame")
1916
+ titleLine.BackgroundColor3 = Color3.fromRGB(40, 40, 46)
1917
+ titleLine.BackgroundTransparency = 1
1918
+ titleLine.LayoutOrder = 1
1919
+ titleLine.Size = UDim2.new(1, 0, 0, 22)
1920
+ titleLine.Parent = row
1921
+
1922
+ local contentDisclosure = Instance.new("TextButton")
1923
+ contentDisclosure.BackgroundTransparency = 1
1924
+ contentDisclosure.Font = Enum.Font.BuilderSansBold
1925
+ contentDisclosure.Size = UDim2.new(0, 18, 1, 0)
1926
+ contentDisclosure.Text = "▸"
1927
+ contentDisclosure.TextColor3 = Color3.fromRGB(170, 170, 180)
1928
+ contentDisclosure.TextSize = 11
1929
+ contentDisclosure.Visible = false
1930
+ contentDisclosure.Parent = titleLine
1931
+ addButtonHover(contentDisclosure)
1932
+
1933
+ local icon = Instance.new("ImageLabel")
1934
+ icon.BackgroundTransparency = 1
1935
+ icon.Position = UDim2.new(0, 19, 0.5, -8)
1936
+ icon.Size = UDim2.fromOffset(16, 16)
1937
+ icon.Parent = titleLine
1938
+ local rootInstance = resolveRoot(manifest.studioPath)
1939
+ applyClassIcon(icon, if rootInstance then rootInstance.ClassName else "Model")
1940
+
1941
+ local title = Instance.new("TextButton")
1942
+ title.BackgroundTransparency = 1
1943
+ title.Font = Enum.Font.BuilderSansBold
1944
+ title.Position = UDim2.new(0, 39, 0, 0)
1945
+ title.Size = UDim2.new(1, -109, 1, 0)
1946
+ title.Text = manifest.studioPath[#manifest.studioPath]
1947
+ title.TextColor3 = Color3.fromRGB(235, 235, 240)
1948
+ title.TextSize = 13
1949
+ title.TextXAlignment = Enum.TextXAlignment.Left
1950
+ title.Parent = titleLine
1951
+
1952
+ local dot = Instance.new("TextLabel")
1953
+ dot.BackgroundTransparency = 1
1954
+ dot.Font = Enum.Font.BuilderSansBold
1955
+ dot.Position = UDim2.new(1, -70, 0, 0)
1956
+ dot.Size = UDim2.new(0, 12, 1, 0)
1957
+ dot.Text = "●"
1958
+ dot.TextColor3 = Color3.fromRGB(180, 180, 190)
1959
+ dot.TextSize = 9
1960
+ dot.Parent = titleLine
1961
+
1962
+ local infoButton = Instance.new("TextButton")
1963
+ infoButton.BackgroundTransparency = 1
1964
+ infoButton.Font = Enum.Font.BuilderSansBold
1965
+ infoButton.Position = UDim2.new(1, -58, 0, 0)
1966
+ infoButton.Size = UDim2.new(0, 18, 1, 0)
1967
+ infoButton.Text = "•••"
1968
+ infoButton.TextColor3 = Color3.fromRGB(150, 150, 160)
1969
+ infoButton.TextSize = 10
1970
+ infoButton.Parent = titleLine
1971
+ addButtonHover(infoButton)
1972
+
1973
+ local miniPush = Instance.new("TextButton")
1974
+ miniPush.BackgroundTransparency = 1
1975
+ miniPush.Font = Enum.Font.BuilderSansBold
1976
+ miniPush.Position = UDim2.new(1, -40, 0, 0)
1977
+ miniPush.Size = UDim2.new(0, 20, 1, 0)
1978
+ miniPush.Text = "→"
1979
+ miniPush.TextColor3 = Color3.fromRGB(115, 182, 255)
1980
+ miniPush.TextSize = 15
1981
+ miniPush.Parent = titleLine
1982
+ addButtonHover(miniPush)
1983
+
1984
+ local miniPull = Instance.new("TextButton")
1985
+ miniPull.BackgroundTransparency = 1
1986
+ miniPull.Font = Enum.Font.BuilderSansBold
1987
+ miniPull.Position = UDim2.new(1, -20, 0, 0)
1988
+ miniPull.Size = UDim2.new(0, 20, 1, 0)
1989
+ miniPull.Text = "←"
1990
+ miniPull.TextColor3 = Color3.fromRGB(115, 182, 255)
1991
+ miniPull.TextSize = 15
1992
+ miniPull.Parent = titleLine
1993
+ addButtonHover(miniPull)
1994
+
1995
+ titleLine.MouseEnter:Connect(function()
1996
+ titleLine.BackgroundTransparency = 0
1997
+ end)
1998
+ titleLine.MouseLeave:Connect(function()
1999
+ titleLine.BackgroundTransparency = 1
2000
+ end)
2001
+
2002
+ local file = Instance.new("TextLabel")
2003
+ file.BackgroundTransparency = 1
2004
+ file.Font = Enum.Font.Code
2005
+ file.LayoutOrder = 2
2006
+ file.Size = UDim2.new(1, 0, 0, 18)
2007
+ file.Text = manifest.file
2008
+ file.TextColor3 = Color3.fromRGB(145, 145, 155)
2009
+ file.TextSize = 11
2010
+ file.TextTruncate = Enum.TextTruncate.AtEnd
2011
+ file.TextXAlignment = Enum.TextXAlignment.Left
2012
+ file.Visible = false
2013
+ file.Parent = row
2014
+
2015
+ local status = Instance.new("TextLabel")
2016
+ status.AutomaticSize = Enum.AutomaticSize.Y
2017
+ status.BackgroundTransparency = 1
2018
+ status.Font = Enum.Font.BuilderSans
2019
+ status.LayoutOrder = 3
2020
+ status.Size = UDim2.new(1, 0, 0, 18)
2021
+ status.Text = "Checking..."
2022
+ status.TextColor3 = Color3.fromRGB(180, 180, 190)
2023
+ status.TextSize = 12
2024
+ status.TextWrapped = true
2025
+ status.TextXAlignment = Enum.TextXAlignment.Left
2026
+ status.Visible = false
2027
+ status.Parent = row
2028
+
2029
+ local buttons = Instance.new("Frame")
2030
+ buttons.BackgroundTransparency = 1
2031
+ buttons.LayoutOrder = 4
2032
+ buttons.Size = UDim2.new(1, 0, 0, 26)
2033
+ buttons.Visible = false
2034
+ buttons.Parent = row
2035
+
2036
+ local buttonsLayout = Instance.new("UIListLayout")
2037
+ buttonsLayout.FillDirection = Enum.FillDirection.Horizontal
2038
+ buttonsLayout.Padding = UDim.new(0, 6)
2039
+ buttonsLayout.Parent = buttons
2040
+
2041
+ local pushButton = Instance.new("TextButton")
2042
+ pushButton.BackgroundColor3 = Color3.fromRGB(70, 70, 80)
2043
+ pushButton.BorderSizePixel = 0
2044
+ pushButton.Font = Enum.Font.BuilderSansBold
2045
+ pushButton.Size = UDim2.new(0.5, -3, 1, 0)
2046
+ pushButton.Text = "Push Studio"
2047
+ pushButton.TextColor3 = Color3.fromRGB(240, 240, 245)
2048
+ pushButton.TextSize = 12
2049
+ pushButton.Parent = buttons
2050
+ Instance.new("UICorner", pushButton).CornerRadius = UDim.new(0, 4)
2051
+
2052
+ local pullButton = Instance.new("TextButton")
2053
+ pullButton.BackgroundColor3 = Color3.fromRGB(70, 70, 80)
2054
+ pullButton.BorderSizePixel = 0
2055
+ pullButton.Font = Enum.Font.BuilderSansBold
2056
+ pullButton.Size = UDim2.new(0.5, -3, 1, 0)
2057
+ pullButton.Text = "Pull File"
2058
+ pullButton.TextColor3 = Color3.fromRGB(240, 240, 245)
2059
+ pullButton.TextSize = 12
2060
+ pullButton.Parent = buttons
2061
+ Instance.new("UICorner", pullButton).CornerRadius = UDim.new(0, 4)
2062
+
2063
+ local mergeButton = Instance.new("TextButton")
2064
+ mergeButton.BackgroundColor3 = Color3.fromRGB(151, 92, 41)
2065
+ mergeButton.BorderSizePixel = 0
2066
+ mergeButton.Font = Enum.Font.BuilderSansBold
2067
+ mergeButton.Size = UDim2.fromScale(1, 1)
2068
+ mergeButton.Text = "Open Merge Workspace"
2069
+ mergeButton.TextColor3 = Color3.fromRGB(255, 245, 230)
2070
+ mergeButton.TextSize = 12
2071
+ mergeButton.Visible = false
2072
+ mergeButton.Parent = buttons
2073
+ Instance.new("UICorner", mergeButton).CornerRadius = UDim.new(0, 4)
2074
+
2075
+ local diffButton = Instance.new("TextButton")
2076
+ diffButton.BackgroundColor3 = Color3.fromRGB(48, 91, 132)
2077
+ diffButton.BorderSizePixel = 0
2078
+ diffButton.Font = Enum.Font.BuilderSansBold
2079
+ diffButton.Size = UDim2.new(0.5, -3, 1, 0)
2080
+ diffButton.Text = "Property Diff"
2081
+ diffButton.TextColor3 = Color3.fromRGB(240, 247, 255)
2082
+ diffButton.TextSize = 12
2083
+ diffButton.Visible = false
2084
+ diffButton.Parent = buttons
2085
+ Instance.new("UICorner", diffButton).CornerRadius = UDim.new(0, 4)
2086
+
2087
+ local contents = Instance.new("Frame")
2088
+ contents.AutomaticSize = Enum.AutomaticSize.Y
2089
+ contents.BackgroundTransparency = 1
2090
+ contents.LayoutOrder = 5
2091
+ contents.Size = UDim2.new(1, 0, 0, 0)
2092
+ contents.Visible = false
2093
+ contents.Parent = row
2094
+ local contentsGuide = Instance.new("Frame")
2095
+ contentsGuide.BackgroundColor3 = Color3.fromRGB(72, 72, 82)
2096
+ contentsGuide.BorderSizePixel = 0
2097
+ contentsGuide.Position = UDim2.new(0, 6, 0, 0)
2098
+ contentsGuide.Size = UDim2.fromOffset(1, 0)
2099
+ contentsGuide.Parent = contents
2100
+ local contentsList = Instance.new("Frame")
2101
+ contentsList.AutomaticSize = Enum.AutomaticSize.Y
2102
+ contentsList.BackgroundTransparency = 1
2103
+ contentsList.Position = UDim2.new(0, 14, 0, 0)
2104
+ contentsList.Size = UDim2.new(1, -14, 0, 0)
2105
+ contentsList.Parent = contents
2106
+ local contentsLayout = Instance.new("UIListLayout", contentsList)
2107
+ contentsLayout.Padding = UDim.new(0, 2)
2108
+ contentsLayout.SortOrder = Enum.SortOrder.LayoutOrder
2109
+ contentsLayout:GetPropertyChangedSignal("AbsoluteContentSize"):Connect(function()
2110
+ contentsGuide.Size = UDim2.fromOffset(1, contentsLayout.AbsoluteContentSize.Y)
2111
+ end)
2112
+
2113
+ local savedHash = plugin:GetSetting(
2114
+ SETTINGS_PREFIX .. serverConfig.projectId .. ":" .. serverConfig.workspaceId .. ":" .. manifest.id .. ":hash"
2115
+ ) or plugin:GetSetting(SETTINGS_PREFIX .. serverConfig.projectId .. ":" .. manifest.id .. ":hash")
2116
+ local state = {
2117
+ manifest = manifest,
2118
+ group = group,
2119
+ row = row,
2120
+ title = title,
2121
+ icon = icon,
2122
+ iconClass = if rootInstance then rootInstance.ClassName else "Model",
2123
+ contentDisclosure = contentDisclosure,
2124
+ contents = contents,
2125
+ contentsList = contentsList,
2126
+ contentsLayout = contentsLayout,
2127
+ contentsLoaded = false,
2128
+ contentsExpanded = false,
2129
+ searchQuery = nil,
2130
+ searchMatches = true,
2131
+ dot = dot,
2132
+ infoButton = infoButton,
2133
+ miniPush = miniPush,
2134
+ miniPull = miniPull,
2135
+ file = file,
2136
+ status = status,
2137
+ buttons = buttons,
2138
+ pushButton = pushButton,
2139
+ pullButton = pullButton,
2140
+ mergeButton = mergeButton,
2141
+ diffButton = diffButton,
2142
+ root = nil,
2143
+ rootConnections = {},
2144
+ instanceConnections = {},
2145
+ dirty = false,
2146
+ generation = 0,
2147
+ suppress = false,
2148
+ conflict = false,
2149
+ uploading = false,
2150
+ pulling = false,
2151
+ deleting = false,
2152
+ conflictWorkspace = nil,
2153
+ expanded = false,
2154
+ lastHash = if type(savedHash) == "string" then savedHash else nil,
2155
+ }
2156
+ states[manifest.id] = state
2157
+ contentDisclosure.Activated:Connect(function()
2158
+ toggleRootContents(state)
2159
+ end)
2160
+ infoButton.Activated:Connect(function()
2161
+ setRootExpanded(state, not state.expanded)
2162
+ end)
2163
+ title.Activated:Connect(function()
2164
+ local root = resolveRoot(state.manifest.studioPath)
2165
+ if root then
2166
+ Selection:Set({ root })
2167
+ end
2168
+ end)
2169
+ miniPush.Activated:Connect(function()
2170
+ upload(state, true)
2171
+ end)
2172
+ miniPull.Activated:Connect(function()
2173
+ pull(state)
2174
+ end)
2175
+ pushButton.Activated:Connect(function()
2176
+ upload(state, true)
2177
+ end)
2178
+ pullButton.Activated:Connect(function()
2179
+ pull(state)
2180
+ end)
2181
+ mergeButton.Activated:Connect(function()
2182
+ if state.conflictWorkspace then
2183
+ resolveGitConflict(state)
2184
+ else
2185
+ openGitConflict(state)
2186
+ end
2187
+ end)
2188
+ diffButton.Activated:Connect(function()
2189
+ if state.conflictWorkspace then
2190
+ inspectorWidget.Enabled = true
2191
+ end
2192
+ end)
2193
+ return state
2194
+ end
2195
+
2196
+ local function reconcile(state, manifest)
2197
+ state.manifest = manifest
2198
+ if manifest.gitConflict then
2199
+ state.conflict = true
2200
+ state.pushButton.Visible = false
2201
+ state.pullButton.Visible = false
2202
+ state.miniPush.Visible = false
2203
+ state.miniPull.Visible = false
2204
+ state.mergeButton.Visible = true
2205
+ setStatus(state, "Git conflict — resolve in VS Code or open the optional merge workspace.", Color3.fromRGB(245, 106, 106))
2206
+ if autoExpandConflicts then
2207
+ setGroupExpanded(state.group, true)
2208
+ setRootExpanded(state, true)
2209
+ end
2210
+ return
2211
+ end
2212
+ state.pushButton.Visible = true
2213
+ state.pullButton.Visible = true
2214
+ state.miniPush.Visible = true
2215
+ state.miniPull.Visible = true
2216
+ state.mergeButton.Visible = false
2217
+ state.pushButton.Active = serverConfig.rojoConnected
2218
+ state.pullButton.Active = serverConfig.rojoConnected
2219
+ state.miniPush.Active = serverConfig.rojoConnected
2220
+ state.miniPull.Active = serverConfig.rojoConnected
2221
+ state.pushButton.AutoButtonColor = serverConfig.rojoConnected
2222
+ state.pullButton.AutoButtonColor = serverConfig.rojoConnected
2223
+ state.miniPush.AutoButtonColor = serverConfig.rojoConnected
2224
+ state.miniPull.AutoButtonColor = serverConfig.rojoConnected
2225
+ state.miniPush.TextColor3 = if serverConfig.rojoConnected
2226
+ then Color3.fromRGB(115, 182, 255)
2227
+ else Color3.fromRGB(95, 95, 105)
2228
+ state.miniPull.TextColor3 = state.miniPush.TextColor3
2229
+ if state.conflictWorkspace then
2230
+ destroyConflictWorkspace(state)
2231
+ end
2232
+ if not serverConfig.rojoConnected then
2233
+ setStatus(state, "Paused — Rojo offline; conflict viewer remains available.", Color3.fromRGB(244, 194, 92))
2234
+ return
2235
+ end
2236
+ local root = resolveRoot(manifest.studioPath)
2237
+ if state.contentRoot ~= root then
2238
+ state.contentRoot = root
2239
+ state.searchQuery = nil
2240
+ resetStateContents(state)
2241
+ elseif root then
2242
+ state.contentDisclosure.Visible = visualDepth > 0 and #root:GetChildren() > 0
2243
+ end
2244
+ if root and state.iconClass ~= root.ClassName then
2245
+ state.iconClass = root.ClassName
2246
+ applyClassIcon(state.icon, root.ClassName)
2247
+ end
2248
+ if root and state.root ~= root then
2249
+ watchRoot(state, root)
2250
+ end
2251
+
2252
+ if not manifest.exists then
2253
+ if manifest.deleted and not root then
2254
+ state.conflict = false
2255
+ setStatus(state, "Deleted", Color3.fromRGB(102, 214, 139))
2256
+ elseif state.lastHash then
2257
+ state.conflict = true
2258
+ setStatus(state, "File was deleted. Push to recreate it.", Color3.fromRGB(245, 106, 106))
2259
+ elseif root then
2260
+ upload(state, false)
2261
+ else
2262
+ setStatus(state, "Both Studio root and file are missing.", Color3.fromRGB(245, 106, 106))
2263
+ end
2264
+ return
2265
+ end
2266
+
2267
+ if not root then
2268
+ if not state.conflict and not state.deleting then
2269
+ deleteSnapshot(state)
2270
+ end
2271
+ return
2272
+ end
2273
+ if not state.lastHash then
2274
+ state.conflict = true
2275
+ setStatus(state, "First sync needs Push or Pull once.", Color3.fromRGB(244, 194, 92))
2276
+ return
2277
+ end
2278
+ if manifest.hash ~= state.lastHash then
2279
+ if state.dirty or state.uploading then
2280
+ state.conflict = true
2281
+ setStatus(state, "Conflict: Studio and file both changed.", Color3.fromRGB(245, 106, 106))
2282
+ else
2283
+ pull(state)
2284
+ end
2285
+ return
2286
+ end
2287
+ if not state.dirty and not state.uploading and not state.pulling then
2288
+ state.conflict = false
2289
+ setStatus(state, "Synced", Color3.fromRGB(102, 214, 139))
2290
+ end
2291
+ end
2292
+
2293
+ local function refreshSettingsLabels()
2294
+ showSyncedButton.Text = (if showSynced then "✓ " else " ") .. "Show synced files"
2295
+ autoExpandButton.Text = (if autoExpandConflicts then "✓ " else " ") .. "Auto-expand conflicts"
2296
+ depthLabel.Text = string.format("Internal browse depth: %d", visualDepth)
2297
+ refreshUnsyncedButton()
2298
+ end
2299
+
2300
+ applyTreeFilter = function()
2301
+ local query = string.lower(searchBox.Text)
2302
+ for _, state in states do
2303
+ if state.searchQuery ~= query then
2304
+ local searchable = string.lower(
2305
+ table.concat(state.manifest.studioPath, ".") .. " " .. state.manifest.file .. " " .. state.status.Text
2306
+ )
2307
+ local matches = query == "" or string.find(searchable, query, 1, true) ~= nil
2308
+ if not matches then
2309
+ local root = resolveRoot(state.manifest.studioPath)
2310
+ if root then
2311
+ for _, descendant in root:GetDescendants() do
2312
+ if string.find(string.lower(descendant.Name .. " " .. descendant.ClassName), query, 1, true) then
2313
+ matches = true
2314
+ break
2315
+ end
2316
+ end
2317
+ end
2318
+ end
2319
+ state.searchQuery = query
2320
+ state.searchMatches = matches
2321
+ end
2322
+ state.row.Visible = state.searchMatches and (showSynced or state.statusKind ~= "synced")
2323
+ end
2324
+ for _, group in groups do
2325
+ local hasVisibleDescendant = false
2326
+ for _, state in states do
2327
+ if state.row.Visible and pathStartsWith(group.path, state.manifest.studioPath) then
2328
+ hasVisibleDescendant = true
2329
+ break
2330
+ end
2331
+ end
2332
+ group.section.Visible = hasVisibleDescendant
2333
+ if query ~= "" and hasVisibleDescendant then
2334
+ setGroupExpanded(group, true)
2335
+ end
2336
+ end
2337
+ end
2338
+
2339
+ addButtonHover(settingsButton)
2340
+ addButtonHover(showSyncedButton)
2341
+ addButtonHover(autoExpandButton)
2342
+ addButtonHover(depthMinus)
2343
+ addButtonHover(depthPlus)
2344
+ unsyncedButton.MouseEnter:Connect(function()
2345
+ unsyncedButton.BackgroundColor3 = if showSynced
2346
+ then Color3.fromRGB(58, 58, 68)
2347
+ else Color3.fromRGB(94, 78, 42)
2348
+ end)
2349
+ unsyncedButton.MouseLeave:Connect(refreshUnsyncedButton)
2350
+ refreshSettingsLabels()
2351
+
2352
+ settingsButton.Activated:Connect(function()
2353
+ settingsPanel.Visible = not settingsPanel.Visible
2354
+ end)
2355
+ unsyncedButton.Activated:Connect(function()
2356
+ showSynced = not showSynced
2357
+ plugin:SetSetting(UI_SETTINGS_PREFIX .. "showSynced", showSynced)
2358
+ refreshSettingsLabels()
2359
+ applyTreeFilter()
2360
+ end)
2361
+ showSyncedButton.Activated:Connect(function()
2362
+ showSynced = not showSynced
2363
+ plugin:SetSetting(UI_SETTINGS_PREFIX .. "showSynced", showSynced)
2364
+ refreshSettingsLabels()
2365
+ applyTreeFilter()
2366
+ end)
2367
+ autoExpandButton.Activated:Connect(function()
2368
+ autoExpandConflicts = not autoExpandConflicts
2369
+ plugin:SetSetting(UI_SETTINGS_PREFIX .. "autoExpandConflicts", autoExpandConflicts)
2370
+ refreshSettingsLabels()
2371
+ end)
2372
+ depthMinus.Activated:Connect(function()
2373
+ visualDepth = math.max(0, visualDepth - 1)
2374
+ plugin:SetSetting(UI_SETTINGS_PREFIX .. "visualDepth", visualDepth)
2375
+ refreshSettingsLabels()
2376
+ resetInstanceTrees()
2377
+ end)
2378
+ depthPlus.Activated:Connect(function()
2379
+ visualDepth = math.min(5, visualDepth + 1)
2380
+ plugin:SetSetting(UI_SETTINGS_PREFIX .. "visualDepth", visualDepth)
2381
+ refreshSettingsLabels()
2382
+ resetInstanceTrees()
2383
+ end)
2384
+
2385
+ local searchGeneration = 0
2386
+ searchBox:GetPropertyChangedSignal("Text"):Connect(function()
2387
+ searchGeneration += 1
2388
+ local generation = searchGeneration
2389
+ task.delay(0.15, function()
2390
+ if generation == searchGeneration then
2391
+ applyTreeFilter()
2392
+ end
2393
+ end)
2394
+ end)
2395
+
2396
+ refreshConfig = function()
2397
+ local response, requestError = request("GET", "/v1/config")
2398
+ if not response or not response.Success then
2399
+ local reason = requestError or (if response then responseError(response) else nil) or "connect Rojo to start syncing"
2400
+ if serverConfig then
2401
+ serverConfig.rojoConnected = false
2402
+ end
2403
+ connectionStatus.Text = "Offline · " .. reason
2404
+ connectionStatus.TextColor3 = Color3.fromRGB(245, 106, 106)
2405
+ connectionDot.TextColor3 = connectionStatus.TextColor3
2406
+ return false
2407
+ end
2408
+
2409
+ local decoded = HttpService:JSONDecode(response.Body)
2410
+ if serverConfig
2411
+ and (serverConfig.projectId ~= decoded.projectId or serverConfig.workspaceId ~= decoded.workspaceId)
2412
+ then
2413
+ for _, state in states do
2414
+ disconnectRoot(state)
2415
+ destroyConflictWorkspace(state)
2416
+ end
2417
+ table.clear(states)
2418
+ for _, child in rootsContainer:GetChildren() do
2419
+ if child ~= rootsLayout then
2420
+ child:Destroy()
2421
+ end
2422
+ end
2423
+ table.clear(groups)
2424
+ end
2425
+ serverConfig = decoded
2426
+ if decoded.rojoConnected then
2427
+ connectionStatus.Text = "Connected · " .. decoded.name
2428
+ connectionStatus.TextColor3 = Color3.fromRGB(102, 214, 139)
2429
+ else
2430
+ connectionStatus.Text = "Rojo offline · connect Rojo to start syncing"
2431
+ connectionStatus.TextColor3 = Color3.fromRGB(244, 194, 92)
2432
+ end
2433
+ connectionDot.TextColor3 = connectionStatus.TextColor3
2434
+
2435
+ local existingSplitPaths = {}
2436
+ for _, manifest in decoded.roots do
2437
+ if manifest.splitRootId then
2438
+ existingSplitPaths[manifest.splitRootId .. "\0" .. table.concat(manifest.studioPath, "\0")] = true
2439
+ end
2440
+ end
2441
+ for _, splitRoot in decoded.splitRoots do
2442
+ local root = resolveRoot(splitRoot.studioPath)
2443
+ if root then
2444
+ local missingPaths = {}
2445
+ for _, relativePath in collectSplitPaths(root, splitRoot.splitDepth) do
2446
+ local fullPath = table.clone(splitRoot.studioPath)
2447
+ for _, segment in relativePath do
2448
+ table.insert(fullPath, segment)
2449
+ end
2450
+ if not existingSplitPaths[splitRoot.id .. "\0" .. table.concat(fullPath, "\0")] then
2451
+ table.insert(missingPaths, relativePath)
2452
+ end
2453
+ end
2454
+ if #missingPaths > 0 then
2455
+ request("POST", "/v1/splits/" .. splitRoot.id .. "/discover", HttpService:JSONEncode({ paths = missingPaths }), {
2456
+ ["Content-Type"] = "application/json",
2457
+ })
2458
+ end
2459
+ end
2460
+ end
2461
+
2462
+ for order, manifest in decoded.roots do
2463
+ local state = states[manifest.id] or createRootRow(manifest, order)
2464
+ reconcile(state, manifest)
2465
+ end
2466
+ local currentIds = {}
2467
+ for _, manifest in decoded.roots do
2468
+ currentIds[manifest.id] = true
2469
+ end
2470
+ local staleIds = {}
2471
+ for id in states do
2472
+ if not currentIds[id] then
2473
+ table.insert(staleIds, id)
2474
+ end
2475
+ end
2476
+ for _, id in staleIds do
2477
+ local state = states[id]
2478
+ disconnectRoot(state)
2479
+ destroyConflictWorkspace(state)
2480
+ state.row:Destroy()
2481
+ states[id] = nil
2482
+ end
2483
+ updateGroupSummaries()
2484
+ applyTreeFilter()
2485
+ return true
2486
+ end
2487
+
2488
+ toolbarButton.Click:Connect(function()
2489
+ widget.Enabled = not widget.Enabled
2490
+ if widget.Enabled then
2491
+ refreshConfig()
2492
+ end
2493
+ end)
2494
+ widget:GetPropertyChangedSignal("Enabled"):Connect(function()
2495
+ toolbarButton:SetActive(widget.Enabled)
2496
+ end)
2497
+
2498
+ plugin.Unloading:Connect(function()
2499
+ running = false
2500
+ for _, state in states do
2501
+ disconnectRoot(state)
2502
+ destroyConflictWorkspace(state)
2503
+ end
2504
+ end)
2505
+
2506
+ task.spawn(function()
2507
+ while running do
2508
+ refreshConfig()
2509
+ task.wait((serverConfig and serverConfig.pollIntervalMs or 750) / 1000)
2510
+ end
2511
+ end)
2512
+ ]]></string>
2513
+ </Properties>
2514
+ </Item>
2515
+ </Item>
2516
+ </roblox>