Improved string diff viewer (#994)

This commit is contained in:
boatbomber
2025-11-18 20:26:44 -08:00
committed by GitHub
parent 31ec216a95
commit 071b6e7e23
8 changed files with 705 additions and 221 deletions

View File

@@ -31,8 +31,10 @@ Making a new release? Simply add the new header with the version and date undern
## Unreleased ## Unreleased
* Fixed bugs and improved performance & UX for the script diff viewer ([#994])
* Added support for `.jsonc` files for all JSON-related files (e.g. `.project.jsonc` and `.meta.jsonc`) to accompany JSONC support ([#1159]) * Added support for `.jsonc` files for all JSON-related files (e.g. `.project.jsonc` and `.meta.jsonc`) to accompany JSONC support ([#1159])
[#994]: https://github.com/rojo-rbx/rojo/pull/994
[#1159]: https://github.com/rojo-rbx/rojo/pull/1159 [#1159]: https://github.com/rojo-rbx/rojo/pull/1159
## [7.6.1] (November 6th, 2025) ## [7.6.1] (November 6th, 2025)

View File

@@ -1,66 +0,0 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Highlighter = require(Packages.Highlighter)
Highlighter.matchStudioSettings()
local e = Roact.createElement
local Theme = require(Plugin.App.Theme)
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 Theme.with(function(theme)
return e("TextLabel", {
Size = self.props.size,
Position = self.props.position,
Text = self.props.text,
BackgroundTransparency = 1,
FontFace = theme.Font.Code,
TextSize = theme.TextSize.Code,
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)
end
return CodeLabel

View File

@@ -1,3 +1,4 @@
--!strict
--[[ --[[
Based on DiffMatchPatch by Neil Fraser. Based on DiffMatchPatch by Neil Fraser.
https://github.com/google/diff-match-patch https://github.com/google/diff-match-patch
@@ -67,8 +68,187 @@ function StringDiff.findDiffs(text1: string, text2: string): Diffs
end end
-- Cleanup the diff -- Cleanup the diff
diffs = StringDiff._cleanupSemantic(diffs)
diffs = StringDiff._reorderAndMerge(diffs) diffs = StringDiff._reorderAndMerge(diffs)
-- Remove any empty diffs
local cursor = 1
while cursor and diffs[cursor] do
if diffs[cursor].value == "" then
table.remove(diffs, cursor)
else
cursor += 1
end
end
return diffs
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._cleanupSemantic(diffs: Diffs): Diffs
-- Reduce the number of edits by eliminating semantically trivial equalities.
local changes = false
local equalities = {} -- Stack of indices where equalities are found.
local equalitiesLength = 0 -- Keeping our own length var is faster.
local lastEquality: string? = nil
-- Always equal to diffs[equalities[equalitiesLength]].value
local pointer = 1 -- Index of current position.
-- Number of characters that changed prior to the equality.
local length_insertions1 = 0
local length_deletions1 = 0
-- Number of characters that changed after the equality.
local length_insertions2 = 0
local length_deletions2 = 0
while diffs[pointer] do
if diffs[pointer].actionType == StringDiff.ActionTypes.Equal then -- Equality found.
equalitiesLength = equalitiesLength + 1
equalities[equalitiesLength] = pointer
length_insertions1 = length_insertions2
length_deletions1 = length_deletions2
length_insertions2 = 0
length_deletions2 = 0
lastEquality = diffs[pointer].value
else -- An insertion or deletion.
if diffs[pointer].actionType == StringDiff.ActionTypes.Insert then
length_insertions2 = length_insertions2 + #diffs[pointer].value
else
length_deletions2 = length_deletions2 + #diffs[pointer].value
end
-- Eliminate an equality that is smaller or equal to the edits on both
-- sides of it.
if
lastEquality
and (#lastEquality <= math.max(length_insertions1, length_deletions1))
and (#lastEquality <= math.max(length_insertions2, length_deletions2))
then
-- Duplicate record.
table.insert(
diffs,
equalities[equalitiesLength],
{ actionType = StringDiff.ActionTypes.Delete, value = lastEquality }
)
-- Change second copy to insert.
diffs[equalities[equalitiesLength] + 1].actionType = StringDiff.ActionTypes.Insert
-- Throw away the equality we just deleted.
equalitiesLength = equalitiesLength - 1
-- Throw away the previous equality (it needs to be reevaluated).
equalitiesLength = equalitiesLength - 1
pointer = (equalitiesLength > 0) and equalities[equalitiesLength] or 0
length_insertions1, length_deletions1 = 0, 0 -- Reset the counters.
length_insertions2, length_deletions2 = 0, 0
lastEquality = nil
changes = true
end
end
pointer = pointer + 1
end
-- Normalize the diff.
if changes then
StringDiff._reorderAndMerge(diffs)
end
StringDiff._cleanupSemanticLossless(diffs)
-- Find any overlaps between deletions and insertions.
-- e.g: <del>abcxxx</del><ins>xxxdef</ins>
-- -> <del>abc</del>xxx<ins>def</ins>
-- e.g: <del>xxxabc</del><ins>defxxx</ins>
-- -> <ins>def</ins>xxx<del>abc</del>
-- Only extract an overlap if it is as big as the edit ahead or behind it.
pointer = 2
while diffs[pointer] do
if
diffs[pointer - 1].actionType == StringDiff.ActionTypes.Delete
and diffs[pointer].actionType == StringDiff.ActionTypes.Insert
then
local deletion = diffs[pointer - 1].value
local insertion = diffs[pointer].value
local overlap_length1 = StringDiff._commonOverlap(deletion, insertion)
local overlap_length2 = StringDiff._commonOverlap(insertion, deletion)
if overlap_length1 >= overlap_length2 then
if overlap_length1 >= #deletion / 2 or overlap_length1 >= #insertion / 2 then
-- Overlap found. Insert an equality and trim the surrounding edits.
table.insert(
diffs,
pointer,
{ actionType = StringDiff.ActionTypes.Equal, value = string.sub(insertion, 1, overlap_length1) }
)
diffs[pointer - 1].value = string.sub(deletion, 1, #deletion - overlap_length1)
diffs[pointer + 1].value = string.sub(insertion, overlap_length1 + 1)
pointer = pointer + 1
end
else
if overlap_length2 >= #deletion / 2 or overlap_length2 >= #insertion / 2 then
-- Reverse overlap found.
-- Insert an equality and swap and trim the surrounding edits.
table.insert(
diffs,
pointer,
{ actionType = StringDiff.ActionTypes.Equal, value = string.sub(deletion, 1, overlap_length2) }
)
diffs[pointer - 1] = {
actionType = StringDiff.ActionTypes.Insert,
value = string.sub(insertion, 1, #insertion - overlap_length2),
}
diffs[pointer + 1] = {
actionType = StringDiff.ActionTypes.Delete,
value = string.sub(deletion, overlap_length2 + 1),
}
pointer = pointer + 1
end
end
pointer = pointer + 1
end
pointer = pointer + 1
end
return diffs return diffs
end end
@@ -124,51 +304,164 @@ function StringDiff._sharedSuffix(text1: string, text2: string): number
return pointerMid return pointerMid
end end
function StringDiff._computeDiff(text1: string, text2: string): Diffs function StringDiff._commonOverlap(text1: string, text2: string): number
-- Assumes that the prefix and suffix have already been trimmed off -- Determine if the suffix of one string is the prefix of another.
-- and shortcut returns have been made so these texts must be different
local text1Length, text2Length = #text1, #text2 -- Cache the text lengths to prevent multiple calls.
local text1_length = #text1
if text1Length == 0 then local text2_length = #text2
-- It's simply inserting all of text2 into text1 -- Eliminate the null case.
return { { actionType = StringDiff.ActionTypes.Insert, value = text2 } } if text1_length == 0 or text2_length == 0 then
return 0
end
-- Truncate the longer string.
if text1_length > text2_length then
text1 = string.sub(text1, text1_length - text2_length + 1)
elseif text1_length < text2_length then
text2 = string.sub(text2, 1, text1_length)
end
local text_length = math.min(text1_length, text2_length)
-- Quick check for the worst case.
if text1 == text2 then
return text_length
end end
if text2Length == 0 then -- Start by looking for a single character match
-- It's simply deleting all of text1 -- and increase length until no match is found.
return { { actionType = StringDiff.ActionTypes.Delete, value = text1 } } -- Performance analysis: https://neil.fraser.name/news/2010/11/04/
end local best = 0
local length = 1
local longText = if text1Length > text2Length then text1 else text2 while true do
local shortText = if text1Length > text2Length then text2 else text1 local pattern = string.sub(text1, text_length - length + 1)
local shortTextLength = #shortText local found = string.find(text2, pattern, 1, true)
if found == nil then
-- Shortcut if the shorter string exists entirely inside the longer one return best
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 end
return diffs length = length + found - 1
if found == 1 or string.sub(text1, text_length - length + 1) == string.sub(text2, 1, length) then
best = length
length = length + 1
end
end
end
function StringDiff._cleanupSemanticScore(one: string, two: string): number
-- Given two strings, compute a score representing whether the internal
-- boundary falls on logical boundaries.
-- Scores range from 6 (best) to 0 (worst).
if (#one == 0) or (#two == 0) then
-- Edges are the best.
return 6
end end
if shortTextLength == 1 then -- Each port of this function behaves slightly differently due to
-- Single character string -- subtle differences in each language's definition of things like
-- After the previous shortcut, the character can't be an equality -- 'whitespace'. Since this function's purpose is largely cosmetic,
return { -- the choice has been made to use each language's native features
{ actionType = StringDiff.ActionTypes.Delete, value = text1 }, -- rather than force total conformity.
{ actionType = StringDiff.ActionTypes.Insert, value = text2 }, local char1 = string.sub(one, -1)
} local char2 = string.sub(two, 1, 1)
end local nonAlphaNumeric1 = string.match(char1, "%W")
local nonAlphaNumeric2 = string.match(char2, "%W")
local whitespace1 = nonAlphaNumeric1 and string.match(char1, "%s")
local whitespace2 = nonAlphaNumeric2 and string.match(char2, "%s")
local lineBreak1 = whitespace1 and string.match(char1, "%c")
local lineBreak2 = whitespace2 and string.match(char2, "%c")
local blankLine1 = lineBreak1 and string.match(one, "\n\r?\n$")
local blankLine2 = lineBreak2 and string.match(two, "^\r?\n\r?\n")
return StringDiff._bisect(text1, text2) if blankLine1 or blankLine2 then
-- Five points for blank lines.
return 5
elseif lineBreak1 or lineBreak2 then
-- Four points for line breaks
-- DEVIATION: Prefer to start on a line break instead of end on it
return if lineBreak1 then 4 else 4.5
elseif nonAlphaNumeric1 and not whitespace1 and whitespace2 then
-- Three points for end of sentences.
return 3
elseif whitespace1 or whitespace2 then
-- Two points for whitespace.
return 2
elseif nonAlphaNumeric1 or nonAlphaNumeric2 then
-- One point for non-alphanumeric.
return 1
end
return 0
end
function StringDiff._cleanupSemanticLossless(diffs: Diffs)
-- Look for single edits surrounded on both sides by equalities
-- which can be shifted sideways to align the edit to a word boundary.
-- e.g: The c<ins>at c</ins>ame. -> The <ins>cat </ins>came.
local pointer = 2
-- Intentionally ignore the first and last element (don't need checking).
while diffs[pointer + 1] 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 diff = diffs[pointer]
local equality1 = prevDiff.value
local edit = diff.value
local equality2 = nextDiff.value
-- First, shift the edit as far left as possible.
local commonOffset = StringDiff._sharedSuffix(equality1, edit)
if commonOffset > 0 then
local commonString = string.sub(edit, -commonOffset)
equality1 = string.sub(equality1, 1, -commonOffset - 1)
edit = commonString .. string.sub(edit, 1, -commonOffset - 1)
equality2 = commonString .. equality2
end
-- Second, step character by character right, looking for the best fit.
local bestEquality1 = equality1
local bestEdit = edit
local bestEquality2 = equality2
local bestScore = StringDiff._cleanupSemanticScore(equality1, edit)
+ StringDiff._cleanupSemanticScore(edit, equality2)
while string.byte(edit, 1) == string.byte(equality2, 1) do
equality1 = equality1 .. string.sub(edit, 1, 1)
edit = string.sub(edit, 2) .. string.sub(equality2, 1, 1)
equality2 = string.sub(equality2, 2)
local score = StringDiff._cleanupSemanticScore(equality1, edit)
+ StringDiff._cleanupSemanticScore(edit, equality2)
-- The > (rather than >=) encourages leading rather than trailing whitespace on edits.
-- I just think it looks better for indentation changes to start the line,
-- since then indenting several lines all have aligned diffs at the start
if score > bestScore then
bestScore = score
bestEquality1 = equality1
bestEdit = edit
bestEquality2 = equality2
end
end
if prevDiff.value ~= bestEquality1 then
-- We have an improvement, save it back to the diff.
if #bestEquality1 > 0 then
diffs[pointer - 1].value = bestEquality1
else
table.remove(diffs, pointer - 1)
pointer = pointer - 1
end
diffs[pointer].value = bestEdit
if #bestEquality2 > 0 then
diffs[pointer + 1].value = bestEquality2
else
table.remove(diffs, pointer + 1)
pointer = pointer - 1
end
end
end
pointer = pointer + 1
end
end end
function StringDiff._bisect(text1: string, text2: string): Diffs function StringDiff._bisect(text1: string, text2: string): Diffs

View File

@@ -5,15 +5,15 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Log = require(Packages.Log) local Log = require(Packages.Log)
local Highlighter = require(Packages.Highlighter) local Highlighter = require(Packages.Highlighter)
Highlighter.matchStudioSettings()
local StringDiff = require(script:FindFirstChild("StringDiff")) local StringDiff = require(script:FindFirstChild("StringDiff"))
local Timer = require(Plugin.Timer) local Timer = require(Plugin.Timer)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync) local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
local CodeLabel = require(Plugin.App.Components.CodeLabel)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame) local VirtualScroller = require(Plugin.App.Components.VirtualScroller)
local e = Roact.createElement local e = Roact.createElement
@@ -21,26 +21,29 @@ local StringDiffVisualizer = Roact.Component:extend("StringDiffVisualizer")
function StringDiffVisualizer:init() function StringDiffVisualizer:init()
self.scriptBackground, self.setScriptBackground = Roact.createBinding(Color3.fromRGB(0, 0, 0)) self.scriptBackground, self.setScriptBackground = Roact.createBinding(Color3.fromRGB(0, 0, 0))
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0)) self.updateEvent = Instance.new("BindableEvent")
self.lineHeight, self.setLineHeight = Roact.createBinding(15)
self.canvasPosition, self.setCanvasPosition = Roact.createBinding(Vector2.zero)
self.windowWidth, self.setWindowWidth = Roact.createBinding(math.huge)
-- Ensure that the script background is up to date with the current theme -- Ensure that the script background is up to date with the current theme
self.themeChangedConnection = settings().Studio.ThemeChanged:Connect(function() self.themeChangedConnection = settings().Studio.ThemeChanged:Connect(function()
task.defer(function() -- Delay to allow Highlighter to process the theme change first
-- Defer to allow Highlighter to process the theme change first task.delay(1 / 20, function()
self:updateScriptBackground() self:updateScriptBackground()
self:updateDiffs()
-- Rerender the virtual list elements
self.updateEvent:Fire()
end) end)
end) end)
self:updateScriptBackground() self:updateScriptBackground()
self:updateDiffs()
self:setState({
add = {},
remove = {},
})
end end
function StringDiffVisualizer:willUnmount() function StringDiffVisualizer:willUnmount()
self.themeChangedConnection:Disconnect() self.themeChangedConnection:Disconnect()
self.updateEvent:Destroy()
end end
function StringDiffVisualizer:updateScriptBackground() function StringDiffVisualizer:updateScriptBackground()
@@ -51,96 +54,188 @@ function StringDiffVisualizer:updateScriptBackground()
end end
function StringDiffVisualizer:didUpdate(previousProps) function StringDiffVisualizer:didUpdate(previousProps)
if previousProps.oldString ~= self.props.oldString or previousProps.newString ~= self.props.newString then if
local add, remove = self:calculateDiffLines() previousProps.currentString ~= self.props.currentString
self:setState({ or previousProps.incomingString ~= self.props.incomingString
add = add, then
remove = remove, self:updateDiffs()
})
end end
end end
function StringDiffVisualizer:calculateContentSize(theme) function StringDiffVisualizer:updateDiffs()
local oldString, newString = self.props.oldString, self.props.newString Timer.start("StringDiffVisualizer:updateDiffs")
local currentString, incomingString = self.props.currentString, self.props.incomingString
local oldStringBounds = getTextBoundsAsync(oldString, theme.Font.Code, theme.TextSize.Code, math.huge)
local newStringBounds = getTextBoundsAsync(newString, theme.Font.Code, theme.TextSize.Code, math.huge)
self.setContentSize(
Vector2.new(math.max(oldStringBounds.X, newStringBounds.X), math.max(oldStringBounds.Y, newStringBounds.Y))
)
end
function StringDiffVisualizer:calculateDiffLines()
Timer.start("StringDiffVisualizer:calculateDiffLines")
local oldString, newString = self.props.oldString, self.props.newString
-- Diff the two texts -- Diff the two texts
local startClock = os.clock() local startClock = os.clock()
local diffs = StringDiff.findDiffs(oldString, newString) local diffs =
StringDiff.findDiffs((string.gsub(currentString, "\t", " ")), (string.gsub(incomingString, "\t", " ")))
local stopClock = os.clock() local stopClock = os.clock()
Log.trace( Log.trace(
"Diffing {} byte and {} byte strings took {} microseconds and found {} diff sections", "Diffing {} byte and {} byte strings took {} microseconds and found {} diff sections",
#oldString, #currentString,
#newString, #incomingString,
math.round((stopClock - startClock) * 1000 * 1000), math.round((stopClock - startClock) * 1000 * 1000),
#diffs #diffs
) )
-- Determine which lines to highlight -- Build the rich text lines
local add, remove = {}, {} local currentRichTextLines = Highlighter.buildRichTextLines({
src = currentString,
})
local incomingRichTextLines = Highlighter.buildRichTextLines({
src = incomingString,
})
local oldLineNum, newLineNum = 1, 1 local maxLines = math.max(#currentRichTextLines, #incomingRichTextLines)
-- Find the diff locations
local currentDiffs, incomingDiffs = {}, {}
local firstDiffLineNum = 0
local currentLineNum, incomingLineNum = 1, 1
local currentIdx, incomingIdx = 1, 1
for _, diff in diffs do for _, diff in diffs do
local actionType, text = diff.actionType, diff.value local actionType, text = diff.actionType, diff.value
local lines = select(2, string.gsub(text, "\n", "\n")) local lineCount = select(2, string.gsub(text, "\n", "\n"))
local lines = string.split(text, "\n")
if actionType == StringDiff.ActionTypes.Equal then if actionType == StringDiff.ActionTypes.Equal then
oldLineNum += lines if lineCount > 0 then
newLineNum += lines -- Jump cursor ahead to last line
elseif actionType == StringDiff.ActionTypes.Insert then currentLineNum += lineCount
if lines > 0 then incomingLineNum += lineCount
local textLines = string.split(text, "\n") currentIdx = #lines[#lines]
for i, textLine in textLines do incomingIdx = #lines[#lines]
if string.match(textLine, "%S") then
add[newLineNum + i - 1] = true
end
end
else else
if string.match(text, "%S") then -- Move along this line
add[newLineNum] = true currentIdx += #text
end incomingIdx += #text
end
continue
end
if actionType == StringDiff.ActionTypes.Insert then
if firstDiffLineNum == 0 then
firstDiffLineNum = incomingLineNum
end
for i, lineText in lines do
if i > 1 then
-- Move to next line
incomingLineNum += 1
incomingIdx = 0
end
if not incomingDiffs[incomingLineNum] then
incomingDiffs[incomingLineNum] = {}
end
-- Mark these characters on this line
table.insert(incomingDiffs[incomingLineNum], {
start = incomingIdx,
stop = incomingIdx + #lineText,
})
incomingIdx += #lineText
end end
newLineNum += lines
elseif actionType == StringDiff.ActionTypes.Delete then elseif actionType == StringDiff.ActionTypes.Delete then
if lines > 0 then if firstDiffLineNum == 0 then
local textLines = string.split(text, "\n") firstDiffLineNum = currentLineNum
for i, textLine in textLines do end
if string.match(textLine, "%S") then
remove[oldLineNum + i - 1] = true for i, lineText in lines do
end if i > 1 then
end -- Move to next line
else currentLineNum += 1
if string.match(text, "%S") then currentIdx = 0
remove[oldLineNum] = true end
end if not currentDiffs[currentLineNum] then
currentDiffs[currentLineNum] = {}
end
-- Mark these characters on this line
table.insert(currentDiffs[currentLineNum], {
start = currentIdx,
stop = currentIdx + #lineText,
})
currentIdx += #lineText
end end
oldLineNum += lines
else else
Log.warn("Unknown diff action: {} {}", actionType, text) Log.warn("Unknown diff action: {} {}", actionType, text)
end end
end end
Timer.stop() Timer.stop()
return add, remove
self:setState({
maxLines = maxLines,
currentRichTextLines = currentRichTextLines,
incomingRichTextLines = incomingRichTextLines,
currentDiffs = currentDiffs,
incomingDiffs = incomingDiffs,
})
-- Scroll to the first diff line
task.defer(self.setCanvasPosition, Vector2.new(0, math.max(0, (firstDiffLineNum - 4) * 16)))
end end
function StringDiffVisualizer:render() function StringDiffVisualizer:render()
local oldString, newString = self.props.oldString, self.props.newString local currentDiffs, incomingDiffs = self.state.currentDiffs, self.state.incomingDiffs
local currentRichTextLines, incomingRichTextLines =
self.state.currentRichTextLines, self.state.incomingRichTextLines
local maxLines = self.state.maxLines
return Theme.with(function(theme) return Theme.with(function(theme)
self:calculateContentSize(theme) self.setLineHeight(theme.TextSize.Code)
-- Calculate the width of the canvas
-- (One line at a time to avoid the char limit of getTextBoundsAsync)
local canvasWidth = 0
for i = 1, maxLines do
local currentLine = currentRichTextLines[i]
if currentLine and string.find(currentLine, "%S") then
local bounds = getTextBoundsAsync(currentLine, theme.Font.Code, theme.TextSize.Code, math.huge, true)
if bounds.X > canvasWidth then
canvasWidth = bounds.X
end
end
local incomingLine = incomingRichTextLines[i]
if incomingLine and string.find(incomingLine, "%S") then
local bounds = getTextBoundsAsync(incomingLine, theme.Font.Code, theme.TextSize.Code, math.huge, true)
if bounds.X > canvasWidth then
canvasWidth = bounds.X
end
end
end
local lineNumberWidth =
getTextBoundsAsync(tostring(maxLines), theme.Font.Code, theme.TextSize.Body, math.huge, true).X
canvasWidth += lineNumberWidth + 12
local removalScrollMarkers = {}
local insertionScrollMarkers = {}
for lineNum in currentDiffs do
table.insert(
removalScrollMarkers,
e("Frame", {
Size = UDim2.fromScale(0.5, 1 / maxLines),
Position = UDim2.fromScale(0, (lineNum - 1) / maxLines),
BorderSizePixel = 0,
BackgroundColor3 = theme.Diff.Background.Remove,
})
)
end
for lineNum in incomingDiffs do
table.insert(
insertionScrollMarkers,
e("Frame", {
Size = UDim2.fromScale(0.5, 1 / maxLines),
Position = UDim2.fromScale(0.5, (lineNum - 1) / maxLines),
BorderSizePixel = 0,
BackgroundColor3 = theme.Diff.Background.Add,
})
)
end
return e(BorderedContainer, { return e(BorderedContainer, {
size = self.props.size, size = self.props.size,
@@ -159,43 +254,196 @@ function StringDiffVisualizer:render()
CornerRadius = UDim.new(0, 5), CornerRadius = UDim.new(0, 5),
}), }),
}), }),
Separator = e("Frame", { Main = e("Frame", {
Size = UDim2.new(0, 2, 1, 0), Size = UDim2.new(1, -10, 1, -2),
Position = UDim2.new(0.5, 0, 0, 0), Position = UDim2.new(0, 2, 0, 2),
AnchorPoint = Vector2.new(0.5, 0), BackgroundTransparency = 1,
BorderSizePixel = 0, [Roact.Change.AbsoluteSize] = function(rbx)
BackgroundColor3 = theme.BorderedContainer.BorderColor, self.setWindowWidth(rbx.AbsoluteSize.X * 0.5 - 10)
BackgroundTransparency = 0.5, end,
}),
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, { Separator = e("Frame", {
size = UDim2.new(1, 0, 1, 0), 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,
}),
Current = e(VirtualScroller, {
position = UDim2.new(0, 0, 0, 0), position = UDim2.new(0, 0, 0, 0),
text = oldString, size = UDim2.new(0.5, -1, 1, 0),
lineBackground = theme.Diff.Background.Remove, transparency = self.props.transparency,
markedLines = self.state.remove, count = maxLines,
updateEvent = self.updateEvent.Event,
canvasWidth = canvasWidth,
canvasPosition = self.canvasPosition,
onCanvasPositionChanged = self.setCanvasPosition,
render = function(i)
local lineDiffs = currentDiffs[i]
local diffFrames = table.create(if lineDiffs then #lineDiffs else 0)
-- Show diff markers over the specific changed characters
if lineDiffs then
local charWidth = math.round(theme.TextSize.Code * 0.5)
for diffIdx, diff in lineDiffs do
local start, stop = diff.start, diff.stop
diffFrames[diffIdx] = e("Frame", {
Size = if #lineDiffs == 1
and start == 0
and stop == 0
then UDim2.fromScale(1, 1)
else UDim2.new(
0,
math.max(charWidth * (stop - start), charWidth * 0.4),
1,
0
),
Position = UDim2.fromOffset(charWidth * start, 0),
BackgroundColor3 = theme.Diff.Background.Remove,
BackgroundTransparency = 0.85,
BorderSizePixel = 0,
ZIndex = -1,
})
end
end
return Roact.createFragment({
LineNumber = e("TextLabel", {
Size = UDim2.new(0, lineNumberWidth + 8, 1, 0),
Text = i,
BackgroundColor3 = Color3.new(0, 0, 0),
BackgroundTransparency = 0.9,
BorderSizePixel = 0,
FontFace = theme.Font.Code,
TextSize = theme.TextSize.Body,
TextColor3 = if lineDiffs then theme.Diff.Background.Remove else theme.SubTextColor,
TextXAlignment = Enum.TextXAlignment.Right,
}, {
Padding = e("UIPadding", { PaddingRight = UDim.new(0, 6) }),
}),
Content = e("Frame", {
Size = UDim2.new(1, -(lineNumberWidth + 10), 1, 0),
Position = UDim2.fromScale(1, 0),
AnchorPoint = Vector2.new(1, 0),
BackgroundColor3 = theme.Diff.Background.Remove,
BackgroundTransparency = if lineDiffs then 0.95 else 1,
BorderSizePixel = 0,
}, {
CodeLabel = e("TextLabel", {
Size = UDim2.fromScale(1, 1),
Position = UDim2.fromScale(0, 0),
Text = currentRichTextLines[i] or "",
RichText = true,
BackgroundTransparency = 1,
BorderSizePixel = 0,
FontFace = theme.Font.Code,
TextSize = theme.TextSize.Code,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
TextColor3 = Color3.fromRGB(255, 255, 255),
}),
DiffFrames = Roact.createFragment(diffFrames),
}),
})
end,
getHeightBinding = function()
return self.lineHeight
end,
}),
Incoming = e(VirtualScroller, {
position = UDim2.new(0.5, 1, 0, 0),
size = UDim2.new(0.5, -1, 1, 0),
transparency = self.props.transparency,
count = maxLines,
updateEvent = self.updateEvent.Event,
canvasWidth = canvasWidth,
canvasPosition = self.canvasPosition,
onCanvasPositionChanged = self.setCanvasPosition,
render = function(i)
local lineDiffs = incomingDiffs[i]
local diffFrames = table.create(if lineDiffs then #lineDiffs else 0)
-- Show diff markers over the specific changed characters
if lineDiffs then
local charWidth = math.round(theme.TextSize.Code * 0.5)
for diffIdx, diff in lineDiffs do
local start, stop = diff.start, diff.stop
diffFrames[diffIdx] = e("Frame", {
Size = if #lineDiffs == 1
and start == 0
and stop == 0
then UDim2.fromScale(1, 1)
else UDim2.new(
0,
math.max(charWidth * (stop - start), charWidth * 0.4),
1,
0
),
Position = UDim2.fromOffset(charWidth * start, 0),
BackgroundColor3 = theme.Diff.Background.Add,
BackgroundTransparency = 0.85,
BorderSizePixel = 0,
ZIndex = -1,
})
end
end
return Roact.createFragment({
LineNumber = e("TextLabel", {
Size = UDim2.new(0, lineNumberWidth + 8, 1, 0),
Text = i,
BackgroundColor3 = Color3.new(0, 0, 0),
BackgroundTransparency = 0.9,
BorderSizePixel = 0,
FontFace = theme.Font.Code,
TextSize = theme.TextSize.Body,
TextColor3 = if lineDiffs then theme.Diff.Background.Add else theme.SubTextColor,
TextXAlignment = Enum.TextXAlignment.Right,
}, {
Padding = e("UIPadding", { PaddingRight = UDim.new(0, 6) }),
}),
Content = e("Frame", {
Size = UDim2.new(1, -(lineNumberWidth + 10), 1, 0),
Position = UDim2.fromScale(1, 0),
AnchorPoint = Vector2.new(1, 0),
BackgroundColor3 = theme.Diff.Background.Add,
BackgroundTransparency = if lineDiffs then 0.95 else 1,
BorderSizePixel = 0,
}, {
CodeLabel = e("TextLabel", {
Size = UDim2.fromScale(1, 1),
Position = UDim2.fromScale(0, 0),
Text = incomingRichTextLines[i] or "",
RichText = true,
BackgroundColor3 = theme.Diff.Background.Add,
BackgroundTransparency = 1,
BorderSizePixel = 0,
FontFace = theme.Font.Code,
TextSize = theme.TextSize.Code,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
TextColor3 = Color3.fromRGB(255, 255, 255),
}),
DiffFrames = Roact.createFragment(diffFrames),
}),
})
end,
getHeightBinding = function()
return self.lineHeight
end,
}), }),
}), }),
New = e(ScrollingFrame, { ScrollMarkers = e("Frame", {
position = UDim2.new(0.5, 5, 0, 2), Size = self.windowWidth:map(function(windowWidth)
size = UDim2.new(0.5, -7, 1, -4), return UDim2.new(0, 8, 1, -4 - (if canvasWidth > windowWidth then 10 else 0))
scrollingDirection = Enum.ScrollingDirection.XY, end),
transparency = self.props.transparency, Position = UDim2.new(1, -2, 0, 2),
contentSize = self.contentSize, AnchorPoint = Vector2.new(1, 0),
BackgroundTransparency = 1,
}, { }, {
Source = e(CodeLabel, { insertions = Roact.createFragment(insertionScrollMarkers),
size = UDim2.new(1, 0, 1, 0), removals = Roact.createFragment(removalScrollMarkers),
position = UDim2.new(0, 0, 0, 0),
text = newString,
lineBackground = theme.Diff.Background.Add,
markedLines = self.state.add,
}),
}), }),
}) })
end) end)

View File

@@ -15,8 +15,10 @@ local VirtualScroller = Roact.Component:extend("VirtualScroller")
function VirtualScroller:init() function VirtualScroller:init()
self.scrollFrameRef = Roact.createRef() self.scrollFrameRef = Roact.createRef()
self:setState({ self:setState({
WindowSize = Vector2.new(), WindowSize = Vector2.zero,
CanvasPosition = Vector2.new(), CanvasPosition = if self.props.canvasPosition
then self.props.canvasPosition:getValue() or Vector2.zero
else Vector2.zero,
}) })
self.totalCanvas, self.setTotalCanvas = Roact.createBinding(0) self.totalCanvas, self.setTotalCanvas = Roact.createBinding(0)
@@ -41,6 +43,10 @@ function VirtualScroller:didMount()
local canvasPositionSignal = rbx:GetPropertyChangedSignal("CanvasPosition") local canvasPositionSignal = rbx:GetPropertyChangedSignal("CanvasPosition")
self.canvasPositionChanged = canvasPositionSignal:Connect(function() self.canvasPositionChanged = canvasPositionSignal:Connect(function()
if self.props.onCanvasPositionChanged then
pcall(self.props.onCanvasPositionChanged, rbx.CanvasPosition)
end
if math.abs(rbx.CanvasPosition.Y - self.state.CanvasPosition.Y) > 5 then if math.abs(rbx.CanvasPosition.Y - self.state.CanvasPosition.Y) > 5 then
self:setState({ CanvasPosition = rbx.CanvasPosition }) self:setState({ CanvasPosition = rbx.CanvasPosition })
self:refresh() self:refresh()
@@ -134,8 +140,9 @@ function VirtualScroller:render()
BackgroundColor3 = props.backgroundColor3 or theme.BorderedContainer.BackgroundColor, BackgroundColor3 = props.backgroundColor3 or theme.BorderedContainer.BackgroundColor,
BorderColor3 = props.borderColor3 or theme.BorderedContainer.BorderColor, BorderColor3 = props.borderColor3 or theme.BorderedContainer.BorderColor,
CanvasSize = self.totalCanvas:map(function(s) CanvasSize = self.totalCanvas:map(function(s)
return UDim2.fromOffset(0, s) return UDim2.fromOffset(props.canvasWidth or 0, s)
end), end),
CanvasPosition = self.props.canvasPosition,
ScrollBarThickness = 9, ScrollBarThickness = 9,
ScrollBarImageColor3 = theme.ScrollBarColor, ScrollBarImageColor3 = theme.ScrollBarColor,
ScrollBarImageTransparency = props.transparency:map(function(value) ScrollBarImageTransparency = props.transparency:map(function(value)
@@ -146,7 +153,7 @@ function VirtualScroller:render()
BottomImage = Assets.Images.ScrollBar.Bottom, BottomImage = Assets.Images.ScrollBar.Bottom,
ElasticBehavior = Enum.ElasticBehavior.Always, ElasticBehavior = Enum.ElasticBehavior.Always,
ScrollingDirection = Enum.ScrollingDirection.Y, ScrollingDirection = Enum.ScrollingDirection.XY,
VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar, VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar,
[Roact.Ref] = self.scrollFrameRef, [Roact.Ref] = self.scrollFrameRef,
}, { }, {

View File

@@ -23,8 +23,8 @@ function ConfirmingPage:init()
self:setState({ self:setState({
showingStringDiff = false, showingStringDiff = false,
oldString = "", currentString = "",
newString = "", incomingString = "",
showingTableDiff = false, showingTableDiff = false,
oldTable = {}, oldTable = {},
newTable = {}, newTable = {},
@@ -56,11 +56,11 @@ function ConfirmingPage:render()
patchTree = self.props.patchTree, patchTree = self.props.patchTree,
showStringDiff = function(oldString: string, newString: string) showStringDiff = function(currentString: string, incomingString: string)
self:setState({ self:setState({
showingStringDiff = true, showingStringDiff = true,
oldString = oldString, currentString = currentString,
newString = newString, incomingString = incomingString,
}) })
end, end,
showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? }) showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? })
@@ -167,8 +167,8 @@ function ConfirmingPage:render()
anchorPoint = Vector2.new(0, 0), anchorPoint = Vector2.new(0, 0),
transparency = self.props.transparency, transparency = self.props.transparency,
oldString = self.state.oldString, currentString = self.state.currentString,
newString = self.state.newString, incomingString = self.state.incomingString,
}), }),
}), }),
}), }),

