mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 14:01:37 +00:00
Update syncback feature to handle instance pulling from Studio
- Updated the `ServeSession` to implement logic for pulling instances that exist in Studio but not in Rojo's filesystem, creating appropriate files during sync confirmation. - Introduced a new `encodeInstance` function to serialize Studio instances for transmission, supporting all instance types. - Modified the `PatchTree` to allow explicit user selection for push/pull/skip actions, enhancing user control during sync operations. - Added tests for the new encoding functionality and updated existing tests to ensure proper behavior with the new syncback logic.
This commit is contained in:
parent
ace839e46f
commit
c7cddb72c2
15 changed files with 3534 additions and 39 deletions
132
plugin/src/ChangeBatcher/encodeInstance.lua
Normal file
132
plugin/src/ChangeBatcher/encodeInstance.lua
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
--[[
|
||||
Encodes a Roblox Studio instance into a format suitable for transmission
|
||||
to the Rojo server for syncback (creating files from Studio instances).
|
||||
|
||||
Supports ALL instance types:
|
||||
- Scripts (ModuleScript, Script, LocalScript) -> .luau files or directories with init files if has children
|
||||
- Folders -> directories (with Attributes if present)
|
||||
- All other instances -> .model.json files or directories
|
||||
|
||||
Children are recursively encoded and included.
|
||||
]]
|
||||
|
||||
local Packages = script.Parent.Parent.Parent.Packages
|
||||
local Log = require(Packages.Log)
|
||||
local RbxDom = require(Packages.RbxDom)
|
||||
|
||||
local encodeProperty = require(script.Parent.encodeProperty)
|
||||
|
||||
-- Script class names that need Source property
|
||||
local SCRIPT_CLASS_NAMES = {
|
||||
Script = true,
|
||||
LocalScript = true,
|
||||
ModuleScript = true,
|
||||
}
|
||||
|
||||
-- Properties to skip when encoding (internal Roblox properties)
|
||||
local SKIP_PROPERTIES = {
|
||||
Parent = true,
|
||||
Name = true, -- We handle this separately
|
||||
Archivable = true,
|
||||
}
|
||||
|
||||
-- Classes that become directories but may have properties (like Attributes)
|
||||
local DIRECTORY_CLASSES = {
|
||||
Folder = true,
|
||||
Configuration = true,
|
||||
Tool = true,
|
||||
ScreenGui = true,
|
||||
SurfaceGui = true,
|
||||
BillboardGui = true,
|
||||
AdGui = true,
|
||||
}
|
||||
|
||||
-- Forward declaration for recursion
|
||||
local encodeInstance
|
||||
|
||||
-- Encode Attributes if present on any instance
|
||||
local function encodeAttributes(instance, properties)
|
||||
-- Try to get Attributes - this works for all instance types
|
||||
local success, attributes = pcall(function()
|
||||
return instance:GetAttributes()
|
||||
end)
|
||||
|
||||
if success and attributes and next(attributes) then
|
||||
-- Attributes need to be encoded specially
|
||||
-- The server expects them in the properties map under "Attributes"
|
||||
local attributeDescriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, "Attributes")
|
||||
if attributeDescriptor then
|
||||
local encodeSuccess, encodeResult = encodeProperty(instance, "Attributes", attributeDescriptor)
|
||||
if encodeSuccess and encodeResult ~= nil then
|
||||
properties.Attributes = encodeResult
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
encodeInstance = function(instance, parentId)
|
||||
local properties = {}
|
||||
|
||||
-- Always try to encode Attributes for any instance type
|
||||
encodeAttributes(instance, properties)
|
||||
|
||||
if SCRIPT_CLASS_NAMES[instance.ClassName] then
|
||||
-- For scripts, encode the Source property
|
||||
local sourceDescriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, "Source")
|
||||
if sourceDescriptor then
|
||||
local encodeSuccess, encodeResult = encodeProperty(instance, "Source", sourceDescriptor)
|
||||
if encodeSuccess then
|
||||
properties.Source = encodeResult
|
||||
else
|
||||
Log.warn("Failed to encode Source property for {:?}", instance)
|
||||
return nil
|
||||
end
|
||||
else
|
||||
Log.warn("Could not find Source property descriptor for {:?}", instance)
|
||||
return nil
|
||||
end
|
||||
elseif not DIRECTORY_CLASSES[instance.ClassName] then
|
||||
-- For non-directory, non-script classes, encode all relevant properties
|
||||
local classDescriptor = RbxDom.findClassDescriptor(instance.ClassName)
|
||||
if classDescriptor then
|
||||
for propertyName, propertyDescriptor in classDescriptor.properties do
|
||||
if SKIP_PROPERTIES[propertyName] then
|
||||
continue
|
||||
end
|
||||
|
||||
-- Skip Attributes here since we handle it separately above
|
||||
if propertyName == "Attributes" then
|
||||
continue
|
||||
end
|
||||
|
||||
-- Only encode serializable properties
|
||||
if propertyDescriptor.scriptability == "ReadWrite" or propertyDescriptor.scriptability == "Read" then
|
||||
local encodeSuccess, encodeResult = encodeProperty(instance, propertyName, propertyDescriptor)
|
||||
if encodeSuccess and encodeResult ~= nil then
|
||||
properties[propertyName] = encodeResult
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- For directory classes (Folder, Configuration, etc.), we only encode Attributes (done above)
|
||||
|
||||
-- Recursively encode children
|
||||
local children = {}
|
||||
for _, child in ipairs(instance:GetChildren()) do
|
||||
local encodedChild = encodeInstance(child, nil) -- parentId will be resolved server-side
|
||||
if encodedChild then
|
||||
table.insert(children, encodedChild)
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
parent = parentId,
|
||||
name = instance.Name,
|
||||
className = instance.ClassName,
|
||||
properties = properties,
|
||||
children = children, -- Include children array (may be empty)
|
||||
}
|
||||
end
|
||||
|
||||
return encodeInstance
|
||||
160
plugin/src/ChangeBatcher/encodeInstance.spec.lua
Normal file
160
plugin/src/ChangeBatcher/encodeInstance.spec.lua
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
return function()
|
||||
local encodeInstance = require(script.Parent.encodeInstance)
|
||||
|
||||
describe("encodeInstance", function()
|
||||
describe("script encoding", function()
|
||||
it("should encode ModuleScript correctly", function()
|
||||
local moduleScript = Instance.new("ModuleScript")
|
||||
moduleScript.Name = "TestModule"
|
||||
moduleScript.Source = "return {}"
|
||||
|
||||
local encoded = encodeInstance(moduleScript, "PARENT_ID")
|
||||
|
||||
expect(encoded).to.be.ok()
|
||||
expect(encoded.parent).to.equal("PARENT_ID")
|
||||
expect(encoded.name).to.equal("TestModule")
|
||||
expect(encoded.className).to.equal("ModuleScript")
|
||||
expect(encoded.properties).to.be.ok()
|
||||
expect(encoded.properties.Source).to.be.ok()
|
||||
|
||||
moduleScript:Destroy()
|
||||
end)
|
||||
|
||||
it("should encode Script correctly", function()
|
||||
local script = Instance.new("Script")
|
||||
script.Name = "TestScript"
|
||||
script.Source = "print('hello')"
|
||||
|
||||
local encoded = encodeInstance(script, "PARENT_ID")
|
||||
|
||||
expect(encoded).to.be.ok()
|
||||
expect(encoded.name).to.equal("TestScript")
|
||||
expect(encoded.className).to.equal("Script")
|
||||
expect(encoded.properties.Source).to.be.ok()
|
||||
|
||||
script:Destroy()
|
||||
end)
|
||||
|
||||
it("should encode LocalScript correctly", function()
|
||||
local localScript = Instance.new("LocalScript")
|
||||
localScript.Name = "TestClient"
|
||||
localScript.Source = "print('client')"
|
||||
|
||||
local encoded = encodeInstance(localScript, "PARENT_ID")
|
||||
|
||||
expect(encoded).to.be.ok()
|
||||
expect(encoded.name).to.equal("TestClient")
|
||||
expect(encoded.className).to.equal("LocalScript")
|
||||
expect(encoded.properties.Source).to.be.ok()
|
||||
|
||||
localScript:Destroy()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("folder encoding", function()
|
||||
it("should encode Folder correctly", function()
|
||||
local folder = Instance.new("Folder")
|
||||
folder.Name = "TestFolder"
|
||||
|
||||
local encoded = encodeInstance(folder, "PARENT_ID")
|
||||
|
||||
expect(encoded).to.be.ok()
|
||||
expect(encoded.parent).to.equal("PARENT_ID")
|
||||
expect(encoded.name).to.equal("TestFolder")
|
||||
expect(encoded.className).to.equal("Folder")
|
||||
-- Folders have no special properties
|
||||
expect(encoded.properties).to.be.ok()
|
||||
|
||||
folder:Destroy()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("other instance encoding", function()
|
||||
it("should encode Part with properties", function()
|
||||
local part = Instance.new("Part")
|
||||
part.Name = "TestPart"
|
||||
part.Anchored = true
|
||||
|
||||
local encoded = encodeInstance(part, "PARENT_ID")
|
||||
|
||||
expect(encoded).to.be.ok()
|
||||
expect(encoded.name).to.equal("TestPart")
|
||||
expect(encoded.className).to.equal("Part")
|
||||
expect(encoded.properties).to.be.ok()
|
||||
-- Properties should be captured
|
||||
-- Note: Exact properties depend on RbxDom descriptors
|
||||
|
||||
part:Destroy()
|
||||
end)
|
||||
|
||||
it("should encode StringValue", function()
|
||||
local stringValue = Instance.new("StringValue")
|
||||
stringValue.Name = "TestValue"
|
||||
stringValue.Value = "Hello World"
|
||||
|
||||
local encoded = encodeInstance(stringValue, "PARENT_ID")
|
||||
|
||||
expect(encoded).to.be.ok()
|
||||
expect(encoded.name).to.equal("TestValue")
|
||||
expect(encoded.className).to.equal("StringValue")
|
||||
|
||||
stringValue:Destroy()
|
||||
end)
|
||||
|
||||
it("should encode Configuration", function()
|
||||
local config = Instance.new("Configuration")
|
||||
config.Name = "TestConfig"
|
||||
|
||||
local encoded = encodeInstance(config, "PARENT_ID")
|
||||
|
||||
expect(encoded).to.be.ok()
|
||||
expect(encoded.className).to.equal("Configuration")
|
||||
|
||||
config:Destroy()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("parent ID handling", function()
|
||||
it("should include parent ID in all encodings", function()
|
||||
local instances = {
|
||||
Instance.new("ModuleScript"),
|
||||
Instance.new("Folder"),
|
||||
Instance.new("Part"),
|
||||
}
|
||||
|
||||
for _, instance in instances do
|
||||
local encoded = encodeInstance(instance, "UNIQUE_PARENT_ID")
|
||||
expect(encoded).to.be.ok()
|
||||
expect(encoded.parent).to.equal("UNIQUE_PARENT_ID")
|
||||
instance:Destroy()
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("edge cases", function()
|
||||
it("should handle instances with special characters in name", function()
|
||||
local folder = Instance.new("Folder")
|
||||
folder.Name = "Test Folder (1)"
|
||||
|
||||
local encoded = encodeInstance(folder, "PARENT")
|
||||
|
||||
expect(encoded).to.be.ok()
|
||||
expect(encoded.name).to.equal("Test Folder (1)")
|
||||
|
||||
folder:Destroy()
|
||||
end)
|
||||
|
||||
it("should handle instances with empty name", function()
|
||||
local folder = Instance.new("Folder")
|
||||
folder.Name = ""
|
||||
|
||||
local encoded = encodeInstance(folder, "PARENT")
|
||||
|
||||
expect(encoded).to.be.ok()
|
||||
expect(encoded.name).to.equal("")
|
||||
|
||||
folder:Destroy()
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
|
@ -28,6 +28,7 @@ function ChangeBatcher.new(instanceMap, onChangesFlushed)
|
|||
__onChangesFlushed = onChangesFlushed,
|
||||
__pendingPropertyChanges = {},
|
||||
__syncSourceOnly = false,
|
||||
__paused = false,
|
||||
}, ChangeBatcher)
|
||||
|
||||
return self
|
||||
|
|
@ -37,6 +38,21 @@ function ChangeBatcher:setSyncSourceOnly(enabled)
|
|||
self.__syncSourceOnly = enabled
|
||||
end
|
||||
|
||||
-- Pause the batcher to prevent change accumulation during confirmation
|
||||
function ChangeBatcher:pause()
|
||||
self.__paused = true
|
||||
end
|
||||
|
||||
-- Resume the batcher after confirmation
|
||||
function ChangeBatcher:resume()
|
||||
self.__paused = false
|
||||
end
|
||||
|
||||
-- Check if the batcher is paused
|
||||
function ChangeBatcher:isPaused()
|
||||
return self.__paused
|
||||
end
|
||||
|
||||
function ChangeBatcher:stop()
|
||||
self.__renderSteppedConnection:Disconnect()
|
||||
self.__pendingPropertyChanges = {}
|
||||
|
|
@ -54,6 +70,11 @@ function ChangeBatcher:add(instance, propertyName)
|
|||
end
|
||||
|
||||
function ChangeBatcher:__cycle(dt)
|
||||
-- Skip processing when paused (during confirmation dialogue)
|
||||
if self.__paused then
|
||||
return
|
||||
end
|
||||
|
||||
self.__accumulator += dt
|
||||
|
||||
if self.__accumulator >= BATCH_INTERVAL then
|
||||
|
|
|
|||
|
|
@ -98,4 +98,74 @@ return function()
|
|||
expect(changeBatcher:__flush()).to.equal(nil)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("pause/resume", function()
|
||||
it("should start unpaused by default", function()
|
||||
local changeBatcher = ChangeBatcher.new(InstanceMap.new(), noop)
|
||||
expect(changeBatcher:isPaused()).to.equal(false)
|
||||
changeBatcher:stop()
|
||||
end)
|
||||
|
||||
it("should be pausable", function()
|
||||
local changeBatcher = ChangeBatcher.new(InstanceMap.new(), noop)
|
||||
changeBatcher:pause()
|
||||
expect(changeBatcher:isPaused()).to.equal(true)
|
||||
changeBatcher:stop()
|
||||
end)
|
||||
|
||||
it("should be resumable after pause", function()
|
||||
local changeBatcher = ChangeBatcher.new(InstanceMap.new(), noop)
|
||||
changeBatcher:pause()
|
||||
expect(changeBatcher:isPaused()).to.equal(true)
|
||||
changeBatcher:resume()
|
||||
expect(changeBatcher:isPaused()).to.equal(false)
|
||||
changeBatcher:stop()
|
||||
end)
|
||||
|
||||
it("should not flush when paused", function()
|
||||
local flushed = false
|
||||
local instanceMap = InstanceMap.new()
|
||||
local changeBatcher = ChangeBatcher.new(instanceMap, function()
|
||||
flushed = true
|
||||
end)
|
||||
|
||||
local part = Instance.new("Part")
|
||||
instanceMap:insert("PART", part)
|
||||
changeBatcher.__pendingPropertyChanges[part] = { Name = true }
|
||||
|
||||
changeBatcher:pause()
|
||||
|
||||
-- Call __cycle with a large dt to trigger flush (normally 0.2s threshold)
|
||||
changeBatcher:__cycle(1.0)
|
||||
|
||||
-- Should not have flushed because we're paused
|
||||
expect(flushed).to.equal(false)
|
||||
|
||||
changeBatcher:stop()
|
||||
part:Destroy()
|
||||
end)
|
||||
|
||||
it("should flush after resume", function()
|
||||
local flushed = false
|
||||
local instanceMap = InstanceMap.new()
|
||||
local changeBatcher = ChangeBatcher.new(instanceMap, function()
|
||||
flushed = true
|
||||
end)
|
||||
|
||||
local part = Instance.new("Part")
|
||||
instanceMap:insert("PART", part)
|
||||
changeBatcher.__pendingPropertyChanges[part] = { Name = true }
|
||||
|
||||
changeBatcher:pause()
|
||||
changeBatcher:__cycle(1.0)
|
||||
expect(flushed).to.equal(false)
|
||||
|
||||
changeBatcher:resume()
|
||||
changeBatcher:__cycle(1.0)
|
||||
expect(flushed).to.equal(true)
|
||||
|
||||
changeBatcher:stop()
|
||||
part:Destroy()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue