Merge branch 'master' of https://github.com/boatbomber/rojo into headless-api

This commit is contained in:
boatbomber 2023-07-16 21:05:57 -07:00
commit 1220b5c0d6
57 changed files with 1332 additions and 613 deletions

View file

@ -11,13 +11,6 @@ local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse)
local validateApiRead = Types.ifEnabled(Types.ApiReadResponse)
local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse)
--[[
Returns a promise that will never resolve nor reject.
]]
local function hangingPromise()
return Promise.new(function() end)
end
local function rejectFailedRequests(response)
if response.code >= 400 then
local message = string.format("HTTP %s:\n%s", tostring(response.code), response.body)
@ -212,12 +205,8 @@ function ApiContext:retrieveMessages()
local function sendRequest()
local request = Http.get(url)
:catch(function(err)
if err.type == Http.Error.Kind.Timeout then
if self.__connected then
return sendRequest()
else
return hangingPromise()
end
if err.type == Http.Error.Kind.Timeout and self.__connected then
return sendRequest()
end
return Promise.reject(err)

View file

@ -51,10 +51,15 @@ function Checkbox:render()
ZIndex = self.props.zIndex,
BackgroundTransparency = 1,
[Roact.Event.Activated] = self.props.onClick,
[Roact.Event.Activated] = function()
if self.props.locked then return end
self.props.onClick()
end,
}, {
StateTip = e(Tooltip.Trigger, {
text = if self.props.active then "Enabled" else "Disabled",
text =
(if self.props.locked then "[LOCKED] " else "")
.. (if self.props.active then "Enabled" else "Disabled"),
}),
Active = e(SlicedImage, {
@ -65,7 +70,7 @@ function Checkbox:render()
zIndex = 2,
}, {
Icon = e("ImageLabel", {
Image = Assets.Images.Checkbox.Active,
Image = if self.props.locked then Assets.Images.Checkbox.Locked else Assets.Images.Checkbox.Active,
ImageColor3 = theme.Active.IconColor,
ImageTransparency = activeTransparency,
@ -84,7 +89,7 @@ function Checkbox:render()
size = UDim2.new(1, 0, 1, 0),
}, {
Icon = e("ImageLabel", {
Image = Assets.Images.Checkbox.Inactive,
Image = if self.props.locked then Assets.Images.Checkbox.Locked else Assets.Images.Checkbox.Inactive,
ImageColor3 = theme.Inactive.IconColor,
ImageTransparency = self.props.transparency,

View file

@ -29,7 +29,13 @@ function Dropdown:init()
})
end
function Dropdown:didUpdate()
function Dropdown:didUpdate(prevProps)
if self.props.locked and not prevProps.locked then
self:setState({
open = false,
})
end
self.openMotor:setGoal(
Flipper.Spring.new(self.state.open and 1 or 0, {
frequency = 6,
@ -68,6 +74,7 @@ function Dropdown:render()
Font = Enum.Font.GothamMedium,
[Roact.Event.Activated] = function()
if self.props.locked then return end
self:setState({
open = false,
})
@ -89,6 +96,7 @@ function Dropdown:render()
BackgroundTransparency = 1,
[Roact.Event.Activated] = function()
if self.props.locked then return end
self:setState({
open = not self.state.open,
})
@ -101,7 +109,7 @@ function Dropdown:render()
size = UDim2.new(1, 0, 1, 0),
}, {
DropArrow = e("ImageLabel", {
Image = Assets.Images.Dropdown.Arrow,
Image = if self.props.locked then Assets.Images.Dropdown.Locked else Assets.Images.Dropdown.Arrow,
ImageColor3 = self.openBinding:map(function(a)
return theme.Closed.IconColor:Lerp(theme.Open.IconColor, a)
end),

View file

@ -8,6 +8,8 @@ local Theme = require(Plugin.App.Theme)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local DisplayValue = require(script.Parent.DisplayValue)
local EMPTY_TABLE = {}
local e = Roact.createElement
local ChangeList = Roact.Component:extend("ChangeList")
@ -26,8 +28,6 @@ function ChangeList:render()
return 0.93 + (0.07 * t)
end)
local columnVisibility = props.columnVisibility
local rows = {}
local pad = {
PaddingLeft = UDim.new(0, 5),
@ -48,7 +48,6 @@ function ChangeList:render()
VerticalAlignment = Enum.VerticalAlignment.Center,
}),
A = e("TextLabel", {
Visible = columnVisibility[1],
Text = tostring(changes[1][1]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamBold,
@ -61,7 +60,6 @@ function ChangeList:render()
LayoutOrder = 1,
}),
B = e("TextLabel", {
Visible = columnVisibility[2],
Text = tostring(changes[1][2]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamBold,
@ -74,7 +72,6 @@ function ChangeList:render()
LayoutOrder = 2,
}),
C = e("TextLabel", {
Visible = columnVisibility[3],
Text = tostring(changes[1][3]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamBold,
@ -93,6 +90,9 @@ function ChangeList:render()
continue -- Skip headers, already handled above
end
local metadata = values[4] or EMPTY_TABLE
local isWarning = metadata.isWarning
rows[row] = e("Frame", {
Size = UDim2.new(1, 0, 0, 30),
BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1,
@ -108,12 +108,11 @@ function ChangeList:render()
VerticalAlignment = Enum.VerticalAlignment.Center,
}),
A = e("TextLabel", {
Visible = columnVisibility[1],
Text = tostring(values[1]),
Text = (if isWarning then "⚠ " else "") .. tostring(values[1]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextColor3 = if isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
@ -123,7 +122,6 @@ function ChangeList:render()
B = e(
"Frame",
{
Visible = columnVisibility[2],
BackgroundTransparency = 1,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 2,
@ -131,12 +129,12 @@ function ChangeList:render()
e(DisplayValue, {
value = values[2],
transparency = props.transparency,
textColor = if isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor,
})
),
C = e(
"Frame",
{
Visible = columnVisibility[3],
BackgroundTransparency = 1,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 3,
@ -144,6 +142,7 @@ function ChangeList:render()
e(DisplayValue, {
value = values[3],
transparency = props.transparency,
textColor = if isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor,
})
),
})

View file

@ -34,7 +34,7 @@ local function DisplayValue(props)
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextColor3 = props.textColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
@ -78,7 +78,7 @@ local function DisplayValue(props)
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextColor3 = props.textColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
@ -95,7 +95,7 @@ local function DisplayValue(props)
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextColor3 = props.textColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,

View file

@ -1,3 +1,4 @@
local SelectionService = game:GetService("Selection")
local StudioService = game:GetService("StudioService")
local Rojo = script:FindFirstAncestor("Rojo")
@ -14,6 +15,7 @@ local bindingUtil = require(Plugin.App.bindingUtil)
local e = Roact.createElement
local ChangeList = require(script.Parent.ChangeList)
local Tooltip = require(script.Parent.Parent.Tooltip)
local Expansion = Roact.Component:extend("Expansion")
@ -32,7 +34,6 @@ function Expansion:render()
ChangeList = e(ChangeList, {
changes = props.changeList,
transparency = props.transparency,
columnVisibility = props.columnVisibility,
}),
})
end
@ -69,6 +70,22 @@ function DomLabel:init()
end)
end
function DomLabel:didUpdate(prevProps)
if
prevProps.instance ~= self.props.instance
or prevProps.patchType ~= self.props.patchType
or prevProps.name ~= self.props.name
or prevProps.changeList ~= self.props.changeList
then
-- Close the expansion when the domlabel is changed to a different thing
self.expanded = false
self.motor:setGoal(Flipper.Spring.new(30, {
frequency = 5,
dampingRatio = 1,
}))
end
end
function DomLabel:render()
local props = self.props
@ -106,28 +123,53 @@ function DomLabel:render()
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
local goalHeight = 30 + (if self.expanded then math.clamp(#self.props.changeList * 30, 30, 30 * 6) else 0)
self.motor:setGoal(Flipper.Spring.new(goalHeight, {
frequency = 5,
dampingRatio = 1,
}))
end,
})
else nil,
Button = e("TextButton", {
BackgroundTransparency = 1,
Text = "",
Size = UDim2.new(1, 0, 1, 0),
[Roact.Event.Activated] = function(_rbx: Instance, _input: InputObject, clickCount: number)
if clickCount == 1 then
-- Double click opens the instance in explorer
self.lastDoubleClickTime = os.clock()
if props.instance then
SelectionService:Set({ props.instance })
end
elseif clickCount == 0 then
-- Single click expands the changes
task.wait(0.25)
if os.clock() - (self.lastDoubleClickTime or 0) <= 0.25 then
-- This is a double click, so don't expand
return
end
if props.changeList then
self.expanded = not self.expanded
local goalHeight = 30
+ (if self.expanded then math.clamp(#props.changeList * 30, 30, 30 * 6) else 0)
self.motor:setGoal(Flipper.Spring.new(goalHeight, {
frequency = 5,
dampingRatio = 1,
}))
end
end
end,
}, {
StateTip = if (props.instance or props.changeList)
then e(Tooltip.Trigger, {
text = (if props.changeList
then "Click to " .. (if self.expanded then "hide" else "view") .. " changes"
else "") .. (if props.instance
then (if props.changeList then " & d" else "D") .. "ouble click to open in Explorer"
else ""),
})
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
@ -152,7 +194,7 @@ function DomLabel:render()
AnchorPoint = Vector2.new(0, 0.5),
}),
InstanceName = e("TextLabel", {
Text = props.name .. (props.hint and string.format(
Text = (if props.isWarning then "⚠ " else "") .. props.name .. (props.hint and string.format(
' <font color="#%s">%s</font>',
theme.AddressEntry.PlaceholderColor:ToHex(),
props.hint
@ -161,7 +203,7 @@ function DomLabel:render()
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextColor3 = if props.isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,

View file

@ -1,143 +1,18 @@
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 PatchTree = require(Plugin.PatchTree)
local PatchSet = require(Plugin.PatchSet)
local decodeValue = require(Plugin.Reconciler.decodeValue)
local getProperty = require(Plugin.Reconciler.getProperty)
local Theme = require(Plugin.App.Theme)
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")
@ -153,250 +28,90 @@ function PatchVisualizer:willUnmount()
end
function PatchVisualizer:shouldUpdate(nextProps)
if self.props.patchTree ~= nextProps.patchTree then
return true
end
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,
})
if currentPatch ~= nil or nextPatch ~= nil then
return not PatchSet.isEqual(currentPatch, nextPatch)
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
return false
end
function PatchVisualizer:render()
local patch = self.props.patch
local instanceMap = self.props.instanceMap
local tree = self:buildTree(patch, instanceMap)
local patchTree = self.props.patchTree
if patchTree == nil and self.props.patch ~= nil then
patchTree = PatchTree.build(self.props.patch, self.props.instanceMap, self.props.changeListHeaders or { "Property", "Current", "Incoming" })
if self.props.unappliedPatch then
patchTree = PatchTree.updateMetadata(patchTree, self.props.patch, self.props.instanceMap, self.props.unappliedPatch)
end
end
-- 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)
if patchTree then
local function drawNode(node, depth)
local elementHeight, setElementHeight = Roact.createBinding(30)
table.insert(elementHeights, elementHeight)
table.insert(
scrollElements,
e(DomLabel, {
updateEvent = self.updateEvent,
elementHeight = elementHeight,
setElementHeight = setElementHeight,
patchType = node.patchType,
className = node.className,
isWarning = node.isWarning,
instance = node.instance,
name = node.name,
hint = node.hint,
changeList = node.changeList,
depth = depth,
transparency = self.props.transparency,
})
)
end
end
for _, node in alphabeticalPairs(tree.ROOT.children) do
drawNode(node, 0)
patchTree:forEach(function(node, depth)
drawNode(node, depth)
end)
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),
return Theme.with(function(theme)
return e(BorderedContainer, {
transparency = self.props.transparency,
count = #scrollElements,
updateEvent = self.updateEvent.Event,
render = function(i)
return scrollElements[i]
end,
getHeightBinding = function(i)
return elementHeights[i]
end,
}),
})
size = self.props.size,
position = self.props.position,
layoutOrder = self.props.layoutOrder,
}, {
CleanMerge = e("TextLabel", {
Visible = #scrollElements == 0,
Text = "No changes to sync, project is up to date.",
Font = Enum.Font.GothamMedium,
TextSize = 15,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextWrapped = true,
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
}),
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)
end
return PatchVisualizer

View file

@ -165,46 +165,94 @@ function Trigger:init()
self.id = HttpService:GenerateGUID(false)
self.ref = Roact.createRef()
self.mousePos = Vector2.zero
self.showingPopup = false
self.destroy = function()
self.props.context.removeTip(self.id)
self.showingPopup = false
end
end
function Trigger:willUnmount()
if self.showDelayThread then
task.cancel(self.showDelayThread)
pcall(task.cancel, self.showDelayThread)
end
if self.destroy then
self.destroy()
end
end
function Trigger:didUpdate(prevProps)
if prevProps.text ~= self.props.text then
-- Any existing popup is now invalid
self.props.context.removeTip(self.id)
self.showingPopup = false
-- Let the new text propagate
self:managePopup()
end
end
function Trigger:isHovering()
local rbx = self.ref.current
if rbx then
local pos = rbx.AbsolutePosition
local size = rbx.AbsoluteSize
local mousePos = self.mousePos
return
mousePos.X >= pos.X and mousePos.X <= pos.X + size.X
and mousePos.Y >= pos.Y and mousePos.Y <= pos.Y + size.Y
end
return false
end
function Trigger:managePopup()
if self:isHovering() then
if self.showingPopup or self.showDelayThread then
-- Don't duplicate popups
return
end
self.showDelayThread = task.delay(DELAY, function()
self.props.context.addTip(self.id, {
Text = self.props.text,
Position = self.mousePos,
Trigger = self.ref,
})
self.showDelayThread = nil
self.showingPopup = true
end)
else
if self.showDelayThread then
pcall(task.cancel, self.showDelayThread)
self.showDelayThread = nil
end
self.props.context.removeTip(self.id)
self.showingPopup = false
end
end
function Trigger:render()
local function recalculate(rbx)
local widget = rbx:FindFirstAncestorOfClass("DockWidgetPluginGui")
if not widget then return end
self.mousePos = widget:GetRelativeMousePosition()
self:managePopup()
end
return e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
ZIndex = self.props.zIndex or 100,
[Roact.Ref] = self.ref,
[Roact.Event.MouseMoved] = function(_rbx, x, y)
self.mousePos = Vector2.new(x, y)
end,
[Roact.Event.MouseEnter] = function()
self.showDelayThread = task.delay(DELAY, function()
self.props.context.addTip(self.id, {
Text = self.props.text,
Position = self.mousePos,
Trigger = self.ref,
})
end)
end,
[Roact.Event.MouseLeave] = function()
if self.showDelayThread then
task.cancel(self.showDelayThread)
end
self.props.context.removeTip(self.id)
end,
[Roact.Change.AbsolutePosition] = recalculate,
[Roact.Change.AbsoluteSize] = recalculate,
[Roact.Event.MouseMoved] = recalculate,
[Roact.Event.MouseLeave] = recalculate,
[Roact.Event.MouseEnter] = recalculate,
})
end

View file

@ -52,7 +52,7 @@ function ConfirmingPage:render()
transparency = self.props.transparency,
layoutOrder = 3,
columnVisibility = {true, true, true},
changeListHeaders = { "Property", "Current", "Incoming" },
patch = self.props.confirmData.patch,
instanceMap = self.props.confirmData.instanceMap,
}),

View file

@ -12,6 +12,7 @@ local PatchSet = require(Plugin.PatchSet)
local Header = require(Plugin.App.Components.Header)
local IconButton = require(Plugin.App.Components.IconButton)
local TextButton = require(Plugin.App.Components.TextButton)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local Tooltip = require(Plugin.App.Components.Tooltip)
local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer)
@ -55,7 +56,7 @@ function ChangesDrawer:render()
return e(BorderedContainer, {
transparency = self.props.transparency,
size = self.props.height:map(function(y)
return UDim2.new(1, 0, y, -180 * y)
return UDim2.new(1, 0, y, -220 * y)
end),
position = UDim2.new(0, 0, 1, 0),
anchorPoint = Vector2.new(0, 1),
@ -82,9 +83,7 @@ function ChangesDrawer:render()
transparency = self.props.transparency,
layoutOrder = 3,
columnVisibility = { true, false, true },
patch = self.props.patch,
instanceMap = self.serveSession.__instanceMap,
patchTree = self.props.patchTree,
}),
})
end)
@ -137,22 +136,6 @@ local function ConnectionDetails(props)
}),
}),
Disconnect = e(IconButton, {
icon = Assets.Images.Icons.Close,
iconSize = 24,
color = theme.ConnectionDetails.DisconnectColor,
transparency = props.transparency,
position = UDim2.new(1, 0, 0.5, 0),
anchorPoint = Vector2.new(1, 0.5),
onClick = props.onDisconnect,
}, {
Tip = e(Tooltip.Trigger, {
text = "Disconnect from the Rojo sync server",
}),
}),
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 17),
PaddingRight = UDim.new(0, 15),
@ -170,9 +153,56 @@ function ConnectedPage:getChangeInfoText()
end
local elapsed = os.time() - patchData.timestamp
local changes = PatchSet.countChanges(patchData.patch)
local unapplied = PatchSet.countChanges(patchData.unapplied)
return string.format("<i>Synced %d change%s %s</i>", changes, changes == 1 and "" or "s", timeSinceText(elapsed))
return
"<i>Synced "
.. timeSinceText(elapsed)
.. (if unapplied > 0 then
string.format(
", <font color=\"#FF8E3C\">but %d change%s failed to apply</font>",
unapplied,
unapplied == 1 and "" or "s"
)
else "")
.. "</i>"
end
function ConnectedPage:startChangeInfoTextUpdater()
-- Cancel any existing updater
self:stopChangeInfoTextUpdater()
-- Start a new updater
self.changeInfoTextUpdater = task.defer(function()
while true do
if self.state.hoveringChangeInfo then
self.setChangeInfoText("<u>" .. self:getChangeInfoText() .. "</u>")
else
self.setChangeInfoText(self:getChangeInfoText())
end
local elapsed = os.time() - self.props.patchData.timestamp
local updateInterval = 1
-- Update timestamp text as frequently as currently needed
for _, UnitData in ipairs(AGE_UNITS) do
local UnitSeconds = UnitData[1]
if elapsed > UnitSeconds then
updateInterval = UnitSeconds
break
end
end
task.wait(updateInterval)
end
end)
end
function ConnectedPage:stopChangeInfoTextUpdater()
if self.changeInfoTextUpdater then
task.cancel(self.changeInfoTextUpdater)
self.changeInfoTextUpdater = nil
end
end
function ConnectedPage:init()
@ -195,34 +225,21 @@ function ConnectedPage:init()
self:setState({
renderChanges = false,
hoveringChangeInfo = false,
})
self.changeInfoText, self.setChangeInfoText = Roact.createBinding("")
self.changeInfoTextUpdater = task.defer(function()
while true do
self.setChangeInfoText(self:getChangeInfoText())
local elapsed = os.time() - self.props.patchData.timestamp
local updateInterval = 1
-- Update timestamp text as frequently as currently needed
for _, UnitData in ipairs(AGE_UNITS) do
local UnitSeconds = UnitData[1]
if elapsed >= UnitSeconds then
updateInterval = UnitSeconds
break
end
end
task.wait(updateInterval)
end
end)
self:startChangeInfoTextUpdater()
end
function ConnectedPage:willUnmount()
if self.changeInfoTextUpdater then
task.cancel(self.changeInfoTextUpdater)
self:stopChangeInfoTextUpdater()
end
function ConnectedPage:didUpdate(previousProps)
if self.props.patchData.timestamp ~= previousProps.patchData.timestamp then
self:startChangeInfoTextUpdater()
end
end
@ -255,6 +272,44 @@ function ConnectedPage:render()
onDisconnect = self.props.onDisconnect,
}),
Buttons = e("Frame", {
Size = UDim2.new(1, 0, 0, 34),
LayoutOrder = 3,
BackgroundTransparency = 1,
ZIndex = 2,
}, {
Settings = e(TextButton, {
text = "Settings",
style = "Bordered",
transparency = self.props.transparency,
layoutOrder = 1,
onClick = self.props.onNavigateSettings,
}, {
Tip = e(Tooltip.Trigger, {
text = "View and modify plugin settings"
}),
}),
Disconnect = e(TextButton, {
text = "Disconnect",
style = "Solid",
transparency = self.props.transparency,
layoutOrder = 2,
onClick = self.props.onDisconnect,
}, {
Tip = e(Tooltip.Trigger, {
text = "Disconnect from the Rojo sync server"
}),
}),
Layout = e("UIListLayout", {
HorizontalAlignment = Enum.HorizontalAlignment.Right,
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 10),
}),
}),
ChangeInfo = e("TextButton", {
Text = self.changeInfoText,
Font = Enum.Font.Gotham,
@ -268,9 +323,23 @@ function ConnectedPage:render()
Size = UDim2.new(1, 0, 0, 28),
LayoutOrder = 3,
LayoutOrder = 4,
BackgroundTransparency = 1,
[Roact.Event.MouseEnter] = function()
self:setState({
hoveringChangeInfo = true,
})
self.setChangeInfoText("<u>" .. self:getChangeInfoText() .. "</u>")
end,
[Roact.Event.MouseLeave] = function()
self:setState({
hoveringChangeInfo = false,
})
self.setChangeInfoText(self:getChangeInfoText())
end,
[Roact.Event.Activated] = function()
if self.state.renderChanges then
self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, {
@ -284,15 +353,19 @@ function ConnectedPage:render()
}))
end
end,
}, {
Tooltip = e(Tooltip.Trigger, {
text = if self.state.renderChanges then "Hide the changes" else "View the changes",
}),
}),
ChangesDrawer = e(ChangesDrawer, {
rendered = self.state.renderChanges,
transparency = self.props.transparency,
patch = self.props.patchData.patch,
patchTree = self.props.patchTree,
serveSession = self.props.serveSession,
height = self.changeDrawerHeight,
layoutOrder = 4,
layoutOrder = 5,
onClose = function()
self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, {

View file

@ -72,6 +72,7 @@ function Setting:render()
self.props.customInput
elseif self.props.options ~= nil then
e(Dropdown, {
locked = self.props.locked,
options = self.props.options,
active = self.state.setting,
transparency = self.props.transparency,
@ -83,6 +84,7 @@ function Setting:render()
})
else
e(Checkbox, {
locked = self.props.locked,
active = self.state.setting,
transparency = self.props.transparency,
position = UDim2.new(1, 0, 0.5, 0),
@ -111,12 +113,13 @@ function Setting:render()
BackgroundTransparency = 1,
}, {
Name = e("TextLabel", {
Text = self.props.name,
Text = (if self.props.experimental then "<font color=\"#FF8E3C\">⚠ </font>" else "") .. self.props.name,
Font = Enum.Font.GothamBold,
TextSize = 17,
TextColor3 = theme.Setting.NameColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency,
RichText = true,
Size = UDim2.new(1, 0, 0, 17),
@ -125,7 +128,7 @@ function Setting:render()
}),
Description = e("TextLabel", {
Text = self.props.description,
Text = (if self.props.experimental then "<font color=\"#FF8E3C\">[Experimental] </font>" else "") .. self.props.description,
Font = Enum.Font.Gotham,
LineHeight = 1.2,
TextSize = 14,
@ -133,11 +136,13 @@ function Setting:render()
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency,
TextWrapped = true,
RichText = true,
Size = self.containerSize:map(function(value)
local desc = (if self.props.experimental then "[Experimental] " else "") .. self.props.description
local offset = (self.props.onReset and 34 or 0) + (self.props.options ~= nil and 120 or 40)
local textBounds = getTextBounds(
self.props.description, 14, Enum.Font.Gotham, 1.2,
desc, 14, Enum.Font.Gotham, 1.2,
Vector2.new(value.X - offset, math.huge)
)
return UDim2.new(1, -offset, 0, textBounds.Y)

View file

@ -156,7 +156,9 @@ function SettingsPage:render()
OpenScriptsExternally = e(Setting, {
id = "openScriptsExternally",
name = "Open Scripts Externally",
description = "EXPERIMENTAL! Attempt to open scripts in an external editor",
description = "Attempt to open scripts in an external editor",
locked = self.props.syncActive,
experimental = true,
transparency = self.props.transparency,
layoutOrder = 5,
}),
@ -164,7 +166,9 @@ function SettingsPage:render()
TwoWaySync = e(Setting, {
id = "twoWaySync",
name = "Two-Way Sync",
description = "EXPERIMENTAL! Editing files in Studio will sync them into the filesystem",
description = "Editing files in Studio will sync them into the filesystem",
locked = self.props.syncActive,
experimental = true,
transparency = self.props.transparency,
layoutOrder = 6,
}),

View file

@ -100,6 +100,7 @@ local lightTheme = strict("LightTheme", {
Remove = hexColor(0xffbdba),
Edit = hexColor(0xbacdff),
Row = hexColor(0x000000),
Warning = hexColor(0xFF8E3C),
},
ConnectionDetails = {
ProjectNameColor = hexColor(0x00000),
@ -195,6 +196,7 @@ local darkTheme = strict("DarkTheme", {
Remove = hexColor(0x3F2D32),
Edit = hexColor(0x193345),
Row = hexColor(0xFFFFFF),
Warning = hexColor(0xFF8E3C),
},
ConnectionDetails = {
ProjectNameColor = hexColor(0xFFFFFF),

View file

@ -1,3 +1,4 @@
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local Players = game:GetService("Players")
local ServerStorage = game:GetService("ServerStorage")
local RunService = game:GetService("RunService")
@ -19,6 +20,7 @@ local ServeSession = require(Plugin.ServeSession)
local ApiContext = require(Plugin.ApiContext)
local HeadlessAPI = require(Plugin.HeadlessAPI)
local PatchSet = require(Plugin.PatchSet)
local PatchTree = require(Plugin.PatchTree)
local preloadAssets = require(Plugin.preloadAssets)
local soundPlayer = require(Plugin.soundPlayer)
local ignorePlaceIds = require(Plugin.ignorePlaceIds)
@ -61,12 +63,71 @@ function App:init()
self.confirmationEvent = self.confirmationBindable.Event
self.notifId = 0
self.waypointConnection = ChangeHistoryService.OnUndo:Connect(function(action: string)
if not string.find(action, "^Rojo: Patch") then
return
end
local undoConnection, redoConnection = nil, nil
local function cleanup()
undoConnection:Disconnect()
redoConnection:Disconnect()
end
Log.warn(
string.format(
"You've undone '%s'.\nIf this was not intended, please Redo in the topbar or with Ctrl/⌘+Y.",
action
)
)
local dismissNotif = self:addNotification(
string.format("You've undone '%s'.\nIf this was not intended, please restore.", action),
10,
{
Restore = {
text = "Restore",
style = "Solid",
layoutOrder = 1,
onClick = function(notification)
cleanup()
notification:dismiss()
ChangeHistoryService:Redo()
end,
},
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
onClick = function(notification)
cleanup()
notification:dismiss()
end,
},
}
)
undoConnection = ChangeHistoryService.OnUndo:Once(function()
-- Our notif is now out of date- redoing will not restore the patch
-- since we've undone even further. Dismiss the notif.
cleanup()
dismissNotif()
end)
redoConnection = ChangeHistoryService.OnRedo:Once(function(redoneAction: string)
if redoneAction == action then
-- The user has restored the patch, so we can dismiss the notif
cleanup()
dismissNotif()
end
end)
end)
self:setState({
appStatus = AppStatus.NotConnected,
guiEnabled = false,
confirmData = {},
patchData = {
patch = PatchSet.newEmpty(),
unapplied = PatchSet.newEmpty(),
timestamp = os.time(),
},
notifications = {},
@ -132,7 +193,7 @@ function App:init()
onClick = function(notification)
notification:dismiss()
self:startSession()
end
end,
},
Dismiss = {
text = "Dismiss",
@ -146,7 +207,16 @@ function App:init()
end
end
function App:addNotification(text: string, timeout: number?, actions: { [string]: {text: string, style: string, layoutOrder: number, onClick: (any) -> ()} }?)
function App:willUnmount()
self.waypointConnection:Disconnect()
self.confirmationBindable:Destroy()
end
function App:addNotification(
text: string,
timeout: number?,
actions: { [string]: { text: string, style: string, layoutOrder: number, onClick: (any) -> () } }?
)
if not Settings:get("showNotifications") then
return
end
@ -199,6 +269,10 @@ function App:addThirdPartyNotification(source: string, text: string, timeout: nu
end
function App:closeNotification(id: number)
if not self.state.notifications[id] then
return
end
local notifications = table.clone(self.state.notifications)
notifications[id] = nil
@ -209,26 +283,38 @@ end
function App:getPriorEndpoint()
local priorEndpoints = Settings:get("priorEndpoints")
if not priorEndpoints then return end
if not priorEndpoints then
return
end
local id = tostring(game.PlaceId)
if ignorePlaceIds[id] then return end
if ignorePlaceIds[id] then
return
end
local place = priorEndpoints[id]
if not place then return end
if not place then
return
end
return place.host, place.port
end
function App:getLastSyncTimestamp()
local priorEndpoints = Settings:get("priorEndpoints")
if not priorEndpoints then return end
if not priorEndpoints then
return
end
local id = tostring(game.PlaceId)
if ignorePlaceIds[id] then return end
if ignorePlaceIds[id] then
return
end
local place = priorEndpoints[id]
if not place then return end
if not place then
return
end
return place.timestamp
end
@ -248,7 +334,9 @@ function App:setPriorEndpoint(host: string, port: string)
end
local id = tostring(game.PlaceId)
if ignorePlaceIds[id] then return end
if ignorePlaceIds[id] then
return
end
priorEndpoints[id] = {
host = if host ~= Config.defaultHost then host else nil,
@ -257,7 +345,6 @@ function App:setPriorEndpoint(host: string, port: string)
}
Log.trace("Saved last used endpoint for {}", game.PlaceId)
Settings:set("priorEndpoints", priorEndpoints)
end
@ -393,35 +480,51 @@ function App:startSession(host: string?, port: string?)
twoWaySync = sessionOptions.twoWaySync,
})
serveSession:onPatchApplied(function(patch, _unapplied)
self.cleanupPrecommit = serveSession.__reconciler:hookPrecommit(function(patch, instanceMap)
-- Build new tree for patch
self:setState({
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }),
})
end)
self.cleanupPostcommit = serveSession.__reconciler:hookPostcommit(function(patch, instanceMap, unappliedPatch)
-- Update tree with unapplied metadata
self:setState(function(prevState)
return {
patchTree = PatchTree.updateMetadata(prevState.patchTree, patch, instanceMap, unappliedPatch),
}
end)
end)
serveSession:hookPostcommit(function(patch, _instanceMap, unapplied)
local now = os.time()
local old = self.state.patchData
if PatchSet.isEmpty(patch) then
-- Ignore empty patches
-- Ignore empty patch, but update timestamp
self:setState({
patchData = {
patch = old.patch,
unapplied = old.unapplied,
timestamp = now,
},
})
return
end
local now = os.time()
local old = self.state.patchData
if now - old.timestamp < 2 then
-- 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:setState({
patchData = {
patch = merged,
timestamp = now,
},
})
else
self:setState({
patchData = {
patch = patch,
timestamp = now,
},
})
patch = PatchSet.assign(PatchSet.newEmpty(), old.patch, patch)
unapplied = PatchSet.assign(PatchSet.newEmpty(), old.unapplied, unapplied)
end
self:setState({
patchData = {
patch = patch,
unapplied = unapplied,
timestamp = now,
},
})
end)
serveSession:onStatusChanged(function(status, details)
@ -449,6 +552,13 @@ function App:startSession(host: string?, port: string?)
elseif status == ServeSession.Status.Disconnected then
self.serveSession = nil
self:releaseSyncLock()
self:setState({
patchData = {
patch = PatchSet.newEmpty(),
unapplied = PatchSet.newEmpty(),
timestamp = os.time(),
},
})
-- Details being present indicates that this
-- disconnection was from an error.
@ -540,6 +650,13 @@ function App:endSession()
appStatus = AppStatus.NotConnected,
})
if self.cleanupPrecommit ~= nil then
self.cleanupPrecommit()
end
if self.cleanupPostcommit ~= nil then
self.cleanupPostcommit()
end
Log.trace("Session terminated by user")
end
@ -602,8 +719,8 @@ function App:render()
initDockState = Enum.InitialDockState.Right,
initEnabled = false,
overridePreviousState = false,
floatingSize = Vector2.new(300, 200),
minimumSize = Vector2.new(300, 120),
floatingSize = Vector2.new(320, 210),
minimumSize = Vector2.new(300, 210),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
@ -632,6 +749,7 @@ function App:render()
end,
onNavigateSettings = function()
self.backPage = AppStatus.NotConnected
self:setState({
appStatus = AppStatus.Settings,
})
@ -658,18 +776,28 @@ function App:render()
Connected = createPageElement(AppStatus.Connected, {
projectName = self.state.projectName,
address = self.state.address,
patchTree = self.state.patchTree,
patchData = self.state.patchData,
serveSession = self.serveSession,
onDisconnect = function()
self:endSession()
end,
onNavigateSettings = function()
self.backPage = AppStatus.Connected
self:setState({
appStatus = AppStatus.Settings,
})
end,
}),
Settings = createPageElement(AppStatus.Settings, {
syncActive = self.serveSession ~= nil and self.serveSession:getStatus() == ServeSession.Status.Connected,
onBack = function()
self:setState({
appStatus = AppStatus.NotConnected,
appStatus = self.backPage or AppStatus.NotConnected,
})
end,

View file

@ -36,9 +36,11 @@ local Assets = {
Checkbox = {
Active = "rbxassetid://6016251644",
Inactive = "rbxassetid://6016251963",
Locked = "rbxassetid://14011257320",
},
Dropdown = {
Arrow = "rbxassetid://10131770538",
Locked = "rbxassetid://14011257320",
},
Spinner = {
Foreground = "rbxassetid://3222731032",

View file

@ -212,13 +212,24 @@ function PatchSet.countChanges(patch)
local count = 0
for _ in patch.added do
-- Adding an instance is 1 change
count += 1
end
for _ in patch.removed do
-- Removing an instance is 1 change
count += 1
end
for _ in patch.updated do
count += 1
for _, update in patch.updated do
-- Updating an instance is 1 change per property updated
for _ in update.changedProperties do
count += 1
end
if update.changedName ~= nil then
count += 1
end
if update.changedClassName ~= nil then
count += 1
end
end
return count

434
plugin/src/PatchTree.lua Normal file
View file

@ -0,0 +1,434 @@
--[[
Methods to turn PatchSets into trees matching the DataModel containing
the changes and metadata for use in the PatchVisualizer component.
]]
local HttpService = game:GetService("HttpService")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Log = require(Packages.Log)
local Types = require(Plugin.Types)
local decodeValue = require(Plugin.Reconciler.decodeValue)
local getProperty = require(Plugin.Reconciler.getProperty)
local function alphabeticalNext(t, state)
-- Equivalent of the next function, but returns the keys in the alphabetic
-- order of node names. We use a temporary ordered key table that is stored in the
-- table being iterated.
local key = nil
if state == nil then
-- First iteration, generate the index
local orderedIndex, i = table.create(5), 0
for k in t do
i += 1
orderedIndex[i] = k
end
table.sort(orderedIndex, function(a, b)
local nodeA, nodeB = t[a], t[b]
return (nodeA.name or "") < (nodeB.name or "")
end)
t.__orderedIndex = orderedIndex
key = orderedIndex[1]
else
-- Fetch the next value
for i, orderedState in t.__orderedIndex do
if orderedState == state then
key = t.__orderedIndex[i + 1]
break
end
end
end
if key then
return key, t[key]
end
-- No more value to return, cleanup
t.__orderedIndex = nil
return
end
local function alphabeticalPairs(t)
-- Equivalent of the pairs() iterator, but sorted
return alphabeticalNext, t, nil
end
local Tree = {}
Tree.__index = Tree
function Tree.new()
local tree = {
idToNode = {},
ROOT = {
className = "DataModel",
name = "ROOT",
children = {},
},
}
-- Add ROOT to idToNode or it won't be found by getNode since that searches *within* ROOT
tree.idToNode["ROOT"] = tree.ROOT
return setmetatable(tree, Tree)
end
-- Iterates over all sub-nodes, depth first
-- node is where to start from, defaults to root
-- depth is used for recursion but can be used to set the starting depth
function Tree:forEach(callback, node, depth)
depth = depth or 1
for _, child in alphabeticalPairs(if node then node.children else self.ROOT.children) do
callback(child, depth)
if type(child.children) == "table" then
self:forEach(callback, child, depth + 1)
end
end
end
-- Finds a node by id, depth first
-- searchNode is the node to start the search within, defaults to root
function Tree:getNode(id, searchNode)
if self.idToNode[id] then
return self.idToNode[id]
end
local searchChildren = (searchNode or self.ROOT).children
for nodeId, node in searchChildren do
if nodeId == id then
self.idToNode[id] = node
return node
end
local descendant = self:getNode(id, node)
if descendant then
return descendant
end
end
return nil
end
-- Adds a node to the tree as a child of the node with id == parent
-- If parent is nil, it defaults to root
-- props must contain id, and cannot contain children or parentId
-- other than those three, it can hold anything
function Tree:addNode(parent, props)
assert(props.id, "props must contain id")
parent = parent or "ROOT"
local node = self:getNode(props.id)
if node then
-- Update existing node
for k, v in props do
node[k] = v
end
return node
end
node = table.clone(props)
node.children = {}
node.parentId = parent
local parentNode = self:getNode(parent)
if not parentNode then
Log.warn("Failed to create node since parent doesnt exist: {}, {}", parent, props)
return
end
parentNode.children[node.id] = node
self.idToNode[node.id] = node
return node
end
-- Given a list of ancestor ids in descending order, builds the nodes for them
-- using the patch and instanceMap info
function Tree:buildAncestryNodes(ancestryIds: { string }, patch, instanceMap)
-- Build nodes for ancestry by going up the tree
local previousId = "ROOT"
for _, ancestorId in ancestryIds 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,
instance = if typeof(value) == "Instance" then value else nil,
})
previousId = ancestorId
end
end
local PatchTree = {}
-- Builds a new tree from a patch and instanceMap
-- uses changeListHeaders in node.changeList
function PatchTree.build(patch, instanceMap, changeListHeaders)
local tree = Tree.new()
for _, change in patch.updated do
local instance = instanceMap.fromIds[change.id]
if not instance then
continue
end
-- Gather ancestors from existing DOM
local ancestryIds = {}
local parentObject = instance.Parent
local parentId = instanceMap.fromInstances[parentObject]
while parentObject do
table.insert(ancestryIds, 1, parentId)
parentObject = parentObject.Parent
parentId = instanceMap.fromInstances[parentObject]
end
tree:buildAncestryNodes(ancestryIds, 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?, metadata: any?)
i += 1
hintBuffer[i] = prop
changeList[i] = { prop, current, incoming, metadata }
end
-- Gather the changes
if change.changedName then
addProp("Name", instance.Name, change.changedName)
end
for prop, incoming in change.changedProperties do
local incomingSuccess, incomingValue = decodeValue(incoming, instanceMap)
local currentSuccess, currentValue = getProperty(instance, prop)
addProp(
prop,
if currentSuccess then currentValue else "[Error]",
if incomingSuccess then incomingValue else 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, changeListHeaders)
end
-- Add this node to tree
tree:addNode(instanceMap.fromInstances[instance.Parent], {
id = change.id,
patchType = "Edit",
className = instance.ClassName,
name = instance.Name,
instance = instance,
hint = hint,
changeList = changeList,
})
end
for _, idOrInstance in patch.removed do
local instance = if Types.RbxId(idOrInstance) then instanceMap.fromIds[idOrInstance] else idOrInstance
if not instance then
-- If we're viewing a past patch, the instance is already removed
-- and we therefore cannot get the tree for it anymore
continue
end
-- Gather ancestors from existing DOM
-- (note that they may have no ID if they're being removed as unknown)
local ancestryIds = {}
local parentObject = instance.Parent
local parentId = instanceMap.fromInstances[parentObject] or HttpService:GenerateGUID(false)
while parentObject do
instanceMap:insert(parentId, parentObject) -- This ensures we can find the parent later
table.insert(ancestryIds, 1, parentId)
parentObject = parentObject.Parent
parentId = instanceMap.fromInstances[parentObject] or HttpService:GenerateGUID(false)
end
tree:buildAncestryNodes(ancestryIds, patch, instanceMap)
-- Add this node to tree
local nodeId = instanceMap.fromInstances[instance] or HttpService:GenerateGUID(false)
instanceMap:insert(nodeId, instance)
tree:addNode(instanceMap.fromInstances[instance.Parent], {
id = nodeId,
patchType = "Remove",
className = instance.ClassName,
name = instance.Name,
instance = instance,
})
end
for id, change in patch.added do
-- Gather ancestors from existing DOM or future additions
local ancestryIds = {}
local parentId = change.Parent
local parentData = patch.added[parentId]
local parentObject = instanceMap.fromIds[parentId]
while parentId do
table.insert(ancestryIds, 1, parentId)
parentId = nil
if parentData then
-- object is parented to an instance that does not exist yet
parentId = parentData.Parent
parentData = patch.added[parentId]
parentObject = instanceMap.fromIds[parentId]
elseif parentObject then
-- object is parented to an instance that exists
parentObject = parentObject.Parent
parentId = instanceMap.fromInstances[parentObject]
parentData = patch.added[parentId]
end
end
tree:buildAncestryNodes(ancestryIds, 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, changeListHeaders)
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,
instance = instanceMap.fromIds[id],
})
end
return tree
end
-- Creates a deep copy of a tree for immutability purposes in Roact
function PatchTree.clone(tree)
if not tree then return end
local newTree = Tree.new()
tree:forEach(function(node)
newTree:addNode(node.parentId, table.clone(node))
end)
return newTree
end
-- Updates the metadata of a tree with the unapplied patch and currently existing instances
-- Builds a new tree from the data if one isn't provided
-- Always returns a new tree for immutability purposes in Roact
function PatchTree.updateMetadata(tree, patch, instanceMap, unappliedPatch)
if tree then
tree = PatchTree.clone(tree)
else
tree = PatchTree.build(patch, instanceMap)
end
-- Update isWarning metadata
for _, failedChange in unappliedPatch.updated do
local node = tree:getNode(failedChange.id)
if node then
node.isWarning = true
Log.trace("Marked node as warning: {} {}", node.id, node.name)
if node.changeList then
for _, change in node.changeList do
if failedChange.changedProperties[change[1]] then
Log.trace(" Marked property as warning: {}", change[1])
if change[4] == nil then
change[4] = {}
end
change[4].isWarning = true
end
end
end
end
end
-- Update if instances exist
tree:forEach(function(node)
if node.instance then
if node.instance.Parent == nil and node.instance ~= game then
-- This instance has been removed
Log.trace("Removed instance from node: {} {}", node.id, node.name)
node.instance = nil
end
else
-- This instance may have been added
node.instance = instanceMap.fromIds[node.id]
if node.instance then
Log.trace("Added instance to node: {} {}", node.id, node.name)
end
end
end)
return tree
end
return PatchTree

View file

@ -5,6 +5,8 @@
Patches can come from the server or be generated by the client.
]]
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local Packages = script.Parent.Parent.Parent.Packages
local Log = require(Packages.Log)
@ -17,6 +19,8 @@ local reify = require(script.Parent.reify)
local setProperty = require(script.Parent.setProperty)
local function applyPatch(instanceMap, patch)
local patchTimestamp = DateTime.now():FormatLocalTime("LTS", "en-us")
-- Tracks any portions of the patch that could not be applied to the DOM.
local unappliedPatch = PatchSet.newEmpty()
@ -199,6 +203,8 @@ local function applyPatch(instanceMap, patch)
end
end
ChangeHistoryService:SetWaypoint("Rojo: Patch " .. patchTimestamp)
return unappliedPatch
end

View file

@ -3,6 +3,9 @@
and mutating the Roblox DOM.
]]
local Packages = script.Parent.Parent.Packages
local Log = require(Packages.Log)
local applyPatch = require(script.applyPatch)
local hydrate = require(script.hydrate)
local diff = require(script.diff)
@ -14,13 +17,63 @@ function Reconciler.new(instanceMap)
local self = {
-- Tracks all of the instances known by the reconciler by ID.
__instanceMap = instanceMap,
__precommitCallbacks = {},
__postcommitCallbacks = {},
}
return setmetatable(self, Reconciler)
end
function Reconciler:hookPrecommit(callback: (patch: any, instanceMap: any) -> ()): () -> ()
table.insert(self.__precommitCallbacks, callback)
Log.trace("Added precommit callback: {}", callback)
return function()
-- Remove the callback from the list
for i, cb in self.__precommitCallbacks do
if cb == callback then
table.remove(self.__precommitCallbacks, i)
Log.trace("Removed precommit callback: {}", callback)
break
end
end
end
end
function Reconciler:hookPostcommit(callback: (patch: any, instanceMap: any, unappliedPatch: any) -> ()): () -> ()
table.insert(self.__postcommitCallbacks, callback)
Log.trace("Added postcommit callback: {}", callback)
return function()
-- Remove the callback from the list
for i, cb in self.__postcommitCallbacks do
if cb == callback then
table.remove(self.__postcommitCallbacks, i)
Log.trace("Removed postcommit callback: {}", callback)
break
end
end
end
end
function Reconciler:applyPatch(patch)
return applyPatch(self.__instanceMap, patch)
for _, callback in self.__precommitCallbacks do
local success, err = pcall(callback, patch, self.__instanceMap)
if not success then
Log.warn("Precommit hook errored: {}", err)
end
end
local unappliedPatch = applyPatch(self.__instanceMap, patch)
for _, callback in self.__postcommitCallbacks do
local success, err = pcall(callback, patch, self.__instanceMap, unappliedPatch)
if not success then
Log.warn("Postcommit hook errored: {}", err)
end
end
return unappliedPatch
end
function Reconciler:hydrate(virtualInstances, rootId, rootInstance)
@ -31,4 +84,4 @@ function Reconciler:diff(virtualInstances, rootId)
return diff(self.__instanceMap, virtualInstances, rootId)
end
return Reconciler
return Reconciler

View file

@ -97,7 +97,6 @@ function ServeSession.new(options)
__instanceMap = instanceMap,
__changeBatcher = changeBatcher,
__statusChangedCallback = nil,
__patchAppliedCallback = nil,
__connections = connections,
}
@ -129,8 +128,12 @@ function ServeSession:setConfirmCallback(callback)
self.__userConfirmCallback = callback
end
function ServeSession:onPatchApplied(callback)
self.__patchAppliedCallback = callback
function ServeSession:hookPrecommit(callback)
return self.__reconciler:hookPrecommit(callback)
end
function ServeSession:hookPostcommit(callback)
return self.__reconciler:hookPostcommit(callback)
end
function ServeSession:start()
@ -291,35 +294,45 @@ function ServeSession:__initialSync(serverInfo)
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
function ServeSession:__mainSyncLoop()
return self.__apiContext:retrieveMessages()
:andThen(function(messages)
Log.trace("Serve session {} retrieved {} messages", tostring(self), #messages)
return Promise.new(function(resolve, reject)
while self.__status == Status.Connected do
local success, result = self.__apiContext:retrieveMessages()
:andThen(function(messages)
if self.__status == Status.Disconnected then
-- In the time it took to retrieve messages, we disconnected
-- so we just resolve immediately without patching anything
return
end
for _, message in ipairs(messages) do
local unappliedPatch = self.__reconciler:applyPatch(message)
Log.trace("Serve session {} retrieved {} messages", tostring(self), #messages)
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
for _, message in messages do
local unappliedPatch = self.__reconciler:applyPatch(message)
if self.__patchAppliedCallback then
pcall(self.__patchAppliedCallback, message, unappliedPatch)
end
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
end
end):await()
if self.__status == Status.Disconnected then
-- If we are no longer connected after applying, we stop silently
-- without checking for errors as they are no longer relevant
break
elseif success == false then
reject(result)
end
end
if self.__status ~= Status.Disconnected then
return self:__mainSyncLoop()
end
end)
-- We are no longer connected, so we resolve the promise
resolve()
end)
end
function ServeSession:__stopInternal(err)