diff --git a/.gitmodules b/.gitmodules index 2851e0573..46789ed14 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,3 +13,6 @@ [submodule "plugin/Packages/TestEZ"] path = plugin/Packages/TestEZ url = https://github.com/roblox/testez.git +[submodule "plugin/Packages/Highlighter"] + path = plugin/Packages/Highlighter + url = https://github.com/boatbomber/highlighter.git diff --git a/CHANGELOG.md b/CHANGELOG.md index 996f4dc75..e81d07dbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ * Add `plugin` flag to the `build` command that outputs to the local plugins folder ([#735]) * Added better support for `Font` properties ([#731]) * Add new plugin template to the `init` command ([#738]) +* Added rich Source diffs in patch visualizer ([#748]) [#668]: https://github.com/rojo-rbx/rojo/pull/668 [#674]: https://github.com/rojo-rbx/rojo/pull/674 @@ -40,6 +41,7 @@ [#735]: https://github.com/rojo-rbx/rojo/pull/735 [#731]: https://github.com/rojo-rbx/rojo/pull/731 [#738]: https://github.com/rojo-rbx/rojo/pull/738 +[#748]: https://github.com/rojo-rbx/rojo/pull/748 ## [7.3.0] - April 22, 2023 * Added `$attributes` to project format. ([#574]) diff --git a/plugin/Packages/Highlighter b/plugin/Packages/Highlighter new file mode 160000 index 000000000..09263eacf --- /dev/null +++ b/plugin/Packages/Highlighter @@ -0,0 +1 @@ +Subproject commit 09263eacfee6ad5ff3326d12244bc45b6ae8d2f0 diff --git a/plugin/src/App/Components/CodeLabel.lua b/plugin/src/App/Components/CodeLabel.lua new file mode 100644 index 000000000..192bcf3ad --- /dev/null +++ b/plugin/src/App/Components/CodeLabel.lua @@ -0,0 +1,61 @@ +local Rojo = script:FindFirstAncestor("Rojo") +local Packages = Rojo.Packages + +local Roact = require(Packages.Roact) +local Highlighter = require(Packages.Highlighter) +Highlighter.matchStudioSettings() + +local e = Roact.createElement + +local CodeLabel = Roact.PureComponent:extend("CodeLabel") + +function CodeLabel:init() + self.labelRef = Roact.createRef() + self.highlightsRef = Roact.createRef() +end + +function CodeLabel:didMount() + Highlighter.highlight({ + textObject = self.labelRef:getValue(), + }) + self:updateHighlights() +end + +function CodeLabel:didUpdate() + self:updateHighlights() +end + +function CodeLabel:updateHighlights() + local highlights = self.highlightsRef:getValue() + if not highlights then + return + end + + for _, lineLabel in highlights:GetChildren() do + local lineNum = tonumber(string.match(lineLabel.Name, "%d+") or "0") + lineLabel.BackgroundColor3 = self.props.lineBackground + lineLabel.BorderSizePixel = 0 + lineLabel.BackgroundTransparency = if self.props.markedLines[lineNum] then 0.25 else 1 + end +end + +function CodeLabel:render() + return e("TextLabel", { + Size = self.props.size, + Position = self.props.position, + Text = self.props.text, + BackgroundTransparency = 1, + Font = Enum.Font.RobotoMono, + TextSize = 16, + TextXAlignment = Enum.TextXAlignment.Left, + TextYAlignment = Enum.TextYAlignment.Top, + TextColor3 = Color3.fromRGB(255, 255, 255), + [Roact.Ref] = self.labelRef, + }, { + SyntaxHighlights = e("Folder", { + [Roact.Ref] = self.highlightsRef, + }), + }) +end + +return CodeLabel diff --git a/plugin/src/App/Components/PatchVisualizer/ChangeList.lua b/plugin/src/App/Components/PatchVisualizer/ChangeList.lua index ed6da37a6..7aac862a5 100644 --- a/plugin/src/App/Components/PatchVisualizer/ChangeList.lua +++ b/plugin/src/App/Components/PatchVisualizer/ChangeList.lua @@ -4,8 +4,10 @@ local Packages = Rojo.Packages local Roact = require(Packages.Roact) +local Assets = require(Plugin.Assets) local Theme = require(Plugin.App.Theme) local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame) +local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local DisplayValue = require(script.Parent.DisplayValue) local EMPTY_TABLE = {} @@ -93,6 +95,89 @@ function ChangeList:render() local metadata = values[4] or EMPTY_TABLE local isWarning = metadata.isWarning + -- Special case for .Source updates + -- because we want to display a syntax highlighted diff for better UX + if self.props.showSourceDiff and tostring(values[1]) == "Source" then + rows[row] = e("Frame", { + Size = UDim2.new(1, 0, 0, 30), + BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1, + BackgroundColor3 = theme.Diff.Row, + BorderSizePixel = 0, + LayoutOrder = row, + }, { + Padding = e("UIPadding", pad), + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Horizontal, + SortOrder = Enum.SortOrder.LayoutOrder, + HorizontalAlignment = Enum.HorizontalAlignment.Left, + VerticalAlignment = Enum.VerticalAlignment.Center, + }), + A = e("TextLabel", { + Text = (if isWarning then "⚠ " else "") .. tostring(values[1]), + BackgroundTransparency = 1, + Font = Enum.Font.GothamMedium, + TextSize = 14, + TextColor3 = if isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor, + TextXAlignment = Enum.TextXAlignment.Left, + TextTransparency = props.transparency, + TextTruncate = Enum.TextTruncate.AtEnd, + Size = UDim2.new(0.3, 0, 1, 0), + LayoutOrder = 1, + }), + Button = e("TextButton", { + Text = "", + Size = UDim2.new(0.7, 0, 1, -4), + LayoutOrder = 2, + BackgroundTransparency = 1, + [Roact.Event.Activated] = function() + if props.showSourceDiff then + props.showSourceDiff(tostring(values[2]), tostring(values[3])) + end + end, + }, { + e(BorderedContainer, { + size = UDim2.new(1, 0, 1, 0), + transparency = self.props.transparency:map(function(t) + return 0.5 + (0.5 * t) + end), + }, { + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Horizontal, + SortOrder = Enum.SortOrder.LayoutOrder, + HorizontalAlignment = Enum.HorizontalAlignment.Center, + VerticalAlignment = Enum.VerticalAlignment.Center, + Padding = UDim.new(0, 5), + }), + Label = e("TextLabel", { + Text = "View Diff", + BackgroundTransparency = 1, + Font = Enum.Font.GothamMedium, + TextSize = 14, + TextColor3 = theme.Settings.Setting.DescriptionColor, + TextXAlignment = Enum.TextXAlignment.Left, + TextTransparency = props.transparency, + TextTruncate = Enum.TextTruncate.AtEnd, + Size = UDim2.new(0, 65, 1, 0), + LayoutOrder = 1, + }), + Icon = e("ImageLabel", { + Image = Assets.Images.Icons.Expand, + ImageColor3 = theme.Settings.Setting.DescriptionColor, + ImageTransparency = self.props.transparency, + + Size = UDim2.new(0, 16, 0, 16), + Position = UDim2.new(0.5, 0, 0.5, 0), + AnchorPoint = Vector2.new(0.5, 0.5), + + BackgroundTransparency = 1, + LayoutOrder = 2, + }), + }), + }), + }) + continue + end + rows[row] = e("Frame", { Size = UDim2.new(1, 0, 0, 30), BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1, diff --git a/plugin/src/App/Components/PatchVisualizer/DomLabel.lua b/plugin/src/App/Components/PatchVisualizer/DomLabel.lua index facf70cd9..8495d7121 100644 --- a/plugin/src/App/Components/PatchVisualizer/DomLabel.lua +++ b/plugin/src/App/Components/PatchVisualizer/DomLabel.lua @@ -34,6 +34,7 @@ function Expansion:render() ChangeList = e(ChangeList, { changes = props.changeList, transparency = props.transparency, + showSourceDiff = props.showSourceDiff, }), }) end @@ -170,6 +171,7 @@ function DomLabel:render() indent = indent, transparency = props.transparency, changeList = props.changeList, + showSourceDiff = props.showSourceDiff, }) else nil, DiffIcon = if props.patchType diff --git a/plugin/src/App/Components/PatchVisualizer/init.lua b/plugin/src/App/Components/PatchVisualizer/init.lua index 73af45029..f95f96b6f 100644 --- a/plugin/src/App/Components/PatchVisualizer/init.lua +++ b/plugin/src/App/Components/PatchVisualizer/init.lua @@ -71,6 +71,7 @@ function PatchVisualizer:render() changeList = node.changeList, depth = depth, transparency = self.props.transparency, + showSourceDiff = self.props.showSourceDiff, }) ) end diff --git a/plugin/src/App/Components/ScrollingFrame.lua b/plugin/src/App/Components/ScrollingFrame.lua index 2231aacb2..f3d11c8cc 100644 --- a/plugin/src/App/Components/ScrollingFrame.lua +++ b/plugin/src/App/Components/ScrollingFrame.lua @@ -23,19 +23,26 @@ local function ScrollingFrame(props) BottomImage = Assets.Images.ScrollBar.Bottom, ElasticBehavior = Enum.ElasticBehavior.Always, - ScrollingDirection = Enum.ScrollingDirection.Y, + ScrollingDirection = props.scrollingDirection or Enum.ScrollingDirection.Y, Size = props.size, Position = props.position, AnchorPoint = props.anchorPoint, CanvasSize = props.contentSize:map(function(value) - return UDim2.new(0, 0, 0, value.Y) + return UDim2.new( + 0, + if (props.scrollingDirection and props.scrollingDirection ~= Enum.ScrollingDirection.Y) + then value.X + else 0, + 0, + value.Y + ) end), BorderSizePixel = 0, BackgroundTransparency = 1, - [Roact.Change.AbsoluteSize] = props[Roact.Change.AbsoluteSize] + [Roact.Change.AbsoluteSize] = props[Roact.Change.AbsoluteSize], }, props[Roact.Children]) end) end diff --git a/plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua b/plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua new file mode 100644 index 000000000..6632c006a --- /dev/null +++ b/plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua @@ -0,0 +1,441 @@ +--[[ + Based on DiffMatchPatch by Neil Fraser. + https://github.com/google/diff-match-patch +]] + +export type DiffAction = number +export type Diff = { actionType: DiffAction, value: string } +export type Diffs = { Diff } + +local StringDiff = { + ActionTypes = table.freeze({ + Equal = 0, + Delete = 1, + Insert = 2, + }), +} + +function StringDiff.findDiffs(text1: string, text2: string): Diffs + -- Validate inputs + if type(text1) ~= "string" or type(text2) ~= "string" then + error( + string.format( + "Invalid inputs to StringDiff.findDiffs, expected strings and got (%s, %s)", + type(text1), + type(text2) + ), + 2 + ) + end + + -- Shortcut if the texts are identical + if text1 == text2 then + return { { actionType = StringDiff.ActionTypes.Equal, value = text1 } } + end + + -- Trim off any shared prefix and suffix + -- These are easy to detect and can be dealt with quickly without needing a complex diff + -- and later we simply add them as Equal to the start and end of the diff + local sharedPrefix, sharedSuffix + local prefixLength = StringDiff._sharedPrefix(text1, text2) + if prefixLength > 0 then + -- Store the prefix + sharedPrefix = string.sub(text1, 1, prefixLength) + -- Now trim it off + text1 = string.sub(text1, prefixLength + 1) + text2 = string.sub(text2, prefixLength + 1) + end + + local suffixLength = StringDiff._sharedSuffix(text1, text2) + if suffixLength > 0 then + -- Store the suffix + sharedSuffix = string.sub(text1, -suffixLength) + -- Now trim it off + text1 = string.sub(text1, 1, -suffixLength - 1) + text2 = string.sub(text2, 1, -suffixLength - 1) + end + + -- Compute the diff on the middle block where the changes lie + local diffs = StringDiff._computeDiff(text1, text2) + + -- Restore the prefix and suffix + if sharedPrefix then + table.insert(diffs, 1, { actionType = StringDiff.ActionTypes.Equal, value = sharedPrefix }) + end + if sharedSuffix then + table.insert(diffs, { actionType = StringDiff.ActionTypes.Equal, value = sharedSuffix }) + end + + -- Cleanup the diff + diffs = StringDiff._reorderAndMerge(diffs) + + return diffs +end + +function StringDiff._sharedPrefix(text1: string, text2: string): number + -- Uses a binary search to find the largest common prefix between the two strings + -- Performance analysis: http://neil.fraser.name/news/2007/10/09/ + + -- Shortcut common cases + if (#text1 == 0) or (#text2 == 0) or (string.byte(text1, 1) ~= string.byte(text2, 1)) then + return 0 + end + + local pointerMin = 1 + local pointerMax = math.min(#text1, #text2) + local pointerMid = pointerMax + local pointerStart = 1 + while pointerMin < pointerMid do + if string.sub(text1, pointerStart, pointerMid) == string.sub(text2, pointerStart, pointerMid) then + pointerMin = pointerMid + pointerStart = pointerMin + else + pointerMax = pointerMid + end + pointerMid = math.floor(pointerMin + (pointerMax - pointerMin) / 2) + end + + return pointerMid +end + +function StringDiff._sharedSuffix(text1: string, text2: string): number + -- Uses a binary search to find the largest common suffix between the two strings + -- Performance analysis: http://neil.fraser.name/news/2007/10/09/ + + -- Shortcut common cases + if (#text1 == 0) or (#text2 == 0) or (string.byte(text1, -1) ~= string.byte(text2, -1)) then + return 0 + end + + local pointerMin = 1 + local pointerMax = math.min(#text1, #text2) + local pointerMid = pointerMax + local pointerEnd = 1 + while pointerMin < pointerMid do + if string.sub(text1, -pointerMid, -pointerEnd) == string.sub(text2, -pointerMid, -pointerEnd) then + pointerMin = pointerMid + pointerEnd = pointerMin + else + pointerMax = pointerMid + end + pointerMid = math.floor(pointerMin + (pointerMax - pointerMin) / 2) + end + + return pointerMid +end + +function StringDiff._computeDiff(text1: string, text2: string): Diffs + -- Assumes that the prefix and suffix have already been trimmed off + -- and shortcut returns have been made so these texts must be different + + local text1Length, text2Length = #text1, #text2 + + if text1Length == 0 then + -- It's simply inserting all of text2 into text1 + return { { actionType = StringDiff.ActionTypes.Insert, value = text2 } } + end + + if text2Length == 0 then + -- It's simply deleting all of text1 + return { { actionType = StringDiff.ActionTypes.Delete, value = text1 } } + end + + local longText = if text1Length > text2Length then text1 else text2 + local shortText = if text1Length > text2Length then text2 else text1 + local shortTextLength = #shortText + + -- Shortcut if the shorter string exists entirely inside the longer one + local indexOf = if shortTextLength == 0 then nil else string.find(longText, shortText, 1, true) + if indexOf ~= nil then + local diffs = { + { actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, 1, indexOf - 1) }, + { actionType = StringDiff.ActionTypes.Equal, value = shortText }, + { actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, indexOf + shortTextLength) }, + } + -- Swap insertions for deletions if diff is reversed + if text1Length > text2Length then + diffs[1].actionType, diffs[3].actionType = StringDiff.ActionTypes.Delete, StringDiff.ActionTypes.Delete + end + return diffs + end + + if shortTextLength == 1 then + -- Single character string + -- After the previous shortcut, the character can't be an equality + return { + { actionType = StringDiff.ActionTypes.Delete, value = text1 }, + { actionType = StringDiff.ActionTypes.Insert, value = text2 }, + } + end + + return StringDiff._bisect(text1, text2) +end + +function StringDiff._bisect(text1: string, text2: string): Diffs + -- Find the 'middle snake' of a diff, split the problem in two + -- and return the recursively constructed diff + -- See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations + + -- Cache the text lengths to prevent multiple calls + local text1Length = #text1 + local text2Length = #text2 + + local _sub, _element + local maxD = math.ceil((text1Length + text2Length) / 2) + local vOffset = maxD + local vLength = 2 * maxD + local v1 = table.create(vLength) + local v2 = table.create(vLength) + + -- Setting all elements to -1 is faster in Lua than mixing integers and nil + for x = 0, vLength - 1 do + v1[x] = -1 + v2[x] = -1 + end + v1[vOffset + 1] = 0 + v2[vOffset + 1] = 0 + local delta = text1Length - text2Length + + -- If the total number of characters is odd, then + -- the front path will collide with the reverse path + local front = (delta % 2 ~= 0) + + -- Offsets for start and end of k loop + -- Prevents mapping of space beyond the grid + local k1Start = 0 + local k1End = 0 + local k2Start = 0 + local k2End = 0 + for d = 0, maxD - 1 do + -- Walk the front path one step + for k1 = -d + k1Start, d - k1End, 2 do + local k1_offset = vOffset + k1 + local x1 + if (k1 == -d) or ((k1 ~= d) and (v1[k1_offset - 1] < v1[k1_offset + 1])) then + x1 = v1[k1_offset + 1] + else + x1 = v1[k1_offset - 1] + 1 + end + local y1 = x1 - k1 + while + (x1 <= text1Length) + and (y1 <= text2Length) + and (string.sub(text1, x1, x1) == string.sub(text2, y1, y1)) + do + x1 = x1 + 1 + y1 = y1 + 1 + end + v1[k1_offset] = x1 + if x1 > text1Length + 1 then + -- Ran off the right of the graph + k1End = k1End + 2 + elseif y1 > text2Length + 1 then + -- Ran off the bottom of the graph + k1Start = k1Start + 2 + elseif front then + local k2_offset = vOffset + delta - k1 + if k2_offset >= 0 and k2_offset < vLength and v2[k2_offset] ~= -1 then + -- Mirror x2 onto top-left coordinate system + local x2 = text1Length - v2[k2_offset] + 1 + if x1 > x2 then + -- Overlap detected + return StringDiff._bisectSplit(text1, text2, x1, y1) + end + end + end + end + + -- Walk the reverse path one step + for k2 = -d + k2Start, d - k2End, 2 do + local k2_offset = vOffset + k2 + local x2 + if (k2 == -d) or ((k2 ~= d) and (v2[k2_offset - 1] < v2[k2_offset + 1])) then + x2 = v2[k2_offset + 1] + else + x2 = v2[k2_offset - 1] + 1 + end + local y2 = x2 - k2 + while + (x2 <= text1Length) + and (y2 <= text2Length) + and (string.sub(text1, -x2, -x2) == string.sub(text2, -y2, -y2)) + do + x2 = x2 + 1 + y2 = y2 + 1 + end + v2[k2_offset] = x2 + if x2 > text1Length + 1 then + -- Ran off the left of the graph + k2End = k2End + 2 + elseif y2 > text2Length + 1 then + -- Ran off the top of the graph + k2Start = k2Start + 2 + elseif not front then + local k1_offset = vOffset + delta - k2 + if k1_offset >= 0 and k1_offset < vLength and v1[k1_offset] ~= -1 then + local x1 = v1[k1_offset] + local y1 = vOffset + x1 - k1_offset + -- Mirror x2 onto top-left coordinate system + x2 = text1Length - x2 + 1 + if x1 > x2 then + -- Overlap detected + return StringDiff._bisectSplit(text1, text2, x1, y1) + end + end + end + end + end + + -- Number of diffs equals number of characters, no commonality at all + return { + { actionType = StringDiff.ActionTypes.Delete, value = text1 }, + { actionType = StringDiff.ActionTypes.Insert, value = text2 }, + } +end + +function StringDiff._bisectSplit(text1: string, text2: string, x: number, y: number): Diffs + -- Given the location of the 'middle snake', + -- split the diff in two parts and recurse + + local text1a = string.sub(text1, 1, x - 1) + local text2a = string.sub(text2, 1, y - 1) + local text1b = string.sub(text1, x) + local text2b = string.sub(text2, y) + + -- Compute both diffs serially + local diffs = StringDiff.findDiffs(text1a, text2a) + local diffsB = StringDiff.findDiffs(text1b, text2b) + + -- Merge diffs + table.move(diffsB, 1, #diffsB, #diffs + 1, diffs) + return diffs +end + +function StringDiff._reorderAndMerge(diffs: Diffs): Diffs + -- Reorder and merge like edit sections and merge equalities + -- Any edit section can move as long as it doesn't cross an equality + + -- Add a dummy entry at the end + table.insert(diffs, { actionType = StringDiff.ActionTypes.Equal, value = "" }) + + local pointer = 1 + local countDelete, countInsert = 0, 0 + local textDelete, textInsert = "", "" + local commonLength + while diffs[pointer] do + local actionType = diffs[pointer].actionType + if actionType == StringDiff.ActionTypes.Insert then + countInsert = countInsert + 1 + textInsert = textInsert .. diffs[pointer].value + pointer = pointer + 1 + elseif actionType == StringDiff.ActionTypes.Delete then + countDelete = countDelete + 1 + textDelete = textDelete .. diffs[pointer].value + pointer = pointer + 1 + elseif actionType == StringDiff.ActionTypes.Equal then + -- Upon reaching an equality, check for prior redundancies + if countDelete + countInsert > 1 then + if (countDelete > 0) and (countInsert > 0) then + -- Factor out any common prefixies + commonLength = StringDiff._sharedPrefix(textInsert, textDelete) + if commonLength > 0 then + local back_pointer = pointer - countDelete - countInsert + if + (back_pointer > 1) and (diffs[back_pointer - 1].actionType == StringDiff.ActionTypes.Equal) + then + diffs[back_pointer - 1].value = diffs[back_pointer - 1].value + .. string.sub(textInsert, 1, commonLength) + else + table.insert(diffs, 1, { + actionType = StringDiff.ActionTypes.Equal, + value = string.sub(textInsert, 1, commonLength), + }) + pointer = pointer + 1 + end + textInsert = string.sub(textInsert, commonLength + 1) + textDelete = string.sub(textDelete, commonLength + 1) + end + -- Factor out any common suffixies + commonLength = StringDiff._sharedSuffix(textInsert, textDelete) + if commonLength ~= 0 then + diffs[pointer].value = string.sub(textInsert, -commonLength) .. diffs[pointer].value + textInsert = string.sub(textInsert, 1, -commonLength - 1) + textDelete = string.sub(textDelete, 1, -commonLength - 1) + end + end + -- Delete the offending records and add the merged ones + pointer = pointer - countDelete - countInsert + for _ = 1, countDelete + countInsert do + table.remove(diffs, pointer) + end + if #textDelete > 0 then + table.insert(diffs, pointer, { actionType = StringDiff.ActionTypes.Delete, value = textDelete }) + pointer = pointer + 1 + end + if #textInsert > 0 then + table.insert(diffs, pointer, { actionType = StringDiff.ActionTypes.Insert, value = textInsert }) + pointer = pointer + 1 + end + pointer = pointer + 1 + elseif (pointer > 1) and (diffs[pointer - 1].actionType == StringDiff.ActionTypes.Equal) then + -- Merge this equality with the previous one + diffs[pointer - 1].value = diffs[pointer - 1].value .. diffs[pointer].value + table.remove(diffs, pointer) + else + pointer = pointer + 1 + end + countInsert, countDelete = 0, 0 + textDelete, textInsert = "", "" + end + end + if diffs[#diffs].value == "" then + -- Remove the dummy entry at the end + diffs[#diffs] = nil + end + + -- Second pass: look for single edits surrounded on both sides by equalities + -- which can be shifted sideways to eliminate an equality + -- e.g: ABAC -> ABAC + local changes = false + pointer = 2 + -- Intentionally ignore the first and last element (don't need checking) + while pointer < #diffs do + local prevDiff, nextDiff = diffs[pointer - 1], diffs[pointer + 1] + if + (prevDiff.actionType == StringDiff.ActionTypes.Equal) + and (nextDiff.actionType == StringDiff.ActionTypes.Equal) + then + -- This is a single edit surrounded by equalities + local currentDiff = diffs[pointer] + local currentText = currentDiff.value + local prevText = prevDiff.value + local nextText = nextDiff.value + if #prevText == 0 then + table.remove(diffs, pointer - 1) + changes = true + elseif string.sub(currentText, -#prevText) == prevText then + -- Shift the edit over the previous equality + currentDiff.value = prevText .. string.sub(currentText, 1, -#prevText - 1) + nextDiff.value = prevText .. nextDiff.value + table.remove(diffs, pointer - 1) + changes = true + elseif string.sub(currentText, 1, #nextText) == nextText then + -- Shift the edit over the next equality + prevDiff.value = prevText .. nextText + currentDiff.value = string.sub(currentText, #nextText + 1) .. nextText + table.remove(diffs, pointer + 1) + changes = true + end + end + pointer = pointer + 1 + end + + -- If shifts were made, the diffs need reordering and another shift sweep + if changes then + return StringDiff._reorderAndMerge(diffs) + end + + return diffs +end + +return StringDiff diff --git a/plugin/src/App/Components/StringDiffVisualizer/init.lua b/plugin/src/App/Components/StringDiffVisualizer/init.lua new file mode 100644 index 000000000..cbdc7dec5 --- /dev/null +++ b/plugin/src/App/Components/StringDiffVisualizer/init.lua @@ -0,0 +1,202 @@ +local TextService = game:GetService("TextService") + +local Rojo = script:FindFirstAncestor("Rojo") +local Plugin = Rojo.Plugin +local Packages = Rojo.Packages + +local Roact = require(Packages.Roact) +local Log = require(Packages.Log) +local Highlighter = require(Packages.Highlighter) +local StringDiff = require(script:FindFirstChild("StringDiff")) + +local Theme = require(Plugin.App.Theme) + +local CodeLabel = require(Plugin.App.Components.CodeLabel) +local BorderedContainer = require(Plugin.App.Components.BorderedContainer) +local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame) + +local e = Roact.createElement + +local StringDiffVisualizer = Roact.Component:extend("StringDiffVisualizer") + +function StringDiffVisualizer:init() + self.scriptBackground, self.setScriptBackground = Roact.createBinding(Color3.fromRGB(0, 0, 0)) + self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0)) + + -- Ensure that the script background is up to date with the current theme + self.themeChangedConnection = settings().Studio.ThemeChanged:Connect(function() + task.defer(function() + -- Defer to allow Highlighter to process the theme change first + self:updateScriptBackground() + end) + end) + + self:calculateContentSize() + self:updateScriptBackground() + + self:setState({ + add = {}, + remove = {}, + }) +end + +function StringDiffVisualizer:willUnmount() + self.themeChangedConnection:Disconnect() +end + +function StringDiffVisualizer:updateScriptBackground() + local backgroundColor = Highlighter.getTokenColor("background") + if backgroundColor ~= self.scriptBackground:getValue() then + self.setScriptBackground(backgroundColor) + end +end + +function StringDiffVisualizer:didUpdate(previousProps) + if previousProps.oldText ~= self.props.oldText or previousProps.newText ~= self.props.newText then + self:calculateContentSize() + local add, remove = self:calculateDiffLines() + self:setState({ + add = add, + remove = remove, + }) + end +end + +function StringDiffVisualizer:calculateContentSize() + local oldText, newText = self.props.oldText, self.props.newText + + local oldTextBounds = TextService:GetTextSize(oldText, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999)) + local newTextBounds = TextService:GetTextSize(newText, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999)) + + self.setContentSize( + Vector2.new(math.max(oldTextBounds.X, newTextBounds.X), math.max(oldTextBounds.Y, newTextBounds.Y)) + ) +end + +function StringDiffVisualizer:calculateDiffLines() + local oldText, newText = self.props.oldText, self.props.newText + + -- Diff the two texts + local startClock = os.clock() + local diffs = StringDiff.findDiffs(oldText, newText) + local stopClock = os.clock() + + Log.trace( + "Diffing {} byte and {} byte strings took {} microseconds and found {} diff sections", + #oldText, + #newText, + math.round((stopClock - startClock) * 1000 * 1000), + #diffs + ) + + -- Determine which lines to highlight + local add, remove = {}, {} + + local oldLineNum, newLineNum = 1, 1 + for _, diff in diffs do + local actionType, text = diff.actionType, diff.value + local lines = select(2, string.gsub(text, "\n", "\n")) + + if actionType == StringDiff.ActionTypes.Equal then + oldLineNum += lines + newLineNum += lines + elseif actionType == StringDiff.ActionTypes.Insert then + if lines > 0 then + local textLines = string.split(text, "\n") + for i, textLine in textLines do + if string.match(textLine, "%S") then + add[newLineNum + i - 1] = true + end + end + else + if string.match(text, "%S") then + add[newLineNum] = true + end + end + newLineNum += lines + elseif actionType == StringDiff.ActionTypes.Delete then + if lines > 0 then + local textLines = string.split(text, "\n") + for i, textLine in textLines do + if string.match(textLine, "%S") then + remove[oldLineNum + i - 1] = true + end + end + else + if string.match(text, "%S") then + remove[oldLineNum] = true + end + end + oldLineNum += lines + else + Log.warn("Unknown diff action: {} {}", actionType, text) + end + end + + return add, remove +end + +function StringDiffVisualizer:render() + local oldText, newText = self.props.oldText, self.props.newText + + return Theme.with(function(theme) + return e(BorderedContainer, { + size = self.props.size, + position = self.props.position, + anchorPoint = self.props.anchorPoint, + transparency = self.props.transparency, + }, { + Background = e("Frame", { + Size = UDim2.new(1, 0, 1, 0), + Position = UDim2.new(0, 0, 0, 0), + BorderSizePixel = 0, + BackgroundColor3 = self.scriptBackground, + ZIndex = -10, + }, { + UICorner = e("UICorner", { + CornerRadius = UDim.new(0, 5), + }), + }), + Separator = e("Frame", { + Size = UDim2.new(0, 2, 1, 0), + Position = UDim2.new(0.5, 0, 0, 0), + AnchorPoint = Vector2.new(0.5, 0), + BorderSizePixel = 0, + BackgroundColor3 = theme.BorderedContainer.BorderColor, + BackgroundTransparency = 0.5, + }), + Old = e(ScrollingFrame, { + position = UDim2.new(0, 2, 0, 2), + size = UDim2.new(0.5, -7, 1, -4), + scrollingDirection = Enum.ScrollingDirection.XY, + transparency = self.props.transparency, + contentSize = self.contentSize, + }, { + Source = e(CodeLabel, { + size = UDim2.new(1, 0, 1, 0), + position = UDim2.new(0, 0, 0, 0), + text = oldText, + lineBackground = theme.Diff.Remove, + markedLines = self.state.remove, + }), + }), + New = e(ScrollingFrame, { + position = UDim2.new(0.5, 5, 0, 2), + size = UDim2.new(0.5, -7, 1, -4), + scrollingDirection = Enum.ScrollingDirection.XY, + transparency = self.props.transparency, + contentSize = self.contentSize, + }, { + Source = e(CodeLabel, { + size = UDim2.new(1, 0, 1, 0), + position = UDim2.new(0, 0, 0, 0), + text = newText, + lineBackground = theme.Diff.Add, + markedLines = self.state.add, + }), + }), + }) + end) +end + +return StringDiffVisualizer diff --git a/plugin/src/App/Components/Studio/StudioPluginGui.lua b/plugin/src/App/Components/Studio/StudioPluginGui.lua index 59bd3d857..6b9910a91 100644 --- a/plugin/src/App/Components/Studio/StudioPluginGui.lua +++ b/plugin/src/App/Components/Studio/StudioPluginGui.lua @@ -76,6 +76,12 @@ function StudioPluginGui:didUpdate(lastProps) if self.props.active ~= lastProps.active then -- This is intentionally in didUpdate to make sure the initial active state -- (if the PluginGui is open initially) is preserved. + + -- Studio widgets are very unreliable and sometimes need to be flickered + -- in order to force them to render correctly + -- This happens within a single frame so it doesn't flicker visibly + self.pluginGui.Enabled = self.props.active + self.pluginGui.Enabled = not self.props.active self.pluginGui.Enabled = self.props.active end end diff --git a/plugin/src/App/StatusPages/Confirming.lua b/plugin/src/App/StatusPages/Confirming.lua index a55923470..4a258bbd9 100644 --- a/plugin/src/App/StatusPages/Confirming.lua +++ b/plugin/src/App/StatusPages/Confirming.lua @@ -13,6 +13,7 @@ local Header = require(Plugin.App.Components.Header) local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui) local Tooltip = require(Plugin.App.Components.Tooltip) local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer) +local StringDiffVisualizer = require(Plugin.App.Components.StringDiffVisualizer) local e = Roact.createElement @@ -21,6 +22,12 @@ local ConfirmingPage = Roact.Component:extend("ConfirmingPage") function ConfirmingPage:init() self.contentSize, self.setContentSize = Roact.createBinding(0) self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0)) + + self:setState({ + showingSourceDiff = false, + oldSource = "", + newSource = "", + }) end function ConfirmingPage:render() @@ -55,6 +62,14 @@ function ConfirmingPage:render() changeListHeaders = { "Property", "Current", "Incoming" }, patch = self.props.confirmData.patch, instanceMap = self.props.confirmData.instanceMap, + + showSourceDiff = function(oldSource: string, newSource: string) + self:setState({ + showingSourceDiff = true, + oldSource = oldSource, + newSource = newSource, + }) + end, }), Buttons = e("Frame", { @@ -120,6 +135,43 @@ function ConfirmingPage:render() PaddingLeft = UDim.new(0, 20), PaddingRight = UDim.new(0, 20), }), + + SourceDiff = e(StudioPluginGui, { + id = "Rojo_ConfirmingSourceDiff", + title = "Source diff", + active = self.state.showingSourceDiff, + + initDockState = Enum.InitialDockState.Float, + overridePreviousState = true, + floatingSize = Vector2.new(500, 350), + minimumSize = Vector2.new(400, 250), + + zIndexBehavior = Enum.ZIndexBehavior.Sibling, + + onClose = function() + self:setState({ + showingSourceDiff = false, + }) + end, + }, { + TooltipsProvider = e(Tooltip.Provider, nil, { + Tooltips = e(Tooltip.Container, nil), + Content = e("Frame", { + Size = UDim2.fromScale(1, 1), + BackgroundTransparency = 1, + }, { + e(StringDiffVisualizer, { + size = UDim2.new(1, -10, 1, -10), + position = UDim2.new(0, 5, 0, 5), + anchorPoint = Vector2.new(0, 0), + transparency = self.props.transparency, + + oldText = self.state.oldSource, + newText = self.state.newSource, + }) + }), + }), + }), }) if self.props.createPopup then @@ -132,7 +184,6 @@ function ConfirmingPage:render() active = true, initDockState = Enum.InitialDockState.Float, - initEnabled = true, overridePreviousState = true, floatingSize = Vector2.new(500, 350), minimumSize = Vector2.new(400, 250), diff --git a/plugin/src/App/StatusPages/Connected.lua b/plugin/src/App/StatusPages/Connected.lua index 00f6822bb..12ba2fc15 100644 --- a/plugin/src/App/StatusPages/Connected.lua +++ b/plugin/src/App/StatusPages/Connected.lua @@ -10,12 +10,14 @@ local Theme = require(Plugin.App.Theme) local Assets = require(Plugin.Assets) local PatchSet = require(Plugin.PatchSet) +local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui) 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) +local StringDiffVisualizer = require(Plugin.App.Components.StringDiffVisualizer) local e = Roact.createElement @@ -39,7 +41,7 @@ function timeSinceText(elapsed: number): string return ageText end -local ChangesDrawer = Roact.Component:extend("ConnectedPage") +local ChangesDrawer = Roact.Component:extend("ChangesDrawer") function ChangesDrawer:init() -- Hold onto the serve session during the lifecycle of this component @@ -84,6 +86,8 @@ function ChangesDrawer:render() layoutOrder = 3, patchTree = self.props.patchTree, + + showSourceDiff = self.props.showSourceDiff, }), }) end) @@ -226,6 +230,9 @@ function ConnectedPage:init() self:setState({ renderChanges = false, hoveringChangeInfo = false, + showingSourceDiff = false, + oldSource = "", + newSource = "", }) self.changeInfoText, self.setChangeInfoText = Roact.createBinding("") @@ -239,7 +246,11 @@ end function ConnectedPage:didUpdate(previousProps) if self.props.patchData.timestamp ~= previousProps.patchData.timestamp then + -- New patch recieved self:startChangeInfoTextUpdater() + self:setState({ + showingSourceDiff = false, + }) end end @@ -367,6 +378,14 @@ function ConnectedPage:render() height = self.changeDrawerHeight, layoutOrder = 5, + showSourceDiff = function(oldSource: string, newSource: string) + self:setState({ + showingSourceDiff = true, + oldSource = oldSource, + newSource = newSource, + }) + end, + onClose = function() self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, { frequency = 4, @@ -374,6 +393,43 @@ function ConnectedPage:render() })) end, }), + + SourceDiff = e(StudioPluginGui, { + id = "Rojo_ConnectedSourceDiff", + title = "Source diff", + active = self.state.showingSourceDiff, + + initDockState = Enum.InitialDockState.Float, + overridePreviousState = true, + floatingSize = Vector2.new(500, 350), + minimumSize = Vector2.new(400, 250), + + zIndexBehavior = Enum.ZIndexBehavior.Sibling, + + onClose = function() + self:setState({ + showingSourceDiff = false, + }) + end, + }, { + TooltipsProvider = e(Tooltip.Provider, nil, { + Tooltips = e(Tooltip.Container, nil), + Content = e("Frame", { + Size = UDim2.fromScale(1, 1), + BackgroundTransparency = 1, + }, { + e(StringDiffVisualizer, { + size = UDim2.new(1, -10, 1, -10), + position = UDim2.new(0, 5, 0, 5), + anchorPoint = Vector2.new(0, 0), + transparency = self.props.transparency, + + oldText = self.state.oldSource, + newText = self.state.newSource, + }) + }), + }), + }), }) end) end diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index f4c30f3c2..1dc88c1ea 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -554,7 +554,6 @@ function App:render() active = self.state.guiEnabled, initDockState = Enum.InitialDockState.Right, - initEnabled = false, overridePreviousState = false, floatingSize = Vector2.new(320, 210), minimumSize = Vector2.new(300, 210), diff --git a/plugin/src/Assets.lua b/plugin/src/Assets.lua index 020ec3eb6..bee8f3c7e 100644 --- a/plugin/src/Assets.lua +++ b/plugin/src/Assets.lua @@ -24,6 +24,7 @@ local Assets = { Close = "rbxassetid://6012985953", Back = "rbxassetid://6017213752", Reset = "rbxassetid://10142422327", + Expand = "rbxassetid://12045401097", }, Diff = { Add = "rbxassetid://10434145835",