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

23
.github/workflows/changelog.yml vendored Normal file
View file

@ -0,0 +1,23 @@
name: Changelog Check
on:
pull_request:
types: [assigned, opened, synchronize, reopened, labeled, unlabeled]
branches:
- master
jobs:
build:
name: Check Actions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Changelog check
uses: Zomzog/changelog-checker@v1.3.0
with:
fileName: CHANGELOG.md
noChangelogLabel: skip changelog
checkNotification: Simple
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -67,3 +67,4 @@ jobs:
- name: Clippy - name: Clippy
run: cargo clippy run: cargo clippy

View file

@ -8,6 +8,16 @@
* Skip confirming patches that contain only a datamodel name change. ([#688]) * Skip confirming patches that contain only a datamodel name change. ([#688])
* Added sync reminder notification. ([#689]) * Added sync reminder notification. ([#689])
* Added protection against syncing a model to a place. ([#691]) * Added protection against syncing a model to a place. ([#691])
* Select Instances from diff tree view ([#709])
* Fix Rojo breaking when users undo/redo in Studio ([#708])
* Improved sync info text on Connected page. ([#692])
* Fix patch visualizer breaking when instances are removed during sync ([#713])
* Patch visualizer now indicates what changes failed to apply. ([#717])
* Add buttons for navigation on the Connected page ([#722])
* Improve tooltip behavior ([#723])
* Better settings controls ([#725])
* Rework patch visualizer with many fixes and improvements ([#726])
* Added support for syncing in `.toml` files ([#633])
[#631]: https://github.com/rojo-rbx/rojo/pull/631 [#631]: https://github.com/rojo-rbx/rojo/pull/631
[#668]: https://github.com/rojo-rbx/rojo/pull/668 [#668]: https://github.com/rojo-rbx/rojo/pull/668
@ -16,6 +26,16 @@
[#688]: https://github.com/rojo-rbx/rojo/pull/688 [#688]: https://github.com/rojo-rbx/rojo/pull/688
[#689]: https://github.com/rojo-rbx/rojo/pull/689 [#689]: https://github.com/rojo-rbx/rojo/pull/689
[#691]: https://github.com/rojo-rbx/rojo/pull/691 [#691]: https://github.com/rojo-rbx/rojo/pull/691
[#709]: https://github.com/rojo-rbx/rojo/pull/709
[#708]: https://github.com/rojo-rbx/rojo/pull/708
[#692]: https://github.com/rojo-rbx/rojo/pull/692
[#713]: https://github.com/rojo-rbx/rojo/pull/713
[#717]: https://github.com/rojo-rbx/rojo/pull/717
[#722]: https://github.com/rojo-rbx/rojo/pull/722
[#723]: https://github.com/rojo-rbx/rojo/pull/723
[#725]: https://github.com/rojo-rbx/rojo/pull/725
[#726]: https://github.com/rojo-rbx/rojo/pull/726
[#633]: https://github.com/rojo-rbx/rojo/pull/633
## [7.3.0] - April 22, 2023 ## [7.3.0] - April 22, 2023
* Added `$attributes` to project format. ([#574]) * Added `$attributes` to project format. ([#574])

9
Cargo.lock generated
View file

@ -1308,9 +1308,9 @@ dependencies = [
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.51" version = "0.10.55"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97ea2d98598bf9ada7ea6ee8a30fb74f9156b63bbe495d64ec2b87c269d2dda3" checksum = "345df152bc43501c5eb9e4654ff05f794effb78d4efe3d53abc158baddc0703d"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"cfg-if 1.0.0", "cfg-if 1.0.0",
@ -1349,9 +1349,9 @@ dependencies = [
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.86" version = "0.9.90"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "992bac49bdbab4423199c654a5515bd2a6c6a23bf03f2dd3bdb7e5ae6259bc69" checksum = "374533b0e45f3a7ced10fcaeccca020e66656bc03dac384f852e4e5a7a8104a6"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
@ -1922,6 +1922,7 @@ dependencies = [
"termcolor", "termcolor",
"thiserror", "thiserror",
"tokio", "tokio",
"toml",
"tracy-client 0.13.2", "tracy-client 0.13.2",
"uuid", "uuid",
"walkdir", "walkdir",

View file

@ -80,6 +80,7 @@ ritz = "0.1.0"
roblox_install = "1.0.0" roblox_install = "1.0.0"
serde = { version = "1.0.130", features = ["derive", "rc"] } serde = { version = "1.0.130", features = ["derive", "rc"] }
serde_json = "1.0.68" serde_json = "1.0.68"
toml = "0.5.9"
termcolor = "1.1.2" termcolor = "1.1.2"
thiserror = "1.0.30" thiserror = "1.0.30"
tokio = { version = "1.12.0", features = ["rt", "rt-multi-thread"] } tokio = { version = "1.12.0", features = ["rt", "rt-multi-thread"] }

View file

@ -11,13 +11,6 @@ local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse)
local validateApiRead = Types.ifEnabled(Types.ApiReadResponse) local validateApiRead = Types.ifEnabled(Types.ApiReadResponse)
local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse) 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) local function rejectFailedRequests(response)
if response.code >= 400 then if response.code >= 400 then
local message = string.format("HTTP %s:\n%s", tostring(response.code), response.body) local message = string.format("HTTP %s:\n%s", tostring(response.code), response.body)
@ -212,12 +205,8 @@ function ApiContext:retrieveMessages()
local function sendRequest() local function sendRequest()
local request = Http.get(url) local request = Http.get(url)
:catch(function(err) :catch(function(err)
if err.type == Http.Error.Kind.Timeout then if err.type == Http.Error.Kind.Timeout and self.__connected then
if self.__connected then
return sendRequest() return sendRequest()
else
return hangingPromise()
end
end end
return Promise.reject(err) return Promise.reject(err)

View file

@ -51,10 +51,15 @@ function Checkbox:render()
ZIndex = self.props.zIndex, ZIndex = self.props.zIndex,
BackgroundTransparency = 1, 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, { 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, { Active = e(SlicedImage, {
@ -65,7 +70,7 @@ function Checkbox:render()
zIndex = 2, zIndex = 2,
}, { }, {
Icon = e("ImageLabel", { 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, ImageColor3 = theme.Active.IconColor,
ImageTransparency = activeTransparency, ImageTransparency = activeTransparency,
@ -84,7 +89,7 @@ function Checkbox:render()
size = UDim2.new(1, 0, 1, 0), size = UDim2.new(1, 0, 1, 0),
}, { }, {
Icon = e("ImageLabel", { 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, ImageColor3 = theme.Inactive.IconColor,
ImageTransparency = self.props.transparency, ImageTransparency = self.props.transparency,

View file

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

View file

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

View file

@ -1,3 +1,4 @@
local SelectionService = game:GetService("Selection")
local StudioService = game:GetService("StudioService") local StudioService = game:GetService("StudioService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
@ -14,6 +15,7 @@ local bindingUtil = require(Plugin.App.bindingUtil)
local e = Roact.createElement local e = Roact.createElement
local ChangeList = require(script.Parent.ChangeList) local ChangeList = require(script.Parent.ChangeList)
local Tooltip = require(script.Parent.Parent.Tooltip)
local Expansion = Roact.Component:extend("Expansion") local Expansion = Roact.Component:extend("Expansion")
@ -32,7 +34,6 @@ function Expansion:render()
ChangeList = e(ChangeList, { ChangeList = e(ChangeList, {
changes = props.changeList, changes = props.changeList,
transparency = props.transparency, transparency = props.transparency,
columnVisibility = props.columnVisibility,
}), }),
}) })
end end
@ -69,6 +70,22 @@ function DomLabel:init()
end) end)
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() function DomLabel:render()
local props = self.props local props = self.props
@ -106,28 +123,53 @@ function DomLabel:render()
PaddingLeft = UDim.new(0, 10), PaddingLeft = UDim.new(0, 10),
PaddingRight = UDim.new(0, 10), PaddingRight = UDim.new(0, 10),
}), }),
ExpandButton = if props.changeList Button = e("TextButton", {
then e("TextButton", {
BackgroundTransparency = 1, BackgroundTransparency = 1,
Text = "", Text = "",
Size = UDim2.new(1, 0, 1, 0), Size = UDim2.new(1, 0, 1, 0),
[Roact.Event.Activated] = function() [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 self.expanded = not self.expanded
local goalHeight = 30 + (if self.expanded then math.clamp(#self.props.changeList * 30, 30, 30 * 6) else 0) local goalHeight = 30
+ (if self.expanded then math.clamp(#props.changeList * 30, 30, 30 * 6) else 0)
self.motor:setGoal(Flipper.Spring.new(goalHeight, { self.motor:setGoal(Flipper.Spring.new(goalHeight, {
frequency = 5, frequency = 5,
dampingRatio = 1, dampingRatio = 1,
})) }))
end
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, else nil,
}),
Expansion = if props.changeList Expansion = if props.changeList
then e(Expansion, { then e(Expansion, {
rendered = self.state.renderExpansion, rendered = self.state.renderExpansion,
indent = indent, indent = indent,
transparency = props.transparency, transparency = props.transparency,
changeList = props.changeList, changeList = props.changeList,
columnVisibility = props.columnVisibility,
}) })
else nil, else nil,
DiffIcon = if props.patchType DiffIcon = if props.patchType
@ -152,7 +194,7 @@ function DomLabel:render()
AnchorPoint = Vector2.new(0, 0.5), AnchorPoint = Vector2.new(0, 0.5),
}), }),
InstanceName = e("TextLabel", { 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>', ' <font color="#%s">%s</font>',
theme.AddressEntry.PlaceholderColor:ToHex(), theme.AddressEntry.PlaceholderColor:ToHex(),
props.hint props.hint
@ -161,7 +203,7 @@ function DomLabel:render()
BackgroundTransparency = 1, BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium, Font = Enum.Font.GothamMedium,
TextSize = 14, TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor, TextColor3 = if props.isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency, TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd, TextTruncate = Enum.TextTruncate.AtEnd,

View file

@ -1,143 +1,18 @@
local HttpService = game:GetService("HttpService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Log = require(Packages.Log)
local PatchTree = require(Plugin.PatchTree)
local PatchSet = require(Plugin.PatchSet) 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 BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local VirtualScroller = require(Plugin.App.Components.VirtualScroller) local VirtualScroller = require(Plugin.App.Components.VirtualScroller)
local e = Roact.createElement 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 DomLabel = require(script.DomLabel)
local PatchVisualizer = Roact.Component:extend("PatchVisualizer") local PatchVisualizer = Roact.Component:extend("PatchVisualizer")
@ -153,215 +28,44 @@ function PatchVisualizer:willUnmount()
end end
function PatchVisualizer:shouldUpdate(nextProps) function PatchVisualizer:shouldUpdate(nextProps)
local currentPatch, nextPatch = self.props.patch, nextProps.patch if self.props.patchTree ~= nextProps.patchTree then
return true
end
local currentPatch, nextPatch = self.props.patch, nextProps.patch
if currentPatch ~= nil or nextPatch ~= nil then
return not PatchSet.isEqual(currentPatch, nextPatch) return not PatchSet.isEqual(currentPatch, nextPatch)
end end
function PatchVisualizer:buildTree(patch, instanceMap) return false
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 end
function PatchVisualizer:render() function PatchVisualizer:render()
local patch = self.props.patch local patchTree = self.props.patchTree
local instanceMap = self.props.instanceMap 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" })
local tree = self:buildTree(patch, instanceMap) if self.props.unappliedPatch then
patchTree = PatchTree.updateMetadata(patchTree, self.props.patch, self.props.instanceMap, self.props.unappliedPatch)
end
end
-- Recusively draw tree -- Recusively draw tree
local scrollElements, elementHeights = {}, {} local scrollElements, elementHeights = {}, {}
if patchTree then
local function drawNode(node, depth) local function drawNode(node, depth)
local elementHeight, setElementHeight = Roact.createBinding(30) local elementHeight, setElementHeight = Roact.createBinding(30)
table.insert(elementHeights, elementHeight) table.insert(elementHeights, elementHeight)
table.insert( table.insert(
scrollElements, scrollElements,
e(DomLabel, { e(DomLabel, {
columnVisibility = self.props.columnVisibility,
updateEvent = self.updateEvent, updateEvent = self.updateEvent,
elementHeight = elementHeight, elementHeight = elementHeight,
setElementHeight = setElementHeight, setElementHeight = setElementHeight,
patchType = node.patchType, patchType = node.patchType,
className = node.className, className = node.className,
isWarning = node.isWarning,
instance = node.instance,
name = node.name, name = node.name,
hint = node.hint, hint = node.hint,
changeList = node.changeList, changeList = node.changeList,
@ -369,21 +73,31 @@ function PatchVisualizer:render()
transparency = self.props.transparency, 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 end
patchTree:forEach(function(node, depth)
drawNode(node, depth)
end)
end
return Theme.with(function(theme)
return e(BorderedContainer, { return e(BorderedContainer, {
transparency = self.props.transparency, transparency = self.props.transparency,
size = self.props.size, size = self.props.size,
position = self.props.position, position = self.props.position,
layoutOrder = self.props.layoutOrder, 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, { VirtualScroller = e(VirtualScroller, {
size = UDim2.new(1, 0, 1, 0), size = UDim2.new(1, 0, 1, 0),
transparency = self.props.transparency, transparency = self.props.transparency,
@ -397,6 +111,7 @@ function PatchVisualizer:render()
end, end,
}), }),
}) })
end)
end end
return PatchVisualizer return PatchVisualizer

View file

@ -165,46 +165,94 @@ function Trigger:init()
self.id = HttpService:GenerateGUID(false) self.id = HttpService:GenerateGUID(false)
self.ref = Roact.createRef() self.ref = Roact.createRef()
self.mousePos = Vector2.zero self.mousePos = Vector2.zero
self.showingPopup = false
self.destroy = function() self.destroy = function()
self.props.context.removeTip(self.id) self.props.context.removeTip(self.id)
self.showingPopup = false
end end
end end
function Trigger:willUnmount() function Trigger:willUnmount()
if self.showDelayThread then if self.showDelayThread then
task.cancel(self.showDelayThread) pcall(task.cancel, self.showDelayThread)
end end
if self.destroy then if self.destroy then
self.destroy() self.destroy()
end end
end end
function Trigger:render() function Trigger:didUpdate(prevProps)
return e("Frame", { if prevProps.text ~= self.props.text then
Size = UDim2.fromScale(1, 1), -- Any existing popup is now invalid
BackgroundTransparency = 1, self.props.context.removeTip(self.id)
ZIndex = self.props.zIndex or 100, self.showingPopup = false
[Roact.Ref] = self.ref,
-- 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
[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.showDelayThread = task.delay(DELAY, function()
self.props.context.addTip(self.id, { self.props.context.addTip(self.id, {
Text = self.props.text, Text = self.props.text,
Position = self.mousePos, Position = self.mousePos,
Trigger = self.ref, Trigger = self.ref,
}) })
self.showDelayThread = nil
self.showingPopup = true
end) end)
end, else
[Roact.Event.MouseLeave] = function()
if self.showDelayThread then if self.showDelayThread then
task.cancel(self.showDelayThread) pcall(task.cancel, self.showDelayThread)
self.showDelayThread = nil
end end
self.props.context.removeTip(self.id) self.props.context.removeTip(self.id)
end, 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.Change.AbsolutePosition] = recalculate,
[Roact.Change.AbsoluteSize] = recalculate,
[Roact.Event.MouseMoved] = recalculate,
[Roact.Event.MouseLeave] = recalculate,
[Roact.Event.MouseEnter] = recalculate,
}) })
end end

View file

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

View file

@ -12,6 +12,7 @@ local PatchSet = require(Plugin.PatchSet)
local Header = require(Plugin.App.Components.Header) local Header = require(Plugin.App.Components.Header)
local IconButton = require(Plugin.App.Components.IconButton) local IconButton = require(Plugin.App.Components.IconButton)
local TextButton = require(Plugin.App.Components.TextButton)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local Tooltip = require(Plugin.App.Components.Tooltip) local Tooltip = require(Plugin.App.Components.Tooltip)
local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer) local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer)
@ -55,7 +56,7 @@ function ChangesDrawer:render()
return e(BorderedContainer, { return e(BorderedContainer, {
transparency = self.props.transparency, transparency = self.props.transparency,
size = self.props.height:map(function(y) size = self.props.height:map(function(y)
return UDim2.new(1, 0, y, -180 * y) return UDim2.new(1, 0, y, -220 * y)
end), end),
position = UDim2.new(0, 0, 1, 0), position = UDim2.new(0, 0, 1, 0),
anchorPoint = Vector2.new(0, 1), anchorPoint = Vector2.new(0, 1),
@ -82,9 +83,7 @@ function ChangesDrawer:render()
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 3, layoutOrder = 3,
columnVisibility = { true, false, true }, patchTree = self.props.patchTree,
patch = self.props.patch,
instanceMap = self.serveSession.__instanceMap,
}), }),
}) })
end) 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", { Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 17), PaddingLeft = UDim.new(0, 17),
PaddingRight = UDim.new(0, 15), PaddingRight = UDim.new(0, 15),
@ -170,9 +153,56 @@ function ConnectedPage:getChangeInfoText()
end end
local elapsed = os.time() - patchData.timestamp 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 end
function ConnectedPage:init() function ConnectedPage:init()
@ -195,34 +225,21 @@ function ConnectedPage:init()
self:setState({ self:setState({
renderChanges = false, renderChanges = false,
hoveringChangeInfo = false,
}) })
self.changeInfoText, self.setChangeInfoText = Roact.createBinding("") self.changeInfoText, self.setChangeInfoText = Roact.createBinding("")
self.changeInfoTextUpdater = task.defer(function() self:startChangeInfoTextUpdater()
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)
end end
function ConnectedPage:willUnmount() function ConnectedPage:willUnmount()
if self.changeInfoTextUpdater then self:stopChangeInfoTextUpdater()
task.cancel(self.changeInfoTextUpdater) end
function ConnectedPage:didUpdate(previousProps)
if self.props.patchData.timestamp ~= previousProps.patchData.timestamp then
self:startChangeInfoTextUpdater()
end end
end end
@ -255,6 +272,44 @@ function ConnectedPage:render()
onDisconnect = self.props.onDisconnect, 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", { ChangeInfo = e("TextButton", {
Text = self.changeInfoText, Text = self.changeInfoText,
Font = Enum.Font.Gotham, Font = Enum.Font.Gotham,
@ -268,9 +323,23 @@ function ConnectedPage:render()
Size = UDim2.new(1, 0, 0, 28), Size = UDim2.new(1, 0, 0, 28),
LayoutOrder = 3, LayoutOrder = 4,
BackgroundTransparency = 1, 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() [Roact.Event.Activated] = function()
if self.state.renderChanges then if self.state.renderChanges then
self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, { self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, {
@ -284,15 +353,19 @@ function ConnectedPage:render()
})) }))
end end
end, end,
}, {
Tooltip = e(Tooltip.Trigger, {
text = if self.state.renderChanges then "Hide the changes" else "View the changes",
}),
}), }),
ChangesDrawer = e(ChangesDrawer, { ChangesDrawer = e(ChangesDrawer, {
rendered = self.state.renderChanges, rendered = self.state.renderChanges,
transparency = self.props.transparency, transparency = self.props.transparency,
patch = self.props.patchData.patch, patchTree = self.props.patchTree,
serveSession = self.props.serveSession, serveSession = self.props.serveSession,
height = self.changeDrawerHeight, height = self.changeDrawerHeight,
layoutOrder = 4, layoutOrder = 5,
onClose = function() onClose = function()
self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, { self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, {

View file

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

View file

@ -156,7 +156,9 @@ function SettingsPage:render()
OpenScriptsExternally = e(Setting, { OpenScriptsExternally = e(Setting, {
id = "openScriptsExternally", id = "openScriptsExternally",
name = "Open Scripts Externally", 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, transparency = self.props.transparency,
layoutOrder = 5, layoutOrder = 5,
}), }),
@ -164,7 +166,9 @@ function SettingsPage:render()
TwoWaySync = e(Setting, { TwoWaySync = e(Setting, {
id = "twoWaySync", id = "twoWaySync",
name = "Two-Way Sync", 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, transparency = self.props.transparency,
layoutOrder = 6, layoutOrder = 6,
}), }),

View file

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

View file

@ -1,3 +1,4 @@
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local Players = game:GetService("Players") local Players = game:GetService("Players")
local ServerStorage = game:GetService("ServerStorage") local ServerStorage = game:GetService("ServerStorage")
local RunService = game:GetService("RunService") local RunService = game:GetService("RunService")
@ -19,6 +20,7 @@ local ServeSession = require(Plugin.ServeSession)
local ApiContext = require(Plugin.ApiContext) local ApiContext = require(Plugin.ApiContext)
local HeadlessAPI = require(Plugin.HeadlessAPI) local HeadlessAPI = require(Plugin.HeadlessAPI)
local PatchSet = require(Plugin.PatchSet) local PatchSet = require(Plugin.PatchSet)
local PatchTree = require(Plugin.PatchTree)
local preloadAssets = require(Plugin.preloadAssets) local preloadAssets = require(Plugin.preloadAssets)
local soundPlayer = require(Plugin.soundPlayer) local soundPlayer = require(Plugin.soundPlayer)
local ignorePlaceIds = require(Plugin.ignorePlaceIds) local ignorePlaceIds = require(Plugin.ignorePlaceIds)
@ -61,12 +63,71 @@ function App:init()
self.confirmationEvent = self.confirmationBindable.Event self.confirmationEvent = self.confirmationBindable.Event
self.notifId = 0 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({ self:setState({
appStatus = AppStatus.NotConnected, appStatus = AppStatus.NotConnected,
guiEnabled = false, guiEnabled = false,
confirmData = {}, confirmData = {},
patchData = { patchData = {
patch = PatchSet.newEmpty(), patch = PatchSet.newEmpty(),
unapplied = PatchSet.newEmpty(),
timestamp = os.time(), timestamp = os.time(),
}, },
notifications = {}, notifications = {},
@ -132,7 +193,7 @@ function App:init()
onClick = function(notification) onClick = function(notification)
notification:dismiss() notification:dismiss()
self:startSession() self:startSession()
end end,
}, },
Dismiss = { Dismiss = {
text = "Dismiss", text = "Dismiss",
@ -146,7 +207,16 @@ function App:init()
end end
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 if not Settings:get("showNotifications") then
return return
end end
@ -199,6 +269,10 @@ function App:addThirdPartyNotification(source: string, text: string, timeout: nu
end end
function App:closeNotification(id: number) function App:closeNotification(id: number)
if not self.state.notifications[id] then
return
end
local notifications = table.clone(self.state.notifications) local notifications = table.clone(self.state.notifications)
notifications[id] = nil notifications[id] = nil
@ -209,26 +283,38 @@ end
function App:getPriorEndpoint() function App:getPriorEndpoint()
local priorEndpoints = Settings:get("priorEndpoints") local priorEndpoints = Settings:get("priorEndpoints")
if not priorEndpoints then return end if not priorEndpoints then
return
end
local id = tostring(game.PlaceId) local id = tostring(game.PlaceId)
if ignorePlaceIds[id] then return end if ignorePlaceIds[id] then
return
end
local place = priorEndpoints[id] local place = priorEndpoints[id]
if not place then return end if not place then
return
end
return place.host, place.port return place.host, place.port
end end
function App:getLastSyncTimestamp() function App:getLastSyncTimestamp()
local priorEndpoints = Settings:get("priorEndpoints") local priorEndpoints = Settings:get("priorEndpoints")
if not priorEndpoints then return end if not priorEndpoints then
return
end
local id = tostring(game.PlaceId) local id = tostring(game.PlaceId)
if ignorePlaceIds[id] then return end if ignorePlaceIds[id] then
return
end
local place = priorEndpoints[id] local place = priorEndpoints[id]
if not place then return end if not place then
return
end
return place.timestamp return place.timestamp
end end
@ -248,7 +334,9 @@ function App:setPriorEndpoint(host: string, port: string)
end end
local id = tostring(game.PlaceId) local id = tostring(game.PlaceId)
if ignorePlaceIds[id] then return end if ignorePlaceIds[id] then
return
end
priorEndpoints[id] = { priorEndpoints[id] = {
host = if host ~= Config.defaultHost then host else nil, 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) Log.trace("Saved last used endpoint for {}", game.PlaceId)
Settings:set("priorEndpoints", priorEndpoints) Settings:set("priorEndpoints", priorEndpoints)
end end
@ -393,35 +480,51 @@ function App:startSession(host: string?, port: string?)
twoWaySync = sessionOptions.twoWaySync, 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 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 return
end end
local now = os.time()
local old = self.state.patchData
if now - old.timestamp < 2 then if now - old.timestamp < 2 then
-- Patches that apply in the same second are -- Patches that apply in the same second are
-- considered to be part of the same change for human clarity -- considered to be part of the same change for human clarity
local merged = PatchSet.newEmpty() patch = PatchSet.assign(PatchSet.newEmpty(), old.patch, patch)
PatchSet.assign(merged, old.patch, patch) unapplied = PatchSet.assign(PatchSet.newEmpty(), old.unapplied, unapplied)
end
self:setState({
patchData = {
patch = merged,
timestamp = now,
},
})
else
self:setState({ self:setState({
patchData = { patchData = {
patch = patch, patch = patch,
unapplied = unapplied,
timestamp = now, timestamp = now,
}, },
}) })
end
end) end)
serveSession:onStatusChanged(function(status, details) serveSession:onStatusChanged(function(status, details)
@ -449,6 +552,13 @@ function App:startSession(host: string?, port: string?)
elseif status == ServeSession.Status.Disconnected then elseif status == ServeSession.Status.Disconnected then
self.serveSession = nil self.serveSession = nil
self:releaseSyncLock() self:releaseSyncLock()
self:setState({
patchData = {
patch = PatchSet.newEmpty(),
unapplied = PatchSet.newEmpty(),
timestamp = os.time(),
},
})
-- Details being present indicates that this -- Details being present indicates that this
-- disconnection was from an error. -- disconnection was from an error.
@ -540,6 +650,13 @@ function App:endSession()
appStatus = AppStatus.NotConnected, 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") Log.trace("Session terminated by user")
end end
@ -602,8 +719,8 @@ function App:render()
initDockState = Enum.InitialDockState.Right, initDockState = Enum.InitialDockState.Right,
initEnabled = false, initEnabled = false,
overridePreviousState = false, overridePreviousState = false,
floatingSize = Vector2.new(300, 200), floatingSize = Vector2.new(320, 210),
minimumSize = Vector2.new(300, 120), minimumSize = Vector2.new(300, 210),
zIndexBehavior = Enum.ZIndexBehavior.Sibling, zIndexBehavior = Enum.ZIndexBehavior.Sibling,
@ -632,6 +749,7 @@ function App:render()
end, end,
onNavigateSettings = function() onNavigateSettings = function()
self.backPage = AppStatus.NotConnected
self:setState({ self:setState({
appStatus = AppStatus.Settings, appStatus = AppStatus.Settings,
}) })
@ -658,18 +776,28 @@ function App:render()
Connected = createPageElement(AppStatus.Connected, { Connected = createPageElement(AppStatus.Connected, {
projectName = self.state.projectName, projectName = self.state.projectName,
address = self.state.address, address = self.state.address,
patchTree = self.state.patchTree,
patchData = self.state.patchData, patchData = self.state.patchData,
serveSession = self.serveSession, serveSession = self.serveSession,
onDisconnect = function() onDisconnect = function()
self:endSession() self:endSession()
end, end,
onNavigateSettings = function()
self.backPage = AppStatus.Connected
self:setState({
appStatus = AppStatus.Settings,
})
end,
}), }),
Settings = createPageElement(AppStatus.Settings, { Settings = createPageElement(AppStatus.Settings, {
syncActive = self.serveSession ~= nil and self.serveSession:getStatus() == ServeSession.Status.Connected,
onBack = function() onBack = function()
self:setState({ self:setState({
appStatus = AppStatus.NotConnected, appStatus = self.backPage or AppStatus.NotConnected,
}) })
end, end,

View file

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

View file

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

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. 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 Packages = script.Parent.Parent.Parent.Packages
local Log = require(Packages.Log) local Log = require(Packages.Log)
@ -17,6 +19,8 @@ local reify = require(script.Parent.reify)
local setProperty = require(script.Parent.setProperty) local setProperty = require(script.Parent.setProperty)
local function applyPatch(instanceMap, patch) 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. -- Tracks any portions of the patch that could not be applied to the DOM.
local unappliedPatch = PatchSet.newEmpty() local unappliedPatch = PatchSet.newEmpty()
@ -199,6 +203,8 @@ local function applyPatch(instanceMap, patch)
end end
end end
ChangeHistoryService:SetWaypoint("Rojo: Patch " .. patchTimestamp)
return unappliedPatch return unappliedPatch
end end

View file

@ -3,6 +3,9 @@
and mutating the Roblox DOM. and mutating the Roblox DOM.
]] ]]
local Packages = script.Parent.Parent.Packages
local Log = require(Packages.Log)
local applyPatch = require(script.applyPatch) local applyPatch = require(script.applyPatch)
local hydrate = require(script.hydrate) local hydrate = require(script.hydrate)
local diff = require(script.diff) local diff = require(script.diff)
@ -14,13 +17,63 @@ function Reconciler.new(instanceMap)
local self = { local self = {
-- Tracks all of the instances known by the reconciler by ID. -- Tracks all of the instances known by the reconciler by ID.
__instanceMap = instanceMap, __instanceMap = instanceMap,
__precommitCallbacks = {},
__postcommitCallbacks = {},
} }
return setmetatable(self, Reconciler) return setmetatable(self, Reconciler)
end 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) 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 end
function Reconciler:hydrate(virtualInstances, rootId, rootInstance) function Reconciler:hydrate(virtualInstances, rootId, rootInstance)

View file

@ -97,7 +97,6 @@ function ServeSession.new(options)
__instanceMap = instanceMap, __instanceMap = instanceMap,
__changeBatcher = changeBatcher, __changeBatcher = changeBatcher,
__statusChangedCallback = nil, __statusChangedCallback = nil,
__patchAppliedCallback = nil,
__connections = connections, __connections = connections,
} }
@ -129,8 +128,12 @@ function ServeSession:setConfirmCallback(callback)
self.__userConfirmCallback = callback self.__userConfirmCallback = callback
end end
function ServeSession:onPatchApplied(callback) function ServeSession:hookPrecommit(callback)
self.__patchAppliedCallback = callback return self.__reconciler:hookPrecommit(callback)
end
function ServeSession:hookPostcommit(callback)
return self.__reconciler:hookPostcommit(callback)
end end
function ServeSession:start() function ServeSession:start()
@ -291,34 +294,44 @@ function ServeSession:__initialSync(serverInfo)
Log.warn("Could not apply all changes requested by the Rojo server:\n{}", Log.warn("Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch)) PatchSet.humanSummary(self.__instanceMap, unappliedPatch))
end end
if self.__patchAppliedCallback then
pcall(self.__patchAppliedCallback, catchUpPatch, unappliedPatch)
end
end end
end) end)
end end
function ServeSession:__mainSyncLoop() function ServeSession:__mainSyncLoop()
return self.__apiContext:retrieveMessages() return Promise.new(function(resolve, reject)
while self.__status == Status.Connected do
local success, result = self.__apiContext:retrieveMessages()
:andThen(function(messages) :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
Log.trace("Serve session {} retrieved {} messages", tostring(self), #messages) Log.trace("Serve session {} retrieved {} messages", tostring(self), #messages)
for _, message in ipairs(messages) do for _, message in messages do
local unappliedPatch = self.__reconciler:applyPatch(message) local unappliedPatch = self.__reconciler:applyPatch(message)
if not PatchSet.isEmpty(unappliedPatch) then if not PatchSet.isEmpty(unappliedPatch) then
Log.warn("Could not apply all changes requested by the Rojo server:\n{}", Log.warn("Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch)) PatchSet.humanSummary(self.__instanceMap, unappliedPatch))
end end
end
end):await()
if self.__patchAppliedCallback then if self.__status == Status.Disconnected then
pcall(self.__patchAppliedCallback, message, unappliedPatch) -- 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
end end
if self.__status ~= Status.Disconnected then -- We are no longer connected, so we resolve the promise
return self:__mainSyncLoop() resolve()
end
end) end)
end end

View file

@ -17,9 +17,8 @@ use super::InstanceMetadata;
// - Replace use of Variant with a sum of Variant + borrowed value // - Replace use of Variant with a sum of Variant + borrowed value
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InstanceSnapshot { pub struct InstanceSnapshot {
// FIXME: Don't use Option<Ref> anymore!
/// A temporary ID applied to the snapshot that's used for Ref properties. /// A temporary ID applied to the snapshot that's used for Ref properties.
pub snapshot_id: Option<Ref>, pub snapshot_id: Ref,
/// Rojo-specific metadata associated with the instance. /// Rojo-specific metadata associated with the instance.
pub metadata: InstanceMetadata, pub metadata: InstanceMetadata,
@ -42,7 +41,7 @@ pub struct InstanceSnapshot {
impl InstanceSnapshot { impl InstanceSnapshot {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
snapshot_id: None, snapshot_id: Ref::none(),
metadata: InstanceMetadata::default(), metadata: InstanceMetadata::default(),
name: Cow::Borrowed("DEFAULT"), name: Cow::Borrowed("DEFAULT"),
class_name: Cow::Borrowed("DEFAULT"), class_name: Cow::Borrowed("DEFAULT"),
@ -88,7 +87,7 @@ impl InstanceSnapshot {
} }
} }
pub fn snapshot_id(self, snapshot_id: Option<Ref>) -> Self { pub fn snapshot_id(self, snapshot_id: Ref) -> Self {
Self { Self {
snapshot_id, snapshot_id,
..self ..self
@ -120,7 +119,7 @@ impl InstanceSnapshot {
.collect(); .collect();
Self { Self {
snapshot_id: Some(id), snapshot_id: id,
metadata: InstanceMetadata::default(), metadata: InstanceMetadata::default(),
name: Cow::Owned(instance.name), name: Cow::Owned(instance.name),
class_name: Cow::Owned(instance.class), class_name: Cow::Owned(instance.class),

View file

@ -135,7 +135,7 @@ fn apply_add_child(
context.has_refs_to_rewrite.insert(id); context.has_refs_to_rewrite.insert(id);
} }
if let Some(snapshot_id) = snapshot_id { if snapshot_id.is_some() {
context.snapshot_id_to_instance_id.insert(snapshot_id, id); context.snapshot_id_to_instance_id.insert(snapshot_id, id);
} }
@ -231,7 +231,7 @@ mod test {
let root_id = tree.get_root_id(); let root_id = tree.get_root_id();
let snapshot = InstanceSnapshot { let snapshot = InstanceSnapshot {
snapshot_id: None, snapshot_id: Ref::none(),
metadata: Default::default(), metadata: Default::default(),
name: Cow::Borrowed("Foo"), name: Cow::Borrowed("Foo"),
class_name: Cow::Borrowed("Bar"), class_name: Cow::Borrowed("Bar"),

View file

@ -77,8 +77,10 @@ fn compute_patch_set_internal(
id: Ref, id: Ref,
patch_set: &mut PatchSet, patch_set: &mut PatchSet,
) { ) {
if let Some(snapshot_id) = snapshot.snapshot_id { if snapshot.snapshot_id.is_some() {
context.snapshot_id_to_instance_id.insert(snapshot_id, id); context
.snapshot_id_to_instance_id
.insert(snapshot.snapshot_id, id);
} }
let instance = tree let instance = tree
@ -244,7 +246,7 @@ mod test {
// addition of a prop named Self, which is a self-referential Ref. // addition of a prop named Self, which is a self-referential Ref.
let snapshot_id = Ref::new(); let snapshot_id = Ref::new();
let snapshot = InstanceSnapshot { let snapshot = InstanceSnapshot {
snapshot_id: Some(snapshot_id), snapshot_id: snapshot_id,
properties: hashmap! { properties: hashmap! {
"Self".to_owned() => Variant::Ref(snapshot_id), "Self".to_owned() => Variant::Ref(snapshot_id),
}, },
@ -286,13 +288,13 @@ mod test {
// This patch describes the existing instance with a new child added. // This patch describes the existing instance with a new child added.
let snapshot_id = Ref::new(); let snapshot_id = Ref::new();
let snapshot = InstanceSnapshot { let snapshot = InstanceSnapshot {
snapshot_id: Some(snapshot_id), snapshot_id: snapshot_id,
children: vec![InstanceSnapshot { children: vec![InstanceSnapshot {
properties: hashmap! { properties: hashmap! {
"Self".to_owned() => Variant::Ref(snapshot_id), "Self".to_owned() => Variant::Ref(snapshot_id),
}, },
snapshot_id: None, snapshot_id: Ref::none(),
metadata: Default::default(), metadata: Default::default(),
name: Cow::Borrowed("child"), name: Cow::Borrowed("child"),
class_name: Cow::Borrowed("child"), class_name: Cow::Borrowed("child"),
@ -311,7 +313,7 @@ mod test {
added_instances: vec![PatchAdd { added_instances: vec![PatchAdd {
parent_id: root_id, parent_id: root_id,
instance: InstanceSnapshot { instance: InstanceSnapshot {
snapshot_id: None, snapshot_id: Ref::none(),
metadata: Default::default(), metadata: Default::default(),
properties: hashmap! { properties: hashmap! {
"Self".to_owned() => Variant::Ref(root_id), "Self".to_owned() => Variant::Ref(root_id),

View file

@ -3,6 +3,7 @@ use std::borrow::Cow;
use insta::assert_yaml_snapshot; use insta::assert_yaml_snapshot;
use maplit::hashmap; use maplit::hashmap;
use rbx_dom_weak::types::Ref;
use rojo_insta_ext::RedactionMap; use rojo_insta_ext::RedactionMap;
use crate::snapshot::{compute_patch_set, InstanceSnapshot, RojoTree}; use crate::snapshot::{compute_patch_set, InstanceSnapshot, RojoTree};
@ -15,7 +16,7 @@ fn set_name_and_class_name() {
redactions.intern(tree.get_root_id()); redactions.intern(tree.get_root_id());
let snapshot = InstanceSnapshot { let snapshot = InstanceSnapshot {
snapshot_id: None, snapshot_id: Ref::none(),
metadata: Default::default(), metadata: Default::default(),
name: Cow::Borrowed("Some Folder"), name: Cow::Borrowed("Some Folder"),
class_name: Cow::Borrowed("Folder"), class_name: Cow::Borrowed("Folder"),
@ -37,7 +38,7 @@ fn set_property() {
redactions.intern(tree.get_root_id()); redactions.intern(tree.get_root_id());
let snapshot = InstanceSnapshot { let snapshot = InstanceSnapshot {
snapshot_id: None, snapshot_id: Ref::none(),
metadata: Default::default(), metadata: Default::default(),
name: Cow::Borrowed("ROOT"), name: Cow::Borrowed("ROOT"),
class_name: Cow::Borrowed("ROOT"), class_name: Cow::Borrowed("ROOT"),
@ -70,7 +71,7 @@ fn remove_property() {
} }
let snapshot = InstanceSnapshot { let snapshot = InstanceSnapshot {
snapshot_id: None, snapshot_id: Ref::none(),
metadata: Default::default(), metadata: Default::default(),
name: Cow::Borrowed("ROOT"), name: Cow::Borrowed("ROOT"),
class_name: Cow::Borrowed("ROOT"), class_name: Cow::Borrowed("ROOT"),
@ -92,13 +93,13 @@ fn add_child() {
redactions.intern(tree.get_root_id()); redactions.intern(tree.get_root_id());
let snapshot = InstanceSnapshot { let snapshot = InstanceSnapshot {
snapshot_id: None, snapshot_id: Ref::none(),
metadata: Default::default(), metadata: Default::default(),
name: Cow::Borrowed("ROOT"), name: Cow::Borrowed("ROOT"),
class_name: Cow::Borrowed("ROOT"), class_name: Cow::Borrowed("ROOT"),
properties: Default::default(), properties: Default::default(),
children: vec![InstanceSnapshot { children: vec![InstanceSnapshot {
snapshot_id: None, snapshot_id: Ref::none(),
metadata: Default::default(), metadata: Default::default(),
name: Cow::Borrowed("New"), name: Cow::Borrowed("New"),
class_name: Cow::Borrowed("Folder"), class_name: Cow::Borrowed("Folder"),
@ -131,7 +132,7 @@ fn remove_child() {
} }
let snapshot = InstanceSnapshot { let snapshot = InstanceSnapshot {
snapshot_id: None, snapshot_id: Ref::none(),
metadata: Default::default(), metadata: Default::default(),
name: Cow::Borrowed("ROOT"), name: Cow::Borrowed("ROOT"),
class_name: Cow::Borrowed("ROOT"), class_name: Cow::Borrowed("ROOT"),

View file

@ -6,7 +6,7 @@ removed_instances: []
added_instances: added_instances:
- parent_id: id-1 - parent_id: id-1
instance: instance:
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: false ignore_unknown_instances: false
relevant_paths: [] relevant_paths: []
@ -16,3 +16,4 @@ added_instances:
properties: {} properties: {}
children: [] children: []
updated_instances: [] updated_instances: []

View file

@ -2,7 +2,7 @@ use std::{borrow::Cow, collections::HashMap, path::Path, str};
use anyhow::Context; use anyhow::Context;
use memofs::Vfs; use memofs::Vfs;
use rbx_dom_weak::types::Attributes; use rbx_dom_weak::types::{Attributes, Ref};
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
@ -112,7 +112,7 @@ impl JsonModel {
} }
Ok(InstanceSnapshot { Ok(InstanceSnapshot {
snapshot_id: None, snapshot_id: Ref::none(),
metadata: Default::default(), metadata: Default::default(),
name: Cow::Owned(name), name: Cow::Owned(name),
class_name: Cow::Owned(class_name), class_name: Cow::Owned(class_name),

View file

@ -14,6 +14,7 @@ mod meta_file;
mod project; mod project;
mod rbxm; mod rbxm;
mod rbxmx; mod rbxmx;
mod toml;
mod txt; mod txt;
mod util; mod util;
@ -32,6 +33,7 @@ use self::{
project::snapshot_project, project::snapshot_project,
rbxm::snapshot_rbxm, rbxm::snapshot_rbxm,
rbxmx::snapshot_rbxmx, rbxmx::snapshot_rbxmx,
toml::snapshot_toml,
txt::snapshot_txt, txt::snapshot_txt,
util::PathExt, util::PathExt,
}; };
@ -117,6 +119,8 @@ pub fn snapshot_from_vfs(
return Ok(None); return Ok(None);
} else if path.file_name_ends_with(".json") { } else if path.file_name_ends_with(".json") {
return snapshot_json(context, vfs, path); return snapshot_json(context, vfs, path);
} else if path.file_name_ends_with(".toml") {
return snapshot_toml(context, vfs, path);
} else if let Ok(name) = csv_name { } else if let Ok(name) = csv_name {
match name { match name {
// init csv are handled elsewhere and should not turn into // init csv are handled elsewhere and should not turn into

View file

@ -2,7 +2,7 @@ use std::{borrow::Cow, collections::HashMap, path::Path};
use anyhow::{bail, Context}; use anyhow::{bail, Context};
use memofs::Vfs; use memofs::Vfs;
use rbx_dom_weak::types::Attributes; use rbx_dom_weak::types::{Attributes, Ref};
use rbx_reflection::ClassTag; use rbx_reflection::ClassTag;
use crate::{ use crate::{
@ -271,7 +271,7 @@ pub fn snapshot_project_node(
)); ));
Ok(Some(InstanceSnapshot { Ok(Some(InstanceSnapshot {
snapshot_id: None, snapshot_id: Ref::none(),
name, name,
class_name, class_name,
properties, properties,

View file

@ -1,9 +1,8 @@
--- ---
source: src/snapshot_middleware/csv.rs source: src/snapshot_middleware/csv.rs
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: false ignore_unknown_instances: false
instigating_source: instigating_source:

View file

@ -1,9 +1,8 @@
--- ---
source: src/snapshot_middleware/csv.rs source: src/snapshot_middleware/csv.rs
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: true ignore_unknown_instances: true
instigating_source: instigating_source:

View file

@ -1,9 +1,8 @@
--- ---
source: src/snapshot_middleware/dir.rs source: src/snapshot_middleware/dir.rs
assertion_line: 127
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: false ignore_unknown_instances: false
instigating_source: instigating_source:

View file

@ -1,9 +1,8 @@
--- ---
source: src/snapshot_middleware/dir.rs source: src/snapshot_middleware/dir.rs
assertion_line: 148
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: false ignore_unknown_instances: false
instigating_source: instigating_source:
@ -23,7 +22,7 @@ name: foo
class_name: Folder class_name: Folder
properties: {} properties: {}
children: children:
- snapshot_id: ~ - snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: false ignore_unknown_instances: false
instigating_source: instigating_source:

View file

@ -1,9 +1,8 @@
--- ---
source: src/snapshot_middleware/json.rs source: src/snapshot_middleware/json.rs
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: false ignore_unknown_instances: false
instigating_source: instigating_source:

View file

@ -1,9 +1,8 @@
--- ---
source: src/snapshot_middleware/json_model.rs source: src/snapshot_middleware/json_model.rs
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: false ignore_unknown_instances: false
instigating_source: instigating_source:
@ -17,7 +16,7 @@ properties:
Value: Value:
Int64: 5 Int64: 5
children: children:
- snapshot_id: ~ - snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: false ignore_unknown_instances: false
relevant_paths: [] relevant_paths: []

View file

@ -1,9 +1,8 @@
--- ---
source: src/snapshot_middleware/json_model.rs source: src/snapshot_middleware/json_model.rs
assertion_line: 186
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: false ignore_unknown_instances: false
instigating_source: instigating_source:
@ -17,7 +16,7 @@ properties:
Value: Value:
Int64: 5 Int64: 5
children: children:
- snapshot_id: ~ - snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: false ignore_unknown_instances: false
relevant_paths: [] relevant_paths: []

View file

@ -1,9 +1,8 @@
--- ---
source: src/snapshot_middleware/lua.rs source: src/snapshot_middleware/lua.rs
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: false ignore_unknown_instances: false
instigating_source: instigating_source:

View file

@ -1,9 +1,8 @@
--- ---
source: src/snapshot_middleware/lua.rs source: src/snapshot_middleware/lua.rs
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: false ignore_unknown_instances: false
instigating_source: instigating_source:

View file

@ -1,9 +1,8 @@
--- ---
source: src/snapshot_middleware/lua.rs source: src/snapshot_middleware/lua.rs
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: true ignore_unknown_instances: true
instigating_source: instigating_source:

View file

@ -1,9 +1,8 @@
--- ---
source: src/snapshot_middleware/lua.rs source: src/snapshot_middleware/lua.rs
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: false ignore_unknown_instances: false
instigating_source: instigating_source:

View file

@ -1,9 +1,8 @@
--- ---
source: src/snapshot_middleware/lua.rs source: src/snapshot_middleware/lua.rs
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: true ignore_unknown_instances: true
instigating_source: instigating_source:

View file

@ -1,9 +1,8 @@
--- ---
source: src/snapshot_middleware/lua.rs source: src/snapshot_middleware/lua.rs
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: false ignore_unknown_instances: false
instigating_source: instigating_source:

View file

@ -2,7 +2,7 @@
source: src/snapshot_middleware/project.rs source: src/snapshot_middleware/project.rs
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: true ignore_unknown_instances: true
instigating_source: instigating_source:
@ -14,3 +14,4 @@ name: direct-project
class_name: Model class_name: Model
properties: {} properties: {}
children: [] children: []

View file

@ -1,9 +1,8 @@
--- ---
source: src/snapshot_middleware/project.rs source: src/snapshot_middleware/project.rs
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: true ignore_unknown_instances: true
instigating_source: instigating_source:

View file

@ -2,7 +2,7 @@
source: src/snapshot_middleware/project.rs source: src/snapshot_middleware/project.rs
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: true ignore_unknown_instances: true
instigating_source: instigating_source:
@ -14,7 +14,7 @@ name: children
class_name: Folder class_name: Folder
properties: {} properties: {}
children: children:
- snapshot_id: ~ - snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: true ignore_unknown_instances: true
instigating_source: instigating_source:
@ -29,3 +29,4 @@ children:
class_name: Model class_name: Model
properties: {} properties: {}
children: [] children: []

View file

@ -2,7 +2,7 @@
source: src/snapshot_middleware/project.rs source: src/snapshot_middleware/project.rs
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: true ignore_unknown_instances: true
instigating_source: instigating_source:
@ -15,3 +15,4 @@ name: path-project
class_name: Model class_name: Model
properties: {} properties: {}
children: [] children: []

View file

@ -2,7 +2,7 @@
source: src/snapshot_middleware/project.rs source: src/snapshot_middleware/project.rs
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: true ignore_unknown_instances: true
instigating_source: instigating_source:
@ -15,7 +15,7 @@ name: path-child-project
class_name: Folder class_name: Folder
properties: {} properties: {}
children: children:
- snapshot_id: ~ - snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: true ignore_unknown_instances: true
instigating_source: instigating_source:
@ -30,3 +30,4 @@ children:
class_name: Model class_name: Model
properties: {} properties: {}
children: [] children: []

View file

@ -1,9 +1,8 @@
--- ---
source: src/snapshot_middleware/project.rs source: src/snapshot_middleware/project.rs
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: false ignore_unknown_instances: false
instigating_source: instigating_source:

View file

@ -1,9 +1,8 @@
--- ---
source: src/snapshot_middleware/project.rs source: src/snapshot_middleware/project.rs
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: true ignore_unknown_instances: true
instigating_source: instigating_source:

View file

@ -1,9 +1,8 @@
--- ---
source: src/snapshot_middleware/project.rs source: src/snapshot_middleware/project.rs
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: true ignore_unknown_instances: true
instigating_source: instigating_source:

View file

@ -0,0 +1,20 @@
---
source: src/snapshot_middleware/toml.rs
expression: instance_snapshot
---
snapshot_id: "00000000000000000000000000000000"
metadata:
ignore_unknown_instances: false
instigating_source:
Path: /foo.toml
relevant_paths:
- /foo.toml
- /foo.meta.json
context: {}
name: foo
class_name: ModuleScript
properties:
Source:
String: "return {\n\t[\"1invalidident\"] = \"nice\",\n\tarray = {1, 2, 3},\n\tdates = {\n\t\tlocaldate = \"1979-05-27\",\n\t\tlocaldatetime = \"1979-05-27T07:32:00\",\n\t\tlocaltime = \"00:32:00.999999\",\n\t\toffset1 = \"1979-05-27T00:32:00.999999-07:00\",\n\t\toffset2 = \"1979-05-27T07:32:00Z\",\n\t},\n\t[\"false\"] = false,\n\tfloat = 1234.5452,\n\tint = 1234,\n\tobject = {\n\t\thello = \"world\",\n\t},\n\t[\"true\"] = true,\n}"
children: []

View file

@ -1,9 +1,8 @@
--- ---
source: src/snapshot_middleware/txt.rs source: src/snapshot_middleware/txt.rs
expression: instance_snapshot expression: instance_snapshot
--- ---
snapshot_id: ~ snapshot_id: "00000000000000000000000000000000"
metadata: metadata:
ignore_unknown_instances: false ignore_unknown_instances: false
instigating_source: instigating_source:

View file

@ -0,0 +1,123 @@
use std::path::Path;
use anyhow::Context;
use maplit::hashmap;
use memofs::{IoResultExt, Vfs};
use crate::{
lua_ast::{Expression, Statement},
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot},
};
use super::{meta_file::AdjacentMetadata, util::PathExt};
pub fn snapshot_toml(
context: &InstanceContext,
vfs: &Vfs,
path: &Path,
) -> anyhow::Result<Option<InstanceSnapshot>> {
let name = path.file_name_trim_end(".toml")?;
let contents = vfs.read(path)?;
let value: toml::Value = toml::from_slice(&contents)
.with_context(|| format!("File contains malformed TOML: {}", path.display()))?;
let as_lua = toml_to_lua(value).to_string();
let properties = hashmap! {
"Source".to_owned() => as_lua.into(),
};
let meta_path = path.with_file_name(format!("{}.meta.json", name));
let mut snapshot = InstanceSnapshot::new()
.name(name)
.class_name("ModuleScript")
.properties(properties)
.metadata(
InstanceMetadata::new()
.instigating_source(path)
.relevant_paths(vec![path.to_path_buf(), meta_path.clone()])
.context(context),
);
if let Some(meta_contents) = vfs.read(&meta_path).with_not_found()? {
let mut metadata = AdjacentMetadata::from_slice(&meta_contents, meta_path)?;
metadata.apply_all(&mut snapshot)?;
}
Ok(Some(snapshot))
}
fn toml_to_lua(value: toml::Value) -> Statement {
Statement::Return(toml_to_lua_value(value))
}
fn toml_to_lua_value(value: toml::Value) -> Expression {
use toml::Value;
match value {
Value::Datetime(value) => Expression::String(value.to_string()),
Value::Boolean(value) => Expression::Bool(value),
Value::Float(value) => Expression::Number(value),
Value::Integer(value) => Expression::Number(value as f64),
Value::String(value) => Expression::String(value),
Value::Array(values) => {
Expression::Array(values.into_iter().map(toml_to_lua_value).collect())
}
Value::Table(values) => Expression::table(
values
.into_iter()
.map(|(key, value)| (key.into(), toml_to_lua_value(value)))
.collect(),
),
}
}
#[cfg(test)]
mod test {
use super::*;
use memofs::{InMemoryFs, VfsSnapshot};
#[test]
fn instance_from_vfs() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/foo.toml",
VfsSnapshot::file(
r#"
array = [1, 2, 3]
true = true
false = false
int = 1234
float = 1234.5452
"1invalidident" = "nice"
[object]
hello = "world"
[dates]
offset1 = 1979-05-27T00:32:00.999999-07:00
offset2 = 1979-05-27 07:32:00Z
localdatetime = 1979-05-27T07:32:00
localdate = 1979-05-27
localtime = 00:32:00.999999
"#,
),
)
.unwrap();
let mut vfs = Vfs::new(imfs.clone());
let instance_snapshot = snapshot_toml(
&InstanceContext::default(),
&mut vfs,
Path::new("/foo.toml"),
)
.unwrap()
.unwrap();
insta::assert_yaml_snapshot!(instance_snapshot);
}
}