Complete implementation of Git-style change metadata and diff utilities

- Finalized the `ChangeMetadata` module to compute structured change metadata, replacing inline calculations in `PatchTree`.
- Introduced `DiffUtil` for accurate line difference calculations using the Myers algorithm, ensuring consistency with Git's behavior.
- Updated `DomLabel` to display git-style change indicators, including added and removed line counts, with hover tooltips for detailed information.
- Comprehensive tests for `DiffUtil` have been added to validate functionality across various scenarios.
- Removed the obsolete `WhitespaceUtil` as its functionality is now integrated into the new modules.
This commit is contained in:
Joe 2026-02-08 16:12:40 -06:00
parent 6deafe2650
commit 9b832c4637
11 changed files with 1253 additions and 193 deletions

View file

@ -16,7 +16,18 @@ local e = Roact.createElement
local ChangeList = require(script.Parent.ChangeList)
local ClassIcon = require(Plugin.App.Components.ClassIcon)
local function ChangeTag(props)
local ChangeTag = Roact.Component:extend("ChangeTag")
function ChangeTag:init()
self:setState({
isHovered = false,
})
end
function ChangeTag:render()
local props = self.props
local isHovered = self.state.isHovered
return Theme.with(function(theme)
local tagColor = props.color or theme.SubTextColor
return e("Frame", {
@ -27,6 +38,13 @@ local function ChangeTag(props)
return 0.85 + (0.15 * t)
end),
LayoutOrder = props.layoutOrder or 1,
Active = true,
[Roact.Event.MouseEnter] = function()
self:setState({ isHovered = true })
end,
[Roact.Event.MouseLeave] = function()
self:setState({ isHovered = false })
end,
}, {
Corner = e("UICorner", {
CornerRadius = UDim.new(0, 3),
@ -46,10 +64,95 @@ local function ChangeTag(props)
Size = UDim2.new(0, 0, 1, 0),
AutomaticSize = Enum.AutomaticSize.X,
}),
Tooltip = if props.tooltipText and isHovered
then e("TextLabel", {
Text = props.tooltipText,
BackgroundColor3 = theme.BorderedContainer.BackgroundColor,
BackgroundTransparency = 0.05,
TextColor3 = theme.TextColor,
FontFace = theme.Font.Main,
TextSize = theme.TextSize.Small,
Size = UDim2.new(0, 0, 0, 20),
AutomaticSize = Enum.AutomaticSize.X,
Position = UDim2.new(0.5, 0, 0, -22),
AnchorPoint = Vector2.new(0.5, 0),
ZIndex = 100,
}, {
Corner = e("UICorner", {
CornerRadius = UDim.new(0, 3),
}),
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 6),
PaddingRight = UDim.new(0, 6),
}),
})
else nil,
})
end)
end
-- Proportional diff bar showing the ratio of removals/additions/property changes
local function DiffBar(props)
local removed = props.removed or 0
local added = props.added or 0
local propChanges = props.propChanges or 0
local total = removed + added + propChanges
if total == 0 then
return nil
end
return Theme.with(function(theme)
local children = {
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
}),
Corner = e("UICorner", {
CornerRadius = UDim.new(0, 2),
}),
}
if removed > 0 then
local color = if props.isWhitespaceOnly then theme.Diff.WhitespaceOnly else theme.Diff.Remove
children.SegRemove = e("Frame", {
Size = UDim2.fromScale(removed / total, 1),
BackgroundColor3 = color,
BackgroundTransparency = props.transparency,
BorderSizePixel = 0,
LayoutOrder = 1,
})
end
if added > 0 then
local color = if props.isWhitespaceOnly then theme.Diff.WhitespaceOnly else theme.Diff.Add
children.SegAdd = e("Frame", {
Size = UDim2.fromScale(added / total, 1),
BackgroundColor3 = color,
BackgroundTransparency = props.transparency,
BorderSizePixel = 0,
LayoutOrder = 2,
})
end
if propChanges > 0 then
children.SegProp = e("Frame", {
Size = UDim2.fromScale(propChanges / total, 1),
BackgroundColor3 = theme.Diff.Property,
BackgroundTransparency = props.transparency,
BorderSizePixel = 0,
LayoutOrder = 3,
})
end
return e("Frame", {
Size = UDim2.new(0, 40, 0, 8),
BackgroundTransparency = 1,
ClipsDescendants = true,
LayoutOrder = props.layoutOrder or 10,
}, children)
end)
end
-- Radio button option for selection (as a component to track hover state)
local SelectionOption = Roact.Component:extend("SelectionOption")
@ -255,7 +358,11 @@ function Expansion:render()
ChangeList = e(ChangeList, {
changes = props.changeList,
transparency = props.transparency,
showStringDiff = props.showStringDiff,
showStringDiff = if props.showStringDiff
then function(current: string, incoming: string)
props.showStringDiff(current, incoming, props.instancePath)
end
else nil,
showTableDiff = props.showTableDiff,
}),
})
@ -447,14 +554,56 @@ function DomLabel:render()
end
-- Use self.props to get current props, not stale closure
if self.props.changeList then
self.expanded = not self.expanded
local goalHeight = 24
+ (if self.expanded then math.clamp(#self.props.changeList * 24, 24, 24 * 6) else 0)
self.motor:setGoal(Flipper.Spring.new(goalHeight, {
frequency = 5,
dampingRatio = 1,
}))
local p = self.props
-- Removed script: show diff of old source vs empty
if
p.patchType == "Remove"
and p.instance
and p.changeInfo
and p.changeInfo.linesRemoved
and p.showStringDiff
then
local ok, source = pcall(function()
return (p.instance :: any).Source
end)
if ok and type(source) == "string" then
p.showStringDiff(source, "", p.instancePath)
return
end
end
-- Added script: find Source in changeList and show diff of empty vs new source
if
p.patchType == "Add"
and p.changeList
and p.changeInfo
and p.changeInfo.linesAdded
and p.showStringDiff
then
for _, entry in p.changeList do
if entry[1] == "Source" and type(entry[3]) == "string" then
p.showStringDiff("", tostring(entry[3]), p.instancePath)
return
end
end
end
if p.changeList then
-- If the only change is Source, open the diff viewer directly
-- changeList[1] is the header, changeList[2+] are entries
local cl = p.changeList
if #cl == 2 and cl[2][1] == "Source" and p.showStringDiff then
p.showStringDiff(tostring(cl[2][2]), tostring(cl[2][3]), p.instancePath)
else
self.expanded = not self.expanded
local goalHeight = 24
+ (if self.expanded then math.clamp(#p.changeList * 24, 24, 24 * 6) else 0)
self.motor:setGoal(Flipper.Spring.new(goalHeight, {
frequency = 5,
dampingRatio = 1,
}))
end
end
end)
end
@ -516,31 +665,82 @@ function DomLabel:render()
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 4),
}),
LinesTag = if props.changeInfo and props.changeInfo.lineChanges
-- Git-style line change tags: -M (red) then +N (green)
-- Always show both when any line change data exists (even -0 or +0).
-- Grayed when whitespace-only. Always exact counts.
RemovedTag = if props.changeInfo
and (props.changeInfo.linesRemoved ~= nil or props.changeInfo.linesAdded ~= nil)
then e(ChangeTag, {
text = (if props.changeInfo.isWhitespaceOnly then "Whitespace " else "Lines ")
.. (if props.changeInfo.lineChanges >= 10000 then "100+" else props.changeInfo.lineChanges),
text = "-" .. (props.changeInfo.linesRemoved or 0),
color = if props.changeInfo.isWhitespaceOnly
or (props.changeInfo.linesRemoved or 0) == 0
then theme.Diff.WhitespaceOnly
else theme.Diff.Changes,
else theme.Diff.Remove,
transparency = props.transparency,
layoutOrder = 1,
tooltipText = (props.changeInfo.linesRemoved or 0)
.. (if (props.changeInfo.linesRemoved or 0) == 1 then " line removed" else " lines removed")
.. (if props.changeInfo.isWhitespaceOnly then " (whitespace only)" else ""),
})
else nil,
PropsTag = if props.changeInfo and props.changeInfo.propChanges
AddedTag = if props.changeInfo
and (props.changeInfo.linesRemoved ~= nil or props.changeInfo.linesAdded ~= nil)
then e(ChangeTag, {
text = "Props " .. props.changeInfo.propChanges,
color = theme.Diff.Changes or theme.Diff.Background.Edit,
text = "+" .. (props.changeInfo.linesAdded or 0),
color = if props.changeInfo.isWhitespaceOnly
or (props.changeInfo.linesAdded or 0) == 0
then theme.Diff.WhitespaceOnly
else theme.Diff.Add,
transparency = props.transparency,
layoutOrder = 2,
tooltipText = (props.changeInfo.linesAdded or 0)
.. (if (props.changeInfo.linesAdded or 0) == 1 then " line added" else " lines added")
.. (if props.changeInfo.isWhitespaceOnly then " (whitespace only)" else ""),
})
else nil,
-- Property changes: compact "NP" format in yellow
PropsTag = if props.changeInfo and props.changeInfo.propChanges
then e(ChangeTag, {
text = props.changeInfo.propChanges .. "P",
color = theme.Diff.Property,
transparency = props.transparency,
layoutOrder = 3,
tooltipText = props.changeInfo.propChanges .. (if props.changeInfo.propChanges == 1
then " property change"
else " property changes") .. " (excluding Source)",
})
else nil,
-- Proportional diff bar
Bar = if props.changeInfo
and (
(props.changeInfo.linesRemoved and props.changeInfo.linesRemoved > 0)
or (props.changeInfo.linesAdded and props.changeInfo.linesAdded > 0)
or (props.changeInfo.propChanges and props.changeInfo.propChanges > 0)
)
then e(DiffBar, {
removed = props.changeInfo.linesRemoved or 0,
added = props.changeInfo.linesAdded or 0,
propChanges = props.changeInfo.propChanges or 0,
isWhitespaceOnly = props.changeInfo.isWhitespaceOnly,
transparency = props.transparency,
layoutOrder = 4,
})
else nil,
-- Failed changes
Failed = if props.changeInfo and props.changeInfo.failed
then e(ChangeTag, {
text = "Failed " .. props.changeInfo.failed,
color = theme.Diff.Warning,
transparency = props.transparency,
layoutOrder = 3,
layoutOrder = 5,
tooltipText = props.changeInfo.failed .. (if props.changeInfo.failed == 1
then " change failed to apply"
else " changes failed to apply"),
})
else nil,
}),

