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