mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 14:01:37 +00:00
- Introduced a new endpoint `/api/syncback` for one-click full sync from Roblox Studio to the filesystem, eliminating the need for an active WebSocket connection. - Implemented `SyncbackRequest` and `ServiceChunk` structures to handle service-level properties and child instances during syncback. - Enhanced `encodeService` to include ObjectValue carriers for accurate reference handling and prevent data loss. - Updated the syncback process to utilize Luau-style relative paths for reference attributes, improving path management and consistency. - Integrated session caching in the matching algorithm to optimize performance during syncback operations. These changes significantly improve the live syncback experience, ensuring reliable data synchronization and enhanced performance during development.
715 lines
20 KiB
Lua
715 lines
20 KiB
Lua
--[[
|
|
Methods to turn PatchSets into trees matching the DataModel containing
|
|
the changes and metadata for use in the PatchVisualizer component.
|
|
]]
|
|
|
|
local HttpService = game:GetService("HttpService")
|
|
local ScriptEditorService = game:GetService("ScriptEditorService")
|
|
|
|
local Rojo = script:FindFirstAncestor("Rojo")
|
|
local Plugin = Rojo.Plugin
|
|
local Packages = Rojo.Packages
|
|
|
|
local Log = require(Packages.Log)
|
|
|
|
local ChangeMetadata = require(Plugin.ChangeMetadata)
|
|
local Config = require(Plugin.Config)
|
|
local SHA1 = require(Plugin.SHA1)
|
|
local Timer = require(Plugin.Timer)
|
|
local Types = require(Plugin.Types)
|
|
local decodeValue = require(Plugin.Reconciler.decodeValue)
|
|
local getProperty = require(Plugin.Reconciler.getProperty)
|
|
|
|
local function yieldIfNeeded(clock)
|
|
if os.clock() - clock > 1 / 20 then
|
|
task.wait()
|
|
return os.clock()
|
|
end
|
|
return clock
|
|
end
|
|
|
|
local function alphabeticalNext(t, state)
|
|
-- Equivalent of the next function, but returns the keys in the alphabetic
|
|
-- order of node names. We use a temporary ordered key table that is stored in the
|
|
-- table being iterated.
|
|
|
|
local key = nil
|
|
if state == nil then
|
|
-- First iteration, generate the index
|
|
local orderedIndex, i = table.create(5), 0
|
|
for k in t do
|
|
i += 1
|
|
orderedIndex[i] = k
|
|
end
|
|
table.sort(orderedIndex, function(a, b)
|
|
local nodeA, nodeB = t[a], t[b]
|
|
return (nodeA.name or "") < (nodeB.name or "")
|
|
end)
|
|
|
|
t.__orderedIndex = orderedIndex
|
|
key = orderedIndex[1]
|
|
else
|
|
-- Fetch the next value
|
|
for i, orderedState in t.__orderedIndex do
|
|
if orderedState == state then
|
|
key = t.__orderedIndex[i + 1]
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
if key then
|
|
return key, t[key]
|
|
end
|
|
|
|
-- No more value to return, cleanup
|
|
t.__orderedIndex = nil
|
|
|
|
return
|
|
end
|
|
|
|
local function alphabeticalPairs(t)
|
|
-- Equivalent of the pairs() iterator, but sorted
|
|
return alphabeticalNext, t, nil
|
|
end
|
|
|
|
local Tree = {}
|
|
Tree.__index = Tree
|
|
|
|
function Tree.new()
|
|
local tree = {
|
|
idToNode = {},
|
|
ROOT = {
|
|
id = "ROOT",
|
|
className = "DataModel",
|
|
name = "game",
|
|
children = {},
|
|
},
|
|
}
|
|
-- Add ROOT to idToNode or it won't be found by getNode since that searches *within* ROOT
|
|
tree.idToNode["ROOT"] = tree.ROOT
|
|
|
|
return setmetatable(tree, Tree)
|
|
end
|
|
|
|
-- Iterates over all nodes and counts them up
|
|
function Tree:getCount()
|
|
local count = 0
|
|
self:forEach(function()
|
|
count += 1
|
|
end)
|
|
return count
|
|
end
|
|
|
|
-- Iterates over all sub-nodes, depth first
|
|
-- node is where to start from, defaults to root
|
|
-- depth is used for recursion but can be used to set the starting depth
|
|
function Tree:forEach(callback, node, depth)
|
|
depth = depth or 1
|
|
for _, child in alphabeticalPairs(if node then node.children else self.ROOT.children) do
|
|
callback(child, depth)
|
|
if type(child.children) == "table" then
|
|
self:forEach(callback, child, depth + 1)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Finds a node by id, depth first
|
|
-- searchNode is the node to start the search within, defaults to root
|
|
function Tree:getNode(id, searchNode)
|
|
if self.idToNode[id] then
|
|
return self.idToNode[id]
|
|
end
|
|
|
|
local searchChildren = (searchNode or self.ROOT).children
|
|
for nodeId, node in searchChildren do
|
|
if nodeId == id then
|
|
self.idToNode[id] = node
|
|
return node
|
|
end
|
|
local descendant = self:getNode(id, node)
|
|
if descendant then
|
|
return descendant
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
function Tree:doesNodeExist(id)
|
|
return self.idToNode[id] ~= nil
|
|
end
|
|
|
|
-- Adds a node to the tree as a child of the node with id == parent
|
|
-- If parent is nil, it defaults to root
|
|
-- props must contain id, and cannot contain children or parentId
|
|
-- other than those three, it can hold anything
|
|
function Tree:addNode(parent, props)
|
|
assert(props.id, "props must contain id")
|
|
|
|
parent = parent or "ROOT"
|
|
|
|
if self:doesNodeExist(props.id) then
|
|
-- Update existing node
|
|
local node = self:getNode(props.id)
|
|
for k, v in props do
|
|
node[k] = v
|
|
end
|
|
return node
|
|
end
|
|
|
|
local node = table.clone(props)
|
|
node.children = {}
|
|
node.parentId = parent
|
|
|
|
local parentNode = self:getNode(parent)
|
|
if not parentNode then
|
|
Log.warn("Failed to create node since parent doesnt exist: {}, {}", parent, props)
|
|
return
|
|
end
|
|
|
|
parentNode.children[node.id] = node
|
|
self.idToNode[node.id] = node
|
|
|
|
return node
|
|
end
|
|
|
|
-- Given a list of ancestor ids in descending order, builds the nodes for them
|
|
-- using the patch and instanceMap info
|
|
function Tree:buildAncestryNodes(previousId: string?, ancestryIds: { string }, patch, instanceMap)
|
|
local clock = os.clock()
|
|
-- Build nodes for ancestry by going up the tree
|
|
previousId = previousId or "ROOT"
|
|
|
|
for _, ancestorId in ancestryIds do
|
|
clock = yieldIfNeeded(clock)
|
|
|
|
local value = instanceMap.fromIds[ancestorId] or patch.added[ancestorId]
|
|
if not value then
|
|
-- Ancestor is missing from both instanceMap and patch.added.
|
|
-- This can happen during rapid changes (e.g., undo operations) where
|
|
-- patches arrive referencing instances that were never fully synced.
|
|
-- Create a placeholder node to maintain the ancestry chain, allowing
|
|
-- the visualization to still work with incomplete information.
|
|
Log.warn("Failed to find ancestor object for {}; creating placeholder node", ancestorId)
|
|
self:addNode(previousId, {
|
|
id = ancestorId,
|
|
className = "Unknown",
|
|
name = "[Unknown Ancestor]",
|
|
instance = nil,
|
|
})
|
|
previousId = ancestorId
|
|
continue
|
|
end
|
|
self:addNode(previousId, {
|
|
id = ancestorId,
|
|
className = value.ClassName,
|
|
name = value.Name,
|
|
instance = if typeof(value) == "Instance" then value else nil,
|
|
})
|
|
previousId = ancestorId
|
|
end
|
|
end
|
|
|
|
local function computeRootChangeInfo(tree)
|
|
local totalLinesAdded = 0
|
|
local totalLinesRemoved = 0
|
|
local totalPropChanges = 0
|
|
local hasLineData = false
|
|
|
|
tree:forEach(function(node)
|
|
if not node.changeInfo then
|
|
return
|
|
end
|
|
if node.changeInfo.linesAdded then
|
|
totalLinesAdded += node.changeInfo.linesAdded
|
|
hasLineData = true
|
|
end
|
|
if node.changeInfo.linesRemoved then
|
|
totalLinesRemoved += node.changeInfo.linesRemoved
|
|
hasLineData = true
|
|
end
|
|
if node.changeInfo.propChanges then
|
|
totalPropChanges += node.changeInfo.propChanges
|
|
end
|
|
end)
|
|
|
|
if not hasLineData and totalPropChanges == 0 then
|
|
return nil
|
|
end
|
|
|
|
return {
|
|
linesAdded = if hasLineData then totalLinesAdded else nil,
|
|
linesRemoved = if hasLineData then totalLinesRemoved else nil,
|
|
isWhitespaceOnly = false,
|
|
propChanges = if totalPropChanges > 0 then totalPropChanges else nil,
|
|
}
|
|
end
|
|
|
|
local PatchTree = {}
|
|
|
|
-- Builds a new tree from a patch and instanceMap
|
|
-- uses changeListHeaders in node.changeList
|
|
-- gitMetadata is optional: { changedIds: {string}, scriptCommittedHashes: {[string]: {string}} }
|
|
function PatchTree.build(patch, instanceMap, changeListHeaders, gitMetadata)
|
|
Timer.start("PatchTree.build")
|
|
local clock = os.clock()
|
|
|
|
local tree = Tree.new()
|
|
|
|
local knownAncestors = {}
|
|
|
|
local changedIdSet = nil
|
|
if gitMetadata and gitMetadata.changedIds then
|
|
changedIdSet = {}
|
|
for _, id in gitMetadata.changedIds do
|
|
changedIdSet[id] = true
|
|
end
|
|
end
|
|
|
|
Timer.start("patch.updated")
|
|
for _, change in patch.updated do
|
|
clock = yieldIfNeeded(clock)
|
|
|
|
local instance = instanceMap.fromIds[change.id]
|
|
if not instance then
|
|
continue
|
|
end
|
|
|
|
-- Gather ancestors from existing DOM
|
|
local ancestryIds = {}
|
|
local parentObject = instance.Parent
|
|
local parentId = instanceMap.fromInstances[parentObject]
|
|
local previousId = nil
|
|
while parentObject and parentId do
|
|
if knownAncestors[parentId] then
|
|
-- We've already added this ancestor
|
|
previousId = parentId
|
|
break
|
|
end
|
|
|
|
table.insert(ancestryIds, 1, parentId)
|
|
knownAncestors[parentId] = true
|
|
parentObject = parentObject.Parent
|
|
parentId = instanceMap.fromInstances[parentObject]
|
|
end
|
|
|
|
tree:buildAncestryNodes(previousId, ancestryIds, patch, instanceMap)
|
|
|
|
-- Gather detail text
|
|
local changeList, changeInfo = nil, nil
|
|
if next(change.changedProperties) or change.changedName then
|
|
changeList = {}
|
|
|
|
local changeIndex = 0
|
|
local function addProp(prop: string, current: any?, incoming: any?)
|
|
changeIndex += 1
|
|
changeList[changeIndex] = { prop, current, incoming }
|
|
end
|
|
|
|
-- Gather the changes
|
|
|
|
if change.changedName then
|
|
addProp("Name", instance.Name, change.changedName)
|
|
end
|
|
|
|
for prop, incoming in change.changedProperties do
|
|
local incomingSuccess, incomingValue = decodeValue(incoming, instanceMap)
|
|
local currentSuccess, currentValue = getProperty(instance, prop)
|
|
|
|
-- If incoming is a raw number and current is an EnumItem, convert incoming
|
|
-- to the proper EnumItem for better display (server sends Enum as raw numbers)
|
|
if
|
|
incomingSuccess
|
|
and currentSuccess
|
|
and typeof(incomingValue) == "number"
|
|
and typeof(currentValue) == "EnumItem"
|
|
then
|
|
local enumType = currentValue.EnumType
|
|
local ok, converted = pcall(function()
|
|
return enumType:FromValue(incomingValue)
|
|
end)
|
|
if ok and converted then
|
|
incomingValue = converted
|
|
end
|
|
end
|
|
|
|
addProp(
|
|
prop,
|
|
if currentSuccess then currentValue else "[Error]",
|
|
if incomingSuccess then incomingValue else select(2, next(incoming))
|
|
)
|
|
end
|
|
|
|
-- Compute structured change metadata via ChangeMetadata module
|
|
changeInfo = ChangeMetadata.compute(changeList)
|
|
|
|
-- Sort changes and add header
|
|
table.sort(changeList, function(a, b)
|
|
return a[1] < b[1]
|
|
end)
|
|
table.insert(changeList, 1, changeListHeaders)
|
|
end
|
|
|
|
-- Compute default selection from git metadata
|
|
local defaultSelection = nil
|
|
if changedIdSet then
|
|
if not changedIdSet[change.id] then
|
|
defaultSelection = "pull"
|
|
elseif instance:IsA("LuaSourceContainer") and gitMetadata.scriptCommittedHashes then
|
|
local hashes = gitMetadata.scriptCommittedHashes[change.id]
|
|
if hashes then
|
|
local source = instance.Source
|
|
local gitBlob = "blob " .. tostring(#source) .. "\0" .. source
|
|
local studioHash = SHA1(buffer.fromstring(gitBlob))
|
|
for _, hash in hashes do
|
|
if studioHash == hash then
|
|
defaultSelection = "push"
|
|
break
|
|
end
|
|
end
|
|
|
|
if defaultSelection == nil then
|
|
local ok, draft = pcall(ScriptEditorService.GetEditorSource, ScriptEditorService, instance)
|
|
if ok and draft ~= source then
|
|
local draftBlob = "blob " .. tostring(#draft) .. "\0" .. draft
|
|
local draftHash = SHA1(buffer.fromstring(draftBlob))
|
|
for _, hash in hashes do
|
|
if draftHash == hash then
|
|
defaultSelection = "push"
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
tree:addNode(instanceMap.fromInstances[instance.Parent], {
|
|
id = change.id,
|
|
patchType = "Edit",
|
|
className = instance.ClassName,
|
|
name = instance.Name,
|
|
instance = instance,
|
|
changeInfo = changeInfo,
|
|
changeList = changeList,
|
|
defaultSelection = defaultSelection,
|
|
})
|
|
end
|
|
Timer.stop()
|
|
|
|
Timer.start("patch.removed")
|
|
for _, idOrInstance in patch.removed do
|
|
clock = yieldIfNeeded(clock)
|
|
|
|
local instance = if Types.RbxId(idOrInstance) then instanceMap.fromIds[idOrInstance] else idOrInstance
|
|
if not instance then
|
|
-- If we're viewing a past patch, the instance is already removed
|
|
-- and we therefore cannot get the tree for it anymore
|
|
continue
|
|
end
|
|
|
|
-- Skip auto-created instances that shouldn't be synced
|
|
if Config.ignoredClassNames[instance.ClassName] then
|
|
continue
|
|
end
|
|
|
|
-- Gather ancestors from existing DOM
|
|
-- (note that they may have no ID if they're being removed as unknown)
|
|
local ancestryIds = {}
|
|
local parentObject = instance.Parent
|
|
local parentId = instanceMap.fromInstances[parentObject] or HttpService:GenerateGUID(false)
|
|
local previousId = nil
|
|
while parentObject do
|
|
if knownAncestors[parentId] then
|
|
-- We've already added this ancestor
|
|
previousId = parentId
|
|
break
|
|
end
|
|
|
|
instanceMap:insert(parentId, parentObject) -- This ensures we can find the parent later
|
|
table.insert(ancestryIds, 1, parentId)
|
|
knownAncestors[parentId] = true
|
|
parentObject = parentObject.Parent
|
|
parentId = instanceMap.fromInstances[parentObject] or HttpService:GenerateGUID(false)
|
|
end
|
|
|
|
tree:buildAncestryNodes(previousId, ancestryIds, patch, instanceMap)
|
|
|
|
-- Compute line count metadata for removed scripts
|
|
local changeInfo = ChangeMetadata.computeForRemoval(instance)
|
|
|
|
-- Add this node to tree
|
|
-- Default to nil (unselected) - user must explicitly choose Push/Pull/Skip
|
|
local nodeId = instanceMap.fromInstances[instance] or HttpService:GenerateGUID(false)
|
|
instanceMap:insert(nodeId, instance)
|
|
tree:addNode(instanceMap.fromInstances[instance.Parent], {
|
|
id = nodeId,
|
|
patchType = "Remove",
|
|
className = instance.ClassName,
|
|
name = instance.Name,
|
|
instance = instance,
|
|
changeInfo = changeInfo,
|
|
defaultSelection = nil,
|
|
})
|
|
end
|
|
Timer.stop()
|
|
|
|
Timer.start("patch.added")
|
|
for id, change in patch.added do
|
|
clock = yieldIfNeeded(clock)
|
|
|
|
-- Gather ancestors from existing DOM or future additions
|
|
local ancestryIds = {}
|
|
local parentId = change.Parent
|
|
local parentData = patch.added[parentId]
|
|
local parentObject = instanceMap.fromIds[parentId]
|
|
local previousId = nil
|
|
while parentId do
|
|
if knownAncestors[parentId] then
|
|
-- We've already added this ancestor
|
|
previousId = parentId
|
|
break
|
|
end
|
|
|
|
table.insert(ancestryIds, 1, parentId)
|
|
knownAncestors[parentId] = true
|
|
parentId = nil
|
|
|
|
if parentData then
|
|
-- object is parented to an instance that does not exist yet
|
|
parentId = parentData.Parent
|
|
parentData = patch.added[parentId]
|
|
parentObject = instanceMap.fromIds[parentId]
|
|
elseif parentObject then
|
|
-- object is parented to an instance that exists
|
|
parentObject = parentObject.Parent
|
|
parentId = instanceMap.fromInstances[parentObject]
|
|
parentData = patch.added[parentId]
|
|
end
|
|
end
|
|
|
|
tree:buildAncestryNodes(previousId, ancestryIds, patch, instanceMap)
|
|
|
|
-- Gather detail text
|
|
local changeList, changeInfo = nil, nil
|
|
if next(change.Properties) then
|
|
changeList = {}
|
|
|
|
local changeIndex = 0
|
|
local function addProp(prop: string, incoming: any)
|
|
changeIndex += 1
|
|
changeList[changeIndex] = { prop, "N/A", incoming }
|
|
end
|
|
|
|
for prop, incoming in change.Properties do
|
|
local success, incomingValue = decodeValue(incoming, instanceMap)
|
|
addProp(prop, if success then incomingValue else select(2, next(incoming)))
|
|
end
|
|
|
|
-- Compute structured change metadata via ChangeMetadata module
|
|
changeInfo = ChangeMetadata.computeForAddition(changeList)
|
|
|
|
-- Sort changes and add header
|
|
table.sort(changeList, function(a, b)
|
|
return a[1] < b[1]
|
|
end)
|
|
table.insert(changeList, 1, changeListHeaders)
|
|
end
|
|
|
|
-- Add this node to tree
|
|
-- Default to nil (unselected) - user must explicitly choose Push/Pull/Skip
|
|
tree:addNode(change.Parent, {
|
|
id = change.Id,
|
|
patchType = "Add",
|
|
className = change.ClassName,
|
|
name = change.Name,
|
|
changeInfo = changeInfo,
|
|
changeList = changeList,
|
|
instance = instanceMap.fromIds[id],
|
|
defaultSelection = nil,
|
|
})
|
|
end
|
|
Timer.stop()
|
|
|
|
tree.ROOT.changeInfo = computeRootChangeInfo(tree)
|
|
|
|
Timer.stop()
|
|
return tree
|
|
end
|
|
|
|
-- Builds initial selections map from the tree using defaultSelection values
|
|
-- Returns a table mapping node id -> selection ("push", "pull", or "ignore")
|
|
function PatchTree.buildInitialSelections(tree)
|
|
local selections = {}
|
|
tree:forEach(function(node)
|
|
if node.patchType and node.defaultSelection then
|
|
selections[node.id] = node.defaultSelection
|
|
end
|
|
end)
|
|
return selections
|
|
end
|
|
|
|
-- Gets the count of unselected items (nodes with patchType but no selection)
|
|
-- Returns count of unselected items
|
|
function PatchTree.countUnselected(tree, selections)
|
|
local count = 0
|
|
tree:forEach(function(node)
|
|
if node.patchType and selections[node.id] == nil then
|
|
count += 1
|
|
end
|
|
end)
|
|
return count
|
|
end
|
|
|
|
-- Gets all selectable node IDs (nodes that have a patchType)
|
|
-- Returns an array of node IDs
|
|
function PatchTree.getSelectableNodeIds(tree)
|
|
local ids = {}
|
|
tree:forEach(function(node)
|
|
if node.patchType then
|
|
table.insert(ids, node.id)
|
|
end
|
|
end)
|
|
return ids
|
|
end
|
|
|
|
-- Checks if all selectable nodes have a selection
|
|
function PatchTree.allNodesSelected(tree, selections)
|
|
local allSelected = true
|
|
tree:forEach(function(node)
|
|
if node.patchType and not selections[node.id] then
|
|
allSelected = false
|
|
end
|
|
end)
|
|
return allSelected
|
|
end
|
|
|
|
-- Updates the metadata of a tree with the unapplied patch and currently existing instances
|
|
-- Builds a new tree from the data if one isn't provided
|
|
-- Always returns a new tree for immutability purposes in Roact
|
|
function PatchTree.updateMetadata(tree, patch, instanceMap, unappliedPatch)
|
|
Timer.start("PatchTree.updateMetadata")
|
|
if tree then
|
|
-- A shallow copy is enough for our purposes here since we really only need a new top-level object
|
|
-- for immutable comparison checks in Roact
|
|
tree = table.clone(tree)
|
|
else
|
|
tree = PatchTree.build(patch, instanceMap)
|
|
end
|
|
|
|
-- Update isWarning metadata
|
|
Timer.start("isWarning")
|
|
for _, failedChange in unappliedPatch.updated do
|
|
local node = tree:getNode(failedChange.id)
|
|
if not node then
|
|
continue
|
|
end
|
|
|
|
node.isWarning = true
|
|
Log.trace("Marked node as warning: {} {}", node.id, node.name)
|
|
|
|
if not node.changeList then
|
|
continue
|
|
end
|
|
|
|
local warnings = 0
|
|
for _, change in node.changeList do
|
|
local property = change[1]
|
|
local propertyFailedToApply = if property == "Name"
|
|
then failedChange.changedName ~= nil -- Name is not in changedProperties, so it needs a special case
|
|
else failedChange.changedProperties[property] ~= nil
|
|
|
|
if not propertyFailedToApply then
|
|
-- This change didn't fail, no need to mark
|
|
continue
|
|
end
|
|
|
|
warnings += 1
|
|
if change[4] == nil then
|
|
change[4] = { isWarning = true }
|
|
else
|
|
change[4].isWarning = true
|
|
end
|
|
Log.trace(" Marked property as warning: {}.{}", node.name, property)
|
|
end
|
|
|
|
-- Preserve existing metadata fields (linesAdded, etc.) and add failure tracking
|
|
node.changeInfo = table.clone(node.changeInfo)
|
|
node.changeInfo.edits = (node.changeInfo.edits or (#node.changeList - 1)) - warnings
|
|
node.changeInfo.failed = if warnings > 0 then warnings else nil
|
|
end
|
|
for failedAdditionId in unappliedPatch.added do
|
|
local node = tree:getNode(failedAdditionId)
|
|
if not node then
|
|
continue
|
|
end
|
|
|
|
node.isWarning = true
|
|
Log.trace("Marked node as warning: {} {}", node.id, node.name)
|
|
|
|
if not node.changeList then
|
|
continue
|
|
end
|
|
|
|
for _, change in node.changeList do
|
|
-- Failed addition means that all properties failed to be added
|
|
if change[4] == nil then
|
|
change[4] = { isWarning = true }
|
|
else
|
|
change[4].isWarning = true
|
|
end
|
|
Log.trace(" Marked property as warning: {}.{}", node.name, change[1])
|
|
end
|
|
|
|
-- Preserve existing metadata fields and mark all edits as failed
|
|
local totalEdits = node.changeInfo.edits or (#node.changeList - 1)
|
|
node.changeInfo = table.clone(node.changeInfo)
|
|
node.changeInfo.failed = totalEdits
|
|
node.changeInfo.edits = nil
|
|
end
|
|
for _, failedRemovalIdOrInstance in unappliedPatch.removed do
|
|
local failedRemovalId = if Types.RbxId(failedRemovalIdOrInstance)
|
|
then failedRemovalIdOrInstance
|
|
else instanceMap.fromInstances[failedRemovalIdOrInstance]
|
|
if not failedRemovalId then
|
|
continue
|
|
end
|
|
|
|
local node = tree:getNode(failedRemovalId)
|
|
if not node then
|
|
continue
|
|
end
|
|
|
|
node.isWarning = true
|
|
Log.trace("Marked node as warning: {} {}", node.id, node.name)
|
|
end
|
|
Timer.stop()
|
|
|
|
-- Update if instances exist
|
|
Timer.start("instanceAncestry")
|
|
tree:forEach(function(node)
|
|
if node.instance then
|
|
if node.instance.Parent == nil and node.instance ~= game then
|
|
-- This instance has been removed
|
|
Log.trace("Removed instance from node: {} {}", node.id, node.name)
|
|
node.instance = nil
|
|
end
|
|
else
|
|
-- This instance may have been added
|
|
node.instance = instanceMap.fromIds[node.id]
|
|
if node.instance then
|
|
Log.trace("Added instance to node: {} {}", node.id, node.name)
|
|
end
|
|
end
|
|
end)
|
|
Timer.stop()
|
|
|
|
tree.ROOT.changeInfo = computeRootChangeInfo(tree)
|
|
|
|
Timer.stop()
|
|
return tree
|
|
end
|
|
|
|
return PatchTree
|