goldsync 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,6 +73,10 @@ Place `goldsync.project.json` at the game repository root:
73
73
 
74
74
  A `file` root becomes one RBXM. A `directory` root creates separate files for descendants at `splitDepth`. Split at complete models, effects, or UI components—not individual parts or attachments. Never map the same hierarchy in Rojo, and do not include scripts inside a GoldSync root.
75
75
 
76
+ Directory roots can contain more specific roots. Discovery skips branches owned by those roots, including existing files on disk. For example, split `ReplicatedStorage.Assets` at depth 1 and add another depth-1 directory root for `ReplicatedStorage.Assets.Effects`. New asset categories and individual effects are discovered automatically, without creating an overlapping `Effects.rbxm`. Existing explicit file roots can stay in place to preserve their sync history. File roots cannot contain other roots because their snapshots include the entire subtree.
77
+
78
+ When upgrading an existing project, put the new parent directory roots in the optional top-level `discoveryRoots` array, using the same fields as directory roots. Older GoldSync versions ignore that array, so they cannot create overlapping snapshots before the service is restarted with nested-root support. Existing roots and files remain unchanged.
79
+
76
80
  GoldSync reads the Rojo project name and fallback `servePort` from `default.project.json`. It automatically detects `rojo serve --port` overrides and verifies the listener's project name before connecting. That active Rojo session selects the GoldSync workspace, so Studio place IDs and names do not need configuration. Set `rojo.projectFile` only when the repository uses another project file.
77
81
 
78
82
  Map GoldSync's generated live-session marker in the Rojo project tree:
@@ -22,7 +22,7 @@ local CLIENT_HEADERS = {
22
22
  }
23
23
  local SETTINGS_PREFIX = "GoldSync:v2:"
24
24
  local UI_SETTINGS_PREFIX = "GoldSync:ui:v1:"
25
- local VERSION = "0.1.13"
25
+ local VERSION = "0.1.15"
26
26
  local REQUEST_INTERVAL_SECONDS = 0.15
27
27
  local REQUEST_RETRY_DELAYS = { 1, 2, 4 }
28
28
 
@@ -340,6 +340,8 @@ local lastRequestAt = 0
340
340
  local serverConfig
341
341
  local states = {}
342
342
  local groups = {}
343
+ local batchingStatusUpdates = false
344
+ local splitRootConnections = {}
343
345
  local refreshConfig
344
346
  local updateGroupSummaries
345
347
  local refreshStatusSummary
@@ -369,6 +371,31 @@ local function collectSplitPaths(root, depth)
369
371
  return paths
370
372
  end
371
373
 
374
+ local function collectSplitInstances(root, depth)
375
+ local current = { root }
376
+ for _ = 1, depth do
377
+ local nextLevel = {}
378
+ for _, parent in current do
379
+ for _, child in parent:GetChildren() do
380
+ table.insert(nextLevel, child)
381
+ end
382
+ end
383
+ current = nextLevel
384
+ end
385
+ return current
386
+ end
387
+
388
+ local function isAtSplitDepth(root, instance, depth)
389
+ local current = instance
390
+ for _ = 1, depth do
391
+ if not current or current == root then
392
+ return false
393
+ end
394
+ current = current.Parent
395
+ end
396
+ return current == root
397
+ end
398
+
372
399
  local function acquireRequestLock()
373
400
  if requestLocked then
374
401
  local waiter = Instance.new("BindableEvent")
@@ -464,6 +491,131 @@ local function resolveParent(studioPath)
464
491
  return current
465
492
  end
466
493
 
