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",