View File

@@ -307,8 +307,8 @@ function ConnectedPage:init()
renderChanges = false, renderChanges = false,
hoveringChangeInfo = false, hoveringChangeInfo = false,
showingStringDiff = false, showingStringDiff = false,
oldString = "", currentString = "",
newString = "", incomingString = "",
}) })
self.changeInfoText, self.setChangeInfoText = Roact.createBinding("") self.changeInfoText, self.setChangeInfoText = Roact.createBinding("")
@@ -511,11 +511,11 @@ function ConnectedPage:render()
patchData = self.props.patchData, patchData = self.props.patchData,
patchTree = self.props.patchTree, patchTree = self.props.patchTree,
serveSession = self.props.serveSession, serveSession = self.props.serveSession,
showStringDiff = function(oldString: string, newString: string) showStringDiff = function(currentString: string, incomingString: string)
self:setState({ self:setState({
showingStringDiff = true, showingStringDiff = true,
oldString = oldString, currentString = currentString,
newString = newString, incomingString = incomingString,
}) })
end, end,
showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? }) showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? })
@@ -566,8 +566,8 @@ function ConnectedPage:render()
anchorPoint = Vector2.new(0, 0), anchorPoint = Vector2.new(0, 0),
transparency = self.props.transparency, transparency = self.props.transparency,
oldString = self.state.oldString, currentString = self.state.currentString,
newString = self.state.newString, incomingString = self.state.incomingString,
}), }),
}), }),
}), }),