forked from rojo-rbx/rojo
539 lines
14 KiB
Lua
539 lines
14 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 Rojo = script:FindFirstAncestor("Rojo")
|
|
local Plugin = Rojo.Plugin
|
|
local Packages = Rojo.Packages
|
|
|
|
local Log = require(Packages.Log)
|
|
|
|
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 = {
|
|
className = "DataModel",
|
|
name = "ROOT",
|
|
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
|
|
Log.warn("Failed to find ancestor object for " .. 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 PatchTree = {}
|
|
|
|
-- Builds a new tree from a patch and instanceMap
|
|
-- uses changeListHeaders in node.changeList
|
|
function PatchTree.build(patch, instanceMap, changeListHeaders)
|
|
Timer.start("PatchTree.build")
|
|
local clock = os.clock()
|
|
|
|
local tree = Tree.new()
|
|
|
|
local knownAncestors = {}
|
|
|
|
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 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?, metadata: any?)
|
|
changeIndex += 1
|
|
changeList[changeIndex] = { prop, current, incoming, metadata }
|
|
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)
|
|
|
|
addProp(
|
|
prop,
|
|
if currentSuccess then currentValue else "[Error]",
|
|
if incomingSuccess then incomingValue else select(2, next(incoming))
|
|
)
|
|
end
|
|
|
|
changeInfo = {
|
|
edits = changeIndex,
|
|
}
|
|
|
|
-- 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
|
|
tree:addNode(instanceMap.fromInstances[instance.Parent], {
|
|
id = change.id,
|
|
patchType = "Edit",
|
|
className = instance.ClassName,
|
|
name = instance.Name,
|
|
instance = instance,
|
|
changeInfo = changeInfo,
|
|
changeList = changeList,
|
|
})
|
|
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
|
|
|
|
-- 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)
|
|
|
|
-- Add this node to tree
|
|
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,
|
|
})
|
|
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
|
|
|
|
changeInfo = {
|
|
edits = changeIndex,
|
|
}
|
|
|
|
-- 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
|
|
tree:addNode(change.Parent, {
|
|
id = change.Id,
|
|
patchType = "Add",
|
|
className = change.ClassName,
|
|
name = change.Name,
|
|
changeInfo = changeInfo,
|
|
changeList = changeList,
|
|
instance = instanceMap.fromIds[id],
|
|
})
|
|
end
|
|
Timer.stop()
|
|
|
|
Timer.stop()
|
|
return tree
|
|
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
|
|
|
|
node.changeInfo = {
|
|
edits = (node.changeInfo.edits or (#node.changeList - 1)) - warnings,
|
|
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
|
|
|
|
node.changeInfo = {
|
|
failed = node.changeInfo.edits or (#node.changeList - 1),
|
|
}
|
|
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()
|
|
|
|
Timer.stop()
|
|
return tree
|
|
end
|
|
|
|
return PatchTree
|