494
+ local function disconnectSplitRoot(id)
495
+ local watched = splitRootConnections[id]
496
+ if not watched then
497
+ return
498
+ end
499
+ for _, connection in watched.connections do
500
+ connection:Disconnect()
501
+ end
502
+ for _, connection in watched.nameConnections do
503
+ connection:Disconnect()
504
+ end
505
+ splitRootConnections[id] = nil
506
+ end
507
+
508
+ local function disconnectSplitRoots()
509
+ local ids = {}
510
+ for id in splitRootConnections do
511
+ table.insert(ids, id)
512
+ end
513
+ for _, id in ids do
514
+ disconnectSplitRoot(id)
515
+ end
516
+ end
517
+
518
+ local function discoverSplitRoot(splitRoot, root)
519
+ if not serverConfig or splitRootConnections[splitRoot.id] == nil then
520
+ return
521
+ end
522
+ local existingPaths = {}
523
+ for _, manifest in serverConfig.roots do
524
+ if manifest.splitRootId == splitRoot.id then
525
+ existingPaths[table.concat(manifest.studioPath, "\0")] = true
526
+ end
527
+ end
528
+ local missingPaths = {}
529
+ for _, relativePath in collectSplitPaths(root, splitRoot.splitDepth) do
530
+ local excluded = false
531
+ for _, excludedPath in splitRoot.excludedPaths or {} do
532
+ local matches = true
533
+ for index, segment in excludedPath do
534
+ if relativePath[index] ~= segment then
535
+ matches = false
536
+ break
537
+ end
538
+ end
539
+ if matches then
540
+ excluded = true
541
+ break
542
+ end
543
+ end
544
+ if excluded then
545
+ continue
546
+ end
547
+
548
+ local fullPath = table.clone(splitRoot.studioPath)
549
+ for _, segment in relativePath do
550
+ table.insert(fullPath, segment)
551
+ end
552
+ if not existingPaths[table.concat(fullPath, "\0")] then
553
+ table.insert(missingPaths, relativePath)
554
+ end
555
+ end
556
+ if #missingPaths > 0 then
557
+ request("POST", "/v1/splits/" .. splitRoot.id .. "/discover", HttpService:JSONEncode({ paths = missingPaths }), {
558
+ ["Content-Type"] = "application/json",
559
+ })
560
+ end
561
+ end
562
+
563
+ local function watchSplitRoot(splitRoot)
564
+ local root = resolveRoot(splitRoot.studioPath)
565
+ local watched = splitRootConnections[splitRoot.id]
566
+ if watched and watched.root == root then
567
+ return
568
+ end
569
+ disconnectSplitRoot(splitRoot.id)
570
+ if not root then
571
+ return
572
+ end
573
+
574
+ local entry = {
575
+ root = root,
576
+ generation = 0,
577
+ connections = {},
578
+ nameConnections = {},
579
+ }
580
+ splitRootConnections[splitRoot.id] = entry
581
+ local function scheduleDiscovery()
582
+ entry.generation += 1
583
+ local generation = entry.generation
584
+ task.delay(0.1, function()
585
+ if running and splitRootConnections[splitRoot.id] == entry and entry.generation == generation then
586
+ discoverSplitRoot(splitRoot, root)
587
+ end
588
+ end)
589
+ end
590
+ local function watchName(instance)
591
+ if entry.nameConnections[instance] or not isAtSplitDepth(root, instance, splitRoot.splitDepth) then
592
+ return
593
+ end
594
+ entry.nameConnections[instance] = instance:GetPropertyChangedSignal("Name"):Connect(scheduleDiscovery)
595
+ end
596
+ for _, instance in collectSplitInstances(root, splitRoot.splitDepth) do
597
+ watchName(instance)
598
+ end
599
+ table.insert(entry.connections, root.DescendantAdded:Connect(function(descendant)
600
+ watchName(descendant)
601
+ scheduleDiscovery()
602
+ end))
603
+ table.insert(entry.connections, root.DescendantRemoving:Connect(function(descendant)
604
+ local connection = entry.nameConnections[descendant]
605
+ if connection then
606
+ connection:Disconnect()
607
+ entry.nameConnections[descendant] = nil
608
+ end
609
+ scheduleDiscovery()
610
+ end))
611
+ table.insert(entry.connections, root.AncestryChanged:Connect(function()
612
+ if resolveRoot(splitRoot.studioPath) ~= root then
613
+ disconnectSplitRoot(splitRoot.id)
614
+ end
615
+ end))
616
+ discoverSplitRoot(splitRoot, root)
617
+ end
618
+
467
619
  local function applyClassIcon(image, className)
