rojo/plugin/src/PatchSet.lua
warsam a05acf8d35 Make "Always" confirmation behavior setting work by handling it for ongoing sync patches
- Add "Always" option to confirmationBehavior setting
- Route WebSocket patches through the confirmation callback
- Fix UI not transitioning back to Connected after confirming ongoing patches
- Fix DomLabel Flipper motor cleanup on unmount to prevent errors
2026-01-27 04:59:17 -06:00

506 lines
12 KiB
Lua

--[[
Methods to operate on either a patch created by the hydrate method, or a
patch returned from the API.
]]
local Packages = script.Parent.Parent.Packages
local t = require(Packages.t)
local Types = require(script.Parent.Types)
local decodeValue = require(script.Parent.Reconciler.decodeValue)
local function deepEqual(a: any, b: any): boolean
local typeA = typeof(a)
if typeA ~= typeof(b) then
return false
end
if typeof(a) == "table" then
local checkedKeys = {}
for key, value in a do
checkedKeys[key] = true
if deepEqual(value, b[key]) == false then
return false
end
end
for key, value in b do
if checkedKeys[key] then
continue
end
if deepEqual(value, a[key]) == false then
return false
end
end
return true
end
if a == b then
return true
end
return false
end
local PatchSet = {}
PatchSet.validate = t.interface({
removed = t.array(t.union(Types.RbxId, t.Instance)),
added = t.map(Types.RbxId, Types.ApiInstance),
updated = t.array(Types.ApiInstanceUpdate),
})
--[[
Create a new, empty PatchSet.
]]
function PatchSet.newEmpty()
return {
removed = {},
added = {},
updated = {},
}
end
--[[
Tells whether the given PatchSet is empty.
]]
function PatchSet.isEmpty(patchSet)
return next(patchSet.removed) == nil and next(patchSet.added) == nil and next(patchSet.updated) == nil
end
--[[
Tells whether the given PatchSet has any remove operations.
]]
function PatchSet.hasRemoves(patchSet)
return next(patchSet.removed) ~= nil
end
--[[
Tells whether the given PatchSet has any add operations.
]]
function PatchSet.hasAdditions(patchSet)
return next(patchSet.added) ~= nil
end
--[[
Tells whether the given PatchSet has any update operations.
]]
function PatchSet.hasUpdates(patchSet)
return next(patchSet.updated) ~= nil
end
--[[
Tells whether the given PatchSet contains changes to the given instance id
]]
function PatchSet.containsId(patchSet, instanceMap, id)
if patchSet.added[id] ~= nil then
return true
end
for _, idOrInstance in patchSet.removed do
local removedId = if Types.RbxId(idOrInstance) then idOrInstance else instanceMap.fromInstances[idOrInstance]
if removedId == id then
return true
end
end
for _, update in patchSet.updated do
if update.id == id then
return true
end
end
return false
end
--[[
Tells whether the given PatchSet contains changes to the given instance.
If the given InstanceMap does not contain the instance, this function always returns false.
]]
function PatchSet.containsInstance(patchSet, instanceMap, instance)
local id = instanceMap.fromInstances[instance]
if id == nil then
return false
end
return PatchSet.containsId(patchSet, instanceMap, id)
end
--[[
Tells whether the given PatchSet contains changes to nothing but the given instance id
]]
function PatchSet.containsOnlyId(patchSet, instanceMap, id)
if not PatchSet.containsId(patchSet, instanceMap, id) then
-- Patch doesn't contain the id at all
return false
end
for addedId in patchSet.added do
if addedId ~= id then
return false
end
end
for _, idOrInstance in patchSet.removed do
local removedId = if Types.RbxId(idOrInstance) then idOrInstance else instanceMap.fromInstances[idOrInstance]
if removedId ~= id then
return false
end
end
for _, update in patchSet.updated do
if update.id ~= id then
return false
end
end
return true
end
--[[
Tells whether the given PatchSet contains changes to nothing but the given instance.
If the given InstanceMap does not contain the instance, this function always returns false.
]]
function PatchSet.containsOnlyInstance(patchSet, instanceMap, instance)
local id = instanceMap.fromInstances[instance]
if id == nil then
return false
end
return PatchSet.containsOnlyId(patchSet, instanceMap, id)
end
--[[
Returns the update to the given instance id, or nil if there aren't any
]]
function PatchSet.getUpdateForId(patchSet, id)
for _, update in patchSet.updated do
if update.id == id then
return update
end
end
return nil
end
--[[
Returns the update to the given instance, or nil if there aren't any.
If the given InstanceMap does not contain the instance, this function always returns nil.
]]
function PatchSet.getUpdateForInstance(patchSet, instanceMap, instance)
local id = instanceMap.fromInstances[instance]
if id == nil then
return nil
end
return PatchSet.getUpdateForId(patchSet, id)
end
--[[
Tells whether the given PatchSets are equal.
]]
function PatchSet.isEqual(patchA, patchB)
return deepEqual(patchA, patchB)
end
--[[
Count the number of changes in the given PatchSet.
]]
function PatchSet.countChanges(patch)
local count = 0
for _, add in patch.added do
-- Adding an instance is 1 change per property
for _ in add.Properties do
count += 1
end
end
for _ in patch.removed do
-- Removing an instance is 1 change
count += 1
end
for _, update in patch.updated do
-- Updating an instance is 1 change per property updated
for _ in update.changedProperties do
count += 1
end
if update.changedName ~= nil then
count += 1
end
if update.changedClassName ~= nil then
count += 1
end
end
return count
end
--[[
Count the number of instances affected by the given PatchSet.
]]
function PatchSet.countInstances(patch)
local count = 0
-- Added instances
for _ in patch.added do
count += 1
end
-- Removed instances
for _ in patch.removed do
count += 1
end
-- Updated instances
for _ in patch.updated do
count += 1
end
return count
end
--[[
Merge multiple PatchSet objects into the given PatchSet.
]]
function PatchSet.assign(target, ...)
for i = 1, select("#", ...) do
local sourcePatch = select(i, ...)
for _, removed in ipairs(sourcePatch.removed) do
table.insert(target.removed, removed)
end
for id, added in pairs(sourcePatch.added) do
target.added[id] = added
end
for _, update in ipairs(sourcePatch.updated) do
table.insert(target.updated, update)
end
end
return target
end
--[[
Merge a source PatchSet into the target, properly reconciling conflicts.
Unlike assign(), this handles cases where a removal cancels out an addition,
or where an update supersedes a previous update to the same instance.
If instanceMap is provided, updates that revert to the current instance state
will be removed from the patch entirely.
]]
function PatchSet.merge(target, source, instanceMap)
-- Process removals: if we're removing something that was added, just delete the addition
for _, removed in ipairs(source.removed) do
if target.added[removed] ~= nil then
-- This removal cancels out a previous addition
target.added[removed] = nil
else
table.insert(target.removed, removed)
end
end
-- Process additions
for id, added in pairs(source.added) do
target.added[id] = added
end
-- Process updates: merge with existing updates to the same instance
for _, update in ipairs(source.updated) do
local existingIndex = nil
for i, existing in ipairs(target.updated) do
if existing.id == update.id then
existingIndex = i
break
end
end
if existingIndex then
-- Merge the updates
local existing = target.updated[existingIndex]
if update.changedName ~= nil then
existing.changedName = update.changedName
end
if update.changedClassName ~= nil then
existing.changedClassName = update.changedClassName
end
if update.changedMetadata ~= nil then
existing.changedMetadata = update.changedMetadata
end
for prop, value in pairs(update.changedProperties) do
existing.changedProperties[prop] = value
end
-- Check if this update now matches current instance state (i.e., reverted)
-- If so, remove properties that match or remove the update entirely
if instanceMap then
local instance = instanceMap.fromIds[existing.id]
if instance then
-- Check if name change matches current
if existing.changedName ~= nil and existing.changedName == instance.Name then
existing.changedName = nil
end
-- Check if class change matches current
if existing.changedClassName ~= nil and existing.changedClassName == instance.ClassName then
existing.changedClassName = nil
end
-- Check each changed property against current instance value
for propName, encodedValue in pairs(existing.changedProperties) do
local decodeSuccess, decodedValue = decodeValue(encodedValue, instanceMap)
if decodeSuccess then
local currentSuccess, currentValue = pcall(function()
return instance[propName]
end)
if currentSuccess and deepEqual(decodedValue, currentValue) then
existing.changedProperties[propName] = nil
end
end
end
-- If no changes remain, remove the update entirely
local hasChanges = existing.changedName ~= nil
or existing.changedClassName ~= nil
or existing.changedMetadata ~= nil
or next(existing.changedProperties) ~= nil
if not hasChanges then
table.remove(target.updated, existingIndex)
end
end
end
else
table.insert(target.updated, update)
end
end
return target
end
function PatchSet.addedIdList(patchSet): { string }
local idList = table.create(#patchSet.added)
for id in patchSet.added do
table.insert(idList, id)
end
return table.freeze(idList)
end
function PatchSet.updatedIdList(patchSet): { string }
local idList = table.create(#patchSet.updated)
for _, item in patchSet.updated do
table.insert(idList, item.id)
end
return table.freeze(idList)
end
--[[
Create a list of human-readable statements summarizing the contents of this
patch, intended to be displayed to users.
]]
function PatchSet.humanSummary(instanceMap, patchSet)
local statements = {}
for _, idOrInstance in ipairs(patchSet.removed) do
local instance, id
if Types.RbxId(idOrInstance) then
id = idOrInstance
instance = instanceMap.fromIds[id]
else
instance = idOrInstance
id = instanceMap.fromInstances[instance]
end
if instance ~= nil then
table.insert(statements, string.format("- Delete instance %s", instance:GetFullName()))
else
table.insert(statements, string.format("- Delete instance with ID %s", id))
end
end
local additionsMentioned = {}
local function addAllDescendents(virtualInstance)
additionsMentioned[virtualInstance.Id] = true
for _, childId in ipairs(virtualInstance.Children) do
addAllDescendents(patchSet.added[childId])
end
end
for id, addition in pairs(patchSet.added) do
if additionsMentioned[id] then
continue
end
local virtualInstance = addition
while true do
if virtualInstance.Parent == nil then
break
end
local virtualParent = patchSet.added[virtualInstance.Parent]
if virtualParent == nil then
break
end
virtualInstance = virtualParent
end
local parentDisplayName = "nil (how strange!)"
if virtualInstance.Parent ~= nil then
local parent = instanceMap.fromIds[virtualInstance.Parent]
if parent ~= nil then
parentDisplayName = parent:GetFullName()
end
end
table.insert(
statements,
string.format(
"- Add instance %q (ClassName %q) to %s",
virtualInstance.Name,
virtualInstance.ClassName,
parentDisplayName
)
)
end
for _, update in ipairs(patchSet.updated) do
local updatedProperties = {}
if update.changedMetadata ~= nil then
table.insert(updatedProperties, "Rojo's Metadata")
end
if update.changedName ~= nil then
table.insert(updatedProperties, "Name")
end
if update.changedClassName ~= nil then
table.insert(updatedProperties, "ClassName")
end
for name in pairs(update.changedProperties) do
table.insert(updatedProperties, name)
end
local instance = instanceMap.fromIds[update.id]
local displayName
if instance ~= nil then
displayName = instance:GetFullName()
else
displayName = "[unknown instance]"
end
table.insert(
statements,
string.format("- Update properties on %s: %s", displayName, table.concat(updatedProperties, ","))
)
end
return table.concat(statements, "\n")
end
return PatchSet