diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 00000000..699152cc --- /dev/null +++ b/.github/workflows/changelog.yml @@ -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 }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80346f50..b0e9fefe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,3 +67,4 @@ jobs: - name: Clippy run: cargo clippy + diff --git a/CHANGELOG.md b/CHANGELOG.md index b60a9bdb..a8e82bfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ * Skip confirming patches that contain only a datamodel name change. ([#688]) * Added sync reminder notification. ([#689]) * 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 [#668]: https://github.com/rojo-rbx/rojo/pull/668 @@ -16,6 +26,16 @@ [#688]: https://github.com/rojo-rbx/rojo/pull/688 [#689]: https://github.com/rojo-rbx/rojo/pull/689 [#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 * Added `$attributes` to project format. ([#574]) @@ -581,4 +601,4 @@ This is a general maintenance release for the Rojo 0.5.x release series. * More robust syncing with a new reconciler ## [0.1.0](https://github.com/rojo-rbx/rojo/releases/tag/v0.1.0) (November 29, 2017) -* Initial release, functionally very similar to [rbxfs](https://github.com/LPGhatguy/rbxfs) \ No newline at end of file +* Initial release, functionally very similar to [rbxfs](https://github.com/LPGhatguy/rbxfs) diff --git a/Cargo.lock b/Cargo.lock index 12e9f860..cc544cf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1308,9 +1308,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.51" +version = "0.10.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97ea2d98598bf9ada7ea6ee8a30fb74f9156b63bbe495d64ec2b87c269d2dda3" +checksum = "345df152bc43501c5eb9e4654ff05f794effb78d4efe3d53abc158baddc0703d" dependencies = [ "bitflags", "cfg-if 1.0.0", @@ -1349,9 +1349,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.86" +version = "0.9.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "992bac49bdbab4423199c654a5515bd2a6c6a23bf03f2dd3bdb7e5ae6259bc69" +checksum = "374533b0e45f3a7ced10fcaeccca020e66656bc03dac384f852e4e5a7a8104a6" dependencies = [ "cc", "libc", @@ -1922,6 +1922,7 @@ dependencies = [ "termcolor", "thiserror", "tokio", + "toml", "tracy-client 0.13.2", "uuid", "walkdir", diff --git a/Cargo.toml b/Cargo.toml index bbbbcbf9..124d5c8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,7 @@ ritz = "0.1.0" roblox_install = "1.0.0" serde = { version = "1.0.130", features = ["derive", "rc"] } serde_json = "1.0.68" +toml = "0.5.9" termcolor = "1.1.2" thiserror = "1.0.30" tokio = { version = "1.12.0", features = ["rt", "rt-multi-thread"] } diff --git a/plugin/src/ApiContext.lua b/plugin/src/ApiContext.lua index 98890d4f..9a683dd5 100644 --- a/plugin/src/ApiContext.lua +++ b/plugin/src/ApiContext.lua @@ -11,13 +11,6 @@ local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse) local validateApiRead = Types.ifEnabled(Types.ApiReadResponse) local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse) ---[[ - Returns a promise that will never resolve nor reject. -]] -local function hangingPromise() - return Promise.new(function() end) -end - local function rejectFailedRequests(response) if response.code >= 400 then local message = string.format("HTTP %s:\n%s", tostring(response.code), response.body) @@ -212,12 +205,8 @@ function ApiContext:retrieveMessages() local function sendRequest() local request = Http.get(url) :catch(function(err) - if err.type == Http.Error.Kind.Timeout then - if self.__connected then - return sendRequest() - else - return hangingPromise() - end + if err.type == Http.Error.Kind.Timeout and self.__connected then + return sendRequest() end return Promise.reject(err) diff --git a/plugin/src/App/Components/Checkbox.lua b/plugin/src/App/Components/Checkbox.lua index 21ec56b3..aa351b1f 100644 --- a/plugin/src/App/Components/Checkbox.lua +++ b/plugin/src/App/Components/Checkbox.lua @@ -51,10 +51,15 @@ function Checkbox:render() ZIndex = self.props.zIndex, BackgroundTransparency = 1, - [Roact.Event.Activated] = self.props.onClick, + [Roact.Event.Activated] = function() + if self.props.locked then return end + self.props.onClick() + end, }, { StateTip = e(Tooltip.Trigger, { - text = if self.props.active then "Enabled" else "Disabled", + text = + (if self.props.locked then "[LOCKED] " else "") + .. (if self.props.active then "Enabled" else "Disabled"), }), Active = e(SlicedImage, { @@ -65,7 +70,7 @@ function Checkbox:render() zIndex = 2, }, { Icon = e("ImageLabel", { - Image = Assets.Images.Checkbox.Active, + Image = if self.props.locked then Assets.Images.Checkbox.Locked else Assets.Images.Checkbox.Active, ImageColor3 = theme.Active.IconColor, ImageTransparency = activeTransparency, @@ -84,7 +89,7 @@ function Checkbox:render() size = UDim2.new(1, 0, 1, 0), }, { Icon = e("ImageLabel", { - Image = Assets.Images.Checkbox.Inactive, + Image = if self.props.locked then Assets.Images.Checkbox.Locked else Assets.Images.Checkbox.Inactive, ImageColor3 = theme.Inactive.IconColor, ImageTransparency = self.props.transparency, diff --git a/plugin/src/App/Components/Dropdown.lua b/plugin/src/App/Components/Dropdown.lua index ef9a4211..21a60908 100644 --- a/plugin/src/App/Components/Dropdown.lua +++ b/plugin/src/App/Components/Dropdown.lua @@ -29,7 +29,13 @@ function Dropdown:init() }) end -function Dropdown:didUpdate() +function Dropdown:didUpdate(prevProps) + if self.props.locked and not prevProps.locked then + self:setState({ + open = false, + }) + end + self.openMotor:setGoal( Flipper.Spring.new(self.state.open and 1 or 0, { frequency = 6, @@ -68,6 +74,7 @@ function Dropdown:render() Font = Enum.Font.GothamMedium, [Roact.Event.Activated] = function() + if self.props.locked then return end self:setState({ open = false, }) @@ -89,6 +96,7 @@ function Dropdown:render() BackgroundTransparency = 1, [Roact.Event.Activated] = function() + if self.props.locked then return end self:setState({ open = not self.state.open, }) @@ -101,7 +109,7 @@ function Dropdown:render() size = UDim2.new(1, 0, 1, 0), }, { DropArrow = e("ImageLabel", { - Image = Assets.Images.Dropdown.Arrow, + Image = if self.props.locked then Assets.Images.Dropdown.Locked else Assets.Images.Dropdown.Arrow, ImageColor3 = self.openBinding:map(function(a) return theme.Closed.IconColor:Lerp(theme.Open.IconColor, a) end), diff --git a/plugin/src/App/Components/PatchVisualizer/ChangeList.lua b/plugin/src/App/Components/PatchVisualizer/ChangeList.lua index beb0d327..ed6da37a 100644 --- a/plugin/src/App/Components/PatchVisualizer/ChangeList.lua +++ b/plugin/src/App/Components/PatchVisualizer/ChangeList.lua @@ -8,6 +8,8 @@ local Theme = require(Plugin.App.Theme) local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame) local DisplayValue = require(script.Parent.DisplayValue) +local EMPTY_TABLE = {} + local e = Roact.createElement local ChangeList = Roact.Component:extend("ChangeList") @@ -26,8 +28,6 @@ function ChangeList:render() return 0.93 + (0.07 * t) end) - local columnVisibility = props.columnVisibility - local rows = {} local pad = { PaddingLeft = UDim.new(0, 5), @@ -48,7 +48,6 @@ function ChangeList:render() VerticalAlignment = Enum.VerticalAlignment.Center, }), A = e("TextLabel", { - Visible = columnVisibility[1], Text = tostring(changes[1][1]), BackgroundTransparency = 1, Font = Enum.Font.GothamBold, @@ -61,7 +60,6 @@ function ChangeList:render() LayoutOrder = 1, }), B = e("TextLabel", { - Visible = columnVisibility[2], Text = tostring(changes[1][2]), BackgroundTransparency = 1, Font = Enum.Font.GothamBold, @@ -74,7 +72,6 @@ function ChangeList:render() LayoutOrder = 2, }), C = e("TextLabel", { - Visible = columnVisibility[3], Text = tostring(changes[1][3]), BackgroundTransparency = 1, Font = Enum.Font.GothamBold, @@ -93,6 +90,9 @@ function ChangeList:render() continue -- Skip headers, already handled above end + local metadata = values[4] or EMPTY_TABLE + local isWarning = metadata.isWarning + rows[row] = e("Frame", { Size = UDim2.new(1, 0, 0, 30), BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1, @@ -108,12 +108,11 @@ function ChangeList:render() VerticalAlignment = Enum.VerticalAlignment.Center, }), A = e("TextLabel", { - Visible = columnVisibility[1], - Text = tostring(values[1]), + Text = (if isWarning then "⚠ " else "") .. tostring(values[1]), BackgroundTransparency = 1, Font = Enum.Font.GothamMedium, TextSize = 14, - TextColor3 = theme.Settings.Setting.DescriptionColor, + TextColor3 = if isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor, TextXAlignment = Enum.TextXAlignment.Left, TextTransparency = props.transparency, TextTruncate = Enum.TextTruncate.AtEnd, @@ -123,7 +122,6 @@ function ChangeList:render() B = e( "Frame", { - Visible = columnVisibility[2], BackgroundTransparency = 1, Size = UDim2.new(0.35, 0, 1, 0), LayoutOrder = 2, @@ -131,12 +129,12 @@ function ChangeList:render() e(DisplayValue, { value = values[2], transparency = props.transparency, + textColor = if isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor, }) ), C = e( "Frame", { - Visible = columnVisibility[3], BackgroundTransparency = 1, Size = UDim2.new(0.35, 0, 1, 0), LayoutOrder = 3, @@ -144,6 +142,7 @@ function ChangeList:render() e(DisplayValue, { value = values[3], transparency = props.transparency, + textColor = if isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor, }) ), }) diff --git a/plugin/src/App/Components/PatchVisualizer/DisplayValue.lua b/plugin/src/App/Components/PatchVisualizer/DisplayValue.lua index 891ec9f2..3cf1a11a 100644 --- a/plugin/src/App/Components/PatchVisualizer/DisplayValue.lua +++ b/plugin/src/App/Components/PatchVisualizer/DisplayValue.lua @@ -34,7 +34,7 @@ local function DisplayValue(props) BackgroundTransparency = 1, Font = Enum.Font.GothamMedium, TextSize = 14, - TextColor3 = theme.Settings.Setting.DescriptionColor, + TextColor3 = props.textColor, TextXAlignment = Enum.TextXAlignment.Left, TextTransparency = props.transparency, TextTruncate = Enum.TextTruncate.AtEnd, @@ -78,7 +78,7 @@ local function DisplayValue(props) BackgroundTransparency = 1, Font = Enum.Font.GothamMedium, TextSize = 14, - TextColor3 = theme.Settings.Setting.DescriptionColor, + TextColor3 = props.textColor, TextXAlignment = Enum.TextXAlignment.Left, TextTransparency = props.transparency, TextTruncate = Enum.TextTruncate.AtEnd, @@ -95,7 +95,7 @@ local function DisplayValue(props) BackgroundTransparency = 1, Font = Enum.Font.GothamMedium, TextSize = 14, - TextColor3 = theme.Settings.Setting.DescriptionColor, + TextColor3 = props.textColor, TextXAlignment = Enum.TextXAlignment.Left, TextTransparency = props.transparency, TextTruncate = Enum.TextTruncate.AtEnd, diff --git a/plugin/src/App/Components/PatchVisualizer/DomLabel.lua b/plugin/src/App/Components/PatchVisualizer/DomLabel.lua index 9f967c32..facf70cd 100644 --- a/plugin/src/App/Components/PatchVisualizer/DomLabel.lua +++ b/plugin/src/App/Components/PatchVisualizer/DomLabel.lua @@ -1,3 +1,4 @@ +local SelectionService = game:GetService("Selection") local StudioService = game:GetService("StudioService") local Rojo = script:FindFirstAncestor("Rojo") @@ -14,6 +15,7 @@ local bindingUtil = require(Plugin.App.bindingUtil) local e = Roact.createElement local ChangeList = require(script.Parent.ChangeList) +local Tooltip = require(script.Parent.Parent.Tooltip) local Expansion = Roact.Component:extend("Expansion") @@ -32,7 +34,6 @@ function Expansion:render() ChangeList = e(ChangeList, { changes = props.changeList, transparency = props.transparency, - columnVisibility = props.columnVisibility, }), }) end @@ -69,6 +70,22 @@ function DomLabel:init() end) end +function DomLabel:didUpdate(prevProps) + if + prevProps.instance ~= self.props.instance + or prevProps.patchType ~= self.props.patchType + or prevProps.name ~= self.props.name + or prevProps.changeList ~= self.props.changeList + then + -- Close the expansion when the domlabel is changed to a different thing + self.expanded = false + self.motor:setGoal(Flipper.Spring.new(30, { + frequency = 5, + dampingRatio = 1, + })) + end +end + function DomLabel:render() local props = self.props @@ -106,28 +123,53 @@ function DomLabel:render() PaddingLeft = UDim.new(0, 10), PaddingRight = UDim.new(0, 10), }), - ExpandButton = if props.changeList - then e("TextButton", { - BackgroundTransparency = 1, - Text = "", - Size = UDim2.new(1, 0, 1, 0), - [Roact.Event.Activated] = function() - self.expanded = not self.expanded - local goalHeight = 30 + (if self.expanded then math.clamp(#self.props.changeList * 30, 30, 30 * 6) else 0) - self.motor:setGoal(Flipper.Spring.new(goalHeight, { - frequency = 5, - dampingRatio = 1, - })) - end, - }) - else nil, + Button = e("TextButton", { + BackgroundTransparency = 1, + Text = "", + Size = UDim2.new(1, 0, 1, 0), + [Roact.Event.Activated] = function(_rbx: Instance, _input: InputObject, clickCount: number) + if clickCount == 1 then + -- Double click opens the instance in explorer + self.lastDoubleClickTime = os.clock() + if props.instance then + SelectionService:Set({ props.instance }) + end + elseif clickCount == 0 then + -- Single click expands the changes + task.wait(0.25) + if os.clock() - (self.lastDoubleClickTime or 0) <= 0.25 then + -- This is a double click, so don't expand + return + end + + if props.changeList then + self.expanded = not self.expanded + local goalHeight = 30 + + (if self.expanded then math.clamp(#props.changeList * 30, 30, 30 * 6) else 0) + self.motor:setGoal(Flipper.Spring.new(goalHeight, { + frequency = 5, + dampingRatio = 1, + })) + end + end + end, + }, { + StateTip = if (props.instance or props.changeList) + then e(Tooltip.Trigger, { + text = (if props.changeList + then "Click to " .. (if self.expanded then "hide" else "view") .. " changes" + else "") .. (if props.instance + then (if props.changeList then " & d" else "D") .. "ouble click to open in Explorer" + else ""), + }) + else nil, + }), Expansion = if props.changeList then e(Expansion, { rendered = self.state.renderExpansion, indent = indent, transparency = props.transparency, changeList = props.changeList, - columnVisibility = props.columnVisibility, }) else nil, DiffIcon = if props.patchType @@ -152,7 +194,7 @@ function DomLabel:render() AnchorPoint = Vector2.new(0, 0.5), }), InstanceName = e("TextLabel", { - Text = props.name .. (props.hint and string.format( + Text = (if props.isWarning then "⚠ " else "") .. props.name .. (props.hint and string.format( ' %s', theme.AddressEntry.PlaceholderColor:ToHex(), props.hint @@ -161,7 +203,7 @@ function DomLabel:render() BackgroundTransparency = 1, Font = Enum.Font.GothamMedium, TextSize = 14, - TextColor3 = theme.Settings.Setting.DescriptionColor, + TextColor3 = if props.isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor, TextXAlignment = Enum.TextXAlignment.Left, TextTransparency = props.transparency, TextTruncate = Enum.TextTruncate.AtEnd, diff --git a/plugin/src/App/Components/PatchVisualizer/init.lua b/plugin/src/App/Components/PatchVisualizer/init.lua index 95673dfe..73af4502 100644 --- a/plugin/src/App/Components/PatchVisualizer/init.lua +++ b/plugin/src/App/Components/PatchVisualizer/init.lua @@ -1,143 +1,18 @@ -local HttpService = game:GetService("HttpService") - local Rojo = script:FindFirstAncestor("Rojo") local Plugin = Rojo.Plugin local Packages = Rojo.Packages local Roact = require(Packages.Roact) -local Log = require(Packages.Log) +local PatchTree = require(Plugin.PatchTree) local PatchSet = require(Plugin.PatchSet) -local decodeValue = require(Plugin.Reconciler.decodeValue) -local getProperty = require(Plugin.Reconciler.getProperty) +local Theme = require(Plugin.App.Theme) local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local VirtualScroller = require(Plugin.App.Components.VirtualScroller) local e = Roact.createElement -local function alphabeticalNext(t, state) - -- Equivalent of the next function, but returns the keys in the alphabetic - -- order of node names. We use a temporary ordered key table that is stored in the - -- table being iterated. - - local key = nil - if state == nil then - -- First iteration, generate the index - local orderedIndex, i = table.create(5), 0 - for k in t do - i += 1 - orderedIndex[i] = k - end - table.sort(orderedIndex, function(a, b) - local nodeA, nodeB = t[a], t[b] - return (nodeA.name or "") < (nodeB.name or "") - end) - - t.__orderedIndex = orderedIndex - key = orderedIndex[1] - else - -- Fetch the next value - for i, orderedState in t.__orderedIndex do - if orderedState == state then - key = t.__orderedIndex[i + 1] - break - end - end - end - - if key then - return key, t[key] - end - - -- No more value to return, cleanup - t.__orderedIndex = nil - return -end - -local function alphabeticalPairs(t) - -- Equivalent of the pairs() iterator, but sorted - return alphabeticalNext, t, nil -end - -local function Tree() - local tree = { - idToNode = {}, - ROOT = { - className = "DataModel", - name = "ROOT", - children = {}, - }, - } - -- Add ROOT to idToNode or it won't be found by getNode since that searches *within* ROOT - tree.idToNode["ROOT"] = tree.ROOT - - function tree:getNode(id, target) - if self.idToNode[id] then - return self.idToNode[id] - end - - for nodeId, node in target or tree.ROOT.children do - if nodeId == id then - self.idToNode[id] = node - return node - end - local descendant = self:getNode(id, node.children) - if descendant then - return descendant - end - end - - return nil - end - - function tree:addNode(parent, props) - parent = parent or "ROOT" - - local node = self:getNode(props.id) - if node then - for k, v in props do - node[k] = v - end - return node - end - - node = table.clone(props) - node.children = {} - - local parentNode = self:getNode(parent) - if not parentNode then - Log.warn("Failed to create node since parent doesnt exist: {}, {}", parent, props) - return - end - - parentNode.children[node.id] = node - self.idToNode[node.id] = node - - return node - end - - function tree:buildAncestryNodes(ancestry, patch, instanceMap) - -- Build nodes for ancestry by going up the tree - local previousId = "ROOT" - for _, ancestorId in ancestry do - local value = instanceMap.fromIds[ancestorId] or patch.added[ancestorId] - if not value then - Log.warn("Failed to find ancestor object for " .. ancestorId) - continue - end - self:addNode(previousId, { - id = ancestorId, - className = value.ClassName, - name = value.Name, - }) - previousId = ancestorId - end - end - - return tree -end - local DomLabel = require(script.DomLabel) local PatchVisualizer = Roact.Component:extend("PatchVisualizer") @@ -153,250 +28,90 @@ function PatchVisualizer:willUnmount() end function PatchVisualizer:shouldUpdate(nextProps) + if self.props.patchTree ~= nextProps.patchTree then + return true + end + local currentPatch, nextPatch = self.props.patch, nextProps.patch - - return not PatchSet.isEqual(currentPatch, nextPatch) -end - -function PatchVisualizer:buildTree(patch, instanceMap) - local tree = Tree() - - for _, change in patch.updated do - local instance = instanceMap.fromIds[change.id] - if not instance then - continue - end - - -- Gather ancestors from existing DOM - local ancestry = {} - local parentObject = instance.Parent - local parentId = instanceMap.fromInstances[parentObject] - while parentObject do - table.insert(ancestry, 1, parentId) - parentObject = parentObject.Parent - parentId = instanceMap.fromInstances[parentObject] - end - - tree:buildAncestryNodes(ancestry, patch, instanceMap) - - -- Gather detail text - local changeList, hint = nil, nil - if next(change.changedProperties) or change.changedName then - changeList = {} - - local hintBuffer, i = {}, 0 - local function addProp(prop: string, current: any?, incoming: any?) - i += 1 - hintBuffer[i] = prop - changeList[i] = { prop, current, incoming } - end - - -- Gather the changes - - if change.changedName then - addProp("Name", instance.Name, change.changedName) - end - - for prop, incoming in change.changedProperties do - local incomingSuccess, incomingValue = decodeValue(incoming, instanceMap) - local currentSuccess, currentValue = getProperty(instance, prop) - - addProp( - prop, - if currentSuccess then currentValue else "[Error]", - if incomingSuccess then incomingValue else next(incoming) - ) - end - - -- Finalize detail values - - -- Trim hint to top 3 - table.sort(hintBuffer) - if #hintBuffer > 3 then - hintBuffer = { - hintBuffer[1], - hintBuffer[2], - hintBuffer[3], - i - 3 .. " more", - } - end - hint = table.concat(hintBuffer, ", ") - - -- Sort changes and add header - table.sort(changeList, function(a, b) - return a[1] < b[1] - end) - table.insert(changeList, 1, { "Property", "Current", "Incoming" }) - end - - -- Add this node to tree - tree:addNode(instanceMap.fromInstances[instance.Parent], { - id = change.id, - patchType = "Edit", - className = instance.ClassName, - name = instance.Name, - hint = hint, - changeList = changeList, - }) + if currentPatch ~= nil or nextPatch ~= nil then + return not PatchSet.isEqual(currentPatch, nextPatch) end - for _, instance in patch.removed do - -- Gather ancestors from existing DOM - -- (note that they may have no ID if they're being removed as unknown) - local ancestry = {} - local parentObject = instance.Parent - local parentId = instanceMap.fromInstances[parentObject] or HttpService:GenerateGUID(false) - while parentObject do - instanceMap:insert(parentId, parentObject) - table.insert(ancestry, 1, parentId) - parentObject = parentObject.Parent - parentId = instanceMap.fromInstances[parentObject] or HttpService:GenerateGUID(false) - end - - tree:buildAncestryNodes(ancestry, patch, instanceMap) - - -- Add this node to tree - local nodeId = instanceMap.fromInstances[instance] or HttpService:GenerateGUID(false) - instanceMap:insert(nodeId, instance) - tree:addNode(instanceMap.fromInstances[instance.Parent], { - id = nodeId, - patchType = "Remove", - className = instance.ClassName, - name = instance.Name, - }) - end - - for _, change in patch.added do - -- Gather ancestors from existing DOM or future additions - local ancestry = {} - local parentId = change.Parent - local parentData = patch.added[parentId] - local parentObject = instanceMap.fromIds[parentId] - while parentId do - table.insert(ancestry, 1, parentId) - parentId = nil - - if parentData then - parentId = parentData.Parent - parentData = patch.added[parentId] - parentObject = instanceMap.fromIds[parentId] - elseif parentObject then - parentObject = parentObject.Parent - parentId = instanceMap.fromInstances[parentObject] - parentData = patch.added[parentId] - end - end - - tree:buildAncestryNodes(ancestry, patch, instanceMap) - - -- Gather detail text - local changeList, hint = nil, nil - if next(change.Properties) then - changeList = {} - - local hintBuffer, i = {}, 0 - for prop, incoming in change.Properties do - i += 1 - hintBuffer[i] = prop - - local success, incomingValue = decodeValue(incoming, instanceMap) - if success then - table.insert(changeList, { prop, "N/A", incomingValue }) - else - table.insert(changeList, { prop, "N/A", next(incoming) }) - end - end - - -- Finalize detail values - - -- Trim hint to top 3 - table.sort(hintBuffer) - if #hintBuffer > 3 then - hintBuffer = { - hintBuffer[1], - hintBuffer[2], - hintBuffer[3], - i - 3 .. " more", - } - end - hint = table.concat(hintBuffer, ", ") - - -- Sort changes and add header - table.sort(changeList, function(a, b) - return a[1] < b[1] - end) - table.insert(changeList, 1, { "Property", "Current", "Incoming" }) - end - - -- Add this node to tree - tree:addNode(change.Parent, { - id = change.Id, - patchType = "Add", - className = change.ClassName, - name = change.Name, - hint = hint, - changeList = changeList, - }) - end - - return tree + return false end function PatchVisualizer:render() - local patch = self.props.patch - local instanceMap = self.props.instanceMap - - local tree = self:buildTree(patch, instanceMap) + local patchTree = self.props.patchTree + if patchTree == nil and self.props.patch ~= nil then + patchTree = PatchTree.build(self.props.patch, self.props.instanceMap, self.props.changeListHeaders or { "Property", "Current", "Incoming" }) + if self.props.unappliedPatch then + patchTree = PatchTree.updateMetadata(patchTree, self.props.patch, self.props.instanceMap, self.props.unappliedPatch) + end + end -- Recusively draw tree local scrollElements, elementHeights = {}, {} - local function drawNode(node, depth) - local elementHeight, setElementHeight = Roact.createBinding(30) - table.insert(elementHeights, elementHeight) - table.insert( - scrollElements, - e(DomLabel, { - columnVisibility = self.props.columnVisibility, - updateEvent = self.updateEvent, - elementHeight = elementHeight, - setElementHeight = setElementHeight, - patchType = node.patchType, - className = node.className, - name = node.name, - hint = node.hint, - changeList = node.changeList, - depth = depth, - transparency = self.props.transparency, - }) - ) - for _, childNode in alphabeticalPairs(node.children) do - drawNode(childNode, depth + 1) + if patchTree then + local function drawNode(node, depth) + local elementHeight, setElementHeight = Roact.createBinding(30) + table.insert(elementHeights, elementHeight) + table.insert( + scrollElements, + e(DomLabel, { + updateEvent = self.updateEvent, + elementHeight = elementHeight, + setElementHeight = setElementHeight, + patchType = node.patchType, + className = node.className, + isWarning = node.isWarning, + instance = node.instance, + name = node.name, + hint = node.hint, + changeList = node.changeList, + depth = depth, + transparency = self.props.transparency, + }) + ) end - end - for _, node in alphabeticalPairs(tree.ROOT.children) do - drawNode(node, 0) + + patchTree:forEach(function(node, depth) + drawNode(node, depth) + end) end - return e(BorderedContainer, { - transparency = self.props.transparency, - size = self.props.size, - position = self.props.position, - layoutOrder = self.props.layoutOrder, - }, { - VirtualScroller = e(VirtualScroller, { - size = UDim2.new(1, 0, 1, 0), + return Theme.with(function(theme) + return e(BorderedContainer, { transparency = self.props.transparency, - count = #scrollElements, - updateEvent = self.updateEvent.Event, - render = function(i) - return scrollElements[i] - end, - getHeightBinding = function(i) - return elementHeights[i] - end, - }), - }) + size = self.props.size, + position = self.props.position, + layoutOrder = self.props.layoutOrder, + }, { + CleanMerge = e("TextLabel", { + Visible = #scrollElements == 0, + Text = "No changes to sync, project is up to date.", + Font = Enum.Font.GothamMedium, + TextSize = 15, + TextColor3 = theme.Settings.Setting.DescriptionColor, + TextWrapped = true, + Size = UDim2.new(1, 0, 1, 0), + BackgroundTransparency = 1, + }), + + VirtualScroller = e(VirtualScroller, { + size = UDim2.new(1, 0, 1, 0), + transparency = self.props.transparency, + count = #scrollElements, + updateEvent = self.updateEvent.Event, + render = function(i) + return scrollElements[i] + end, + getHeightBinding = function(i) + return elementHeights[i] + end, + }), + }) + end) end return PatchVisualizer diff --git a/plugin/src/App/Components/Tooltip.lua b/plugin/src/App/Components/Tooltip.lua index bc3443d5..9c1fb151 100644 --- a/plugin/src/App/Components/Tooltip.lua +++ b/plugin/src/App/Components/Tooltip.lua @@ -165,46 +165,94 @@ function Trigger:init() self.id = HttpService:GenerateGUID(false) self.ref = Roact.createRef() self.mousePos = Vector2.zero + self.showingPopup = false self.destroy = function() self.props.context.removeTip(self.id) + self.showingPopup = false end end function Trigger:willUnmount() if self.showDelayThread then - task.cancel(self.showDelayThread) + pcall(task.cancel, self.showDelayThread) end if self.destroy then self.destroy() end end +function Trigger:didUpdate(prevProps) + if prevProps.text ~= self.props.text then + -- Any existing popup is now invalid + self.props.context.removeTip(self.id) + self.showingPopup = false + + -- Let the new text propagate + self:managePopup() + end +end + +function Trigger:isHovering() + local rbx = self.ref.current + if rbx then + local pos = rbx.AbsolutePosition + local size = rbx.AbsoluteSize + local mousePos = self.mousePos + + return + mousePos.X >= pos.X and mousePos.X <= pos.X + size.X + and mousePos.Y >= pos.Y and mousePos.Y <= pos.Y + size.Y + end + return false +end + +function Trigger:managePopup() + if self:isHovering() then + if self.showingPopup or self.showDelayThread then + -- Don't duplicate popups + return + end + + self.showDelayThread = task.delay(DELAY, function() + self.props.context.addTip(self.id, { + Text = self.props.text, + Position = self.mousePos, + Trigger = self.ref, + }) + self.showDelayThread = nil + self.showingPopup = true + end) + else + if self.showDelayThread then + pcall(task.cancel, self.showDelayThread) + self.showDelayThread = nil + end + self.props.context.removeTip(self.id) + self.showingPopup = false + end +end + function Trigger:render() + local function recalculate(rbx) + local widget = rbx:FindFirstAncestorOfClass("DockWidgetPluginGui") + if not widget then return end + self.mousePos = widget:GetRelativeMousePosition() + + self:managePopup() + end + return e("Frame", { Size = UDim2.fromScale(1, 1), BackgroundTransparency = 1, ZIndex = self.props.zIndex or 100, [Roact.Ref] = self.ref, - [Roact.Event.MouseMoved] = function(_rbx, x, y) - self.mousePos = Vector2.new(x, y) - end, - [Roact.Event.MouseEnter] = function() - self.showDelayThread = task.delay(DELAY, function() - self.props.context.addTip(self.id, { - Text = self.props.text, - Position = self.mousePos, - Trigger = self.ref, - }) - end) - end, - [Roact.Event.MouseLeave] = function() - if self.showDelayThread then - task.cancel(self.showDelayThread) - end - self.props.context.removeTip(self.id) - end, + [Roact.Change.AbsolutePosition] = recalculate, + [Roact.Change.AbsoluteSize] = recalculate, + [Roact.Event.MouseMoved] = recalculate, + [Roact.Event.MouseLeave] = recalculate, + [Roact.Event.MouseEnter] = recalculate, }) end diff --git a/plugin/src/App/StatusPages/Confirming.lua b/plugin/src/App/StatusPages/Confirming.lua index 3359eaf6..a5592347 100644 --- a/plugin/src/App/StatusPages/Confirming.lua +++ b/plugin/src/App/StatusPages/Confirming.lua @@ -52,7 +52,7 @@ function ConfirmingPage:render() transparency = self.props.transparency, layoutOrder = 3, - columnVisibility = {true, true, true}, + changeListHeaders = { "Property", "Current", "Incoming" }, patch = self.props.confirmData.patch, instanceMap = self.props.confirmData.instanceMap, }), diff --git a/plugin/src/App/StatusPages/Connected.lua b/plugin/src/App/StatusPages/Connected.lua index af43e6a2..00f6822b 100644 --- a/plugin/src/App/StatusPages/Connected.lua +++ b/plugin/src/App/StatusPages/Connected.lua @@ -12,6 +12,7 @@ local PatchSet = require(Plugin.PatchSet) local Header = require(Plugin.App.Components.Header) local IconButton = require(Plugin.App.Components.IconButton) +local TextButton = require(Plugin.App.Components.TextButton) local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local Tooltip = require(Plugin.App.Components.Tooltip) local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer) @@ -55,7 +56,7 @@ function ChangesDrawer:render() return e(BorderedContainer, { transparency = self.props.transparency, size = self.props.height:map(function(y) - return UDim2.new(1, 0, y, -180 * y) + return UDim2.new(1, 0, y, -220 * y) end), position = UDim2.new(0, 0, 1, 0), anchorPoint = Vector2.new(0, 1), @@ -82,9 +83,7 @@ function ChangesDrawer:render() transparency = self.props.transparency, layoutOrder = 3, - columnVisibility = { true, false, true }, - patch = self.props.patch, - instanceMap = self.serveSession.__instanceMap, + patchTree = self.props.patchTree, }), }) end) @@ -137,22 +136,6 @@ local function ConnectionDetails(props) }), }), - Disconnect = e(IconButton, { - icon = Assets.Images.Icons.Close, - iconSize = 24, - color = theme.ConnectionDetails.DisconnectColor, - transparency = props.transparency, - - position = UDim2.new(1, 0, 0.5, 0), - anchorPoint = Vector2.new(1, 0.5), - - onClick = props.onDisconnect, - }, { - Tip = e(Tooltip.Trigger, { - text = "Disconnect from the Rojo sync server", - }), - }), - Padding = e("UIPadding", { PaddingLeft = UDim.new(0, 17), PaddingRight = UDim.new(0, 15), @@ -170,9 +153,56 @@ function ConnectedPage:getChangeInfoText() end local elapsed = os.time() - patchData.timestamp - local changes = PatchSet.countChanges(patchData.patch) + local unapplied = PatchSet.countChanges(patchData.unapplied) - return string.format("Synced %d change%s %s", changes, changes == 1 and "" or "s", timeSinceText(elapsed)) + return + "Synced " + .. timeSinceText(elapsed) + .. (if unapplied > 0 then + string.format( + ", but %d change%s failed to apply", + unapplied, + unapplied == 1 and "" or "s" + ) + else "") + .. "" +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("" .. self:getChangeInfoText() .. "") + else + self.setChangeInfoText(self:getChangeInfoText()) + end + + local elapsed = os.time() - self.props.patchData.timestamp + local updateInterval = 1 + + -- Update timestamp text as frequently as currently needed + for _, UnitData in ipairs(AGE_UNITS) do + local UnitSeconds = UnitData[1] + if elapsed > UnitSeconds then + updateInterval = UnitSeconds + break + end + end + + task.wait(updateInterval) + end + end) +end + +function ConnectedPage:stopChangeInfoTextUpdater() + if self.changeInfoTextUpdater then + task.cancel(self.changeInfoTextUpdater) + self.changeInfoTextUpdater = nil + end end function ConnectedPage:init() @@ -195,34 +225,21 @@ function ConnectedPage:init() self:setState({ renderChanges = false, + hoveringChangeInfo = false, }) self.changeInfoText, self.setChangeInfoText = Roact.createBinding("") - self.changeInfoTextUpdater = task.defer(function() - while true do - self.setChangeInfoText(self:getChangeInfoText()) - - local elapsed = os.time() - self.props.patchData.timestamp - local updateInterval = 1 - - -- Update timestamp text as frequently as currently needed - for _, UnitData in ipairs(AGE_UNITS) do - local UnitSeconds = UnitData[1] - if elapsed >= UnitSeconds then - updateInterval = UnitSeconds - break - end - end - - task.wait(updateInterval) - end - end) + self:startChangeInfoTextUpdater() end function ConnectedPage:willUnmount() - if self.changeInfoTextUpdater then - task.cancel(self.changeInfoTextUpdater) + self:stopChangeInfoTextUpdater() +end + +function ConnectedPage:didUpdate(previousProps) + if self.props.patchData.timestamp ~= previousProps.patchData.timestamp then + self:startChangeInfoTextUpdater() end end @@ -255,6 +272,44 @@ function ConnectedPage:render() onDisconnect = self.props.onDisconnect, }), + Buttons = e("Frame", { + Size = UDim2.new(1, 0, 0, 34), + LayoutOrder = 3, + BackgroundTransparency = 1, + ZIndex = 2, + }, { + Settings = e(TextButton, { + text = "Settings", + style = "Bordered", + transparency = self.props.transparency, + layoutOrder = 1, + onClick = self.props.onNavigateSettings, + }, { + Tip = e(Tooltip.Trigger, { + text = "View and modify plugin settings" + }), + }), + + Disconnect = e(TextButton, { + text = "Disconnect", + style = "Solid", + transparency = self.props.transparency, + layoutOrder = 2, + onClick = self.props.onDisconnect, + }, { + Tip = e(Tooltip.Trigger, { + text = "Disconnect from the Rojo sync server" + }), + }), + + Layout = e("UIListLayout", { + HorizontalAlignment = Enum.HorizontalAlignment.Right, + FillDirection = Enum.FillDirection.Horizontal, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 10), + }), + }), + ChangeInfo = e("TextButton", { Text = self.changeInfoText, Font = Enum.Font.Gotham, @@ -268,9 +323,23 @@ function ConnectedPage:render() Size = UDim2.new(1, 0, 0, 28), - LayoutOrder = 3, + LayoutOrder = 4, BackgroundTransparency = 1, + [Roact.Event.MouseEnter] = function() + self:setState({ + hoveringChangeInfo = true, + }) + self.setChangeInfoText("" .. self:getChangeInfoText() .. "") + end, + + [Roact.Event.MouseLeave] = function() + self:setState({ + hoveringChangeInfo = false, + }) + self.setChangeInfoText(self:getChangeInfoText()) + end, + [Roact.Event.Activated] = function() if self.state.renderChanges then self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, { @@ -284,15 +353,19 @@ function ConnectedPage:render() })) end end, + }, { + Tooltip = e(Tooltip.Trigger, { + text = if self.state.renderChanges then "Hide the changes" else "View the changes", + }), }), ChangesDrawer = e(ChangesDrawer, { rendered = self.state.renderChanges, transparency = self.props.transparency, - patch = self.props.patchData.patch, + patchTree = self.props.patchTree, serveSession = self.props.serveSession, height = self.changeDrawerHeight, - layoutOrder = 4, + layoutOrder = 5, onClose = function() self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, { diff --git a/plugin/src/App/StatusPages/Settings/Setting.lua b/plugin/src/App/StatusPages/Settings/Setting.lua index af2dc0a2..4ce50de6 100644 --- a/plugin/src/App/StatusPages/Settings/Setting.lua +++ b/plugin/src/App/StatusPages/Settings/Setting.lua @@ -72,6 +72,7 @@ function Setting:render() self.props.customInput elseif self.props.options ~= nil then e(Dropdown, { + locked = self.props.locked, options = self.props.options, active = self.state.setting, transparency = self.props.transparency, @@ -83,6 +84,7 @@ function Setting:render() }) else e(Checkbox, { + locked = self.props.locked, active = self.state.setting, transparency = self.props.transparency, position = UDim2.new(1, 0, 0.5, 0), @@ -111,12 +113,13 @@ function Setting:render() BackgroundTransparency = 1, }, { Name = e("TextLabel", { - Text = self.props.name, + Text = (if self.props.experimental then "⚠ " else "") .. self.props.name, Font = Enum.Font.GothamBold, TextSize = 17, TextColor3 = theme.Setting.NameColor, TextXAlignment = Enum.TextXAlignment.Left, TextTransparency = self.props.transparency, + RichText = true, Size = UDim2.new(1, 0, 0, 17), @@ -125,7 +128,7 @@ function Setting:render() }), Description = e("TextLabel", { - Text = self.props.description, + Text = (if self.props.experimental then "[Experimental] " else "") .. self.props.description, Font = Enum.Font.Gotham, LineHeight = 1.2, TextSize = 14, @@ -133,11 +136,13 @@ function Setting:render() TextXAlignment = Enum.TextXAlignment.Left, TextTransparency = self.props.transparency, TextWrapped = true, + RichText = true, Size = self.containerSize:map(function(value) + local desc = (if self.props.experimental then "[Experimental] " else "") .. self.props.description local offset = (self.props.onReset and 34 or 0) + (self.props.options ~= nil and 120 or 40) local textBounds = getTextBounds( - self.props.description, 14, Enum.Font.Gotham, 1.2, + desc, 14, Enum.Font.Gotham, 1.2, Vector2.new(value.X - offset, math.huge) ) return UDim2.new(1, -offset, 0, textBounds.Y) diff --git a/plugin/src/App/StatusPages/Settings/init.lua b/plugin/src/App/StatusPages/Settings/init.lua index eaa4cc85..b0900c54 100644 --- a/plugin/src/App/StatusPages/Settings/init.lua +++ b/plugin/src/App/StatusPages/Settings/init.lua @@ -156,7 +156,9 @@ function SettingsPage:render() OpenScriptsExternally = e(Setting, { id = "openScriptsExternally", name = "Open Scripts Externally", - description = "EXPERIMENTAL! Attempt to open scripts in an external editor", + description = "Attempt to open scripts in an external editor", + locked = self.props.syncActive, + experimental = true, transparency = self.props.transparency, layoutOrder = 5, }), @@ -164,7 +166,9 @@ function SettingsPage:render() TwoWaySync = e(Setting, { id = "twoWaySync", name = "Two-Way Sync", - description = "EXPERIMENTAL! Editing files in Studio will sync them into the filesystem", + description = "Editing files in Studio will sync them into the filesystem", + locked = self.props.syncActive, + experimental = true, transparency = self.props.transparency, layoutOrder = 6, }), diff --git a/plugin/src/App/Theme.lua b/plugin/src/App/Theme.lua index 309045be..8ef05fab 100644 --- a/plugin/src/App/Theme.lua +++ b/plugin/src/App/Theme.lua @@ -100,6 +100,7 @@ local lightTheme = strict("LightTheme", { Remove = hexColor(0xffbdba), Edit = hexColor(0xbacdff), Row = hexColor(0x000000), + Warning = hexColor(0xFF8E3C), }, ConnectionDetails = { ProjectNameColor = hexColor(0x00000), @@ -195,6 +196,7 @@ local darkTheme = strict("DarkTheme", { Remove = hexColor(0x3F2D32), Edit = hexColor(0x193345), Row = hexColor(0xFFFFFF), + Warning = hexColor(0xFF8E3C), }, ConnectionDetails = { ProjectNameColor = hexColor(0xFFFFFF), diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index bb1ce6b3..9ffc8b8e 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -1,3 +1,4 @@ +local ChangeHistoryService = game:GetService("ChangeHistoryService") local Players = game:GetService("Players") local ServerStorage = game:GetService("ServerStorage") local RunService = game:GetService("RunService") @@ -19,6 +20,7 @@ local ServeSession = require(Plugin.ServeSession) local ApiContext = require(Plugin.ApiContext) local HeadlessAPI = require(Plugin.HeadlessAPI) local PatchSet = require(Plugin.PatchSet) +local PatchTree = require(Plugin.PatchTree) local preloadAssets = require(Plugin.preloadAssets) local soundPlayer = require(Plugin.soundPlayer) local ignorePlaceIds = require(Plugin.ignorePlaceIds) @@ -61,12 +63,71 @@ function App:init() self.confirmationEvent = self.confirmationBindable.Event self.notifId = 0 + self.waypointConnection = ChangeHistoryService.OnUndo:Connect(function(action: string) + if not string.find(action, "^Rojo: Patch") then + return + end + + local undoConnection, redoConnection = nil, nil + local function cleanup() + undoConnection:Disconnect() + redoConnection:Disconnect() + end + + Log.warn( + string.format( + "You've undone '%s'.\nIf this was not intended, please Redo in the topbar or with Ctrl/⌘+Y.", + action + ) + ) + local dismissNotif = self:addNotification( + string.format("You've undone '%s'.\nIf this was not intended, please restore.", action), + 10, + { + Restore = { + text = "Restore", + style = "Solid", + layoutOrder = 1, + onClick = function(notification) + cleanup() + notification:dismiss() + ChangeHistoryService:Redo() + end, + }, + Dismiss = { + text = "Dismiss", + style = "Bordered", + layoutOrder = 2, + onClick = function(notification) + cleanup() + notification:dismiss() + end, + }, + } + ) + + undoConnection = ChangeHistoryService.OnUndo:Once(function() + -- Our notif is now out of date- redoing will not restore the patch + -- since we've undone even further. Dismiss the notif. + cleanup() + dismissNotif() + end) + redoConnection = ChangeHistoryService.OnRedo:Once(function(redoneAction: string) + if redoneAction == action then + -- The user has restored the patch, so we can dismiss the notif + cleanup() + dismissNotif() + end + end) + end) + self:setState({ appStatus = AppStatus.NotConnected, guiEnabled = false, confirmData = {}, patchData = { patch = PatchSet.newEmpty(), + unapplied = PatchSet.newEmpty(), timestamp = os.time(), }, notifications = {}, @@ -132,7 +193,7 @@ function App:init() onClick = function(notification) notification:dismiss() self:startSession() - end + end, }, Dismiss = { text = "Dismiss", @@ -146,7 +207,16 @@ function App:init() end end -function App:addNotification(text: string, timeout: number?, actions: { [string]: {text: string, style: string, layoutOrder: number, onClick: (any) -> ()} }?) +function App:willUnmount() + self.waypointConnection:Disconnect() + self.confirmationBindable:Destroy() +end + +function App:addNotification( + text: string, + timeout: number?, + actions: { [string]: { text: string, style: string, layoutOrder: number, onClick: (any) -> () } }? +) if not Settings:get("showNotifications") then return end @@ -199,6 +269,10 @@ function App:addThirdPartyNotification(source: string, text: string, timeout: nu end function App:closeNotification(id: number) + if not self.state.notifications[id] then + return + end + local notifications = table.clone(self.state.notifications) notifications[id] = nil @@ -209,26 +283,38 @@ end function App:getPriorEndpoint() local priorEndpoints = Settings:get("priorEndpoints") - if not priorEndpoints then return end + if not priorEndpoints then + return + end local id = tostring(game.PlaceId) - if ignorePlaceIds[id] then return end + if ignorePlaceIds[id] then + return + end local place = priorEndpoints[id] - if not place then return end + if not place then + return + end return place.host, place.port end function App:getLastSyncTimestamp() local priorEndpoints = Settings:get("priorEndpoints") - if not priorEndpoints then return end + if not priorEndpoints then + return + end local id = tostring(game.PlaceId) - if ignorePlaceIds[id] then return end + if ignorePlaceIds[id] then + return + end local place = priorEndpoints[id] - if not place then return end + if not place then + return + end return place.timestamp end @@ -248,7 +334,9 @@ function App:setPriorEndpoint(host: string, port: string) end local id = tostring(game.PlaceId) - if ignorePlaceIds[id] then return end + if ignorePlaceIds[id] then + return + end priorEndpoints[id] = { host = if host ~= Config.defaultHost then host else nil, @@ -257,7 +345,6 @@ function App:setPriorEndpoint(host: string, port: string) } Log.trace("Saved last used endpoint for {}", game.PlaceId) - Settings:set("priorEndpoints", priorEndpoints) end @@ -393,35 +480,51 @@ function App:startSession(host: string?, port: string?) twoWaySync = sessionOptions.twoWaySync, }) - serveSession:onPatchApplied(function(patch, _unapplied) + self.cleanupPrecommit = serveSession.__reconciler:hookPrecommit(function(patch, instanceMap) + -- Build new tree for patch + self:setState({ + patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }), + }) + end) + self.cleanupPostcommit = serveSession.__reconciler:hookPostcommit(function(patch, instanceMap, unappliedPatch) + -- Update tree with unapplied metadata + self:setState(function(prevState) + return { + patchTree = PatchTree.updateMetadata(prevState.patchTree, patch, instanceMap, unappliedPatch), + } + end) + end) + + serveSession:hookPostcommit(function(patch, _instanceMap, unapplied) + local now = os.time() + local old = self.state.patchData + if PatchSet.isEmpty(patch) then - -- Ignore empty patches + -- Ignore empty patch, but update timestamp + self:setState({ + patchData = { + patch = old.patch, + unapplied = old.unapplied, + timestamp = now, + }, + }) return end - local now = os.time() - - local old = self.state.patchData if now - old.timestamp < 2 then -- Patches that apply in the same second are -- considered to be part of the same change for human clarity - local merged = PatchSet.newEmpty() - PatchSet.assign(merged, old.patch, patch) - - self:setState({ - patchData = { - patch = merged, - timestamp = now, - }, - }) - else - self:setState({ - patchData = { - patch = patch, - timestamp = now, - }, - }) + patch = PatchSet.assign(PatchSet.newEmpty(), old.patch, patch) + unapplied = PatchSet.assign(PatchSet.newEmpty(), old.unapplied, unapplied) end + + self:setState({ + patchData = { + patch = patch, + unapplied = unapplied, + timestamp = now, + }, + }) end) serveSession:onStatusChanged(function(status, details) @@ -449,6 +552,13 @@ function App:startSession(host: string?, port: string?) elseif status == ServeSession.Status.Disconnected then self.serveSession = nil self:releaseSyncLock() + self:setState({ + patchData = { + patch = PatchSet.newEmpty(), + unapplied = PatchSet.newEmpty(), + timestamp = os.time(), + }, + }) -- Details being present indicates that this -- disconnection was from an error. @@ -540,6 +650,13 @@ function App:endSession() appStatus = AppStatus.NotConnected, }) + if self.cleanupPrecommit ~= nil then + self.cleanupPrecommit() + end + if self.cleanupPostcommit ~= nil then + self.cleanupPostcommit() + end + Log.trace("Session terminated by user") end @@ -602,8 +719,8 @@ function App:render() initDockState = Enum.InitialDockState.Right, initEnabled = false, overridePreviousState = false, - floatingSize = Vector2.new(300, 200), - minimumSize = Vector2.new(300, 120), + floatingSize = Vector2.new(320, 210), + minimumSize = Vector2.new(300, 210), zIndexBehavior = Enum.ZIndexBehavior.Sibling, @@ -632,6 +749,7 @@ function App:render() end, onNavigateSettings = function() + self.backPage = AppStatus.NotConnected self:setState({ appStatus = AppStatus.Settings, }) @@ -658,18 +776,28 @@ function App:render() Connected = createPageElement(AppStatus.Connected, { projectName = self.state.projectName, address = self.state.address, + patchTree = self.state.patchTree, patchData = self.state.patchData, serveSession = self.serveSession, onDisconnect = function() self:endSession() end, + + onNavigateSettings = function() + self.backPage = AppStatus.Connected + self:setState({ + appStatus = AppStatus.Settings, + }) + end, }), Settings = createPageElement(AppStatus.Settings, { + syncActive = self.serveSession ~= nil and self.serveSession:getStatus() == ServeSession.Status.Connected, + onBack = function() self:setState({ - appStatus = AppStatus.NotConnected, + appStatus = self.backPage or AppStatus.NotConnected, }) end, diff --git a/plugin/src/Assets.lua b/plugin/src/Assets.lua index d99e32ed..9abbe41f 100644 --- a/plugin/src/Assets.lua +++ b/plugin/src/Assets.lua @@ -36,9 +36,11 @@ local Assets = { Checkbox = { Active = "rbxassetid://6016251644", Inactive = "rbxassetid://6016251963", + Locked = "rbxassetid://14011257320", }, Dropdown = { Arrow = "rbxassetid://10131770538", + Locked = "rbxassetid://14011257320", }, Spinner = { Foreground = "rbxassetid://3222731032", diff --git a/plugin/src/PatchSet.lua b/plugin/src/PatchSet.lua index c9f621dc..a9bebd80 100644 --- a/plugin/src/PatchSet.lua +++ b/plugin/src/PatchSet.lua @@ -212,13 +212,24 @@ function PatchSet.countChanges(patch) local count = 0 for _ in patch.added do + -- Adding an instance is 1 change count += 1 end for _ in patch.removed do + -- Removing an instance is 1 change count += 1 end - for _ in patch.updated do - count += 1 + for _, update in patch.updated do + -- Updating an instance is 1 change per property updated + for _ in update.changedProperties do + count += 1 + end + if update.changedName ~= nil then + count += 1 + end + if update.changedClassName ~= nil then + count += 1 + end end return count diff --git a/plugin/src/PatchTree.lua b/plugin/src/PatchTree.lua new file mode 100644 index 00000000..b241472d --- /dev/null +++ b/plugin/src/PatchTree.lua @@ -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 diff --git a/plugin/src/Reconciler/applyPatch.lua b/plugin/src/Reconciler/applyPatch.lua index a625ebe9..4faa7310 100644 --- a/plugin/src/Reconciler/applyPatch.lua +++ b/plugin/src/Reconciler/applyPatch.lua @@ -5,6 +5,8 @@ Patches can come from the server or be generated by the client. ]] +local ChangeHistoryService = game:GetService("ChangeHistoryService") + local Packages = script.Parent.Parent.Parent.Packages local Log = require(Packages.Log) @@ -17,6 +19,8 @@ local reify = require(script.Parent.reify) local setProperty = require(script.Parent.setProperty) local function applyPatch(instanceMap, patch) + local patchTimestamp = DateTime.now():FormatLocalTime("LTS", "en-us") + -- Tracks any portions of the patch that could not be applied to the DOM. local unappliedPatch = PatchSet.newEmpty() @@ -199,6 +203,8 @@ local function applyPatch(instanceMap, patch) end end + ChangeHistoryService:SetWaypoint("Rojo: Patch " .. patchTimestamp) + return unappliedPatch end diff --git a/plugin/src/Reconciler/init.lua b/plugin/src/Reconciler/init.lua index 1db295a4..12945782 100644 --- a/plugin/src/Reconciler/init.lua +++ b/plugin/src/Reconciler/init.lua @@ -3,6 +3,9 @@ and mutating the Roblox DOM. ]] +local Packages = script.Parent.Parent.Packages +local Log = require(Packages.Log) + local applyPatch = require(script.applyPatch) local hydrate = require(script.hydrate) local diff = require(script.diff) @@ -14,13 +17,63 @@ function Reconciler.new(instanceMap) local self = { -- Tracks all of the instances known by the reconciler by ID. __instanceMap = instanceMap, + __precommitCallbacks = {}, + __postcommitCallbacks = {}, } return setmetatable(self, Reconciler) end +function Reconciler:hookPrecommit(callback: (patch: any, instanceMap: any) -> ()): () -> () + table.insert(self.__precommitCallbacks, callback) + Log.trace("Added precommit callback: {}", callback) + + return function() + -- Remove the callback from the list + for i, cb in self.__precommitCallbacks do + if cb == callback then + table.remove(self.__precommitCallbacks, i) + Log.trace("Removed precommit callback: {}", callback) + break + end + end + end +end + +function Reconciler:hookPostcommit(callback: (patch: any, instanceMap: any, unappliedPatch: any) -> ()): () -> () + table.insert(self.__postcommitCallbacks, callback) + Log.trace("Added postcommit callback: {}", callback) + + return function() + -- Remove the callback from the list + for i, cb in self.__postcommitCallbacks do + if cb == callback then + table.remove(self.__postcommitCallbacks, i) + Log.trace("Removed postcommit callback: {}", callback) + break + end + end + end +end + function Reconciler:applyPatch(patch) - return applyPatch(self.__instanceMap, patch) + for _, callback in self.__precommitCallbacks do + local success, err = pcall(callback, patch, self.__instanceMap) + if not success then + Log.warn("Precommit hook errored: {}", err) + end + end + + local unappliedPatch = applyPatch(self.__instanceMap, patch) + + for _, callback in self.__postcommitCallbacks do + local success, err = pcall(callback, patch, self.__instanceMap, unappliedPatch) + if not success then + Log.warn("Postcommit hook errored: {}", err) + end + end + + return unappliedPatch end function Reconciler:hydrate(virtualInstances, rootId, rootInstance) @@ -31,4 +84,4 @@ function Reconciler:diff(virtualInstances, rootId) return diff(self.__instanceMap, virtualInstances, rootId) end -return Reconciler \ No newline at end of file +return Reconciler diff --git a/plugin/src/ServeSession.lua b/plugin/src/ServeSession.lua index 08dc4041..6a3fcdc4 100644 --- a/plugin/src/ServeSession.lua +++ b/plugin/src/ServeSession.lua @@ -97,7 +97,6 @@ function ServeSession.new(options) __instanceMap = instanceMap, __changeBatcher = changeBatcher, __statusChangedCallback = nil, - __patchAppliedCallback = nil, __connections = connections, } @@ -129,8 +128,12 @@ function ServeSession:setConfirmCallback(callback) self.__userConfirmCallback = callback end -function ServeSession:onPatchApplied(callback) - self.__patchAppliedCallback = callback +function ServeSession:hookPrecommit(callback) + return self.__reconciler:hookPrecommit(callback) +end + +function ServeSession:hookPostcommit(callback) + return self.__reconciler:hookPostcommit(callback) end function ServeSession:start() @@ -291,35 +294,45 @@ function ServeSession:__initialSync(serverInfo) Log.warn("Could not apply all changes requested by the Rojo server:\n{}", PatchSet.humanSummary(self.__instanceMap, unappliedPatch)) end - if self.__patchAppliedCallback then - pcall(self.__patchAppliedCallback, catchUpPatch, unappliedPatch) - end end end) end function ServeSession:__mainSyncLoop() - return self.__apiContext:retrieveMessages() - :andThen(function(messages) - Log.trace("Serve session {} retrieved {} messages", tostring(self), #messages) + return Promise.new(function(resolve, reject) + while self.__status == Status.Connected do + local success, result = self.__apiContext:retrieveMessages() + :andThen(function(messages) + if self.__status == Status.Disconnected then + -- In the time it took to retrieve messages, we disconnected + -- so we just resolve immediately without patching anything + return + end - for _, message in ipairs(messages) do - local unappliedPatch = self.__reconciler:applyPatch(message) + Log.trace("Serve session {} retrieved {} messages", tostring(self), #messages) - if not PatchSet.isEmpty(unappliedPatch) then - Log.warn("Could not apply all changes requested by the Rojo server:\n{}", - PatchSet.humanSummary(self.__instanceMap, unappliedPatch)) - end + for _, message in messages do + local unappliedPatch = self.__reconciler:applyPatch(message) - if self.__patchAppliedCallback then - pcall(self.__patchAppliedCallback, message, unappliedPatch) - end + if not PatchSet.isEmpty(unappliedPatch) then + Log.warn("Could not apply all changes requested by the Rojo server:\n{}", + PatchSet.humanSummary(self.__instanceMap, unappliedPatch)) + end + end + end):await() + + if self.__status == Status.Disconnected then + -- If we are no longer connected after applying, we stop silently + -- without checking for errors as they are no longer relevant + break + elseif success == false then + reject(result) end + end - if self.__status ~= Status.Disconnected then - return self:__mainSyncLoop() - end - end) + -- We are no longer connected, so we resolve the promise + resolve() + end) end function ServeSession:__stopInternal(err) diff --git a/src/snapshot/instance_snapshot.rs b/src/snapshot/instance_snapshot.rs index 36c51821..0b3a4fca 100644 --- a/src/snapshot/instance_snapshot.rs +++ b/src/snapshot/instance_snapshot.rs @@ -17,9 +17,8 @@ use super::InstanceMetadata; // - Replace use of Variant with a sum of Variant + borrowed value #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct InstanceSnapshot { - // FIXME: Don't use Option anymore! /// A temporary ID applied to the snapshot that's used for Ref properties. - pub snapshot_id: Option, + pub snapshot_id: Ref, /// Rojo-specific metadata associated with the instance. pub metadata: InstanceMetadata, @@ -42,7 +41,7 @@ pub struct InstanceSnapshot { impl InstanceSnapshot { pub fn new() -> Self { Self { - snapshot_id: None, + snapshot_id: Ref::none(), metadata: InstanceMetadata::default(), name: Cow::Borrowed("DEFAULT"), class_name: Cow::Borrowed("DEFAULT"), @@ -88,7 +87,7 @@ impl InstanceSnapshot { } } - pub fn snapshot_id(self, snapshot_id: Option) -> Self { + pub fn snapshot_id(self, snapshot_id: Ref) -> Self { Self { snapshot_id, ..self @@ -120,7 +119,7 @@ impl InstanceSnapshot { .collect(); Self { - snapshot_id: Some(id), + snapshot_id: id, metadata: InstanceMetadata::default(), name: Cow::Owned(instance.name), class_name: Cow::Owned(instance.class), diff --git a/src/snapshot/patch_apply.rs b/src/snapshot/patch_apply.rs index 5818776c..572b8455 100644 --- a/src/snapshot/patch_apply.rs +++ b/src/snapshot/patch_apply.rs @@ -135,7 +135,7 @@ fn apply_add_child( 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); } @@ -231,7 +231,7 @@ mod test { let root_id = tree.get_root_id(); let snapshot = InstanceSnapshot { - snapshot_id: None, + snapshot_id: Ref::none(), metadata: Default::default(), name: Cow::Borrowed("Foo"), class_name: Cow::Borrowed("Bar"), diff --git a/src/snapshot/patch_compute.rs b/src/snapshot/patch_compute.rs index f5d5a67b..5d7abc69 100644 --- a/src/snapshot/patch_compute.rs +++ b/src/snapshot/patch_compute.rs @@ -77,8 +77,10 @@ fn compute_patch_set_internal( id: Ref, patch_set: &mut PatchSet, ) { - if let Some(snapshot_id) = snapshot.snapshot_id { - context.snapshot_id_to_instance_id.insert(snapshot_id, id); + if snapshot.snapshot_id.is_some() { + context + .snapshot_id_to_instance_id + .insert(snapshot.snapshot_id, id); } let instance = tree @@ -244,7 +246,7 @@ mod test { // addition of a prop named Self, which is a self-referential Ref. let snapshot_id = Ref::new(); let snapshot = InstanceSnapshot { - snapshot_id: Some(snapshot_id), + snapshot_id: snapshot_id, properties: hashmap! { "Self".to_owned() => Variant::Ref(snapshot_id), }, @@ -286,13 +288,13 @@ mod test { // This patch describes the existing instance with a new child added. let snapshot_id = Ref::new(); let snapshot = InstanceSnapshot { - snapshot_id: Some(snapshot_id), + snapshot_id: snapshot_id, children: vec![InstanceSnapshot { properties: hashmap! { "Self".to_owned() => Variant::Ref(snapshot_id), }, - snapshot_id: None, + snapshot_id: Ref::none(), metadata: Default::default(), name: Cow::Borrowed("child"), class_name: Cow::Borrowed("child"), @@ -311,7 +313,7 @@ mod test { added_instances: vec![PatchAdd { parent_id: root_id, instance: InstanceSnapshot { - snapshot_id: None, + snapshot_id: Ref::none(), metadata: Default::default(), properties: hashmap! { "Self".to_owned() => Variant::Ref(root_id), diff --git a/src/snapshot/tests/compute.rs b/src/snapshot/tests/compute.rs index 309d73b6..fc6a0b63 100644 --- a/src/snapshot/tests/compute.rs +++ b/src/snapshot/tests/compute.rs @@ -3,6 +3,7 @@ use std::borrow::Cow; use insta::assert_yaml_snapshot; use maplit::hashmap; +use rbx_dom_weak::types::Ref; use rojo_insta_ext::RedactionMap; use crate::snapshot::{compute_patch_set, InstanceSnapshot, RojoTree}; @@ -15,7 +16,7 @@ fn set_name_and_class_name() { redactions.intern(tree.get_root_id()); let snapshot = InstanceSnapshot { - snapshot_id: None, + snapshot_id: Ref::none(), metadata: Default::default(), name: Cow::Borrowed("Some Folder"), class_name: Cow::Borrowed("Folder"), @@ -37,7 +38,7 @@ fn set_property() { redactions.intern(tree.get_root_id()); let snapshot = InstanceSnapshot { - snapshot_id: None, + snapshot_id: Ref::none(), metadata: Default::default(), name: Cow::Borrowed("ROOT"), class_name: Cow::Borrowed("ROOT"), @@ -70,7 +71,7 @@ fn remove_property() { } let snapshot = InstanceSnapshot { - snapshot_id: None, + snapshot_id: Ref::none(), metadata: Default::default(), name: Cow::Borrowed("ROOT"), class_name: Cow::Borrowed("ROOT"), @@ -92,13 +93,13 @@ fn add_child() { redactions.intern(tree.get_root_id()); let snapshot = InstanceSnapshot { - snapshot_id: None, + snapshot_id: Ref::none(), metadata: Default::default(), name: Cow::Borrowed("ROOT"), class_name: Cow::Borrowed("ROOT"), properties: Default::default(), children: vec![InstanceSnapshot { - snapshot_id: None, + snapshot_id: Ref::none(), metadata: Default::default(), name: Cow::Borrowed("New"), class_name: Cow::Borrowed("Folder"), @@ -131,7 +132,7 @@ fn remove_child() { } let snapshot = InstanceSnapshot { - snapshot_id: None, + snapshot_id: Ref::none(), metadata: Default::default(), name: Cow::Borrowed("ROOT"), class_name: Cow::Borrowed("ROOT"), diff --git a/src/snapshot/tests/snapshots/librojo__snapshot__tests__compute__add_child.snap b/src/snapshot/tests/snapshots/librojo__snapshot__tests__compute__add_child.snap index 97bd639e..c4695b7c 100644 --- a/src/snapshot/tests/snapshots/librojo__snapshot__tests__compute__add_child.snap +++ b/src/snapshot/tests/snapshots/librojo__snapshot__tests__compute__add_child.snap @@ -6,7 +6,7 @@ removed_instances: [] added_instances: - parent_id: id-1 instance: - snapshot_id: ~ + snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: false relevant_paths: [] @@ -16,3 +16,4 @@ added_instances: properties: {} children: [] updated_instances: [] + diff --git a/src/snapshot_middleware/json_model.rs b/src/snapshot_middleware/json_model.rs index 0f1388e4..b38c47a7 100644 --- a/src/snapshot_middleware/json_model.rs +++ b/src/snapshot_middleware/json_model.rs @@ -2,7 +2,7 @@ use std::{borrow::Cow, collections::HashMap, path::Path, str}; use anyhow::Context; use memofs::Vfs; -use rbx_dom_weak::types::Attributes; +use rbx_dom_weak::types::{Attributes, Ref}; use serde::Deserialize; use crate::{ @@ -112,7 +112,7 @@ impl JsonModel { } Ok(InstanceSnapshot { - snapshot_id: None, + snapshot_id: Ref::none(), metadata: Default::default(), name: Cow::Owned(name), class_name: Cow::Owned(class_name), diff --git a/src/snapshot_middleware/mod.rs b/src/snapshot_middleware/mod.rs index 3d5de76b..c690a223 100644 --- a/src/snapshot_middleware/mod.rs +++ b/src/snapshot_middleware/mod.rs @@ -14,6 +14,7 @@ mod meta_file; mod project; mod rbxm; mod rbxmx; +mod toml; mod txt; mod util; @@ -32,6 +33,7 @@ use self::{ project::snapshot_project, rbxm::snapshot_rbxm, rbxmx::snapshot_rbxmx, + toml::snapshot_toml, txt::snapshot_txt, util::PathExt, }; @@ -117,6 +119,8 @@ pub fn snapshot_from_vfs( return Ok(None); } else if path.file_name_ends_with(".json") { 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 { match name { // init csv are handled elsewhere and should not turn into diff --git a/src/snapshot_middleware/project.rs b/src/snapshot_middleware/project.rs index f8fa7920..2d54a179 100644 --- a/src/snapshot_middleware/project.rs +++ b/src/snapshot_middleware/project.rs @@ -2,7 +2,7 @@ use std::{borrow::Cow, collections::HashMap, path::Path}; use anyhow::{bail, Context}; use memofs::Vfs; -use rbx_dom_weak::types::Attributes; +use rbx_dom_weak::types::{Attributes, Ref}; use rbx_reflection::ClassTag; use crate::{ @@ -271,7 +271,7 @@ pub fn snapshot_project_node( )); Ok(Some(InstanceSnapshot { - snapshot_id: None, + snapshot_id: Ref::none(), name, class_name, properties, diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_from_vfs.snap index 7a685efb..11a8d81c 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_from_vfs.snap @@ -1,9 +1,8 @@ --- source: src/snapshot_middleware/csv.rs expression: instance_snapshot - --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: false instigating_source: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_with_meta.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_with_meta.snap index 70bae90d..eda82606 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_with_meta.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_with_meta.snap @@ -1,9 +1,8 @@ --- source: src/snapshot_middleware/csv.rs expression: instance_snapshot - --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: true instigating_source: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__empty_folder.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__empty_folder.snap index 948dda0b..e59da8db 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__empty_folder.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__empty_folder.snap @@ -1,9 +1,8 @@ --- source: src/snapshot_middleware/dir.rs -assertion_line: 127 expression: instance_snapshot --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: false instigating_source: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__folder_in_folder.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__folder_in_folder.snap index cf07b0ed..8c2ee9cc 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__folder_in_folder.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__folder_in_folder.snap @@ -1,9 +1,8 @@ --- source: src/snapshot_middleware/dir.rs -assertion_line: 148 expression: instance_snapshot --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: false instigating_source: @@ -23,7 +22,7 @@ name: foo class_name: Folder properties: {} children: - - snapshot_id: ~ + - snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: false instigating_source: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json__test__instance_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json__test__instance_from_vfs.snap index 75329b85..b1cca7cb 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json__test__instance_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json__test__instance_from_vfs.snap @@ -1,9 +1,8 @@ --- source: src/snapshot_middleware/json.rs expression: instance_snapshot - --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: false instigating_source: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs.snap index a6a0743e..5d4db1fc 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs.snap @@ -1,9 +1,8 @@ --- source: src/snapshot_middleware/json_model.rs expression: instance_snapshot - --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: false instigating_source: @@ -17,7 +16,7 @@ properties: Value: Int64: 5 children: - - snapshot_id: ~ + - snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: false relevant_paths: [] diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs_legacy.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs_legacy.snap index a58fdb0f..5d4db1fc 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs_legacy.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs_legacy.snap @@ -1,9 +1,8 @@ --- source: src/snapshot_middleware/json_model.rs -assertion_line: 186 expression: instance_snapshot --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: false instigating_source: @@ -17,7 +16,7 @@ properties: Value: Int64: 5 children: - - snapshot_id: ~ + - snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: false relevant_paths: [] diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__client_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__client_from_vfs.snap index f774f561..40b4bbe2 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__client_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__client_from_vfs.snap @@ -1,9 +1,8 @@ --- source: src/snapshot_middleware/lua.rs expression: instance_snapshot - --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: false instigating_source: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__module_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__module_from_vfs.snap index ac9ac72a..6877e9a9 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__module_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__module_from_vfs.snap @@ -1,9 +1,8 @@ --- source: src/snapshot_middleware/lua.rs expression: instance_snapshot - --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: false instigating_source: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__module_with_meta.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__module_with_meta.snap index f623955f..8dcc57a9 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__module_with_meta.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__module_with_meta.snap @@ -1,9 +1,8 @@ --- source: src/snapshot_middleware/lua.rs expression: instance_snapshot - --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: true instigating_source: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__script_disabled.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__script_disabled.snap index 8a9b4776..3f040877 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__script_disabled.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__script_disabled.snap @@ -1,9 +1,8 @@ --- source: src/snapshot_middleware/lua.rs expression: instance_snapshot - --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: false instigating_source: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__script_with_meta.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__script_with_meta.snap index 1234b48a..6bdd2a38 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__script_with_meta.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__script_with_meta.snap @@ -1,9 +1,8 @@ --- source: src/snapshot_middleware/lua.rs expression: instance_snapshot - --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: true instigating_source: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__server_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__server_from_vfs.snap index 76473c75..63d33a0a 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__server_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__server_from_vfs.snap @@ -1,9 +1,8 @@ --- source: src/snapshot_middleware/lua.rs expression: instance_snapshot - --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: false instigating_source: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_from_direct_file.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_from_direct_file.snap index 42393420..795b14c1 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_from_direct_file.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_from_direct_file.snap @@ -2,7 +2,7 @@ source: src/snapshot_middleware/project.rs expression: instance_snapshot --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: true instigating_source: @@ -14,3 +14,4 @@ name: direct-project class_name: Model properties: {} children: [] + diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_path_property_overrides.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_path_property_overrides.snap index 49b3b506..7427c9dc 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_path_property_overrides.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_path_property_overrides.snap @@ -1,9 +1,8 @@ --- source: src/snapshot_middleware/project.rs expression: instance_snapshot - --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: true instigating_source: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_children.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_children.snap index ccf6b535..b50eb1be 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_children.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_children.snap @@ -2,7 +2,7 @@ source: src/snapshot_middleware/project.rs expression: instance_snapshot --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: true instigating_source: @@ -14,7 +14,7 @@ name: children class_name: Folder properties: {} children: - - snapshot_id: ~ + - snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: true instigating_source: @@ -29,3 +29,4 @@ children: class_name: Model properties: {} children: [] + diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project.snap index a520b7c7..d64a46e7 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project.snap @@ -2,7 +2,7 @@ source: src/snapshot_middleware/project.rs expression: instance_snapshot --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: true instigating_source: @@ -15,3 +15,4 @@ name: path-project class_name: Model properties: {} children: [] + diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project_with_children.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project_with_children.snap index c71b4d2d..0ceb043a 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project_with_children.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project_with_children.snap @@ -2,7 +2,7 @@ source: src/snapshot_middleware/project.rs expression: instance_snapshot --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: true instigating_source: @@ -15,7 +15,7 @@ name: path-child-project class_name: Folder properties: {} children: - - snapshot_id: ~ + - snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: true instigating_source: @@ -30,3 +30,4 @@ children: class_name: Model properties: {} children: [] + diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_txt.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_txt.snap index 38b797c3..51a222f9 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_txt.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_txt.snap @@ -1,9 +1,8 @@ --- source: src/snapshot_middleware/project.rs expression: instance_snapshot - --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: false instigating_source: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_resolved_properties.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_resolved_properties.snap index 29a23a07..9b8e91ef 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_resolved_properties.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_resolved_properties.snap @@ -1,9 +1,8 @@ --- source: src/snapshot_middleware/project.rs expression: instance_snapshot - --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: true instigating_source: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_unresolved_properties.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_unresolved_properties.snap index a422e9f8..a07300bb 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_unresolved_properties.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_unresolved_properties.snap @@ -1,9 +1,8 @@ --- source: src/snapshot_middleware/project.rs expression: instance_snapshot - --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: true instigating_source: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__toml__test__instance_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__toml__test__instance_from_vfs.snap new file mode 100644 index 00000000..3529bf89 --- /dev/null +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__toml__test__instance_from_vfs.snap @@ -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: [] + diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__txt__test__instance_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__txt__test__instance_from_vfs.snap index ad469490..b8a8d4af 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__txt__test__instance_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__txt__test__instance_from_vfs.snap @@ -1,9 +1,8 @@ --- source: src/snapshot_middleware/txt.rs expression: instance_snapshot - --- -snapshot_id: ~ +snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: false instigating_source: diff --git a/src/snapshot_middleware/toml.rs b/src/snapshot_middleware/toml.rs new file mode 100644 index 00000000..6ee9be47 --- /dev/null +++ b/src/snapshot_middleware/toml.rs @@ -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> { + 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); + } +}