468
620
  local success, icon = pcall(function()
469
621
  return StudioService:GetClassIcon(className)
@@ -499,12 +651,8 @@ local function findCode(root)
499
651
  end
500
652
 
501
653
  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"
654
+ local statusColor = color or Color3.fromRGB(180, 180, 190)
655
+ local statusKind = if string.sub(text, 1, 6) == "Synced"
508
656
  then "synced"
509
657
  elseif string.find(text, "syncing")
510
658
  or string.find(text, "Serializing")
@@ -514,7 +662,16 @@ local function setStatus(state, text, color)
514
662
  then "working"
515
663
  elseif string.find(text, "Rojo") and string.find(text, "offline") then "paused"
516
664
  else "issue"
517
- if updateGroupSummaries then
665
+ if state.status.Text == text and state.status.TextColor3 == statusColor and state.statusKind == statusKind then
666
+ return
667
+ end
668
+ local previousStatusKind = state.statusKind
669
+ state.status.Text = text
670
+ state.status.TextColor3 = statusColor
671
+ state.dot.TextColor3 = statusColor
672
+ state.searchQuery = nil
673
+ state.statusKind = statusKind
674
+ if updateGroupSummaries and not batchingStatusUpdates then
518
675
  if previousStatusKind ~= state.statusKind then
519
676
  updateGroupSummaries()
520
677
  elseif refreshStatusSummary then
@@ -1414,14 +1571,6 @@ local function createGroup(path, order)
1414
1571
  section.LayoutOrder = order
1415
1572
  section.Size = UDim2.new(1, 0, 0, 0)
1416
1573
  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
1574
 
1426
1575
  local sectionLayout = Instance.new("UIListLayout")
1427
1576
  sectionLayout.Padding = UDim.new(0, 2)
@@ -1434,6 +1583,14 @@ local function createGroup(path, order)
1434
1583
  header.LayoutOrder = 1
1435
1584
  header.Size = UDim2.new(1, 0, 0, 24)
1436
1585
  header.Parent = section
1586
+ if parentGroup then
1587
+ local connector = Instance.new("Frame")
1588
+ connector.BackgroundColor3 = Color3.fromRGB(72, 72, 82)
1589
+ connector.BorderSizePixel = 0
1590
+ connector.Position = UDim2.new(0, -8, 0, 11)
1591
+ connector.Size = UDim2.fromOffset(35, 1)
1592
+ connector.Parent = header
1593
+ end
1437
1594
 
1438
1595
  local disclosure = Instance.new("TextButton")
1439
1596
  disclosure.BackgroundTransparency = 1
@@ -1726,7 +1883,6 @@ local function createInstanceNode(instance, parent, depth, order)
1726
1883
  connector.BorderSizePixel = 0
1727
1884
  connector.Position = UDim2.new(0, -8, 0, 11)
1728
1885
  connector.Size = UDim2.fromOffset(35, 1)
1729
- connector.Parent = section
1730
1886
 
1731
1887
  local header = Instance.new("Frame")
1732
1888
  header.BackgroundColor3 = Color3.fromRGB(40, 40, 46)
@@ -1734,6 +1890,7 @@ local function createInstanceNode(instance, parent, depth, order)
1734
1890
  header.LayoutOrder = 1
1735
1891
  header.Size = UDim2.new(1, 0, 0, 22)
1736
1892
  header.Parent = section
1893
+ connector.Parent = header
1737
1894
  header.MouseEnter:Connect(function()
1738
1895
  header.BackgroundTransparency = 0
1739
1896
  end)
@@ -1894,7 +2051,6 @@ local function createRootRow(manifest, order)
1894
2051
  connector.BorderSizePixel = 0
1895
2052
  connector.Position = UDim2.new(0, -8, 0, 11)
1896
2053
  connector.Size = UDim2.fromOffset(35, 1)
1897
- connector.Parent = row
1898
2054
 
1899
2055
  local corner = Instance.new("UICorner")
1900
2056
  corner.CornerRadius = UDim.new(0, 6)
@@ -1902,8 +2058,6 @@ local function createRootRow(manifest, order)
1902
2058
 
1903
2059
  local rowPadding = Instance.new("UIPadding")
1904
2060
  rowPadding.PaddingBottom = UDim.new(0, 5)
1905
- rowPadding.PaddingLeft = UDim.new(0, 6)
1906
- rowPadding.PaddingRight = UDim.new(0, 6)
1907
2061
  rowPadding.PaddingTop = UDim.new(0, 5)
1908
2062
  rowPadding.Parent = row
1909
2063
 
@@ -1918,6 +2072,7 @@ local function createRootRow(manifest, order)
1918
2072
  titleLine.LayoutOrder = 1
1919
2073
  titleLine.Size = UDim2.new(1, 0, 0, 22)
1920
2074
  titleLine.Parent = row
2075
+ connector.Parent = titleLine
1921
2076
 
1922
2077
  local contentDisclosure = Instance.new("TextButton")
1923
2078
  contentDisclosure.BackgroundTransparency = 1
@@ -2393,6 +2548,19 @@ searchBox:GetPropertyChangedSignal("Text"):Connect(function()
2393
2548
  end)
2394
2549
  end)
2395
2550
 
2551
+ local function manifestChanged(previous, current)
2552
+ return not previous
2553
+ or previous.hash ~= current.hash
2554
+ or previous.exists ~= current.exists
2555
+ or previous.deleted ~= current.deleted
2556
+ or previous.revision ~= current.revision
2557
+ or previous.gitConflict ~= current.gitConflict
2558
+ or previous.conflictToken ~= current.conflictToken
2559
+ or previous.file ~= current.file
2560
+ or previous.splitRootId ~= current.splitRootId
2561
+ or table.concat(previous.studioPath, "\0") ~= table.concat(current.studioPath, "\0")
2562
+ end
2563
+
2396
2564
  refreshConfig = function()
2397
2565
  local response, requestError = request("GET", "/v1/config")
2398
2566
  if not response or not response.Success then
@@ -2407,13 +2575,15 @@ refreshConfig = function()
2407
2575
  end
2408
2576
 
2409
2577
  local decoded = HttpService:JSONDecode(response.Body)
2410
- if serverConfig
2578
+ local projectChanged = serverConfig
2411
2579
  and (serverConfig.projectId ~= decoded.projectId or serverConfig.workspaceId ~= decoded.workspaceId)
2412
- then
2580
+ local connectionChanged = serverConfig and serverConfig.rojoConnected ~= decoded.rojoConnected
2581
+ if projectChanged then
2413
2582
  for _, state in states do
2414
2583
  disconnectRoot(state)
2415
2584
  destroyConflictWorkspace(state)
2416
2585
  end
2586
+ disconnectSplitRoots()
2417
2587
  table.clear(states)
2418
2588
  for _, child in rootsContainer:GetChildren() do
2419
2589
  if child ~= rootsLayout then
@@ -2432,36 +2602,38 @@ refreshConfig = function()
2432
2602
  end
2433
2603
  connectionDot.TextColor3 = connectionStatus.TextColor3
2434
2604
 
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
2605
+ local currentSplitRootIds = {}
2441
2606
  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
2607
+ currentSplitRootIds[splitRoot.id] = true
2608
+ watchSplitRoot(splitRoot)
2609
+ end
2610
+ local staleSplitRootIds = {}
2611
+ for id in splitRootConnections do
2612
+ if not currentSplitRootIds[id] then
2613
+ table.insert(staleSplitRootIds, id)
2459
2614
  end
2460
2615
  end
2616
+ for _, id in staleSplitRootIds do
2617
+ disconnectSplitRoot(id)
2618
+ end
2461
2619
 
2620
+ local treeChanged = projectChanged == true or connectionChanged == true
2621
+ batchingStatusUpdates = true
2462
2622
  for order, manifest in decoded.roots do
2463
- local state = states[manifest.id] or createRootRow(manifest, order)
2464
- reconcile(state, manifest)
2623
+ local state = states[manifest.id]
2624
+ local created = state == nil
2625
+ if created then
2626
+ state = createRootRow(manifest, order)
2627
+ end
2628
+ local resolvedRoot = resolveRoot(manifest.studioPath)
2629
+ local rootChanged = state.observedRoot ~= resolvedRoot
2630
+ state.observedRoot = resolvedRoot
2631
+ if created or connectionChanged or rootChanged or manifestChanged(state.manifest, manifest) then
2632
+ reconcile(state, manifest)
2633
+ treeChanged = true
2634
+ else
2635
+ state.manifest = manifest
2636
+ end
2465
2637
  end
2466
2638
  local currentIds = {}
2467
2639
  for _, manifest in decoded.roots do
@@ -2479,9 +2651,13 @@ refreshConfig = function()
2479
2651
  destroyConflictWorkspace(state)
2480
2652
  state.row:Destroy()
2481
2653
  states[id] = nil
2654
+ treeChanged = true
2655
+ end
2656
+ batchingStatusUpdates = false
2657
+ if treeChanged then
2658
+ updateGroupSummaries()
2659
+ applyTreeFilter()
2482
2660
  end
2483
- updateGroupSummaries()
2484
- applyTreeFilter()
2485
2661
  return true
2486
2662
  end
2487
2663
 
@@ -2497,6 +2673,7 @@ end)
2497
2673
 
2498
2674
  plugin.Unloading:Connect(function()
2499
2675
  running = false
2676
+ disconnectSplitRoots()
2500
2677
  for _, state in states do
2501
2678
  disconnectRoot(state)
2502
2679
  destroyConflictWorkspace(state)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "goldsync",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Automatic native Roblox asset synchronization for Rojo projects.",
5
5
  "author": "tay",
6
6
  "license": "SEE LICENSE IN LICENSE.txt",
package/src/config.mjs CHANGED
@@ -86,6 +86,10 @@ export async function loadConfig(configPath) {
86
86
  if (!Array.isArray(raw.roots) || raw.roots.length === 0) {
87
87
  throw new Error("roots must contain at least one sync root");
88
88
  }
89
+ if (raw.discoveryRoots !== undefined && (!Array.isArray(raw.discoveryRoots)
90
+ || raw.discoveryRoots.some((root) => !root || root.splitDepth === undefined))) {
91
+ throw new Error("discoveryRoots must contain directory roots with splitDepth");
92
+ }
89
93
  const maxPathDepth = raw.maxPathDepth ?? 3;
90
94
  if (!Number.isInteger(maxPathDepth) || maxPathDepth < 2 || maxPathDepth > 10) {
91
95
  throw new Error("maxPathDepth must be an integer between 2 and 10");
@@ -95,8 +99,8 @@ export async function loadConfig(configPath) {
95
99
  const studioPaths = new Set();
96
100
  const files = new Set();
97
101
  const splitRoots = [];
98
- const roots = raw.roots.flatMap((root, index) => {
99
- const label = `roots[${index}]`;
102
+ const roots = [...raw.roots, ...(raw.discoveryRoots ?? [])].flatMap((root, index) => {
103
+ const label = index < raw.roots.length ? `roots[${index}]` : `discoveryRoots[${index - raw.roots.length}]`;
100
104
  const id = requireString(root.id, `${label}.id`);
101
105
  if (!ID_PATTERN.test(id)) {
102
106
  throw new Error(`${label}.id must contain lowercase letters, numbers, or hyphens`);
@@ -145,6 +149,22 @@ export async function loadConfig(configPath) {
145
149
  }];
146
150
  });
147
151
 
152
+ const configuredRoots = [...roots, ...splitRoots];
153
+ for (const root of roots) {
154
+ if (configuredRoots.some((other) => other !== root
155
+ && other.studioPath.length > root.studioPath.length
156
+ && root.studioPath.every((segment, index) => other.studioPath[index] === segment))) {
157
+ throw new Error(`file root ${root.id} contains another sync root; use a directory root instead`);
158
+ }
159
+ }
160
+ for (const root of splitRoots) {
161
+ root.excludedPaths = configuredRoots
162
+ .filter((other) => other !== root
163
+ && other.studioPath.length > root.studioPath.length
164
+ && root.studioPath.every((segment, index) => other.studioPath[index] === segment))
165
+ .map((other) => other.studioPath.slice(root.studioPath.length, root.studioPath.length + root.splitDepth));
166
+ }
167
+
148
168
  return {
149
169
  version: 1,
150
170
  name: requireString(raw.name, "name"),
package/src/server.mjs CHANGED
@@ -161,6 +161,7 @@ export class GoldSyncServer {
161
161
  id: root.id,
162
162
  studioPath: root.studioPath,
163
163
  splitDepth: root.splitDepth,
164
+ excludedPaths: root.excludedPaths ?? [],
164
165
  })),
165
166
  roots: this.store.getManifests().map((manifest) => {
166
167
  const conflict = conflicts.get(manifest.id);
package/src/store.mjs CHANGED
@@ -140,7 +140,12 @@ export class SnapshotStore {
140
140
  }
141
141
  let changed = false;
142
142
  for (const relativePath of relativePaths) {
143
- changed = (await this.addRoot(this.makeSplitRoot(splitRoot, relativePath))) || changed;
143
+ const root = this.makeSplitRoot(splitRoot, relativePath);
144
+ if ((splitRoot.excludedPaths ?? []).some((excluded) =>
145
+ excluded.every((segment, index) => relativePath[index] === segment))) {
146
+ continue;
147
+ }
148
+ changed = (await this.addRoot(root)) || changed;
144
149
  }
145
150
  if (changed) {
146
151
  await this.persistState();