diff --git a/plugin/src/App/Components/BorderedContainer.lua b/plugin/src/App/Components/BorderedContainer.lua
index 401d5676..42fec65c 100644
--- a/plugin/src/App/Components/BorderedContainer.lua
+++ b/plugin/src/App/Components/BorderedContainer.lua
@@ -24,7 +24,8 @@ local function BorderedContainer(props)
layoutOrder = props.layoutOrder,
}, {
Content = e("Frame", {
- Size = UDim2.new(1, 0, 1, 0),
+ Size = UDim2.new(1, -2, 1, -2),
+ Position = UDim2.new(0, 1, 0, 1),
BackgroundTransparency = 1,
ZIndex = 2,
}, props[Roact.Children]),
diff --git a/plugin/src/App/Components/PatchVisualizer/ChangeList.lua b/plugin/src/App/Components/PatchVisualizer/ChangeList.lua
new file mode 100644
index 00000000..beb0d327
--- /dev/null
+++ b/plugin/src/App/Components/PatchVisualizer/ChangeList.lua
@@ -0,0 +1,181 @@
+local Rojo = script:FindFirstAncestor("Rojo")
+local Plugin = Rojo.Plugin
+local Packages = Rojo.Packages
+
+local Roact = require(Packages.Roact)
+
+local Theme = require(Plugin.App.Theme)
+local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
+local DisplayValue = require(script.Parent.DisplayValue)
+
+local e = Roact.createElement
+
+local ChangeList = Roact.Component:extend("ChangeList")
+
+function ChangeList:init()
+ self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
+end
+
+function ChangeList:render()
+ return Theme.with(function(theme)
+ local props = self.props
+ local changes = props.changes
+
+ -- Color alternating rows for readability
+ local rowTransparency = props.transparency:map(function(t)
+ return 0.93 + (0.07 * t)
+ end)
+
+ local columnVisibility = props.columnVisibility
+
+ local rows = {}
+ local pad = {
+ PaddingLeft = UDim.new(0, 5),
+ PaddingRight = UDim.new(0, 5),
+ }
+
+ local headers = e("Frame", {
+ Size = UDim2.new(1, 0, 0, 30),
+ BackgroundTransparency = rowTransparency,
+ BackgroundColor3 = theme.Diff.Row,
+ LayoutOrder = 0,
+ }, {
+ Padding = e("UIPadding", pad),
+ Layout = e("UIListLayout", {
+ FillDirection = Enum.FillDirection.Horizontal,
+ SortOrder = Enum.SortOrder.LayoutOrder,
+ HorizontalAlignment = Enum.HorizontalAlignment.Left,
+ VerticalAlignment = Enum.VerticalAlignment.Center,
+ }),
+ A = e("TextLabel", {
+ Visible = columnVisibility[1],
+ Text = tostring(changes[1][1]),
+ BackgroundTransparency = 1,
+ Font = Enum.Font.GothamBold,
+ TextSize = 14,
+ TextColor3 = theme.Settings.Setting.DescriptionColor,
+ TextXAlignment = Enum.TextXAlignment.Left,
+ TextTransparency = props.transparency,
+ TextTruncate = Enum.TextTruncate.AtEnd,
+ Size = UDim2.new(0.3, 0, 1, 0),
+ LayoutOrder = 1,
+ }),
+ B = e("TextLabel", {
+ Visible = columnVisibility[2],
+ Text = tostring(changes[1][2]),
+ BackgroundTransparency = 1,
+ Font = Enum.Font.GothamBold,
+ TextSize = 14,
+ TextColor3 = theme.Settings.Setting.DescriptionColor,
+ TextXAlignment = Enum.TextXAlignment.Left,
+ TextTransparency = props.transparency,
+ TextTruncate = Enum.TextTruncate.AtEnd,
+ Size = UDim2.new(0.35, 0, 1, 0),
+ LayoutOrder = 2,
+ }),
+ C = e("TextLabel", {
+ Visible = columnVisibility[3],
+ Text = tostring(changes[1][3]),
+ BackgroundTransparency = 1,
+ Font = Enum.Font.GothamBold,
+ TextSize = 14,
+ TextColor3 = theme.Settings.Setting.DescriptionColor,
+ TextXAlignment = Enum.TextXAlignment.Left,
+ TextTransparency = props.transparency,
+ TextTruncate = Enum.TextTruncate.AtEnd,
+ Size = UDim2.new(0.35, 0, 1, 0),
+ LayoutOrder = 3,
+ }),
+ })
+
+ for row, values in changes do
+ if row == 1 then
+ continue -- Skip headers, already handled above
+ end
+
+ rows[row] = e("Frame", {
+ Size = UDim2.new(1, 0, 0, 30),
+ BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1,
+ BackgroundColor3 = theme.Diff.Row,
+ BorderSizePixel = 0,
+ LayoutOrder = row,
+ }, {
+ Padding = e("UIPadding", pad),
+ Layout = e("UIListLayout", {
+ FillDirection = Enum.FillDirection.Horizontal,
+ SortOrder = Enum.SortOrder.LayoutOrder,
+ HorizontalAlignment = Enum.HorizontalAlignment.Left,
+ VerticalAlignment = Enum.VerticalAlignment.Center,
+ }),
+ A = e("TextLabel", {
+ Visible = columnVisibility[1],
+ Text = tostring(values[1]),
+ BackgroundTransparency = 1,
+ Font = Enum.Font.GothamMedium,
+ TextSize = 14,
+ TextColor3 = theme.Settings.Setting.DescriptionColor,
+ TextXAlignment = Enum.TextXAlignment.Left,
+ TextTransparency = props.transparency,
+ TextTruncate = Enum.TextTruncate.AtEnd,
+ Size = UDim2.new(0.3, 0, 1, 0),
+ LayoutOrder = 1,
+ }),
+ B = e(
+ "Frame",
+ {
+ Visible = columnVisibility[2],
+ BackgroundTransparency = 1,
+ Size = UDim2.new(0.35, 0, 1, 0),
+ LayoutOrder = 2,
+ },
+ e(DisplayValue, {
+ value = values[2],
+ transparency = props.transparency,
+ })
+ ),
+ C = e(
+ "Frame",
+ {
+ Visible = columnVisibility[3],
+ BackgroundTransparency = 1,
+ Size = UDim2.new(0.35, 0, 1, 0),
+ LayoutOrder = 3,
+ },
+ e(DisplayValue, {
+ value = values[3],
+ transparency = props.transparency,
+ })
+ ),
+ })
+ end
+
+ table.insert(
+ rows,
+ e("UIListLayout", {
+ FillDirection = Enum.FillDirection.Vertical,
+ SortOrder = Enum.SortOrder.LayoutOrder,
+ HorizontalAlignment = Enum.HorizontalAlignment.Right,
+ VerticalAlignment = Enum.VerticalAlignment.Top,
+
+ [Roact.Change.AbsoluteContentSize] = function(object)
+ self.setContentSize(object.AbsoluteContentSize)
+ end,
+ })
+ )
+
+ return e("Frame", {
+ Size = UDim2.new(1, 0, 1, 0),
+ BackgroundTransparency = 1,
+ }, {
+ Headers = headers,
+ Values = e(ScrollingFrame, {
+ size = UDim2.new(1, 0, 1, -30),
+ position = UDim2.new(0, 0, 0, 30),
+ contentSize = self.contentSize,
+ transparency = props.transparency,
+ }, rows),
+ })
+ end)
+end
+
+return ChangeList
diff --git a/plugin/src/App/Components/PatchVisualizer/DisplayValue.lua b/plugin/src/App/Components/PatchVisualizer/DisplayValue.lua
new file mode 100644
index 00000000..891ec9f2
--- /dev/null
+++ b/plugin/src/App/Components/PatchVisualizer/DisplayValue.lua
@@ -0,0 +1,107 @@
+local Rojo = script:FindFirstAncestor("Rojo")
+local Plugin = Rojo.Plugin
+local Packages = Rojo.Packages
+
+local Roact = require(Packages.Roact)
+
+local Theme = require(Plugin.App.Theme)
+
+local e = Roact.createElement
+
+local function DisplayValue(props)
+ return Theme.with(function(theme)
+ local t = typeof(props.value)
+ if t == "Color3" then
+ -- Colors get a blot that shows the color
+ return Roact.createFragment({
+ Blot = e("Frame", {
+ BackgroundTransparency = props.transparency,
+ BackgroundColor3 = props.value,
+ Size = UDim2.new(0, 20, 0, 20),
+ Position = UDim2.new(0, 0, 0.5, 0),
+ AnchorPoint = Vector2.new(0, 0.5),
+ }, {
+ Corner = e("UICorner", {
+ CornerRadius = UDim.new(0, 4),
+ }),
+ Stroke = e("UIStroke", {
+ Color = theme.BorderedContainer.BorderColor,
+ Transparency = props.transparency,
+ }),
+ }),
+ Label = e("TextLabel", {
+ Text = string.format("%d,%d,%d", props.value.R * 255, props.value.G * 255, props.value.B * 255),
+ BackgroundTransparency = 1,
+ Font = Enum.Font.GothamMedium,
+ TextSize = 14,
+ TextColor3 = theme.Settings.Setting.DescriptionColor,
+ TextXAlignment = Enum.TextXAlignment.Left,
+ TextTransparency = props.transparency,
+ TextTruncate = Enum.TextTruncate.AtEnd,
+ Size = UDim2.new(1, -25, 1, 0),
+ Position = UDim2.new(0, 25, 0, 0),
+ }),
+ })
+
+ elseif t == "table" then
+ -- Showing a memory address for tables is useless, so we want to show the best we can
+ local textRepresentation = nil
+
+ local meta = getmetatable(props.value)
+ if meta and meta.__tostring then
+ -- If the table has a tostring metamethod, use that
+ textRepresentation = tostring(props.value)
+ elseif next(props.value) == nil then
+ -- If it's empty, show empty braces
+ textRepresentation = "{}"
+ else
+ -- If it has children, list them out
+ local out, i = {}, 0
+ for k, v in pairs(props.value) do
+ i += 1
+
+ -- Wrap strings in quotes
+ if type(k) == "string" then
+ k = "\"" .. k .. "\""
+ end
+ if type(v) == "string" then
+ v = "\"" .. v .. "\""
+ end
+
+ out[i] = string.format("[%s] = %s", tostring(k), tostring(v))
+ end
+ textRepresentation = "{ " .. table.concat(out, ", ") .. " }"
+ end
+
+ return e("TextLabel", {
+ Text = textRepresentation,
+ BackgroundTransparency = 1,
+ Font = Enum.Font.GothamMedium,
+ TextSize = 14,
+ TextColor3 = theme.Settings.Setting.DescriptionColor,
+ TextXAlignment = Enum.TextXAlignment.Left,
+ TextTransparency = props.transparency,
+ TextTruncate = Enum.TextTruncate.AtEnd,
+ Size = UDim2.new(1, 0, 1, 0),
+ })
+ end
+
+ -- TODO: Maybe add visualizations to other datatypes?
+ -- Or special text handling tostring for some?
+ -- Will add as needed, let's see what cases arise.
+
+ return e("TextLabel", {
+ Text = string.gsub(tostring(props.value), "%s", " "),
+ BackgroundTransparency = 1,
+ Font = Enum.Font.GothamMedium,
+ TextSize = 14,
+ TextColor3 = theme.Settings.Setting.DescriptionColor,
+ TextXAlignment = Enum.TextXAlignment.Left,
+ TextTransparency = props.transparency,
+ TextTruncate = Enum.TextTruncate.AtEnd,
+ Size = UDim2.new(1, 0, 1, 0),
+ })
+ end)
+end
+
+return DisplayValue
diff --git a/plugin/src/App/Components/PatchVisualizer/DomLabel.lua b/plugin/src/App/Components/PatchVisualizer/DomLabel.lua
new file mode 100644
index 00000000..8d282ab9
--- /dev/null
+++ b/plugin/src/App/Components/PatchVisualizer/DomLabel.lua
@@ -0,0 +1,180 @@
+local StudioService = game:GetService("StudioService")
+
+local Rojo = script:FindFirstAncestor("Rojo")
+local Plugin = Rojo.Plugin
+local Packages = Rojo.Packages
+
+local Roact = require(Packages.Roact)
+local Flipper = require(Packages.Flipper)
+
+local Assets = require(Plugin.Assets)
+local Theme = require(Plugin.App.Theme)
+local bindingUtil = require(Plugin.App.bindingUtil)
+
+local e = Roact.createElement
+
+local ChangeList = require(script.Parent.ChangeList)
+
+local Expansion = Roact.Component:extend("Expansion")
+
+function Expansion:render()
+ local props = self.props
+
+ if not props.rendered then
+ return nil
+ end
+
+ return e("Frame", {
+ BackgroundTransparency = 1,
+ Size = UDim2.new(1, -props.indent, 1, -30),
+ Position = UDim2.new(0, props.indent, 0, 30),
+ }, {
+ ChangeList = e(ChangeList, {
+ changes = props.changeList,
+ transparency = props.transparency,
+ columnVisibility = props.columnVisibility,
+ }),
+ })
+end
+
+local DomLabel = Roact.Component:extend("DomLabel")
+
+function DomLabel:init()
+ self.maxElementHeight = 0
+ if self.props.changeList then
+ self.maxElementHeight = math.clamp(#self.props.changeList * 30, 30, 30 * 6)
+ end
+
+ local initHeight = self.props.elementHeight:getValue()
+ self.expanded = initHeight > 30
+
+ self.motor = Flipper.SingleMotor.new(initHeight)
+ self.binding = bindingUtil.fromMotor(self.motor)
+
+ self:setState({
+ renderExpansion = self.expanded,
+ })
+ self.motor:onStep(function(value)
+ local renderExpansion = value > 30
+
+ self.props.setElementHeight(value)
+ if self.props.updateEvent then
+ self.props.updateEvent:Fire()
+ end
+
+ self:setState(function(state)
+ if state.renderExpansion == renderExpansion then
+ return nil
+ end
+
+ return {
+ renderExpansion = renderExpansion,
+ }
+ end)
+ end)
+end
+
+function DomLabel:render()
+ local props = self.props
+
+ return Theme.with(function(theme)
+ local iconProps = StudioService:GetClassIcon(props.className)
+ local indent = (props.depth or 0) * 20 + 25
+
+ -- Line guides help indent depth remain readable
+ local lineGuides = {}
+ for i = 1, props.depth or 0 do
+ table.insert(
+ lineGuides,
+ e("Frame", {
+ Name = "Line_" .. i,
+ Size = UDim2.new(0, 2, 1, 2),
+ Position = UDim2.new(0, (20 * i) + 15, 0, -1),
+ BorderSizePixel = 0,
+ BackgroundTransparency = props.transparency,
+ BackgroundColor3 = theme.BorderedContainer.BorderColor,
+ })
+ )
+ end
+
+ return e("Frame", {
+ Name = "Change",
+ ClipsDescendants = true,
+ BackgroundColor3 = if props.patchType then theme.Diff[props.patchType] else nil,
+ BorderSizePixel = 0,
+ BackgroundTransparency = props.patchType and props.transparency or 1,
+ Size = self.binding:map(function(expand)
+ return UDim2.new(1, 0, 0, expand)
+ end),
+ }, {
+ Padding = e("UIPadding", {
+ PaddingLeft = UDim.new(0, 10),
+ PaddingRight = UDim.new(0, 10),
+ }),
+ ExpandButton = if props.changeList
+ then e("TextButton", {
+ BackgroundTransparency = 1,
+ Text = "",
+ Size = UDim2.new(1, 0, 1, 0),
+ [Roact.Event.Activated] = function()
+ self.expanded = not self.expanded
+ self.motor:setGoal(Flipper.Spring.new((self.expanded and self.maxElementHeight or 0) + 30, {
+ frequency = 5,
+ dampingRatio = 1,
+ }))
+ end,
+ })
+ else nil,
+ Expansion = if props.changeList
+ then e(Expansion, {
+ rendered = self.state.renderExpansion,
+ indent = indent,
+ transparency = props.transparency,
+ changeList = props.changeList,
+ columnVisibility = props.columnVisibility,
+ })
+ else nil,
+ DiffIcon = if props.patchType
+ then e("ImageLabel", {
+ Image = Assets.Images.Diff[props.patchType],
+ ImageColor3 = theme.AddressEntry.PlaceholderColor,
+ ImageTransparency = props.transparency,
+ BackgroundTransparency = 1,
+ Size = UDim2.new(0, 20, 0, 20),
+ Position = UDim2.new(0, 0, 0, 15),
+ AnchorPoint = Vector2.new(0, 0.5),
+ })
+ else nil,
+ ClassIcon = e("ImageLabel", {
+ Image = iconProps.Image,
+ ImageTransparency = props.transparency,
+ ImageRectOffset = iconProps.ImageRectOffset,
+ ImageRectSize = iconProps.ImageRectSize,
+ BackgroundTransparency = 1,
+ Size = UDim2.new(0, 20, 0, 20),
+ Position = UDim2.new(0, indent, 0, 15),
+ AnchorPoint = Vector2.new(0, 0.5),
+ }),
+ InstanceName = e("TextLabel", {
+ Text = props.name .. (props.hint and string.format(
+ ' %s',
+ theme.AddressEntry.PlaceholderColor:ToHex(),
+ props.hint
+ ) or ""),
+ RichText = true,
+ BackgroundTransparency = 1,
+ Font = Enum.Font.GothamMedium,
+ TextSize = 14,
+ TextColor3 = theme.Settings.Setting.DescriptionColor,
+ TextXAlignment = Enum.TextXAlignment.Left,
+ TextTransparency = props.transparency,
+ TextTruncate = Enum.TextTruncate.AtEnd,
+ Size = UDim2.new(1, -indent - 50, 0, 30),
+ Position = UDim2.new(0, indent + 30, 0, 0),
+ }),
+ LineGuides = e("Folder", nil, lineGuides),
+ })
+ end)
+end
+
+return DomLabel
diff --git a/plugin/src/App/Components/PatchVisualizer/init.lua b/plugin/src/App/Components/PatchVisualizer/init.lua
new file mode 100644
index 00000000..95673dfe
--- /dev/null
+++ b/plugin/src/App/Components/PatchVisualizer/init.lua
@@ -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
diff --git a/plugin/src/App/Components/Studio/StudioPluginGui.lua b/plugin/src/App/Components/Studio/StudioPluginGui.lua
index e078572b..59bd3d85 100644
--- a/plugin/src/App/Components/Studio/StudioPluginGui.lua
+++ b/plugin/src/App/Components/Studio/StudioPluginGui.lua
@@ -5,6 +5,7 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Dictionary = require(Plugin.Dictionary)
+local Theme = require(Plugin.App.Theme)
local StudioPluginContext = require(script.Parent.StudioPluginContext)
@@ -29,8 +30,10 @@ function StudioPluginGui:init()
self.props.initDockState,
self.props.active,
self.props.overridePreviousState,
- floatingSize.X, floatingSize.Y,
- minimumSize.X, minimumSize.Y
+ floatingSize.X,
+ floatingSize.Y,
+ minimumSize.X,
+ minimumSize.Y
)
local pluginGui = self.props.plugin:CreateDockWidgetPluginGui(self.props.id, dockWidgetPluginGuiInfo)
@@ -57,7 +60,16 @@ end
function StudioPluginGui:render()
return e(Roact.Portal, {
target = self.pluginGui,
- }, self.props[Roact.Children])
+ }, {
+ Background = Theme.with(function(theme)
+ return e("Frame", {
+ Size = UDim2.new(1, 0, 1, 0),
+ BackgroundColor3 = theme.BackgroundColor,
+ ZIndex = 0,
+ BorderSizePixel = 0,
+ }, self.props[Roact.Children])
+ end),
+ })
end
function StudioPluginGui:didUpdate(lastProps)
@@ -75,9 +87,12 @@ end
local function StudioPluginGuiWrapper(props)
return e(StudioPluginContext.Consumer, {
render = function(plugin)
- return e(StudioPluginGui, Dictionary.merge(props, {
- plugin = plugin,
- }))
+ return e(
+ StudioPluginGui,
+ Dictionary.merge(props, {
+ plugin = plugin,
+ })
+ )
end,
})
end
diff --git a/plugin/src/App/Components/Tooltip.lua b/plugin/src/App/Components/Tooltip.lua
index e30a3603..bc3443d5 100644
--- a/plugin/src/App/Components/Tooltip.lua
+++ b/plugin/src/App/Components/Tooltip.lua
@@ -23,7 +23,7 @@ local TooltipContext = Roact.createContext({})
local function Popup(props)
local textSize = TextService:GetTextSize(
props.Text, 16, Enum.Font.GothamMedium, Vector2.new(math.min(props.parentSize.X, 160), math.huge)
- ) + TEXT_PADDING
+ ) + TEXT_PADDING + (Vector2.one * 2)
local trigger = props.Trigger:getValue()
@@ -68,12 +68,12 @@ local function Popup(props)
if displayAbove then
UDim2.new(
0, math.clamp(props.Position.X - X, 6, textSize.X-6),
- 1, -3
+ 1, -1
)
else
UDim2.new(
0, math.clamp(props.Position.X - X, 6, textSize.X-6),
- 0, -TAIL_SIZE+3
+ 0, -TAIL_SIZE+1
),
Size = UDim2.fromOffset(TAIL_SIZE, TAIL_SIZE),
AnchorPoint = Vector2.new(0.5, 0),
diff --git a/plugin/src/App/Components/VirtualScroller.lua b/plugin/src/App/Components/VirtualScroller.lua
new file mode 100644
index 00000000..2922a74f
--- /dev/null
+++ b/plugin/src/App/Components/VirtualScroller.lua
@@ -0,0 +1,156 @@
+local Rojo = script:FindFirstAncestor("Rojo")
+local Plugin = Rojo.Plugin
+local Packages = Rojo.Packages
+
+local Roact = require(Packages.Roact)
+
+local Assets = require(Plugin.Assets)
+local Theme = require(Plugin.App.Theme)
+local bindingUtil = require(Plugin.App.bindingUtil)
+
+local e = Roact.createElement
+
+local VirtualScroller = Roact.Component:extend("VirtualScroller")
+
+function VirtualScroller:init()
+ self.scrollFrameRef = Roact.createRef()
+ self:setState({
+ WindowSize = Vector2.new(),
+ CanvasPosition = Vector2.new(),
+ })
+
+ self.totalCanvas, self.setTotalCanvas = Roact.createBinding(0)
+ self.padding, self.setPadding = Roact.createBinding(0)
+
+ self:refresh()
+ if self.props.updateEvent then
+ self.connection = self.props.updateEvent:Connect(function()
+ self:refresh()
+ end)
+ end
+end
+
+function VirtualScroller:didMount()
+ local rbx = self.scrollFrameRef:getValue()
+
+ local windowSizeSignal = rbx:GetPropertyChangedSignal("AbsoluteWindowSize")
+ self.windowSizeChanged = windowSizeSignal:Connect(function()
+ self:setState({ WindowSize = rbx.AbsoluteWindowSize })
+ self:refresh()
+ end)
+
+ local canvasPositionSignal = rbx:GetPropertyChangedSignal("CanvasPosition")
+ self.canvasPositionChanged = canvasPositionSignal:Connect(function()
+ if math.abs(rbx.CanvasPosition.Y - self.state.CanvasPosition.Y) > 5 then
+ self:setState({ CanvasPosition = rbx.CanvasPosition })
+ self:refresh()
+ end
+ end)
+
+ self:refresh()
+end
+
+function VirtualScroller:willUnmount()
+ self.windowSizeChanged:Disconnect()
+ self.canvasPositionChanged:Disconnect()
+ if self.connection then
+ self.connection:Disconnect()
+ self.connection = nil
+ end
+end
+
+function VirtualScroller:refresh()
+ local props = self.props
+ local state = self.state
+
+ local count = props.count
+ local windowSize, canvasPosition = state.WindowSize.Y, state.CanvasPosition.Y
+ local bottom = canvasPosition + windowSize
+
+ local minIndex, maxIndex = 1, count
+ local padding, canvasSize = 0, 0
+
+ local pos = 0
+ for i = 1, count do
+ local height = props.getHeightBinding(i):getValue()
+ canvasSize += height
+
+ if pos > bottom then
+ -- Below window
+ if maxIndex > i then
+ maxIndex = i
+ end
+ end
+
+ pos += height
+
+ if pos < canvasPosition then
+ -- Above window
+ minIndex = i
+ padding = pos - height
+ end
+ end
+
+ self.setPadding(padding)
+ self.setTotalCanvas(canvasSize)
+ self:setState({
+ Start = minIndex,
+ End = maxIndex,
+ })
+end
+
+function VirtualScroller:render()
+ local props, state = self.props, self.state
+
+ local items = {}
+ for i = state.Start, state.End do
+ items["Item" .. i] = e("Frame", {
+ LayoutOrder = i,
+ Size = props.getHeightBinding(i):map(function(height)
+ return UDim2.new(1, 0, 0, height)
+ end),
+ BackgroundTransparency = 1,
+ }, props.render(i))
+ end
+
+ return Theme.with(function(theme)
+ return e("ScrollingFrame", {
+ Size = props.size,
+ Position = props.position,
+ AnchorPoint = props.anchorPoint,
+ BackgroundTransparency = props.backgroundTransparency or 1,
+ BackgroundColor3 = props.backgroundColor3,
+ BorderColor3 = props.borderColor3,
+ CanvasSize = self.totalCanvas:map(function(s)
+ return UDim2.fromOffset(0, s)
+ end),
+ ScrollBarThickness = 9,
+ ScrollBarImageColor3 = theme.ScrollBarColor,
+ ScrollBarImageTransparency = props.transparency:map(function(value)
+ return bindingUtil.blendAlpha({ 0.65, value })
+ end),
+ TopImage = Assets.Images.ScrollBar.Top,
+ MidImage = Assets.Images.ScrollBar.Middle,
+ BottomImage = Assets.Images.ScrollBar.Bottom,
+
+ ElasticBehavior = Enum.ElasticBehavior.Always,
+ ScrollingDirection = Enum.ScrollingDirection.Y,
+ VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar,
+ [Roact.Ref] = self.scrollFrameRef,
+ }, {
+ Layout = e("UIListLayout", {
+ Padding = UDim.new(0, 0),
+ SortOrder = Enum.SortOrder.LayoutOrder,
+ FillDirection = Enum.FillDirection.Vertical,
+ }),
+ Padding = e("UIPadding", {
+ PaddingTop = self.padding:map(function(p)
+ return UDim.new(0, p)
+ end),
+ }),
+ Content = Roact.createFragment(items),
+ })
+ end)
+end
+
+return VirtualScroller
diff --git a/plugin/src/App/StatusPages/Confirming.lua b/plugin/src/App/StatusPages/Confirming.lua
new file mode 100644
index 00000000..3359eaf6
--- /dev/null
+++ b/plugin/src/App/StatusPages/Confirming.lua
@@ -0,0 +1,156 @@
+local TextService = game:GetService("TextService")
+
+local Rojo = script:FindFirstAncestor("Rojo")
+local Plugin = Rojo.Plugin
+local Packages = Rojo.Packages
+
+local Roact = require(Packages.Roact)
+
+local Settings = require(Plugin.Settings)
+local Theme = require(Plugin.App.Theme)
+local TextButton = require(Plugin.App.Components.TextButton)
+local Header = require(Plugin.App.Components.Header)
+local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui)
+local Tooltip = require(Plugin.App.Components.Tooltip)
+local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer)
+
+local e = Roact.createElement
+
+local ConfirmingPage = Roact.Component:extend("ConfirmingPage")
+
+function ConfirmingPage:init()
+ self.contentSize, self.setContentSize = Roact.createBinding(0)
+ self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0))
+end
+
+function ConfirmingPage:render()
+ return Theme.with(function(theme)
+ local pageContent = Roact.createFragment({
+ Header = e(Header, {
+ transparency = self.props.transparency,
+ layoutOrder = 1,
+ }),
+
+ Title = e("TextLabel", {
+ Text = string.format(
+ "Sync changes for project '%s':",
+ self.props.confirmData.serverInfo.projectName or "UNKNOWN"
+ ),
+ LayoutOrder = 2,
+ Font = Enum.Font.Gotham,
+ LineHeight = 1.2,
+ TextSize = 14,
+ TextColor3 = theme.Settings.Setting.DescriptionColor,
+ TextXAlignment = Enum.TextXAlignment.Left,
+ TextTransparency = self.props.transparency,
+ Size = UDim2.new(1, 0, 0, 20),
+ BackgroundTransparency = 1,
+ }),
+
+ PatchVisualizer = e(PatchVisualizer, {
+ size = UDim2.new(1, 0, 1, -150),
+ transparency = self.props.transparency,
+ layoutOrder = 3,
+
+ columnVisibility = {true, true, true},
+ patch = self.props.confirmData.patch,
+ instanceMap = self.props.confirmData.instanceMap,
+ }),
+
+ Buttons = e("Frame", {
+ Size = UDim2.new(1, 0, 0, 34),
+ LayoutOrder = 4,
+ BackgroundTransparency = 1,
+ }, {
+ Abort = e(TextButton, {
+ text = "Abort",
+ style = "Bordered",
+ transparency = self.props.transparency,
+ layoutOrder = 1,
+ onClick = self.props.onAbort,
+ }, {
+ Tip = e(Tooltip.Trigger, {
+ text = "Stop the connection process"
+ }),
+ }),
+
+ Reject = if Settings:get("twoWaySync")
+ then e(TextButton, {
+ text = "Reject",
+ style = "Bordered",
+ transparency = self.props.transparency,
+ layoutOrder = 2,
+ onClick = self.props.onReject,
+ }, {
+ Tip = e(Tooltip.Trigger, {
+ text = "Push Studio changes to the Rojo server"
+ }),
+ })
+ else nil,
+
+ Accept = e(TextButton, {
+ text = "Accept",
+ style = "Solid",
+ transparency = self.props.transparency,
+ layoutOrder = 3,
+ onClick = self.props.onAccept,
+ }, {
+ Tip = e(Tooltip.Trigger, {
+ text = "Pull Rojo server changes to Studio"
+ }),
+ }),
+
+ Layout = e("UIListLayout", {
+ HorizontalAlignment = Enum.HorizontalAlignment.Right,
+ FillDirection = Enum.FillDirection.Horizontal,
+ SortOrder = Enum.SortOrder.LayoutOrder,
+ Padding = UDim.new(0, 10),
+ }),
+ }),
+
+ Layout = e("UIListLayout", {
+ HorizontalAlignment = Enum.HorizontalAlignment.Center,
+ VerticalAlignment = Enum.VerticalAlignment.Center,
+ FillDirection = Enum.FillDirection.Vertical,
+ SortOrder = Enum.SortOrder.LayoutOrder,
+ Padding = UDim.new(0, 10),
+ }),
+
+ Padding = e("UIPadding", {
+ PaddingLeft = UDim.new(0, 20),
+ PaddingRight = UDim.new(0, 20),
+ }),
+ })
+
+ if self.props.createPopup then
+ return e(StudioPluginGui, {
+ id = "Rojo_DiffSync",
+ title = string.format(
+ "Confirm sync for project '%s':",
+ self.props.confirmData.serverInfo.projectName or "UNKNOWN"
+ ),
+ active = true,
+
+ initDockState = Enum.InitialDockState.Float,
+ initEnabled = true,
+ overridePreviousState = true,
+ floatingSize = Vector2.new(500, 350),
+ minimumSize = Vector2.new(400, 250),
+
+ zIndexBehavior = Enum.ZIndexBehavior.Sibling,
+
+ onClose = self.props.onAbort,
+ }, {
+ Tooltips = e(Tooltip.Container, nil),
+ Content = e("Frame", {
+ Size = UDim2.fromScale(1, 1),
+ BackgroundTransparency = 1,
+ }, pageContent),
+ })
+ end
+
+ return pageContent
+ end)
+end
+
+return ConfirmingPage
diff --git a/plugin/src/App/StatusPages/Connected.lua b/plugin/src/App/StatusPages/Connected.lua
index ac302d46..91484926 100644
--- a/plugin/src/App/StatusPages/Connected.lua
+++ b/plugin/src/App/StatusPages/Connected.lua
@@ -3,14 +3,18 @@ local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
+local Flipper = require(Packages.Flipper)
+local bindingUtil = require(Plugin.App.bindingUtil)
local Theme = require(Plugin.App.Theme)
local Assets = require(Plugin.Assets)
+local PatchSet = require(Plugin.PatchSet)
local Header = require(Plugin.App.Components.Header)
local IconButton = require(Plugin.App.Components.IconButton)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local Tooltip = require(Plugin.App.Components.Tooltip)
+local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer)
local e = Roact.createElement
@@ -34,6 +38,50 @@ function timeSinceText(elapsed: number): string
return ageText
end
+local function ChangesDrawer(props)
+ if props.rendered == false then
+ return nil
+ end
+
+ return Theme.with(function(theme)
+ return e(BorderedContainer, {
+ transparency = props.transparency,
+ size = props.height:map(function(y)
+ return UDim2.new(1, 0, y, -180 * y)
+ end),
+ position = UDim2.new(0, 0, 1, 0),
+ anchorPoint = Vector2.new(0, 1),
+ layoutOrder = props.layoutOrder,
+ }, {
+ Close = e(IconButton, {
+ icon = Assets.Images.Icons.Close,
+ iconSize = 24,
+ color = theme.ConnectionDetails.DisconnectColor,
+ transparency = props.transparency,
+
+ position = UDim2.new(1, 0, 0, 0),
+ anchorPoint = Vector2.new(1, 0),
+
+ onClick = props.onClose,
+ }, {
+ Tip = e(Tooltip.Trigger, {
+ text = "Close the patch visualizer"
+ }),
+ }),
+
+ PatchVisualizer = e(PatchVisualizer, {
+ size = UDim2.new(1, 0, 1, 0),
+ transparency = props.transparency,
+ layoutOrder = 3,
+
+ columnVisibility = {true, false, true},
+ patch = props.patchInfo:getValue().patch,
+ instanceMap = props.serveSession.__instanceMap,
+ }),
+ })
+ end)
+end
+
local function ConnectionDetails(props)
return Theme.with(function(theme)
return e(BorderedContainer, {
@@ -107,9 +155,44 @@ end
local ConnectedPage = Roact.Component:extend("ConnectedPage")
+function ConnectedPage:init()
+ self.changeDrawerMotor = Flipper.SingleMotor.new(0)
+ self.changeDrawerHeight = bindingUtil.fromMotor(self.changeDrawerMotor)
+
+ self.changeDrawerMotor:onStep(function(value)
+ local renderChanges = value > 0.05
+
+ self:setState(function(state)
+ if state.renderChanges == renderChanges then
+ return nil
+ end
+
+ return {
+ renderChanges = renderChanges,
+ }
+ end)
+ end)
+
+ self:setState({
+ renderChanges = false,
+ })
+end
+
function ConnectedPage:render()
return Theme.with(function(theme)
return Roact.createFragment({
+ Padding = e("UIPadding", {
+ PaddingLeft = UDim.new(0, 20),
+ PaddingRight = UDim.new(0, 20),
+ }),
+
+ Layout = e("UIListLayout", {
+ VerticalAlignment = Enum.VerticalAlignment.Center,
+ FillDirection = Enum.FillDirection.Vertical,
+ SortOrder = Enum.SortOrder.LayoutOrder,
+ Padding = UDim.new(0, 10),
+ }),
+
Header = e(Header, {
transparency = self.props.transparency,
layoutOrder = 1,
@@ -124,12 +207,13 @@ function ConnectedPage:render()
onDisconnect = self.props.onDisconnect,
}),
- Info = e("TextLabel", {
+ ChangeInfo = e("TextButton", {
Text = self.props.patchInfo:map(function(info)
+ local changes = PatchSet.countChanges(info.patch)
return string.format(
"Synced %d change%s %s",
- info.changes,
- info.changes == 1 and "" or "s",
+ changes,
+ changes == 1 and "" or "s",
timeSinceText(os.time() - info.timestamp)
)
end),
@@ -146,18 +230,36 @@ function ConnectedPage:render()
LayoutOrder = 3,
BackgroundTransparency = 1,
+
+ [Roact.Event.Activated] = function()
+ if self.state.renderChanges then
+ self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, {
+ frequency = 4,
+ dampingRatio = 1,
+ }))
+ else
+ self.changeDrawerMotor:setGoal(Flipper.Spring.new(1, {
+ frequency = 3,
+ dampingRatio = 1,
+ }))
+ end
+ end,
}),
- Layout = e("UIListLayout", {
- VerticalAlignment = Enum.VerticalAlignment.Center,
- FillDirection = Enum.FillDirection.Vertical,
- SortOrder = Enum.SortOrder.LayoutOrder,
- Padding = UDim.new(0, 10),
- }),
+ ChangesDrawer = e(ChangesDrawer, {
+ rendered = self.state.renderChanges,
+ transparency = self.props.transparency,
+ patchInfo = self.props.patchInfo,
+ serveSession = self.props.serveSession,
+ height = self.changeDrawerHeight,
+ layoutOrder = 4,
- Padding = e("UIPadding", {
- PaddingLeft = UDim.new(0, 20),
- PaddingRight = UDim.new(0, 20),
+ onClose = function()
+ self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, {
+ frequency = 4,
+ dampingRatio = 1,
+ }))
+ end,
}),
})
end)
diff --git a/plugin/src/App/StatusPages/NotConnected.lua b/plugin/src/App/StatusPages/NotConnected.lua
index 40861ba5..3097446e 100644
--- a/plugin/src/App/StatusPages/NotConnected.lua
+++ b/plugin/src/App/StatusPages/NotConnected.lua
@@ -110,6 +110,7 @@ function NotConnectedPage:render()
Size = UDim2.new(1, 0, 0, 34),
LayoutOrder = 3,
BackgroundTransparency = 1,
+ ZIndex = 2,
}, {
Settings = e(TextButton, {
text = "Settings",
diff --git a/plugin/src/App/StatusPages/init.lua b/plugin/src/App/StatusPages/init.lua
index 03d64e45..111c9c55 100644
--- a/plugin/src/App/StatusPages/init.lua
+++ b/plugin/src/App/StatusPages/init.lua
@@ -2,6 +2,7 @@ return {
NotConnected = require(script.NotConnected),
Settings = require(script.Settings),
Connecting = require(script.Connecting),
+ Confirming = require(script.Confirming),
Connected = require(script.Connected),
Error = require(script.Error),
-}
\ No newline at end of file
+}
diff --git a/plugin/src/App/Theme.lua b/plugin/src/App/Theme.lua
index a6572484..309045be 100644
--- a/plugin/src/App/Theme.lua
+++ b/plugin/src/App/Theme.lua
@@ -95,6 +95,12 @@ local lightTheme = strict("LightTheme", {
ForegroundColor = BRAND_COLOR,
BackgroundColor = hexColor(0xEEEEEE),
},
+ Diff = {
+ Add = hexColor(0xbaffbd),
+ Remove = hexColor(0xffbdba),
+ Edit = hexColor(0xbacdff),
+ Row = hexColor(0x000000),
+ },
ConnectionDetails = {
ProjectNameColor = hexColor(0x00000),
AddressColor = hexColor(0x00000),
@@ -184,6 +190,12 @@ local darkTheme = strict("DarkTheme", {
ForegroundColor = BRAND_COLOR,
BackgroundColor = hexColor(0x2B2B2B),
},
+ Diff = {
+ Add = hexColor(0x273732),
+ Remove = hexColor(0x3F2D32),
+ Edit = hexColor(0x193345),
+ Row = hexColor(0xFFFFFF),
+ },
ConnectionDetails = {
ProjectNameColor = hexColor(0xFFFFFF),
AddressColor = hexColor(0xFFFFFF),
diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua
index f141d943..35df8ffb 100644
--- a/plugin/src/App/init.lua
+++ b/plugin/src/App/init.lua
@@ -16,6 +16,7 @@ local strict = require(Plugin.strict)
local Dictionary = require(Plugin.Dictionary)
local ServeSession = require(Plugin.ServeSession)
local ApiContext = require(Plugin.ApiContext)
+local PatchSet = require(Plugin.PatchSet)
local preloadAssets = require(Plugin.preloadAssets)
local soundPlayer = require(Plugin.soundPlayer)
local Theme = require(script.Theme)
@@ -34,6 +35,7 @@ local AppStatus = strict("AppStatus", {
NotConnected = "NotConnected",
Settings = "Settings",
Connecting = "Connecting",
+ Confirming = "Confirming",
Connected = "Connected",
Error = "Error",
})
@@ -50,13 +52,16 @@ function App:init()
self.port, self.setPort = Roact.createBinding(priorPort or "")
self.patchInfo, self.setPatchInfo = Roact.createBinding({
- changes = 0,
+ patch = PatchSet.newEmpty(),
timestamp = os.time(),
})
+ self.confirmationBindable = Instance.new("BindableEvent")
+ self.confirmationEvent = self.confirmationBindable.Event
self:setState({
appStatus = AppStatus.NotConnected,
guiEnabled = false,
+ confirmData = {},
notifications = {},
toolbarIcon = Assets.Images.PluginButton,
})
@@ -214,32 +219,31 @@ function App:startSession()
twoWaySync = sessionOptions.twoWaySync,
})
- serveSession:onPatchApplied(function(patch, unapplied)
+ serveSession:onPatchApplied(function(patch, _unapplied)
+ if PatchSet.isEmpty(patch) then
+ -- Ignore empty patches
+ return
+ end
+
local now = os.time()
- local changes = 0
-
- for _, set in patch do
- for _ in set do
- changes += 1
- end
- end
- for _, set in unapplied do
- for _ in set do
- changes -= 1
- end
- end
-
- if changes == 0 then return end
local old = self.patchInfo:getValue()
if now - old.timestamp < 2 then
- changes += old.changes
- end
+ -- Patches that apply in the same second are
+ -- considered to be part of the same change for human clarity
+ local merged = PatchSet.newEmpty()
+ PatchSet.assign(merged, old.patch, patch)
- self.setPatchInfo({
- changes = changes,
- timestamp = now,
- })
+ self.setPatchInfo({
+ patch = merged,
+ timestamp = now,
+ })
+ else
+ self.setPatchInfo({
+ patch = patch,
+ timestamp = now,
+ })
+ end
end)
serveSession:onStatusChanged(function(status, details)
@@ -285,6 +289,32 @@ function App:startSession()
end
end)
+ serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo)
+ if PatchSet.isEmpty(patch) then
+ return "Accept"
+ end
+
+ self:setState({
+ appStatus = AppStatus.Confirming,
+ confirmData = {
+ instanceMap = instanceMap,
+ patch = patch,
+ serverInfo = serverInfo,
+ },
+ toolbarIcon = Assets.Images.PluginButton,
+ })
+
+ self:addNotification(
+ string.format(
+ "Please accept%sor abort the initializing sync session.",
+ Settings:get("twoWaySync") and ", reject, " or " "
+ ),
+ 7
+ )
+
+ return self.confirmationEvent:Wait()
+ end)
+
serveSession:start()
self.serveSession = serveSession
@@ -295,7 +325,7 @@ function App:startSession()
local patchInfo = table.clone(self.patchInfo:getValue())
self.setPatchInfo(patchInfo)
local elapsed = os.time() - patchInfo.timestamp
- task.wait(elapsed < 60 and 1 or elapsed/5)
+ task.wait(elapsed < 60 and 1 or elapsed / 5)
end
end)
end
@@ -379,12 +409,28 @@ function App:render()
end,
}),
+ ConfirmingPage = createPageElement(AppStatus.Confirming, {
+ confirmData = self.state.confirmData,
+ createPopup = not self.state.guiEnabled,
+
+ onAbort = function()
+ self.confirmationBindable:Fire("Abort")
+ end,
+ onAccept = function()
+ self.confirmationBindable:Fire("Accept")
+ end,
+ onReject = function()
+ self.confirmationBindable:Fire("Reject")
+ end,
+ }),
+
Connecting = createPageElement(AppStatus.Connecting),
Connected = createPageElement(AppStatus.Connected, {
projectName = self.state.projectName,
address = self.state.address,
patchInfo = self.patchInfo,
+ serveSession = self.serveSession,
onDisconnect = function()
self:endSession()
@@ -409,15 +455,6 @@ function App:render()
})
end,
}),
-
- Background = Theme.with(function(theme)
- return e("Frame", {
- Size = UDim2.new(1, 0, 1, 0),
- BackgroundColor3 = theme.BackgroundColor,
- ZIndex = 0,
- BorderSizePixel = 0,
- })
- end),
}),
RojoNotifications = e("ScreenGui", {}, {
@@ -428,10 +465,10 @@ function App:render()
Padding = UDim.new(0, 5),
}),
padding = e("UIPadding", {
- PaddingTop = UDim.new(0, 5);
- PaddingBottom = UDim.new(0, 5);
- PaddingLeft = UDim.new(0, 5);
- PaddingRight = UDim.new(0, 5);
+ PaddingTop = UDim.new(0, 5),
+ PaddingBottom = UDim.new(0, 5),
+ PaddingLeft = UDim.new(0, 5),
+ PaddingRight = UDim.new(0, 5),
}),
notifs = e(Notifications, {
soundPlayer = self.props.soundPlayer,
@@ -452,7 +489,9 @@ function App:render()
onTriggered = function()
if self.serveSession == nil or self.serveSession:getStatus() == ServeSession.Status.NotStarted then
self:startSession()
- elseif self.serveSession ~= nil and self.serveSession:getStatus() == ServeSession.Status.Connected then
+ elseif
+ self.serveSession ~= nil and self.serveSession:getStatus() == ServeSession.Status.Connected
+ then
self:endSession()
end
end,
@@ -500,7 +539,7 @@ function App:render()
}
end)
end,
- })
+ }),
}),
}),
})
diff --git a/plugin/src/Assets.lua b/plugin/src/Assets.lua
index 08f86b3b..3c919b54 100644
--- a/plugin/src/Assets.lua
+++ b/plugin/src/Assets.lua
@@ -25,6 +25,11 @@ local Assets = {
Back = "rbxassetid://6017213752",
Reset = "rbxassetid://10142422327",
},
+ Diff = {
+ Add = "rbxassetid://10434145835",
+ Remove = "rbxassetid://10434408368",
+ Edit = "rbxassetid://10434144680",
+ },
Checkbox = {
Active = "rbxassetid://6016251644",
Inactive = "rbxassetid://6016251963",
diff --git a/plugin/src/PatchSet.lua b/plugin/src/PatchSet.lua
index fac6f945..3451de6a 100644
--- a/plugin/src/PatchSet.lua
+++ b/plugin/src/PatchSet.lua
@@ -8,6 +8,40 @@ local t = require(Packages.t)
local Types = require(script.Parent.Types)
+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({
@@ -57,6 +91,32 @@ function PatchSet.hasUpdates(patchSet)
return next(patchSet.updated) ~= nil
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 _ in patch.added do
+ count += 1
+ end
+ for _ in patch.removed do
+ count += 1
+ end
+ for _ in patch.updated do
+ count += 1
+ end
+
+ return count
+end
+
--[[
Merge multiple PatchSet objects into the given PatchSet.
]]
diff --git a/plugin/src/Reconciler/diff.lua b/plugin/src/Reconciler/diff.lua
index ef729700..e2dac7ff 100644
--- a/plugin/src/Reconciler/diff.lua
+++ b/plugin/src/Reconciler/diff.lua
@@ -15,6 +15,86 @@ local function isEmpty(table)
return next(table) == nil
end
+local function fuzzyEq(a: number, b: number, epsilon: number): boolean
+ return math.abs(a - b) < epsilon
+end
+
+local function trueEquals(a, b): boolean
+ -- Exit early for simple equality values
+ if a == b then
+ return true
+ end
+
+ local typeA, typeB = typeof(a), typeof(b)
+
+ -- For tables, try recursive deep equality
+ if typeA == "table" and typeB == "table" then
+ local checkedKeys = {}
+ for key, value in pairs(a) do
+ checkedKeys[key] = true
+ if not trueEquals(value, b[key]) then
+ return false
+ end
+ end
+ for key, value in pairs(b) do
+ if checkedKeys[key] then continue end
+ if not trueEquals(value, a[key]) then
+ return false
+ end
+ end
+ return true
+
+ -- For numbers, compare with epsilon of 0.0001 to avoid floating point inequality
+ elseif typeA == "number" and typeB == "number" then
+ return fuzzyEq(a, b, 0.0001)
+
+ -- For EnumItem->number, compare the EnumItem's value
+ elseif typeA == "number" and typeB == "EnumItem" then
+ return a == b.Value
+ elseif typeA == "EnumItem" and typeB == "number" then
+ return a.Value == b
+
+ -- For Color3s, compare to RGB ints to avoid floating point inequality
+ elseif typeA == "Color3" and typeB == "Color3" then
+ local aR, aG, aB = math.floor(a.R * 255), math.floor(a.G * 255), math.floor(a.B * 255)
+ local bR, bG, bB = math.floor(b.R * 255), math.floor(b.G * 255), math.floor(b.B * 255)
+ return aR == bR and aG == bG and aB == bB
+
+ -- For CFrames, compare to components with epsilon of 0.0001 to avoid floating point inequality
+ elseif typeA == "CFrame" and typeB == "CFrame" then
+ local aComponents, bComponents = {a:GetComponents()}, {b:GetComponents()}
+ for i, aComponent in aComponents do
+ if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
+ return false
+ end
+ end
+ return true
+
+ -- For Vector3s, compare to components with epsilon of 0.0001 to avoid floating point inequality
+ elseif typeA == "Vector3" and typeB == "Vector3" then
+ local aComponents, bComponents = {a.X, a.Y, a.Z}, {b.X, b.Y, b.Z}
+ for i, aComponent in aComponents do
+ if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
+ return false
+ end
+ end
+ return true
+
+ -- For Vector2s, compare to components with epsilon of 0.0001 to avoid floating point inequality
+ elseif typeA == "Vector2" and typeB == "Vector2" then
+ local aComponents, bComponents = {a.X, a.Y}, {b.X, b.Y}
+ for i, aComponent in aComponents do
+ if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
+ return false
+ end
+ end
+ return true
+
+ end
+
+ return false
+end
+
local function shouldDeleteUnknownInstances(virtualInstance)
if virtualInstance.Metadata ~= nil then
return not virtualInstance.Metadata.ignoreUnknownInstances
@@ -73,7 +153,8 @@ local function diff(instanceMap, virtualInstances, rootId)
local ok, decodedValue = decodeValue(virtualValue, instanceMap)
if ok then
- if existingValue ~= decodedValue then
+ if not trueEquals(existingValue, decodedValue) then
+ Log.debug("{}.{} changed from '{}' to '{}'", instance:GetFullName(), propertyName, existingValue, decodedValue)
changedProperties[propertyName] = virtualValue
end
else
diff --git a/plugin/src/ServeSession.lua b/plugin/src/ServeSession.lua
index 90860ff7..12b9ffae 100644
--- a/plugin/src/ServeSession.lua
+++ b/plugin/src/ServeSession.lua
@@ -5,8 +5,10 @@ local Packages = script.Parent.Parent.Packages
local Log = require(Packages.Log)
local Fmt = require(Packages.Fmt)
local t = require(Packages.t)
+local Promise = require(Packages.Promise)
local ChangeBatcher = require(script.Parent.ChangeBatcher)
+local encodePatchUpdate = require(script.Parent.ChangeBatcher.encodePatchUpdate)
local InstanceMap = require(script.Parent.InstanceMap)
local PatchSet = require(script.Parent.PatchSet)
local Reconciler = require(script.Parent.Reconciler)
@@ -123,6 +125,10 @@ function ServeSession:onStatusChanged(callback)
self.__statusChangedCallback = callback
end
+function ServeSession:setConfirmCallback(callback)
+ self.__userConfirmCallback = callback
+end
+
function ServeSession:onPatchApplied(callback)
self.__patchAppliedCallback = callback
end
@@ -132,13 +138,12 @@ function ServeSession:start()
self.__apiContext:connect()
:andThen(function(serverInfo)
- self:__setStatus(Status.Connected, serverInfo.projectName)
self:__applyGameAndPlaceId(serverInfo)
- local rootInstanceId = serverInfo.rootInstanceId
-
- return self:__initialSync(rootInstanceId)
+ return self:__initialSync(serverInfo)
:andThen(function()
+ self:__setStatus(Status.Connected, serverInfo.projectName)
+
return self:__mainSyncLoop()
end)
end)
@@ -202,8 +207,8 @@ function ServeSession:__onActiveScriptChanged(activeScript)
self.__apiContext:open(scriptId)
end
-function ServeSession:__initialSync(rootInstanceId)
- return self.__apiContext:read({ rootInstanceId })
+function ServeSession:__initialSync(serverInfo)
+ return self.__apiContext:read({ serverInfo.rootInstanceId })
:andThen(function(readResponseBody)
-- Tell the API Context that we're up-to-date with the version of
-- the tree defined in this response.
@@ -212,14 +217,14 @@ function ServeSession:__initialSync(rootInstanceId)
-- For any instances that line up with the Rojo server's view, start
-- tracking them in the reconciler.
Log.trace("Matching existing Roblox instances to Rojo IDs")
- self.__reconciler:hydrate(readResponseBody.instances, rootInstanceId, game)
+ self.__reconciler:hydrate(readResponseBody.instances, serverInfo.rootInstanceId, game)
-- Calculate the initial patch to apply to the DataModel to catch us
-- up to what Rojo thinks the place should look like.
Log.trace("Computing changes that plugin needs to make to catch up to server...")
local success, catchUpPatch = self.__reconciler:diff(
readResponseBody.instances,
- rootInstanceId,
+ serverInfo.rootInstanceId,
game
)
@@ -229,19 +234,50 @@ function ServeSession:__initialSync(rootInstanceId)
Log.trace("Computed hydration patch: {:#?}", debugPatch(catchUpPatch))
- -- TODO: Prompt user to notify them of this patch, since it's
- -- effectively a conflict between the Rojo server and the client. In
- -- the future, we'll ask which changes the user wants to keep.
-
- local unappliedPatch = self.__reconciler:applyPatch(catchUpPatch)
-
- if not PatchSet.isEmpty(unappliedPatch) then
- Log.warn("Could not apply all changes requested by the Rojo server:\n{}",
- PatchSet.humanSummary(self.__instanceMap, unappliedPatch))
+ local userDecision = "Accept"
+ if self.__userConfirmCallback ~= nil then
+ userDecision = self.__userConfirmCallback(self.__instanceMap, catchUpPatch, serverInfo)
end
- if self.__patchAppliedCallback then
- pcall(self.__patchAppliedCallback, catchUpPatch, unappliedPatch)
+ if userDecision == "Abort" then
+ return Promise.reject("Aborted Rojo sync operation")
+
+ elseif userDecision == "Reject" and self.__twoWaySync then
+ -- The user wants their studio DOM to write back to their Rojo DOM
+ -- so we will reverse the patch and send it back
+
+ local inversePatch = PatchSet.newEmpty()
+
+ -- Send back the current properties
+ for _, change in catchUpPatch.updated do
+ local instance = self.__instanceMap.fromIds[change.id]
+ if not instance then continue end
+
+ local update = encodePatchUpdate(instance, change.id, change.changedProperties)
+ table.insert(inversePatch.updated, update)
+ end
+ -- Add the removed instances back to Rojo
+ -- selene:allow(empty_if, unused_variable)
+ for _, instance in catchUpPatch.removed do
+ -- TODO: Generate ID for our instance and add it to inversePatch.added
+ end
+ -- Remove the additions we've rejected
+ for id, _change in catchUpPatch.added do
+ table.insert(inversePatch.removed, id)
+ end
+
+ self.__apiContext:write(inversePatch)
+
+ elseif userDecision == "Accept" then
+ local unappliedPatch = self.__reconciler:applyPatch(catchUpPatch)
+
+ if not PatchSet.isEmpty(unappliedPatch) then
+ Log.warn("Could not apply all changes requested by the Rojo server:\n{}",
+ PatchSet.humanSummary(self.__instanceMap, unappliedPatch))
+ end
+ if self.__patchAppliedCallback then
+ pcall(self.__patchAppliedCallback, catchUpPatch, unappliedPatch)
+ end
end
end)
end
diff --git a/plugin/watch-build.sh b/plugin/watch-build.sh
new file mode 100644
index 00000000..91ce6f89
--- /dev/null
+++ b/plugin/watch-build.sh
@@ -0,0 +1,2 @@
+# Continously build the rojo plugin into the local plugin directory on Windows
+rojo build plugin/default.project.json -o $LOCALAPPDATA/Roblox/Plugins/Rojo.rbxm --watch