diff --git a/CHANGELOG.md b/CHANGELOG.md index b9d199c77..2d08d02c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Added Never option to Confirmation ([#893]) * Added popout diff visualizer for table properties like Attributes and Tags ([#834]) * Updated Theme to use Studio colors ([#838]) +* Improved patch visualizer UX ([#883]) * Added experimental setting for Auto Connect in playtests ([#840]) * Projects may now specify rules for syncing files as if they had a different file extension. ([#813]) This is specified via a new field on project files, `syncRules`: @@ -56,6 +57,7 @@ [#834]: https://github.com/rojo-rbx/rojo/pull/834 [#838]: https://github.com/rojo-rbx/rojo/pull/838 [#840]: https://github.com/rojo-rbx/rojo/pull/840 +[#883]: https://github.com/rojo-rbx/rojo/pull/883 [#893]: https://github.com/rojo-rbx/rojo/pull/893 ## [7.4.1] - February 20, 2024 diff --git a/assets/images/syncsuccess.png b/assets/images/syncsuccess.png new file mode 100644 index 000000000..74958cc13 Binary files /dev/null and b/assets/images/syncsuccess.png differ diff --git a/assets/images/syncwarning.png b/assets/images/syncwarning.png new file mode 100644 index 000000000..eafb93d36 Binary files /dev/null and b/assets/images/syncwarning.png differ diff --git a/plugin/src/App/Components/ClassIcon.lua b/plugin/src/App/Components/ClassIcon.lua new file mode 100644 index 000000000..c3e46e0d2 --- /dev/null +++ b/plugin/src/App/Components/ClassIcon.lua @@ -0,0 +1,126 @@ +local StudioService = game:GetService("StudioService") +local AssetService = game:GetService("AssetService") + +local Rojo = script:FindFirstAncestor("Rojo") +local Plugin = Rojo.Plugin +local Packages = Rojo.Packages + +local Roact = require(Packages.Roact) + +local e = Roact.createElement + +local EditableImage = require(Plugin.App.Components.EditableImage) + +local imageCache = {} +local function getImageSizeAndPixels(image) + if not imageCache[image] then + local editableImage = AssetService:CreateEditableImageAsync(image) + imageCache[image] = { + Size = editableImage.Size, + Pixels = editableImage:ReadPixels(Vector2.zero, editableImage.Size), + } + end + + return imageCache[image].Size, table.clone(imageCache[image].Pixels) +end + +local function getRecoloredClassIcon(className, color) + local iconProps = StudioService:GetClassIcon(className) + + if iconProps and color then + local success, editableImageSize, editableImagePixels = pcall(function() + local size, pixels = getImageSizeAndPixels(iconProps.Image) + + local minVal, maxVal = math.huge, -math.huge + for i = 1, #pixels, 4 do + if pixels[i + 3] == 0 then + continue + end + local pixelVal = math.max(pixels[i], pixels[i + 1], pixels[i + 2]) + + minVal = math.min(minVal, pixelVal) + maxVal = math.max(maxVal, pixelVal) + end + + local hue, sat, val = color:ToHSV() + for i = 1, #pixels, 4 do + if pixels[i + 3] == 0 then + continue + end + + local pixelVal = math.max(pixels[i], pixels[i + 1], pixels[i + 2]) + local newVal = val + if minVal < maxVal then + -- Remap minVal - maxVal to val*0.9 - val + newVal = val * (0.9 + 0.1 * (pixelVal - minVal) / (maxVal - minVal)) + end + + local newPixelColor = Color3.fromHSV(hue, sat, newVal) + pixels[i], pixels[i + 1], pixels[i + 2] = newPixelColor.R, newPixelColor.G, newPixelColor.B + end + return size, pixels + end) + if success then + iconProps.EditableImagePixels = editableImagePixels + iconProps.EditableImageSize = editableImageSize + end + end + + return iconProps +end + +local ClassIcon = Roact.PureComponent:extend("ClassIcon") + +function ClassIcon:init() + self.state = { + iconProps = nil, + } +end + +function ClassIcon:updateIcon() + local props = self.props + local iconProps = getRecoloredClassIcon(props.className, props.color) + self:setState({ + iconProps = iconProps, + }) +end + +function ClassIcon:didMount() + self:updateIcon() +end + +function ClassIcon:didUpdate(lastProps) + if lastProps.className ~= self.props.className or lastProps.color ~= self.props.color then + self:updateIcon() + end +end + +function ClassIcon:render() + local iconProps = self.state.iconProps + if not iconProps then + return nil + end + + return e( + "ImageLabel", + { + Size = self.props.size, + Position = self.props.position, + LayoutOrder = self.props.layoutOrder, + AnchorPoint = self.props.anchorPoint, + ImageTransparency = self.props.transparency, + Image = iconProps.Image, + ImageRectOffset = iconProps.ImageRectOffset, + ImageRectSize = iconProps.ImageRectSize, + BackgroundTransparency = 1, + }, + if iconProps.EditableImagePixels + then e(EditableImage, { + size = iconProps.EditableImageSize, + pixels = iconProps.EditableImagePixels, + }) + else nil + ) +end + +return ClassIcon diff --git a/plugin/src/App/Components/EditableImage.lua b/plugin/src/App/Components/EditableImage.lua new file mode 100644 index 000000000..501a37eee --- /dev/null +++ b/plugin/src/App/Components/EditableImage.lua @@ -0,0 +1,41 @@ +local Rojo = script:FindFirstAncestor("Rojo") +local Packages = Rojo.Packages + +local Roact = require(Packages.Roact) + +local e = Roact.createElement + +local EditableImage = Roact.PureComponent:extend("EditableImage") + +function EditableImage:init() + self.ref = Roact.createRef() +end + +function EditableImage:writePixels() + local image = self.ref.current + if not image then + return + end + if not self.props.pixels then + return + end + + image:WritePixels(Vector2.zero, self.props.size, self.props.pixels) +end + +function EditableImage:render() + return e("EditableImage", { + Size = self.props.size, + [Roact.Ref] = self.ref, + }) +end + +function EditableImage:didMount() + self:writePixels() +end + +function EditableImage:didUpdate() + self:writePixels() +end + +return EditableImage diff --git a/plugin/src/App/Components/PatchVisualizer/ChangeList.lua b/plugin/src/App/Components/PatchVisualizer/ChangeList.lua index ce01b8010..2f10d4587 100644 --- a/plugin/src/App/Components/PatchVisualizer/ChangeList.lua +++ b/plugin/src/App/Components/PatchVisualizer/ChangeList.lua @@ -155,7 +155,7 @@ function ChangeList:render() local headerRow = changes[1] local headers = e("Frame", { - Size = UDim2.new(1, 0, 0, 30), + Size = UDim2.new(1, 0, 0, 24), BackgroundTransparency = rowTransparency, BackgroundColor3 = theme.Diff.Row, LayoutOrder = 0, @@ -214,7 +214,7 @@ function ChangeList:render() local isWarning = metadata.isWarning rows[row] = e("Frame", { - Size = UDim2.new(1, 0, 0, 30), + Size = UDim2.new(1, 0, 0, 24), BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1, BackgroundColor3 = theme.Diff.Row, BorderSizePixel = 0, @@ -269,8 +269,8 @@ function ChangeList:render() }, { Headers = headers, Values = e(ScrollingFrame, { - size = UDim2.new(1, 0, 1, -30), - position = UDim2.new(0, 0, 0, 30), + size = UDim2.new(1, 0, 1, -24), + position = UDim2.new(0, 0, 0, 24), contentSize = self.contentSize, transparency = props.transparency, }, rows), diff --git a/plugin/src/App/Components/PatchVisualizer/DomLabel.lua b/plugin/src/App/Components/PatchVisualizer/DomLabel.lua index 41ec0b8e9..a842dcbc5 100644 --- a/plugin/src/App/Components/PatchVisualizer/DomLabel.lua +++ b/plugin/src/App/Components/PatchVisualizer/DomLabel.lua @@ -1,5 +1,4 @@ local SelectionService = game:GetService("Selection") -local StudioService = game:GetService("StudioService") local Rojo = script:FindFirstAncestor("Rojo") local Plugin = Rojo.Plugin @@ -15,7 +14,8 @@ local bindingUtil = require(Plugin.App.bindingUtil) local e = Roact.createElement local ChangeList = require(script.Parent.ChangeList) -local Tooltip = require(script.Parent.Parent.Tooltip) +local Tooltip = require(Plugin.App.Components.Tooltip) +local ClassIcon = require(Plugin.App.Components.ClassIcon) local Expansion = Roact.Component:extend("Expansion") @@ -28,8 +28,8 @@ function Expansion:render() return e("Frame", { BackgroundTransparency = 1, - Size = UDim2.new(1, -props.indent, 1, -30), - Position = UDim2.new(0, props.indent, 0, 30), + Size = UDim2.new(1, -props.indent, 1, -24), + Position = UDim2.new(0, props.indent, 0, 24), }, { ChangeList = e(ChangeList, { changes = props.changeList, @@ -44,7 +44,7 @@ local DomLabel = Roact.Component:extend("DomLabel") function DomLabel:init() local initHeight = self.props.elementHeight:getValue() - self.expanded = initHeight > 30 + self.expanded = initHeight > 24 self.motor = Flipper.SingleMotor.new(initHeight) self.binding = bindingUtil.fromMotor(self.motor) @@ -53,7 +53,7 @@ function DomLabel:init() renderExpansion = self.expanded, }) self.motor:onStep(function(value) - local renderExpansion = value > 30 + local renderExpansion = value > 24 self.props.setElementHeight(value) if self.props.updateEvent then @@ -81,7 +81,7 @@ function DomLabel:didUpdate(prevProps) then -- Close the expansion when the domlabel is changed to a different thing self.expanded = false - self.motor:setGoal(Flipper.Spring.new(30, { + self.motor:setGoal(Flipper.Spring.new(24, { frequency = 5, dampingRatio = 1, })) @@ -90,17 +90,49 @@ end function DomLabel:render() local props = self.props + local depth = props.depth or 1 return Theme.with(function(theme) - local iconProps = StudioService:GetClassIcon(props.className) - local indent = (props.depth or 0) * 20 + 25 + local color = if props.isWarning + then theme.Diff.Warning + elseif props.patchType then theme.Diff[props.patchType] + else theme.TextColor + + local indent = (depth - 1) * 12 + 15 -- Line guides help indent depth remain readable local lineGuides = {} - for i = 1, props.depth or 0 do - lineGuides["Line_" .. i] = e("Frame", { - Size = UDim2.new(0, 2, 1, 2), - Position = UDim2.new(0, (20 * i) + 15, 0, -1), + for i = 2, depth do + if props.depthsComplete[i] then + continue + end + if props.isFinalChild and i == depth then + -- This line stops halfway down to merge with our connector for the right angle + lineGuides["Line_" .. i] = e("Frame", { + Size = UDim2.new(0, 2, 0, 15), + Position = UDim2.new(0, (12 * (i - 1)) + 6, 0, -1), + BorderSizePixel = 0, + BackgroundTransparency = props.transparency, + BackgroundColor3 = theme.BorderedContainer.BorderColor, + }) + else + -- All other lines go all the way + -- with the exception of the final element, which stops halfway down + lineGuides["Line_" .. i] = e("Frame", { + Size = UDim2.new(0, 2, 1, if props.isFinalElement then -9 else 2), + Position = UDim2.new(0, (12 * (i - 1)) + 6, 0, -1), + BorderSizePixel = 0, + BackgroundTransparency = props.transparency, + BackgroundColor3 = theme.BorderedContainer.BorderColor, + }) + end + end + + if depth ~= 1 then + lineGuides["Connector"] = e("Frame", { + Size = UDim2.new(0, 8, 0, 2), + Position = UDim2.new(0, 2 + (12 * props.depth), 0, 12), + AnchorPoint = Vector2.xAxis, BorderSizePixel = 0, BackgroundTransparency = props.transparency, BackgroundColor3 = theme.BorderedContainer.BorderColor, @@ -109,9 +141,8 @@ function DomLabel:render() return e("Frame", { ClipsDescendants = true, - BackgroundColor3 = if props.patchType then theme.Diff[props.patchType] else nil, - BorderSizePixel = 0, - BackgroundTransparency = props.patchType and props.transparency or 1, + BackgroundTransparency = if props.elementIndex % 2 == 0 then 0.985 else 1, + BackgroundColor3 = theme.Diff.Row, Size = self.binding:map(function(expand) return UDim2.new(1, 0, 0, expand) end), @@ -141,8 +172,8 @@ function DomLabel:render() 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) + local goalHeight = 24 + + (if self.expanded then math.clamp(#props.changeList * 24, 24, 24 * 6) else 0) self.motor:setGoal(Flipper.Spring.new(goalHeight, { frequency = 5, dampingRatio = 1, @@ -174,40 +205,74 @@ function DomLabel:render() DiffIcon = if props.patchType then e("ImageLabel", { Image = Assets.Images.Diff[props.patchType], - ImageColor3 = theme.AddressEntry.PlaceholderColor, + ImageColor3 = color, ImageTransparency = props.transparency, BackgroundTransparency = 1, - Size = UDim2.new(0, 20, 0, 20), - Position = UDim2.new(0, 0, 0, 15), + Size = UDim2.new(0, 14, 0, 14), + Position = UDim2.new(0, 0, 0, 12), AnchorPoint = Vector2.new(0, 0.5), }) else nil, - ClassIcon = e("ImageLabel", { - Image = iconProps.Image, - ImageTransparency = props.transparency, - ImageRectOffset = iconProps.ImageRectOffset, - ImageRectSize = iconProps.ImageRectSize, - BackgroundTransparency = 1, - Size = UDim2.new(0, 20, 0, 20), - Position = UDim2.new(0, indent, 0, 15), - AnchorPoint = Vector2.new(0, 0.5), + ClassIcon = e(ClassIcon, { + className = props.className, + color = color, + transparency = props.transparency, + size = UDim2.new(0, 16, 0, 16), + position = UDim2.new(0, indent + 2, 0, 12), + anchorPoint = Vector2.new(0, 0.5), }), InstanceName = e("TextLabel", { - Text = (if props.isWarning then "⚠ " else "") .. props.name .. (props.hint and string.format( - ' %s', - theme.AddressEntry.PlaceholderColor:ToHex(), - props.hint - ) or ""), + Text = (if props.isWarning then "⚠ " else "") .. props.name, RichText = true, BackgroundTransparency = 1, - Font = Enum.Font.GothamMedium, + Font = if props.patchType then Enum.Font.GothamBold else Enum.Font.GothamMedium, TextSize = 14, - TextColor3 = if props.isWarning then theme.Diff.Warning else theme.TextColor, + TextColor3 = color, TextXAlignment = Enum.TextXAlignment.Left, TextTransparency = props.transparency, TextTruncate = Enum.TextTruncate.AtEnd, - Size = UDim2.new(1, -indent - 50, 0, 30), - Position = UDim2.new(0, indent + 30, 0, 0), + Size = UDim2.new(1, -indent - 50, 0, 24), + Position = UDim2.new(0, indent + 22, 0, 0), + }), + ChangeInfo = e("Frame", { + BackgroundTransparency = 1, + Size = UDim2.new(1, -indent - 80, 0, 24), + Position = UDim2.new(1, -2, 0, 0), + AnchorPoint = Vector2.new(1, 0), + }, { + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Horizontal, + HorizontalAlignment = Enum.HorizontalAlignment.Right, + VerticalAlignment = Enum.VerticalAlignment.Center, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 4), + }), + Edits = if props.changeInfo and props.changeInfo.edits + then e("TextLabel", { + Text = props.changeInfo.edits .. if props.changeInfo.failed then "," else "", + BackgroundTransparency = 1, + Font = Enum.Font.Gotham, + TextSize = 14, + TextColor3 = theme.SubTextColor, + TextTransparency = props.transparency, + Size = UDim2.new(0, 0, 0, 16), + AutomaticSize = Enum.AutomaticSize.X, + LayoutOrder = 2, + }) + else nil, + Failed = if props.changeInfo and props.changeInfo.failed + then e("TextLabel", { + Text = props.changeInfo.failed, + BackgroundTransparency = 1, + Font = Enum.Font.Gotham, + TextSize = 14, + TextColor3 = theme.Diff.Warning, + TextTransparency = props.transparency, + Size = UDim2.new(0, 0, 0, 16), + AutomaticSize = Enum.AutomaticSize.X, + LayoutOrder = 6, + }) + else nil, }), LineGuides = e("Folder", nil, lineGuides), }) diff --git a/plugin/src/App/Components/PatchVisualizer/init.lua b/plugin/src/App/Components/PatchVisualizer/init.lua index 67a4d7b20..a87d499bb 100644 --- a/plugin/src/App/Components/PatchVisualizer/init.lua +++ b/plugin/src/App/Components/PatchVisualizer/init.lua @@ -8,8 +8,8 @@ local PatchTree = require(Plugin.PatchTree) local PatchSet = require(Plugin.PatchSet) local Theme = require(Plugin.App.Theme) -local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local VirtualScroller = require(Plugin.App.Components.VirtualScroller) +local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local e = Roact.createElement @@ -55,34 +55,60 @@ function PatchVisualizer:render() end -- Recusively draw tree - local scrollElements, elementHeights = {}, {} + local scrollElements, elementHeights, elementIndex = {}, {}, 0 if patchTree then + local elementTotal = patchTree:getCount() + local depthsComplete = {} 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, - showStringDiff = self.props.showStringDiff, - showTableDiff = self.props.showTableDiff, - }) - ) + elementIndex += 1 + + local parentNode = patchTree:getNode(node.parentId) + local isFinalChild = true + if parentNode then + for _id, sibling in parentNode.children do + if type(sibling) == "table" and sibling.name and sibling.name > node.name then + isFinalChild = false + break + end + end + end + + local elementHeight, setElementHeight = Roact.createBinding(24) + elementHeights[elementIndex] = elementHeight + scrollElements[elementIndex] = e(DomLabel, { + transparency = self.props.transparency, + showStringDiff = self.props.showStringDiff, + showTableDiff = self.props.showTableDiff, + updateEvent = self.updateEvent, + elementHeight = elementHeight, + setElementHeight = setElementHeight, + elementIndex = elementIndex, + isFinalElement = elementIndex == elementTotal, + depth = depth, + depthsComplete = table.clone(depthsComplete), + hasChildren = (node.children ~= nil and next(node.children) ~= nil), + isFinalChild = isFinalChild, + patchType = node.patchType, + className = node.className, + isWarning = node.isWarning, + instance = node.instance, + name = node.name, + changeInfo = node.changeInfo, + changeList = node.changeList, + }) + + if isFinalChild then + depthsComplete[depth] = true + end end patchTree:forEach(function(node, depth) + depthsComplete[depth] = false + for i = depth + 1, #depthsComplete do + depthsComplete[i] = nil + end + drawNode(node, depth) end) end @@ -92,6 +118,7 @@ function PatchVisualizer:render() transparency = self.props.transparency, size = self.props.size, position = self.props.position, + anchorPoint = self.props.anchorPoint, layoutOrder = self.props.layoutOrder, }, { CleanMerge = e("TextLabel", { @@ -106,7 +133,8 @@ function PatchVisualizer:render() }), VirtualScroller = e(VirtualScroller, { - size = UDim2.new(1, 0, 1, 0), + size = UDim2.new(1, 0, 1, -2), + position = UDim2.new(0, 0, 0, 2), transparency = self.props.transparency, count = #scrollElements, updateEvent = self.updateEvent.Event, diff --git a/plugin/src/App/Components/VirtualScroller.lua b/plugin/src/App/Components/VirtualScroller.lua index 69bcf5f10..466da8a4f 100644 --- a/plugin/src/App/Components/VirtualScroller.lua +++ b/plugin/src/App/Components/VirtualScroller.lua @@ -131,8 +131,8 @@ function VirtualScroller:render() Position = props.position, AnchorPoint = props.anchorPoint, BackgroundTransparency = props.backgroundTransparency or 1, - BackgroundColor3 = props.backgroundColor3, - BorderColor3 = props.borderColor3, + BackgroundColor3 = props.backgroundColor3 or theme.BorderedContainer.BackgroundColor, + BorderColor3 = props.borderColor3 or theme.BorderedContainer.BorderColor, CanvasSize = self.totalCanvas:map(function(s) return UDim2.fromOffset(0, s) end), diff --git a/plugin/src/App/StatusPages/Confirming.lua b/plugin/src/App/StatusPages/Confirming.lua index bd0fd0a13..df8bf5b49 100644 --- a/plugin/src/App/StatusPages/Confirming.lua +++ b/plugin/src/App/StatusPages/Confirming.lua @@ -9,7 +9,6 @@ local PatchTree = require(Plugin.PatchTree) local Settings = require(Plugin.Settings) local Theme = require(Plugin.App.Theme) local TextButton = require(Plugin.App.Components.TextButton) -local Header = require(Plugin.App.Components.Header) local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui) local Tooltip = require(Plugin.App.Components.Tooltip) local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer) @@ -60,17 +59,11 @@ end function ConfirmingPage:render() return Theme.with(function(theme) local pageContent = Roact.createFragment({ - Header = e(Header, { - transparency = self.props.transparency, - layoutOrder = 1, - }), - Title = e("TextLabel", { Text = string.format( "Sync changes for project '%s':", self.props.confirmData.serverInfo.projectName or "UNKNOWN" ), - LayoutOrder = 2, Font = Enum.Font.Gotham, LineHeight = 1.2, TextSize = 14, @@ -82,7 +75,7 @@ function ConfirmingPage:render() }), PatchVisualizer = e(PatchVisualizer, { - size = UDim2.new(1, 0, 1, -150), + size = UDim2.new(1, 0, 1, -100), transparency = self.props.transparency, layoutOrder = 3, @@ -155,6 +148,11 @@ function ConfirmingPage:render() }), }), + Padding = e("UIPadding", { + PaddingLeft = UDim.new(0, 8), + PaddingRight = UDim.new(0, 8), + }), + Layout = e("UIListLayout", { HorizontalAlignment = Enum.HorizontalAlignment.Center, VerticalAlignment = Enum.VerticalAlignment.Center, @@ -163,11 +161,6 @@ function ConfirmingPage:render() Padding = UDim.new(0, 10), }), - Padding = e("UIPadding", { - PaddingLeft = UDim.new(0, 20), - PaddingRight = UDim.new(0, 20), - }), - StringDiff = e(StudioPluginGui, { id = "Rojo_ConfirmingStringDiff", title = "String diff", diff --git a/plugin/src/App/StatusPages/Connected.lua b/plugin/src/App/StatusPages/Connected.lua index d8055b3f3..3f84d15f4 100644 --- a/plugin/src/App/StatusPages/Connected.lua +++ b/plugin/src/App/StatusPages/Connected.lua @@ -3,9 +3,7 @@ local Plugin = Rojo.Plugin local Packages = Rojo.Packages local Roact = require(Packages.Roact) -local Flipper = require(Packages.Flipper) -local bindingUtil = require(Plugin.App.bindingUtil) local Theme = require(Plugin.App.Theme) local Assets = require(Plugin.Assets) local PatchSet = require(Plugin.PatchSet) @@ -23,28 +21,20 @@ local TableDiffVisualizer = require(Plugin.App.Components.TableDiffVisualizer) local e = Roact.createElement local AGE_UNITS = { - { 31556909, "year" }, - { 2629743, "month" }, - { 604800, "week" }, - { 86400, "day" }, - { 3600, "hour" }, - { - 60, - "minute", - }, + { 31556909, "y" }, + { 2629743, "mon" }, + { 604800, "w" }, + { 86400, "d" }, + { 3600, "h" }, + { 60, "m" }, } function timeSinceText(elapsed: number): string - if elapsed < 3 then - return "just now" - end - - local ageText = string.format("%d seconds ago", elapsed) + local ageText = string.format("%ds", elapsed) for _, UnitData in ipairs(AGE_UNITS) do local UnitSeconds, UnitName = UnitData[1], UnitData[2] if elapsed > UnitSeconds then - local c = math.floor(elapsed / UnitSeconds) - ageText = string.format("%d %s%s ago", c, UnitName, c > 1 and "s" or "") + ageText = elapsed // UnitSeconds .. UnitName break end end @@ -52,49 +42,179 @@ function timeSinceText(elapsed: number): string return ageText end -local ChangesDrawer = Roact.Component:extend("ChangesDrawer") +local ChangesViewer = Roact.Component:extend("ChangesViewer") -function ChangesDrawer:init() +function ChangesViewer:init() -- Hold onto the serve session during the lifecycle of this component -- so that it can still render during the fade out after disconnecting self.serveSession = self.props.serveSession end -function ChangesDrawer:render() - if self.props.rendered == false or self.serveSession == nil then +function ChangesViewer:render() + if self.props.rendered == false or self.serveSession == nil or self.props.patchData == nil then return nil end + local unapplied = PatchSet.countChanges(self.props.patchData.unapplied) + local applied = PatchSet.countChanges(self.props.patchData.patch) - unapplied + return Theme.with(function(theme) - return e(BorderedContainer, { - transparency = self.props.transparency, - size = self.props.height:map(function(y) - return UDim2.new(1, 0, y, -220 * y) - end), - position = UDim2.new(0, 0, 1, 0), - anchorPoint = Vector2.new(0, 1), - layoutOrder = self.props.layoutOrder, - }, { - Close = e(IconButton, { - icon = Assets.Images.Icons.Close, - iconSize = 24, - color = theme.ConnectionDetails.DisconnectColor, - transparency = self.props.transparency, + return Roact.createFragment({ + Navbar = e("Frame", { + Size = UDim2.new(1, 0, 0, 40), + BackgroundTransparency = 1, + }, { + Close = e(IconButton, { + icon = Assets.Images.Icons.Close, + iconSize = 24, + color = theme.Settings.Navbar.BackButtonColor, + transparency = self.props.transparency, - position = UDim2.new(1, 0, 0, 0), - anchorPoint = Vector2.new(1, 0), + position = UDim2.new(0, 0, 0.5, 0), + anchorPoint = Vector2.new(0, 0.5), - onClick = self.props.onClose, - }, { - Tip = e(Tooltip.Trigger, { - text = "Close the patch visualizer", + onClick = self.props.onBack, + }, { + Tip = e(Tooltip.Trigger, { + text = "Close", + }), + }), + + Title = e("TextLabel", { + Text = "Sync", + Font = Enum.Font.GothamMedium, + TextSize = 17, + TextXAlignment = Enum.TextXAlignment.Left, + TextColor3 = theme.TextColor, + TextTransparency = self.props.transparency, + Size = UDim2.new(1, -40, 0, 20), + Position = UDim2.new(0, 40, 0, 0), + BackgroundTransparency = 1, + }), + + Subtitle = e("TextLabel", { + Text = DateTime.fromUnixTimestamp(self.props.patchData.timestamp):FormatLocalTime("LTS", "en-us"), + TextXAlignment = Enum.TextXAlignment.Left, + Font = Enum.Font.Gotham, + TextSize = 15, + TextColor3 = theme.SubTextColor, + TextTruncate = Enum.TextTruncate.AtEnd, + TextTransparency = self.props.transparency, + Size = UDim2.new(1, -40, 0, 16), + Position = UDim2.new(0, 40, 0, 20), + BackgroundTransparency = 1, + }), + + Info = e("Frame", { + BackgroundTransparency = 1, + Size = UDim2.new(0, 10, 0, 24), + AutomaticSize = Enum.AutomaticSize.X, + Position = UDim2.new(1, -5, 0.5, 0), + AnchorPoint = Vector2.new(1, 0.5), + }, { + Tooltip = e(Tooltip.Trigger, { + text = `{applied} changes applied` + .. (if unapplied > 0 then `, {unapplied} changes failed` else ""), + }), + Content = e("Frame", { + BackgroundTransparency = 1, + Size = UDim2.new(0, 0, 1, 0), + AutomaticSize = Enum.AutomaticSize.X, + }, { + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Horizontal, + HorizontalAlignment = Enum.HorizontalAlignment.Right, + VerticalAlignment = Enum.VerticalAlignment.Center, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 4), + }), + + StatusIcon = e("ImageLabel", { + BackgroundTransparency = 1, + Image = if unapplied > 0 + then Assets.Images.Icons.SyncWarning + else Assets.Images.Icons.SyncSuccess, + ImageColor3 = if unapplied > 0 then theme.Diff.Warning else theme.TextColor, + Size = UDim2.new(0, 24, 0, 24), + LayoutOrder = 10, + }), + StatusSpacer = e("Frame", { + BackgroundTransparency = 1, + Size = UDim2.new(0, 6, 0, 4), + LayoutOrder = 9, + }), + AppliedIcon = e("ImageLabel", { + BackgroundTransparency = 1, + Image = Assets.Images.Icons.Checkmark, + ImageColor3 = theme.TextColor, + Size = UDim2.new(0, 16, 0, 16), + LayoutOrder = 1, + }), + AppliedText = e("TextLabel", { + Text = applied, + Font = Enum.Font.Gotham, + TextSize = 15, + TextColor3 = theme.TextColor, + TextTransparency = self.props.transparency, + Size = UDim2.new(0, 0, 1, 0), + AutomaticSize = Enum.AutomaticSize.X, + BackgroundTransparency = 1, + LayoutOrder = 2, + }), + Warnings = if unapplied > 0 + then Roact.createFragment({ + WarningsSpacer = e("Frame", { + BackgroundTransparency = 1, + Size = UDim2.new(0, 4, 0, 4), + LayoutOrder = 3, + }), + UnappliedIcon = e("ImageLabel", { + BackgroundTransparency = 1, + Image = Assets.Images.Icons.Exclamation, + ImageColor3 = theme.Diff.Warning, + Size = UDim2.new(0, 4, 0, 16), + LayoutOrder = 4, + }), + UnappliedText = e("TextLabel", { + Text = unapplied, + Font = Enum.Font.Gotham, + TextSize = 15, + TextColor3 = theme.Diff.Warning, + TextTransparency = self.props.transparency, + Size = UDim2.new(0, 0, 1, 0), + AutomaticSize = Enum.AutomaticSize.X, + BackgroundTransparency = 1, + LayoutOrder = 5, + }), + }) + else nil, + }), + }), + + Divider = e("Frame", { + BackgroundColor3 = theme.Settings.DividerColor, + BackgroundTransparency = self.props.transparency, + Size = UDim2.new(1, 0, 0, 1), + Position = UDim2.new(0, 0, 1, 0), + BorderSizePixel = 0, + }, { + Gradient = e("UIGradient", { + Transparency = NumberSequence.new({ + NumberSequenceKeypoint.new(0, 1), + NumberSequenceKeypoint.new(0.1, 0), + NumberSequenceKeypoint.new(0.9, 0), + NumberSequenceKeypoint.new(1, 1), + }), + }), }), }), - PatchVisualizer = e(PatchVisualizer, { - size = UDim2.new(1, 0, 1, 0), + Patch = e(PatchVisualizer, { + size = UDim2.new(1, -10, 1, -65), + position = UDim2.new(0, 5, 1, -5), + anchorPoint = Vector2.new(0, 1), transparency = self.props.transparency, - layoutOrder = 3, + layoutOrder = self.props.layoutOrder, patchTree = self.props.patchTree, @@ -167,20 +287,7 @@ function ConnectedPage:getChangeInfoText() if patchData == nil then return "" end - - local elapsed = os.time() - patchData.timestamp - local unapplied = PatchSet.countChanges(patchData.unapplied) - - return "Synced " - .. timeSinceText(elapsed) - .. (if unapplied > 0 - then string.format( - ', but %d change%s failed to apply', - unapplied, - unapplied == 1 and "" or "s" - ) - else "") - .. "" + return timeSinceText(DateTime.now().UnixTimestamp - patchData.timestamp) end function ConnectedPage:startChangeInfoTextUpdater() @@ -190,13 +297,9 @@ function ConnectedPage:startChangeInfoTextUpdater() -- 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 + self.setChangeInfoText(self:getChangeInfoText()) - local elapsed = os.time() - self.props.patchData.timestamp + local elapsed = DateTime.now().UnixTimestamp - self.props.patchData.timestamp local updateInterval = 1 -- Update timestamp text as frequently as currently needed @@ -221,23 +324,6 @@ function ConnectedPage:stopChangeInfoTextUpdater() end function ConnectedPage:init() - self.changeDrawerMotor = Flipper.SingleMotor.new(0) - self.changeDrawerHeight = bindingUtil.fromMotor(self.changeDrawerMotor) - - self.changeDrawerMotor:onStep(function(value) - local renderChanges = value > 0.05 - - self:setState(function(state) - if state.renderChanges == renderChanges then - return nil - end - - return { - renderChanges = renderChanges, - } - end) - end) - self:setState({ renderChanges = false, hoveringChangeInfo = false, @@ -266,6 +352,10 @@ function ConnectedPage:didUpdate(previousProps) end function ConnectedPage:render() + local syncWarning = self.props.patchData + and self.props.patchData.unapplied + and PatchSet.countChanges(self.props.patchData.unapplied) > 0 + return Theme.with(function(theme) return Roact.createFragment({ Padding = e("UIPadding", { @@ -280,9 +370,88 @@ function ConnectedPage:render() Padding = UDim.new(0, 10), }), - Header = e(Header, { - transparency = self.props.transparency, - layoutOrder = 1, + Heading = e("Frame", { + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, 32), + }, { + Header = e(Header, { + transparency = self.props.transparency, + }), + + ChangeInfo = e("TextButton", { + Text = "", + Size = UDim2.new(0, 0, 1, 0), + AutomaticSize = Enum.AutomaticSize.X, + BackgroundColor3 = theme.BorderedContainer.BorderedColor, + BackgroundTransparency = if self.state.hoveringChangeInfo then 0.7 else 1, + BorderSizePixel = 0, + Position = UDim2.new(1, -5, 0.5, 0), + AnchorPoint = Vector2.new(1, 0.5), + [Roact.Event.MouseEnter] = function() + self:setState({ + hoveringChangeInfo = true, + }) + end, + [Roact.Event.MouseLeave] = function() + self:setState({ + hoveringChangeInfo = false, + }) + end, + [Roact.Event.Activated] = function() + self:setState(function(prevState) + prevState = prevState or {} + return { + renderChanges = not prevState.renderChanges, + } + end) + end, + }, { + Corner = e("UICorner", { + CornerRadius = UDim.new(0, 5), + }), + Tooltip = e(Tooltip.Trigger, { + text = if self.state.renderChanges then "Hide changes" else "View changes", + }), + Content = e("Frame", { + BackgroundTransparency = 1, + Size = UDim2.new(0, 0, 1, 0), + AutomaticSize = Enum.AutomaticSize.X, + }, { + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Horizontal, + HorizontalAlignment = Enum.HorizontalAlignment.Center, + VerticalAlignment = Enum.VerticalAlignment.Center, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 5), + }), + Padding = e("UIPadding", { + PaddingLeft = UDim.new(0, 5), + PaddingRight = UDim.new(0, 5), + }), + Text = e("TextLabel", { + BackgroundTransparency = 1, + Text = self.changeInfoText, + Font = Enum.Font.Gotham, + TextSize = 15, + TextColor3 = if syncWarning then theme.Diff.Warning else theme.Header.VersionColor, + TextTransparency = self.props.transparency, + TextXAlignment = Enum.TextXAlignment.Right, + Size = UDim2.new(0, 0, 1, 0), + AutomaticSize = Enum.AutomaticSize.X, + LayoutOrder = 1, + }), + Icon = e("ImageLabel", { + BackgroundTransparency = 1, + Image = if syncWarning + then Assets.Images.Icons.SyncWarning + else Assets.Images.Icons.SyncSuccess, + ImageColor3 = if syncWarning then theme.Diff.Warning else theme.Header.VersionColor, + ImageTransparency = self.props.transparency, + Size = UDim2.new(0, 24, 0, 24), + LayoutOrder = 2, + }), + }), + }), }), ConnectionDetails = e(ConnectionDetails, { @@ -332,86 +501,61 @@ function ConnectedPage:render() }), }), - ChangeInfo = e("TextButton", { - Text = self.changeInfoText, - Font = Enum.Font.Gotham, - TextSize = 14, - TextWrapped = true, - RichText = true, - TextColor3 = theme.Header.VersionColor, - TextXAlignment = Enum.TextXAlignment.Left, - TextYAlignment = Enum.TextYAlignment.Top, - TextTransparency = self.props.transparency, - - Size = UDim2.new(1, 0, 0, 28), + ChangesViewer = e(StudioPluginGui, { + id = "Rojo_ChangesViewer", + title = "View changes", + active = self.state.renderChanges, + isEphemeral = true, - LayoutOrder = 4, - BackgroundTransparency = 1, + initDockState = Enum.InitialDockState.Float, + overridePreviousState = true, + floatingSize = Vector2.new(400, 500), + minimumSize = Vector2.new(300, 300), - [Roact.Event.MouseEnter] = function() - self:setState({ - hoveringChangeInfo = true, - }) - self.setChangeInfoText("" .. self:getChangeInfoText() .. "") - end, + zIndexBehavior = Enum.ZIndexBehavior.Sibling, - [Roact.Event.MouseLeave] = function() + onClose = function() self:setState({ - hoveringChangeInfo = false, + renderChanges = false, }) - self.setChangeInfoText(self:getChangeInfoText()) - end, - - [Roact.Event.Activated] = function() - if self.state.renderChanges then - self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, { - frequency = 4, - dampingRatio = 1, - })) - else - self.changeDrawerMotor:setGoal(Flipper.Spring.new(1, { - frequency = 3, - dampingRatio = 1, - })) - end end, }, { - Tooltip = e(Tooltip.Trigger, { - text = if self.state.renderChanges then "Hide the changes" else "View the changes", + TooltipsProvider = e(Tooltip.Provider, nil, { + Tooltips = e(Tooltip.Container, nil), + Content = e("Frame", { + Size = UDim2.fromScale(1, 1), + BackgroundTransparency = 1, + }, { + Changes = e(ChangesViewer, { + transparency = self.props.transparency, + rendered = self.state.renderChanges, + patchData = self.props.patchData, + patchTree = self.props.patchTree, + serveSession = self.props.serveSession, + showStringDiff = function(oldString: string, newString: string) + self:setState({ + showingStringDiff = true, + oldString = oldString, + newString = newString, + }) + end, + showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? }) + self:setState({ + showingTableDiff = true, + oldTable = oldTable, + newTable = newTable, + }) + end, + onBack = function() + self:setState({ + renderChanges = false, + }) + end, + }), + }), }), }), - ChangesDrawer = e(ChangesDrawer, { - rendered = self.state.renderChanges, - transparency = self.props.transparency, - patchTree = self.props.patchTree, - serveSession = self.props.serveSession, - height = self.changeDrawerHeight, - layoutOrder = 5, - - showStringDiff = function(oldString: string, newString: string) - self:setState({ - showingStringDiff = true, - oldString = oldString, - newString = newString, - }) - end, - showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? }) - self:setState({ - showingTableDiff = true, - oldTable = oldTable, - newTable = newTable, - }) - end, - - onClose = function() - self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, { - frequency = 4, - dampingRatio = 1, - })) - end, - }), - StringDiff = e(StudioPluginGui, { id = "Rojo_ConnectedStringDiff", title = "String diff", diff --git a/plugin/src/App/Theme.lua b/plugin/src/App/Theme.lua index f1e9c3971..84a429c6d 100644 --- a/plugin/src/App/Theme.lua +++ b/plugin/src/App/Theme.lua @@ -32,9 +32,12 @@ local StudioProvider = Roact.Component:extend("StudioProvider") function StudioProvider:updateTheme() local studioTheme = getStudio().Theme + local isDark = studioTheme.Name == "Dark" + local theme = strict(studioTheme.Name .. "Theme", { BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainBackground), TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainText), + SubTextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.SubText), Button = { Solid = { -- Solid uses brand theming, not Studio theming. @@ -139,9 +142,10 @@ function StudioProvider:updateTheme() BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.InputFieldBackground), }, Diff = { - Add = studioTheme:GetColor(Enum.StudioStyleGuideColor.DiffTextAdditionBackground), - Remove = studioTheme:GetColor(Enum.StudioStyleGuideColor.DiffTextDeletionBackground), - Edit = studioTheme:GetColor(Enum.StudioStyleGuideColor.DiffLineNumSeparatorBackground), + -- Studio doesn't have good colors since their diffs use backgrounds, not text + Add = if isDark then Color3.fromRGB(143, 227, 154) else Color3.fromRGB(41, 164, 45), + Remove = if isDark then Color3.fromRGB(242, 125, 125) else Color3.fromRGB(150, 29, 29), + Edit = if isDark then Color3.fromRGB(120, 154, 248) else Color3.fromRGB(0, 70, 160), Row = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText), Warning = studioTheme:GetColor(Enum.StudioStyleGuideColor.WarningText), }, diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index a9c20ce95..d80bf0169 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -457,7 +457,7 @@ function App:startSession() end) serveSession:hookPostcommit(function(patch, _instanceMap, unapplied) - local now = os.time() + local now = DateTime.now().UnixTimestamp local old = self.state.patchData if PatchSet.isEmpty(patch) then diff --git a/plugin/src/Assets.lua b/plugin/src/Assets.lua index dbc60a166..91f82d1ab 100644 --- a/plugin/src/Assets.lua +++ b/plugin/src/Assets.lua @@ -25,6 +25,10 @@ local Assets = { Back = "rbxassetid://6017213752", Reset = "rbxassetid://10142422327", Expand = "rbxassetid://12045401097", + Checkmark = "rbxassetid://16571012729", + Exclamation = "rbxassetid://16571172190", + SyncSuccess = "rbxassetid://16565035221", + SyncWarning = "rbxassetid://16565325171", }, Diff = { Add = "rbxassetid://10434145835", diff --git a/plugin/src/PatchSet.lua b/plugin/src/PatchSet.lua index abbac0cbb..064def36c 100644 --- a/plugin/src/PatchSet.lua +++ b/plugin/src/PatchSet.lua @@ -211,9 +211,11 @@ end function PatchSet.countChanges(patch) local count = 0 - for _ in patch.added do - -- Adding an instance is 1 change - count += 1 + for _, add in patch.added do + -- Adding an instance is 1 change per property + for _ in add.Properties do + count += 1 + end end for _ in patch.removed do -- Removing an instance is 1 change diff --git a/plugin/src/PatchTree.lua b/plugin/src/PatchTree.lua index 6d871001d..c1c5f91ca 100644 --- a/plugin/src/PatchTree.lua +++ b/plugin/src/PatchTree.lua @@ -79,6 +79,15 @@ function Tree.new() return setmetatable(tree, Tree) end +-- Iterates over all nodes and counts them up +function Tree:getCount() + local count = 0 + self:forEach(function() + count += 1 + end) + return count +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 @@ -219,42 +228,14 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) tree:buildAncestryNodes(previousId, ancestryIds, patch, instanceMap) -- Gather detail text - local changeList, hint = nil, nil + local changeList, changeInfo = nil, nil if next(change.changedProperties) or change.changedName then changeList = {} - local hintBuffer, hintBufferSize, hintOverflow = table.create(3), 0, 0 local changeIndex = 0 local function addProp(prop: string, current: any?, incoming: any?, metadata: any?) changeIndex += 1 changeList[changeIndex] = { prop, current, incoming, metadata } - - if hintBufferSize < 3 then - hintBufferSize += 1 - hintBuffer[hintBufferSize] = prop - return - end - - -- We only want to have 3 hints - -- to keep it deterministic, we sort them alphabetically - - -- Either this prop overflows, or it makes another one move to overflow - hintOverflow += 1 - - -- Shortcut for the common case - if hintBuffer[3] <= prop then - -- This prop is below the last hint, no need to insert - return - end - - -- Find the first available spot - for i, hintItem in hintBuffer do - if prop < hintItem then - -- This prop is before the currently selected hint, - -- so take its place and then continue to find a spot for the old hint - hintBuffer[i], prop = prop, hintBuffer[i] - end - end end -- Gather the changes @@ -274,8 +255,9 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) ) end - -- Finalize detail values - hint = table.concat(hintBuffer, ", ") .. (if hintOverflow == 0 then "" else ", " .. hintOverflow .. " more") + changeInfo = { + edits = changeIndex, + } -- Sort changes and add header table.sort(changeList, function(a, b) @@ -291,7 +273,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) className = instance.ClassName, name = instance.Name, instance = instance, - hint = hint, + changeInfo = changeInfo, changeList = changeList, }) end @@ -376,42 +358,14 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) tree:buildAncestryNodes(previousId, ancestryIds, patch, instanceMap) -- Gather detail text - local changeList, hint = nil, nil + local changeList, changeInfo = nil, nil if next(change.Properties) then changeList = {} - local hintBuffer, hintBufferSize, hintOverflow = table.create(3), 0, 0 local changeIndex = 0 local function addProp(prop: string, incoming: any) changeIndex += 1 changeList[changeIndex] = { prop, "N/A", incoming } - - if hintBufferSize < 3 then - hintBufferSize += 1 - hintBuffer[hintBufferSize] = prop - return - end - - -- We only want to have 3 hints - -- to keep it deterministic, we sort them alphabetically - - -- Either this prop overflows, or it makes another one move to overflow - hintOverflow += 1 - - -- Shortcut for the common case - if hintBuffer[3] <= prop then - -- This prop is below the last hint, no need to insert - return - end - - -- Find the first available spot - for i, hintItem in hintBuffer do - if prop < hintItem then - -- This prop is before the currently selected hint, - -- so take its place and then continue to find a spot for the old hint - hintBuffer[i], prop = prop, hintBuffer[i] - end - end end for prop, incoming in change.Properties do @@ -419,8 +373,9 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) addProp(prop, if success then incomingValue else select(2, next(incoming))) end - -- Finalize detail values - hint = table.concat(hintBuffer, ", ") .. (if hintOverflow == 0 then "" else ", " .. hintOverflow .. " more") + changeInfo = { + edits = changeIndex, + } -- Sort changes and add header table.sort(changeList, function(a, b) @@ -435,7 +390,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) patchType = "Add", className = change.ClassName, name = change.Name, - hint = hint, + changeInfo = changeInfo, changeList = changeList, instance = instanceMap.fromIds[id], }) @@ -473,6 +428,8 @@ function PatchTree.updateMetadata(tree, patch, instanceMap, unappliedPatch) if not node.changeList then continue end + + local warnings = 0 for _, change in node.changeList do local property = change[1] local propertyFailedToApply = if property == "Name" @@ -483,6 +440,8 @@ function PatchTree.updateMetadata(tree, patch, instanceMap, unappliedPatch) -- This change didn't fail, no need to mark continue end + + warnings += 1 if change[4] == nil then change[4] = { isWarning = true } else @@ -490,6 +449,11 @@ function PatchTree.updateMetadata(tree, patch, instanceMap, unappliedPatch) end Log.trace(" Marked property as warning: {}.{}", node.name, property) end + + node.changeInfo = { + edits = (node.changeInfo.edits or (#node.changeList - 1)) - warnings, + failed = if warnings > 0 then warnings else nil, + } end for failedAdditionId in unappliedPatch.added do local node = tree:getNode(failedAdditionId) @@ -503,6 +467,7 @@ function PatchTree.updateMetadata(tree, patch, instanceMap, unappliedPatch) if not node.changeList then continue end + for _, change in node.changeList do -- Failed addition means that all properties failed to be added if change[4] == nil then @@ -512,6 +477,10 @@ function PatchTree.updateMetadata(tree, patch, instanceMap, unappliedPatch) end Log.trace(" Marked property as warning: {}.{}", node.name, change[1]) end + + node.changeInfo = { + failed = node.changeInfo.edits or (#node.changeList - 1), + } end for _, failedRemovalIdOrInstance in unappliedPatch.removed do local failedRemovalId = if Types.RbxId(failedRemovalIdOrInstance)