View file

@ -210,6 +210,7 @@ function PatchVisualizer:render()
className = node.className,
isWarning = node.isWarning,
instance = node.instance,
instancePath = if node.instance then node.instance:GetFullName() else node.name,
name = node.name,
changeInfo = node.changeInfo,
changeList = node.changeList,

View file

@ -63,16 +63,42 @@ function ConfirmingPage:init()
self:setState(function(state)
local newSelections = table.clone(state.selections)
-- Set this node if it has a patchType
if node.patchType then
newSelections[nodeId] = selection
-- Check if all selectable nodes in this subtree already have this selection
local allMatch = true
if node.patchType and newSelections[nodeId] ~= selection then
allMatch = false
end
-- Set all descendants
self.props.patchTree:forEach(function(childNode)
if childNode.patchType then
newSelections[childNode.id] = selection
if allMatch then
self.props.patchTree:forEach(function(childNode)
if childNode.patchType and newSelections[childNode.id] ~= selection then
allMatch = false
end
end, node)
end
if allMatch then
-- All already match: un-select (toggle off)
if node.patchType then
newSelections[nodeId] = nil
end
end, node)
self.props.patchTree:forEach(function(childNode)
if childNode.patchType then
newSelections[childNode.id] = nil
end
end, node)
else
-- Not all match: set them all to the selection
if node.patchType then
newSelections[nodeId] = selection
end
self.props.patchTree:forEach(function(childNode)
if childNode.patchType then
newSelections[childNode.id] = selection
end
end, node)
end
return { selections = newSelections }
end)
end
@ -165,11 +191,12 @@ function ConfirmingPage:render()
onSelectionChange = self.onSelectionChange,
onSubtreeSelectionChange = self.onSubtreeSelectionChange,
showStringDiff = function(currentString: string, incomingString: string)
showStringDiff = function(currentString: string, incomingString: string, instancePath: string?)
self:setState({
showingStringDiff = true,
currentString = currentString,
incomingString = incomingString,
stringDiffTitle = instancePath or "String diff",
})
end,
showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? })
@ -246,13 +273,13 @@ function ConfirmingPage:render()
StringDiff = e(StudioPluginGui, {
id = "Atlas_ConfirmingStringDiff",
title = "String diff",
title = self.state.stringDiffTitle or "String diff",
active = self.state.showingStringDiff,
isEphemeral = true,
initDockState = Enum.InitialDockState.Float,
overridePreviousState = true,
floatingSize = Vector2.new(500, 350),
floatingSize = Vector2.new(1500, 1050),
minimumSize = Vector2.new(400, 250),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
@ -290,7 +317,7 @@ function ConfirmingPage:render()
initDockState = Enum.InitialDockState.Float,
overridePreviousState = true,
floatingSize = Vector2.new(500, 350),
floatingSize = Vector2.new(1500, 1050),
minimumSize = Vector2.new(400, 250),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
@ -410,7 +437,7 @@ function ConfirmingPage:render()
initDockState = Enum.InitialDockState.Float,
overridePreviousState = false,
floatingSize = Vector2.new(500, 350),
floatingSize = Vector2.new(1500, 1050),
minimumSize = Vector2.new(400, 250),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,

View file

@ -511,11 +511,16 @@ function ConnectedPage:render()
patchData = self.props.patchData,
patchTree = self.props.patchTree,
serveSession = self.props.serveSession,
showStringDiff = function(currentString: string, incomingString: string)
showStringDiff = function(
currentString: string,
incomingString: string,
instancePath: string?
)
self:setState({
showingStringDiff = true,
currentString = currentString,
incomingString = incomingString,
stringDiffTitle = instancePath or "String diff",
})
end,
showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? })
@ -537,13 +542,13 @@ function ConnectedPage:render()
StringDiff = e(StudioPluginGui, {
id = "Atlas_ConnectedStringDiff",
title = "String diff",
title = self.state.stringDiffTitle or "String diff",
active = self.state.showingStringDiff,
isEphemeral = true,
initDockState = Enum.InitialDockState.Float,
overridePreviousState = false,
floatingSize = Vector2.new(500, 350),
floatingSize = Vector2.new(1500, 1050),
minimumSize = Vector2.new(400, 250),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
@ -581,7 +586,7 @@ function ConnectedPage:render()
initDockState = Enum.InitialDockState.Float,
overridePreviousState = false,
floatingSize = Vector2.new(500, 350),
floatingSize = Vector2.new(1500, 1050),
minimumSize = Vector2.new(400, 250),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,

View file

@ -230,11 +230,11 @@ function StudioProvider:updateTheme()
BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.InputFieldBackground),
},
Diff = {
-- Very bright different colors in case some places were not updated to use
-- the new background diff colors.
Add = Color3.fromRGB(255, 0, 255),
Remove = Color3.fromRGB(255, 0, 255),
Edit = Color3.fromRGB(255, 0, 255),
-- Tag colors for git-style change indicators
Add = if isDark then Color3.fromRGB(143, 227, 154) else Color3.fromRGB(41, 164, 45),
Remove = if isDark then Color3.fromRGB(242, 125, 125) else Color3.fromRGB(150, 29, 29),
Property = if isDark then Color3.fromRGB(255, 198, 1) else Color3.fromRGB(179, 138, 0),
Edit = if isDark then Color3.fromRGB(120, 154, 248) else Color3.fromRGB(0, 70, 160), -- Legacy alias
Row = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
Warning = studioTheme:GetColor(Enum.StudioStyleGuideColor.WarningText),