From b5ed952d5c3b81edc38f180112bfcc4ef6faf098 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Sat, 1 Apr 2023 23:17:23 -0400 Subject: [PATCH] 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 --- .../src/App/Components/BorderedContainer.lua | 3 +- .../Components/PatchVisualizer/ChangeList.lua | 181 ++++++++ .../PatchVisualizer/DisplayValue.lua | 107 +++++ .../Components/PatchVisualizer/DomLabel.lua | 180 ++++++++ .../App/Components/PatchVisualizer/init.lua | 402 ++++++++++++++++++ .../App/Components/Studio/StudioPluginGui.lua | 27 +- plugin/src/App/Components/Tooltip.lua | 6 +- plugin/src/App/Components/VirtualScroller.lua | 156 +++++++ plugin/src/App/StatusPages/Confirming.lua | 156 +++++++ plugin/src/App/StatusPages/Connected.lua | 126 +++++- plugin/src/App/StatusPages/NotConnected.lua | 1 + plugin/src/App/StatusPages/init.lua | 3 +- plugin/src/App/Theme.lua | 12 + plugin/src/App/init.lua | 115 +++-- plugin/src/Assets.lua | 5 + plugin/src/PatchSet.lua | 60 +++ plugin/src/Reconciler/diff.lua | 83 +++- plugin/src/ServeSession.lua | 74 +++- plugin/watch-build.sh | 2 + 19 files changed, 1618 insertions(+), 81 deletions(-) create mode 100644 plugin/src/App/Components/PatchVisualizer/ChangeList.lua create mode 100644 plugin/src/App/Components/PatchVisualizer/DisplayValue.lua create mode 100644 plugin/src/App/Components/PatchVisualizer/DomLabel.lua create mode 100644 plugin/src/App/Components/PatchVisualizer/init.lua create mode 100644 plugin/src/App/Components/VirtualScroller.lua create mode 100644 plugin/src/App/StatusPages/Confirming.lua create mode 100644 plugin/watch-build.sh 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