forked from rojo-rbx/rojo
Add visual diffs to syncing (#603)
* Add user confirmation to initial sync * Use "Accept" instead of "Confirm" * Draw tree alphabetically for determinism * Add diff table dropdown * Add diff table to newly added objects * Unblock keybind workflow * Only show reject button when two way is enabled * Try to patch back to the files when changes are rejected * Improve text spacing of the prop diff table * Skip user confirmation of perfect syncs * Give instances names for debugging UI * Optimize tree building * Efficiency: dynamic virtual scrolling & lazy rendering * Simplify virtual scroller logic and avoid wasteful rerenders * Remove debug print * Consistent naming * Move new patch applied callback into accept * Pcall archivable * Keybinds open popup diff window * Theme rows in diff * Remove relic of prototype * Color value visuals and better component name * changeBatcher is not needed when no sync is active * Simplify popup roact entrypoint * Alphabetical prop lists and refactor * Add a stroke to color blot for contrast * Make color blots animate transparency with the rest of the page * StyLua formatting on newly added files * Remove wasteful table * Fix diffing custom properties * Display tables more meaningfully * Allow children in the button components * Create a rough tooltip component * Add tooltips to buttons * Use provider+trigger schema to avoid tooltip ZIndex issues * Add triangle point to tooltip * Tooltip underneath instead of covering * Cancel hovers when unmounting * Allow multiple canvases from one provider * Display above or below depending on available space * Move patch equality to PatchSet.isEqual * Use Container * Remove old submodules * Reduce false positives in diff * Add debug log * Fuzzy equals CFrame in diffs to avoid floating point in * Fix decodeValue usage * Support the .changedName patches * Fix content overlapping border * Fix tooltip tail alignment * Fix tooltip text fit * Whoops, fix it properly * Move PatchVisualizer to Components * Provide Connected info with full patch data * Avoid implicit nil return * Add patch visualizer to connected page * Make Current column invisible when visualizing applied patches * Avoid floating point diffs in a numbers and vectors
This commit is contained in:
402
plugin/src/App/Components/PatchVisualizer/init.lua
Normal file
402
plugin/src/App/Components/PatchVisualizer/init.lua
Normal file
@@ -0,0 +1,402 @@
|
||||
local HttpService = game:GetService("HttpService")
|
||||
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
local Packages = Rojo.Packages
|
||||
|
||||
local Roact = require(Packages.Roact)
|
||||
local Log = require(Packages.Log)
|
||||
|
||||
local PatchSet = require(Plugin.PatchSet)
|
||||
local decodeValue = require(Plugin.Reconciler.decodeValue)
|
||||
local getProperty = require(Plugin.Reconciler.getProperty)
|
||||
|
||||
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
|
||||
local VirtualScroller = require(Plugin.App.Components.VirtualScroller)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
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 function Tree()
|
||||
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
|
||||
|
||||
function tree:getNode(id, target)
|
||||
if self.idToNode[id] then
|
||||
return self.idToNode[id]
|
||||
end
|
||||
|
||||
for nodeId, node in target or tree.ROOT.children do
|
||||
if nodeId == id then
|
||||
self.idToNode[id] = node
|
||||
return node
|
||||
end
|
||||
local descendant = self:getNode(id, node.children)
|
||||
if descendant then
|
||||
return descendant
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
function tree:addNode(parent, props)
|
||||
parent = parent or "ROOT"
|
||||
|
||||
local node = self:getNode(props.id)
|
||||
if node then
|
||||
for k, v in props do
|
||||
node[k] = v
|
||||
end
|
||||
return node
|
||||
end
|
||||
|
||||
node = table.clone(props)
|
||||
node.children = {}
|
||||
|
||||
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
|
||||
|
||||
function tree:buildAncestryNodes(ancestry, patch, instanceMap)
|
||||
-- Build nodes for ancestry by going up the tree
|
||||
local previousId = "ROOT"
|
||||
for _, ancestorId in ancestry do
|
||||
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,
|
||||
})
|
||||
previousId = ancestorId
|
||||
end
|
||||
end
|
||||
|
||||
return tree
|
||||
end
|
||||
|
||||
local DomLabel = require(script.DomLabel)
|
||||
|
||||
local PatchVisualizer = Roact.Component:extend("PatchVisualizer")
|
||||
|
||||
function PatchVisualizer:init()
|
||||
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
|
||||
|
||||
self.updateEvent = Instance.new("BindableEvent")
|
||||
end
|
||||
|
||||
function PatchVisualizer:willUnmount()
|
||||
self.updateEvent:Destroy()
|
||||
end
|
||||
|
||||
function PatchVisualizer:shouldUpdate(nextProps)
|
||||
local currentPatch, nextPatch = self.props.patch, nextProps.patch
|
||||
|
||||
return not PatchSet.isEqual(currentPatch, nextPatch)
|
||||
end
|
||||
|
||||
function PatchVisualizer:buildTree(patch, instanceMap)
|
||||
local tree = Tree()
|
||||
|
||||
for _, change in patch.updated do
|
||||
local instance = instanceMap.fromIds[change.id]
|
||||
if not instance then
|
||||
continue
|
||||
end
|
||||
|
||||
-- Gather ancestors from existing DOM
|
||||
local ancestry = {}
|
||||
local parentObject = instance.Parent
|
||||
local parentId = instanceMap.fromInstances[parentObject]
|
||||
while parentObject do
|
||||
table.insert(ancestry, 1, parentId)
|
||||
parentObject = parentObject.Parent
|
||||
parentId = instanceMap.fromInstances[parentObject]
|
||||
end
|
||||
|
||||
tree:buildAncestryNodes(ancestry, patch, instanceMap)
|
||||
|
||||
-- Gather detail text
|
||||
local changeList, hint = nil, nil
|
||||
if next(change.changedProperties) or change.changedName then
|
||||
changeList = {}
|
||||
|
||||
local hintBuffer, i = {}, 0
|
||||
local function addProp(prop: string, current: any?, incoming: any?)
|
||||
i += 1
|
||||
hintBuffer[i] = prop
|
||||
changeList[i] = { 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)
|
||||
|
||||
addProp(
|
||||
prop,
|
||||
if currentSuccess then currentValue else "[Error]",
|
||||
if incomingSuccess then incomingValue else next(incoming)
|
||||
)
|
||||
end
|
||||
|
||||
-- Finalize detail values
|
||||
|
||||
-- Trim hint to top 3
|
||||
table.sort(hintBuffer)
|
||||
if #hintBuffer > 3 then
|
||||
hintBuffer = {
|
||||
hintBuffer[1],
|
||||
hintBuffer[2],
|
||||
hintBuffer[3],
|
||||
i - 3 .. " more",
|
||||
}
|
||||
end
|
||||
hint = table.concat(hintBuffer, ", ")
|
||||
|
||||
-- Sort changes and add header
|
||||
table.sort(changeList, function(a, b)
|
||||
return a[1] < b[1]
|
||||
end)
|
||||
table.insert(changeList, 1, { "Property", "Current", "Incoming" })
|
||||
end
|
||||
|
||||
-- Add this node to tree
|
||||
tree:addNode(instanceMap.fromInstances[instance.Parent], {
|
||||
id = change.id,
|
||||
patchType = "Edit",
|
||||
className = instance.ClassName,
|
||||
name = instance.Name,
|
||||
hint = hint,
|
||||
changeList = changeList,
|
||||
})
|
||||
end
|
||||
|
||||
for _, instance in patch.removed do
|
||||
-- Gather ancestors from existing DOM
|
||||
-- (note that they may have no ID if they're being removed as unknown)
|
||||
local ancestry = {}
|
||||
local parentObject = instance.Parent
|
||||
local parentId = instanceMap.fromInstances[parentObject] or HttpService:GenerateGUID(false)
|
||||
while parentObject do
|
||||
instanceMap:insert(parentId, parentObject)
|
||||
table.insert(ancestry, 1, parentId)
|
||||
parentObject = parentObject.Parent
|
||||
parentId = instanceMap.fromInstances[parentObject] or HttpService:GenerateGUID(false)
|
||||
end
|
||||
|
||||
tree:buildAncestryNodes(ancestry, 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,
|
||||
})
|
||||
end
|
||||
|
||||
for _, change in patch.added do
|
||||
-- Gather ancestors from existing DOM or future additions
|
||||
local ancestry = {}
|
||||
local parentId = change.Parent
|
||||
local parentData = patch.added[parentId]
|
||||
local parentObject = instanceMap.fromIds[parentId]
|
||||
while parentId do
|
||||
table.insert(ancestry, 1, parentId)
|
||||
parentId = nil
|
||||
|
||||
if parentData then
|
||||
parentId = parentData.Parent
|
||||
parentData = patch.added[parentId]
|
||||
parentObject = instanceMap.fromIds[parentId]
|
||||
elseif parentObject then
|
||||
parentObject = parentObject.Parent
|
||||
parentId = instanceMap.fromInstances[parentObject]
|
||||
parentData = patch.added[parentId]
|
||||
end
|
||||
end
|
||||
|
||||
tree:buildAncestryNodes(ancestry, patch, instanceMap)
|
||||
|
||||
-- Gather detail text
|
||||
local changeList, hint = nil, nil
|
||||
if next(change.Properties) then
|
||||
changeList = {}
|
||||
|
||||
local hintBuffer, i = {}, 0
|
||||
for prop, incoming in change.Properties do
|
||||
i += 1
|
||||
hintBuffer[i] = prop
|
||||
|
||||
local success, incomingValue = decodeValue(incoming, instanceMap)
|
||||
if success then
|
||||
table.insert(changeList, { prop, "N/A", incomingValue })
|
||||
else
|
||||
table.insert(changeList, { prop, "N/A", next(incoming) })
|
||||
end
|
||||
end
|
||||
|
||||
-- Finalize detail values
|
||||
|
||||
-- Trim hint to top 3
|
||||
table.sort(hintBuffer)
|
||||
if #hintBuffer > 3 then
|
||||
hintBuffer = {
|
||||
hintBuffer[1],
|
||||
hintBuffer[2],
|
||||
hintBuffer[3],
|
||||
i - 3 .. " more",
|
||||
}
|
||||
end
|
||||
hint = table.concat(hintBuffer, ", ")
|
||||
|
||||
-- Sort changes and add header
|
||||
table.sort(changeList, function(a, b)
|
||||
return a[1] < b[1]
|
||||
end)
|
||||
table.insert(changeList, 1, { "Property", "Current", "Incoming" })
|
||||
end
|
||||
|
||||
-- Add this node to tree
|
||||
tree:addNode(change.Parent, {
|
||||
id = change.Id,
|
||||
patchType = "Add",
|
||||
className = change.ClassName,
|
||||
name = change.Name,
|
||||
hint = hint,
|
||||
changeList = changeList,
|
||||
})
|
||||
end
|
||||
|
||||
return tree
|
||||
end
|
||||
|
||||
function PatchVisualizer:render()
|
||||
local patch = self.props.patch
|
||||
local instanceMap = self.props.instanceMap
|
||||
|
||||
local tree = self:buildTree(patch, instanceMap)
|
||||
|
||||
-- Recusively draw tree
|
||||
local scrollElements, elementHeights = {}, {}
|
||||
local function drawNode(node, depth)
|
||||
local elementHeight, setElementHeight = Roact.createBinding(30)
|
||||
table.insert(elementHeights, elementHeight)
|
||||
table.insert(
|
||||
scrollElements,
|
||||
e(DomLabel, {
|
||||
columnVisibility = self.props.columnVisibility,
|
||||
updateEvent = self.updateEvent,
|
||||
elementHeight = elementHeight,
|
||||
setElementHeight = setElementHeight,
|
||||
patchType = node.patchType,
|
||||
className = node.className,
|
||||
name = node.name,
|
||||
hint = node.hint,
|
||||
changeList = node.changeList,
|
||||
depth = depth,
|
||||
transparency = self.props.transparency,
|
||||
})
|
||||
)
|
||||
|
||||
for _, childNode in alphabeticalPairs(node.children) do
|
||||
drawNode(childNode, depth + 1)
|
||||
end
|
||||
end
|
||||
for _, node in alphabeticalPairs(tree.ROOT.children) do
|
||||
drawNode(node, 0)
|
||||
end
|
||||
|
||||
return e(BorderedContainer, {
|
||||
transparency = self.props.transparency,
|
||||
size = self.props.size,
|
||||
position = self.props.position,
|
||||
layoutOrder = self.props.layoutOrder,
|
||||
}, {
|
||||
VirtualScroller = e(VirtualScroller, {
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
transparency = self.props.transparency,
|
||||
count = #scrollElements,
|
||||
updateEvent = self.updateEvent.Event,
|
||||
render = function(i)
|
||||
return scrollElements[i]
|
||||
end,
|
||||
getHeightBinding = function(i)
|
||||
return elementHeights[i]
|
||||
end,
|
||||
}),
|
||||
})
|
||||
end
|
||||
|
||||
return PatchVisualizer
|
||||
Reference in New Issue
Block a user