diff --git a/.cursor/plans/git-style_change_metadata_9d8c8345.plan.md b/.cursor/plans/git-style_change_metadata_9d8c8345.plan.md index a96b4d26..da2c7a2b 100644 --- a/.cursor/plans/git-style_change_metadata_9d8c8345.plan.md +++ b/.cursor/plans/git-style_change_metadata_9d8c8345.plan.md @@ -4,31 +4,31 @@ overview: Extract ad-hoc change calculation from PatchTree into a dedicated Chan todos: - id: create-diff-util content: Replace WhitespaceUtil with DiffUtil -- Myers diff algorithm (line-level), returns { added, removed, isWhitespaceOnly, overflow } - status: pending + status: completed - id: diff-util-spec content: Create DiffUtil.spec.lua with comprehensive tests (empty, identical, pure add/remove, mixed, whitespace-only, overflow, edge cases) - status: pending + status: completed - id: create-change-metadata content: Create plugin/src/ChangeMetadata.lua module with compute() function that takes changeList and returns metadata struct - status: pending + status: completed - id: update-patch-tree content: Refactor PatchTree.build() and updateMetadata() to use ChangeMetadata.compute() instead of inline calculation - status: pending + status: completed - id: update-dom-label content: Update DomLabel to display git-style -M/+N tags (red/green), grayed when whitespace-only - status: pending + status: completed - id: diff-bar content: Add GitHub-style mini diff bar (5 colored segments) next to the line change tags in DomLabel - status: pending + status: completed - id: tooltips content: Add hover tooltips to change tags with human-readable detail text - status: pending + status: completed - id: restyle-props content: Restyle the Props tag to be visually consistent with the new git-style tags - status: pending + status: completed - id: update-theme content: Update Theme.lua Diff colors to use the new hardcoded values (#F21717, - status: pending + status: completed isProject: false --- diff --git a/plugin/src/App/Components/PatchVisualizer/DomLabel.lua b/plugin/src/App/Components/PatchVisualizer/DomLabel.lua index 00c7c461..8dd78716 100644 --- a/plugin/src/App/Components/PatchVisualizer/DomLabel.lua +++ b/plugin/src/App/Components/PatchVisualizer/DomLabel.lua @@ -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, }), diff --git a/plugin/src/App/Components/PatchVisualizer/init.lua b/plugin/src/App/Components/PatchVisualizer/init.lua index a9c8bfa8..6c90f328 100644 --- a/plugin/src/App/Components/PatchVisualizer/init.lua +++ b/plugin/src/App/Components/PatchVisualizer/init.lua @@ -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, diff --git a/plugin/src/App/StatusPages/Confirming.lua b/plugin/src/App/StatusPages/Confirming.lua index 7b6d08a2..abf48793 100644 --- a/plugin/src/App/StatusPages/Confirming.lua +++ b/plugin/src/App/StatusPages/Confirming.lua @@ -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, diff --git a/plugin/src/App/StatusPages/Connected.lua b/plugin/src/App/StatusPages/Connected.lua index c6f2a7bb..f88e7a8a 100644 --- a/plugin/src/App/StatusPages/Connected.lua +++ b/plugin/src/App/StatusPages/Connected.lua @@ -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, diff --git a/plugin/src/App/Theme.lua b/plugin/src/App/Theme.lua index edfe31ea..23984ec1 100644 --- a/plugin/src/App/Theme.lua +++ b/plugin/src/App/Theme.lua @@ -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), diff --git a/plugin/src/ChangeMetadata.lua b/plugin/src/ChangeMetadata.lua new file mode 100644 index 00000000..e76b98c3 --- /dev/null +++ b/plugin/src/ChangeMetadata.lua @@ -0,0 +1,159 @@ +--[[ + Computes structured change metadata from a changeList. + + Produces both summary fields (for display tags and diff bar) and + detail fields (for future merge dialogue). Replaces the ad-hoc + inline metadata computation that was previously in PatchTree.build(). +]] + +local Rojo = script:FindFirstAncestor("Rojo") +local Plugin = Rojo.Plugin + +local DiffUtil = require(Plugin.DiffUtil) + +local ChangeMetadata = {} + +--[[ + Compute metadata for an updated instance's changeList. + changeList is an array of {prop, currentValue, incomingValue} entries + (without the header row -- that is added separately by PatchTree). + + Returns a ChangeMetadata table with: + Summary (for display): + linesAdded, linesRemoved, isWhitespaceOnly, propChanges + Detail (for future merge dialogue): + hunks, propertyDetails + Tracking: + edits, failed (set later by updateMetadata) +]] +function ChangeMetadata.compute(changeList: { { any } }): { [string]: any } + local linesAdded: number? = nil + local linesRemoved: number? = nil + local isWhitespaceOnly: boolean? = nil + local hunks: { any }? = nil + local propertyDetails: { any }? = nil + local propCount = 0 + local totalEdits = #changeList + + for _, entry in changeList do + local prop = entry[1] + local currentValue = entry[2] + local incomingValue = entry[3] + + if prop == "Source" and type(currentValue) == "string" and type(incomingValue) == "string" then + -- Source property: compute line diff (always exact, no thresholds) + local diffResult = DiffUtil.diffLines(currentValue, incomingValue) + + if diffResult.added > 0 or diffResult.removed > 0 then + linesAdded = diffResult.added + linesRemoved = diffResult.removed + isWhitespaceOnly = diffResult.isWhitespaceOnly + hunks = diffResult.hunks + end + else + -- Non-Source property + propCount += 1 + + -- Build property detail for future merge dialogue + if not propertyDetails then + propertyDetails = {} + end + + table.insert(propertyDetails, { + name = prop, + oldValue = currentValue, + newValue = incomingValue, + valueType = typeof(incomingValue) or typeof(currentValue) or "unknown", + }) + end + end + + return { + -- Summary (for display tags and diff bar) + linesAdded = linesAdded, + linesRemoved = linesRemoved, + isWhitespaceOnly = isWhitespaceOnly, + propChanges = if propCount > 0 then propCount else nil, + + -- Tracking (edits used by added instances and failure tracking) + edits = totalEdits, + + -- Detail (for future merge dialogue -- not consumed by UI yet) + hunks = hunks, + propertyDetails = propertyDetails, + } +end + +--[[ + Compute metadata for an added instance's changeList. + These are all new properties, so there are no "current" values. + changeList entries are {prop, "N/A", incomingValue}. +]] +function ChangeMetadata.computeForAddition(changeList: { { any } }): { [string]: any } + local linesAdded: number? = nil + local hunks: { any }? = nil + local propertyDetails: { any }? = nil + + for _, entry in changeList do + local prop = entry[1] + local incomingValue = entry[3] + + if prop == "Source" and type(incomingValue) == "string" then + -- New script: count lines being added + local diffResult = DiffUtil.diffLines("", incomingValue) + if diffResult.added > 0 then + linesAdded = diffResult.added + hunks = diffResult.hunks + end + end + + if not propertyDetails then + propertyDetails = {} + end + + table.insert(propertyDetails, { + name = prop, + oldValue = nil, + newValue = incomingValue, + valueType = typeof(incomingValue) or "unknown", + }) + end + + return { + edits = #changeList, + linesAdded = if linesAdded then linesAdded else nil, + linesRemoved = if linesAdded then 0 else nil, + isWhitespaceOnly = false, + hunks = hunks, + propertyDetails = propertyDetails, + } +end + +--[[ + Compute metadata for a removed instance. + Reads the Source property (if it exists) to show line counts. + Returns nil if the instance has no Source (non-script removal). +]] +function ChangeMetadata.computeForRemoval(instance: Instance): { [string]: any }? + local ok, source = pcall(function() + return (instance :: any).Source + end) + + if not ok or type(source) ~= "string" then + return nil + end + + local diffResult = DiffUtil.diffLines(source, "") + + if diffResult.removed == 0 then + return nil + end + + return { + linesAdded = 0, + linesRemoved = diffResult.removed, + isWhitespaceOnly = false, + } +end + +return ChangeMetadata diff --git a/plugin/src/DiffUtil.lua b/plugin/src/DiffUtil.lua new file mode 100644 index 00000000..b73b2451 --- /dev/null +++ b/plugin/src/DiffUtil.lua @@ -0,0 +1,489 @@ +--!strict +--!optimize 2 +--!native + +--[[ + Line-level diff utility using Myers O(ND) diff algorithm. + Produces git-identical line change counts and unified diff hunks. + + Based on: "An O(ND) Difference Algorithm and Its Variations" (Myers, 1986) + This is the same algorithm git uses internally for line-level diffs. + Always computes exact results -- no thresholds or approximations. + + Public API: + DiffUtil.countLineDifferences(current, incoming) -> LineDiffResult + DiffUtil.diffLines(current, incoming, contextLines?) -> DiffResult +]] + +local DiffUtil = {} + +local EQUAL = 0 +local DELETE = 1 +local INSERT = 2 + +DiffUtil.EditType = table.freeze({ + Equal = EQUAL, + Delete = DELETE, + Insert = INSERT, +}) + +-- Split string into lines on \n, matching git behavior. +-- A trailing \n does NOT produce a phantom empty line. +local function splitLines(str: string): { string } + if str == "" then + return {} + end + + local lines = {} + local count = 0 + local start = 1 + + while true do + local pos = string.find(str, "\n", start, true) + if pos then + count += 1 + lines[count] = string.sub(str, start, pos - 1) + start = pos + 1 + else + local last = string.sub(str, start) + if last ~= "" then + count += 1 + lines[count] = last + end + break + end + end + + return lines +end + +-- Normalize a line for whitespace comparison (strip trailing whitespace and \r) +local function normalizeLine(line: string): string + return (string.gsub(string.gsub(line, "\r+$", ""), "[ \t]+$", "")) +end + +-- Normalize an array of lines for whitespace comparison +local function normalizeLines(lines: { string }): { string } + local result = table.create(#lines) + for i = 1, #lines do + result[i] = normalizeLine(lines[i]) + end + return result +end + +--[[ + Myers algorithm: compute edit distance only (no edit script). + Returns D (number of edits). Always runs to completion. + O(ND) time, O(N) space. +]] +local function myersEditDistance(compareA: { string }, compareB: { string }): number + local m, n = #compareA, #compareB + if m == 0 then + return n + end + if n == 0 then + return m + end + + local maxD = m + n + local offset = maxD + 1 + local v = table.create(2 * maxD + 2, 0) + v[offset + 1] = 0 + + for d = 0, maxD do + for k = -d, d, 2 do + local x + if k == -d or (k ~= d and v[offset + k - 1] < v[offset + k + 1]) then + x = v[offset + k + 1] + else + x = v[offset + k - 1] + 1 + end + + local y = x - k + + -- Follow diagonal (equal lines) + while x < m and y < n and compareA[x + 1] == compareB[y + 1] do + x += 1 + y += 1 + end + + v[offset + k] = x + + if x >= m and y >= n then + return d + end + end + end + + -- Unreachable for valid inputs, but satisfies the type checker + return maxD +end + +--[[ + Myers algorithm: compute full edit script with backtracking. + Returns array of {editType, content}. Always runs to completion. + O(ND) time, O(D*N) space (stores V array for each step for backtracking). + + linesA/linesB: original lines (used for output content) + compareA/compareB: lines used for comparison (may be normalized) +]] +local function myersEditScript( + linesA: { string }, + linesB: { string }, + compareA: { string }?, + compareB: { string }? +): { { number } } + local cmpA = compareA or linesA + local cmpB = compareB or linesB + local m, n = #linesA, #linesB + + -- Trivial cases + if m == 0 and n == 0 then + return {} + end + if m == 0 then + local result = table.create(n) + for i = 1, n do + result[i] = { INSERT, linesB[i] } + end + return result + end + if n == 0 then + local result = table.create(m) + for i = 1, m do + result[i] = { DELETE, linesA[i] } + end + return result + end + + local maxD = m + n + local offset = maxD + 1 + local v = table.create(2 * maxD + 2, 0) + v[offset + 1] = 0 + + local vHistory = {} + local foundD: number? = nil + + -- Forward pass: find shortest edit path, storing V at each step + for d = 0, maxD do + for k = -d, d, 2 do + local x + if k == -d or (k ~= d and v[offset + k - 1] < v[offset + k + 1]) then + x = v[offset + k + 1] + else + x = v[offset + k - 1] + 1 + end + + local y = x - k + + while x < m and y < n and cmpA[x + 1] == cmpB[y + 1] do + x += 1 + y += 1 + end + + v[offset + k] = x + + if x >= m and y >= n then + foundD = d + break + end + end + + -- Save V AFTER computing step d (needed for backtracking) + vHistory[d + 1] = table.clone(v) + + if foundD then + break + end + end + + -- foundD is always set for valid inputs (worst case D = m + n) + assert(foundD, "Myers algorithm failed to find edit path") + + -- Backtrack to recover the edit script (built in reverse, then flipped) + local edits = {} + local editCount = 0 + local x, y = m, n + + for d = foundD, 1, -1 do + local k = x - y + -- V from step d-1 is stored at index d (1-indexed history) + local prevV = vHistory[d] + + -- Determine which predecessor we came from + local prevK + if k == -d or (k ~= d and prevV[offset + k - 1] < prevV[offset + k + 1]) then + prevK = k + 1 -- came from insert (down move) + else + prevK = k - 1 -- came from delete (right move) + end + + local prevX = prevV[offset + prevK] + local prevY = prevX - prevK + + -- After the edit, we landed at: + local endX, endY + if k < prevK then + -- Insert: y increased by 1 + endX, endY = prevX, prevY + 1 + else + -- Delete: x increased by 1 + endX, endY = prevX + 1, prevY + end + + -- Diagonal moves (equal lines) from edit endpoint to current (x, y) + while x > endX and y > endY do + x -= 1 + y -= 1 + editCount += 1 + edits[editCount] = { EQUAL, linesA[x + 1] } + end + + -- The edit itself + if k < prevK then + -- Insert + y -= 1 + editCount += 1 + edits[editCount] = { INSERT, linesB[y + 1] } + else + -- Delete + x -= 1 + editCount += 1 + edits[editCount] = { DELETE, linesA[x + 1] } + end + end + + -- Remaining diagonals from (0, 0) to current position + while x > 0 and y > 0 do + x -= 1 + y -= 1 + editCount += 1 + edits[editCount] = { EQUAL, linesA[x + 1] } + end + + -- Reverse (we built it backwards) + local result = table.create(editCount) + for i = 1, editCount do + result[i] = edits[editCount - i + 1] + end + + return result +end + +--[[ + Group an edit script into unified diff hunks with context lines. + Each hunk contains contiguous changes with surrounding context, + matching git's unified diff format. +]] +local function buildHunks(editScript: { { any } }, contextLines: number): { any } + if #editScript == 0 then + return {} + end + + -- Find indices of changed entries (non-Equal) + local changeIndices = {} + local changeCount = 0 + for i, edit in editScript do + if edit[1] ~= EQUAL then + changeCount += 1 + changeIndices[changeCount] = i + end + end + + if changeCount == 0 then + return {} + end + + -- Group changes whose contexts would overlap (gap <= 2 * contextLines) + local groups = {} + local groupCount = 0 + local groupStart = changeIndices[1] + local groupEnd = changeIndices[1] + + for i = 2, changeCount do + local idx = changeIndices[i] + if idx - groupEnd <= 2 * contextLines then + -- Close enough to merge into current group + groupEnd = idx + else + -- Start a new group + groupCount += 1 + groups[groupCount] = { groupStart, groupEnd } + groupStart = idx + groupEnd = idx + end + end + groupCount += 1 + groups[groupCount] = { groupStart, groupEnd } + + -- Build hunks from groups + local hunks = {} + for gi = 1, groupCount do + local group = groups[gi] + local hunkStart = math.max(1, group[1] - contextLines) + local hunkEnd = math.min(#editScript, group[2] + contextLines) + + -- Count old/new lines before hunk to get starting line numbers + local oldLine, newLine = 0, 0 + for i = 1, hunkStart - 1 do + local editType = editScript[i][1] + if editType == EQUAL then + oldLine += 1 + newLine += 1 + elseif editType == DELETE then + oldLine += 1 + else + newLine += 1 + end + end + + local oldStart = oldLine + 1 + local newStart = newLine + 1 + local oldCount, newCount = 0, 0 + local lines = {} + local lineCount = 0 + + for i = hunkStart, hunkEnd do + local edit = editScript[i] + local editType = edit[1] + local lineType + + if editType == EQUAL then + lineType = "context" + oldCount += 1 + newCount += 1 + elseif editType == DELETE then + lineType = "remove" + oldCount += 1 + else + lineType = "add" + newCount += 1 + end + + lineCount += 1 + lines[lineCount] = { type = lineType, content = edit[2] } + end + + hunks[gi] = { + oldStart = oldStart, + oldCount = oldCount, + newStart = newStart, + newCount = newCount, + lines = lines, + } + end + + return hunks +end + +--[[ + Count line differences between two strings (lightweight, just counts). + Returns a structured result matching git diff --stat behavior: + added = lines present in incoming but not current + removed = lines present in current but not incoming + isWhitespaceOnly = true when ALL changes are whitespace-only + + A modified line counts as 1 removal + 1 addition (matching git). + Always computes exact results with no thresholds. +]] +function DiffUtil.countLineDifferences( + current: string, + incoming: string +): { added: number, removed: number, isWhitespaceOnly: boolean } + if type(current) ~= "string" or type(incoming) ~= "string" then + return { added = 0, removed = 0, isWhitespaceOnly = false } + end + + -- Fast path for identical strings + if current == incoming then + return { added = 0, removed = 0, isWhitespaceOnly = false } + end + + local currentLines = splitLines(current) + local incomingLines = splitLines(incoming) + local m, n = #currentLines, #incomingLines + + -- Compute exact edit distance using Myers + local d = myersEditDistance(currentLines, incomingLines) + + if d == 0 then + return { added = 0, removed = 0, isWhitespaceOnly = false } + end + + -- Derive added/removed from edit distance and line counts + -- D = removed + added, and removed - added = m - n + local removed = (d + m - n) // 2 + local added = (d - m + n) // 2 + + -- Check whitespace-only: run normalized comparison + local normD = myersEditDistance(normalizeLines(currentLines), normalizeLines(incomingLines)) + local isWhitespaceOnly = normD == 0 + + return { + added = added, + removed = removed, + isWhitespaceOnly = isWhitespaceOnly, + } +end + +--[[ + Full line diff with hunks (for merge dialogue and detailed views). + Returns counts, whitespace info, and unified diff hunks. + contextLines defaults to 3, matching git's default. + Always computes exact results with no thresholds. +]] +function DiffUtil.diffLines( + current: string, + incoming: string, + contextLines: number? +): { + added: number, + removed: number, + isWhitespaceOnly: boolean, + hunks: { any }, +} + contextLines = contextLines or 3 + + if type(current) ~= "string" or type(incoming) ~= "string" then + return { added = 0, removed = 0, isWhitespaceOnly = false, hunks = {} } + end + + if current == incoming then + return { added = 0, removed = 0, isWhitespaceOnly = false, hunks = {} } + end + + local currentLines = splitLines(current) + local incomingLines = splitLines(incoming) + + -- Compute full edit script + local editScript = myersEditScript(currentLines, incomingLines) + + -- Count edits from the script + local added, removed = 0, 0 + for _, edit in editScript do + if edit[1] == DELETE then + removed += 1 + elseif edit[1] == INSERT then + added += 1 + end + end + + -- Check whitespace-only + local isWhitespaceOnly = false + if added > 0 or removed > 0 then + local normD = myersEditDistance(normalizeLines(currentLines), normalizeLines(incomingLines)) + isWhitespaceOnly = normD == 0 + end + + -- Build unified diff hunks + local hunks = buildHunks(editScript, contextLines) + + return { + added = added, + removed = removed, + isWhitespaceOnly = isWhitespaceOnly, + hunks = hunks, + } +end + +return DiffUtil diff --git a/plugin/src/DiffUtil.spec.lua b/plugin/src/DiffUtil.spec.lua new file mode 100644 index 00000000..b918386f --- /dev/null +++ b/plugin/src/DiffUtil.spec.lua @@ -0,0 +1,305 @@ +return function() + local DiffUtil = require(script.Parent.DiffUtil) + + describe("countLineDifferences", function() + it("should return zeros for identical strings", function() + local result = DiffUtil.countLineDifferences("hello\nworld\n", "hello\nworld\n") + expect(result.added).to.equal(0) + expect(result.removed).to.equal(0) + expect(result.isWhitespaceOnly).to.equal(false) + end) + + it("should return zeros for empty strings", function() + local result = DiffUtil.countLineDifferences("", "") + expect(result.added).to.equal(0) + expect(result.removed).to.equal(0) + expect(result.isWhitespaceOnly).to.equal(false) + end) + + it("should handle nil/non-string inputs gracefully", function() + local result = DiffUtil.countLineDifferences(nil, "hello") + expect(result.added).to.equal(0) + expect(result.removed).to.equal(0) + + result = DiffUtil.countLineDifferences("hello", 42) + expect(result.added).to.equal(0) + expect(result.removed).to.equal(0) + end) + + it("should count pure additions (empty to non-empty)", function() + local result = DiffUtil.countLineDifferences("", "line1\nline2\nline3\n") + expect(result.added).to.equal(3) + expect(result.removed).to.equal(0) + expect(result.isWhitespaceOnly).to.equal(false) + end) + + it("should count pure removals (non-empty to empty)", function() + local result = DiffUtil.countLineDifferences("line1\nline2\nline3\n", "") + expect(result.added).to.equal(0) + expect(result.removed).to.equal(3) + expect(result.isWhitespaceOnly).to.equal(false) + end) + + it("should count a single modified line as 1 removal + 1 addition", function() + -- git diff: old line removed, new line added + local result = DiffUtil.countLineDifferences("hello\n", "world\n") + expect(result.added).to.equal(1) + expect(result.removed).to.equal(1) + expect(result.isWhitespaceOnly).to.equal(false) + end) + + it("should count multiple modified lines correctly", function() + local old = "line1\nline2\nline3\n" + local new = "changed1\nchanged2\nline3\n" + -- Lines 1 and 2 changed: 2 removals + 2 additions + local result = DiffUtil.countLineDifferences(old, new) + expect(result.added).to.equal(2) + expect(result.removed).to.equal(2) + end) + + it("should handle mixed additions and removals", function() + local old = "alpha\nbeta\ngamma\n" + local new = "alpha\ndelta\ngamma\nepsilon\n" + -- beta -> delta (1 remove + 1 add), + epsilon (1 add) + local result = DiffUtil.countLineDifferences(old, new) + expect(result.added).to.equal(2) + expect(result.removed).to.equal(1) + end) + + it("should detect whitespace-only changes (trailing spaces)", function() + local old = "hello\nworld\n" + local new = "hello \nworld \n" + local result = DiffUtil.countLineDifferences(old, new) + expect(result.added).to.equal(2) + expect(result.removed).to.equal(2) + expect(result.isWhitespaceOnly).to.equal(true) + end) + + it("should detect whitespace-only changes (trailing tabs)", function() + local old = "hello\n" + local new = "hello\t\n" + local result = DiffUtil.countLineDifferences(old, new) + expect(result.isWhitespaceOnly).to.equal(true) + end) + + it("should detect whitespace-only changes (trailing \\r)", function() + local old = "hello\n" + local new = "hello\r\n" + local result = DiffUtil.countLineDifferences(old, new) + expect(result.isWhitespaceOnly).to.equal(true) + end) + + it("should NOT mark as whitespace-only when content changes", function() + local old = "hello\nworld\n" + local new = "hello \nchanged\n" + local result = DiffUtil.countLineDifferences(old, new) + expect(result.isWhitespaceOnly).to.equal(false) + end) + + it("should not produce phantom diffs from trailing newlines", function() + -- "hello\n" and "hello" should diff the same as identical + local result = DiffUtil.countLineDifferences("hello\n", "hello") + expect(result.added).to.equal(0) + expect(result.removed).to.equal(0) + end) + + it("should handle single line strings", function() + local result = DiffUtil.countLineDifferences("old", "new") + expect(result.added).to.equal(1) + expect(result.removed).to.equal(1) + end) + + it("should compute exact counts for large diffs (no overflow)", function() + local lines = {} + for i = 1, 150 do + lines[i] = "line" .. i + end + local big = table.concat(lines, "\n") .. "\n" + local result = DiffUtil.countLineDifferences("", big) + expect(result.added).to.equal(150) + expect(result.removed).to.equal(0) + end) + + it("should handle adding lines at the end", function() + local old = "A\nB\n" + local new = "A\nB\nC\nD\n" + local result = DiffUtil.countLineDifferences(old, new) + expect(result.added).to.equal(2) + expect(result.removed).to.equal(0) + end) + + it("should handle removing lines from the beginning", function() + local old = "A\nB\nC\nD\n" + local new = "C\nD\n" + local result = DiffUtil.countLineDifferences(old, new) + expect(result.added).to.equal(0) + expect(result.removed).to.equal(2) + end) + + it("should handle interleaved changes", function() + local old = "A\nB\nC\nD\nE\n" + local new = "A\nX\nC\nY\nE\n" + -- B -> X, D -> Y: 2 removals + 2 additions + local result = DiffUtil.countLineDifferences(old, new) + expect(result.added).to.equal(2) + expect(result.removed).to.equal(2) + end) + end) + + describe("diffLines", function() + it("should return empty hunks for identical strings", function() + local result = DiffUtil.diffLines("hello\nworld\n", "hello\nworld\n") + expect(result.added).to.equal(0) + expect(result.removed).to.equal(0) + expect(#result.hunks).to.equal(0) + end) + + it("should return correct counts matching countLineDifferences", function() + local old = "alpha\nbeta\ngamma\n" + local new = "alpha\ndelta\ngamma\nepsilon\n" + + local counts = DiffUtil.countLineDifferences(old, new) + local full = DiffUtil.diffLines(old, new) + + expect(full.added).to.equal(counts.added) + expect(full.removed).to.equal(counts.removed) + expect(full.isWhitespaceOnly).to.equal(counts.isWhitespaceOnly) + end) + + it("should produce a single hunk for a simple change", function() + local old = "hello\n" + local new = "world\n" + local result = DiffUtil.diffLines(old, new) + expect(#result.hunks).to.equal(1) + + local hunk = result.hunks[1] + expect(hunk.oldStart).to.equal(1) + expect(hunk.oldCount).to.equal(1) + expect(hunk.newStart).to.equal(1) + expect(hunk.newCount).to.equal(1) + end) + + it("should include context lines around changes", function() + local old = "A\nB\nC\nD\nE\nF\nG\n" + local new = "A\nB\nC\nX\nE\nF\nG\n" + -- D -> X in the middle, with 3 lines context on each side + local result = DiffUtil.diffLines(old, new, 3) + expect(#result.hunks).to.equal(1) + + local hunk = result.hunks[1] + -- Should include context: A,B,C (before) + change + E,F,G (after) = all 7 lines + expect(hunk.oldStart).to.equal(1) + expect(hunk.newStart).to.equal(1) + end) + + it("should merge adjacent hunks when their contexts overlap", function() + -- Changes at lines 2 and 6 with 3 context = gap of 4 (< 2*3=6), should merge + local old = "A\nB\nC\nD\nE\nF\nG\n" + local new = "A\nX\nC\nD\nE\nY\nG\n" + local result = DiffUtil.diffLines(old, new, 3) + -- B->X and F->Y are only 4 lines apart, contexts overlap -> 1 hunk + expect(#result.hunks).to.equal(1) + end) + + it("should produce separate hunks when changes are far apart", function() + -- Build a file with changes separated by more than 2*contextLines + local lines = {} + for i = 1, 20 do + lines[i] = "line" .. i + end + local old = table.concat(lines, "\n") .. "\n" + + lines[1] = "changed1" + lines[20] = "changed20" + local new = table.concat(lines, "\n") .. "\n" + + local result = DiffUtil.diffLines(old, new, 3) + -- Changes at line 1 and 20, gap of 19 > 2*3=6 -> separate hunks + expect(#result.hunks).to.equal(2) + end) + + it("should have correct hunk line types", function() + local old = "A\nB\nC\n" + local new = "A\nX\nC\n" + local result = DiffUtil.diffLines(old, new, 1) + expect(#result.hunks).to.equal(1) + + local hunk = result.hunks[1] + -- Should have: context A, remove B, add X, context C + local types = {} + for _, line in hunk.lines do + table.insert(types, line.type) + end + + -- Find the remove and add + local hasRemove = false + local hasAdd = false + local hasContext = false + for _, t in types do + if t == "remove" then + hasRemove = true + end + if t == "add" then + hasAdd = true + end + if t == "context" then + hasContext = true + end + end + + expect(hasRemove).to.equal(true) + expect(hasAdd).to.equal(true) + expect(hasContext).to.equal(true) + end) + + it("should handle pure additions in hunks", function() + local old = "A\nB\n" + local new = "A\nB\nC\nD\n" + local result = DiffUtil.diffLines(old, new, 3) + expect(result.added).to.equal(2) + expect(result.removed).to.equal(0) + expect(#result.hunks).to.equal(1) + end) + + it("should handle pure removals in hunks", function() + local old = "A\nB\nC\nD\n" + local new = "A\nB\n" + local result = DiffUtil.diffLines(old, new, 3) + expect(result.added).to.equal(0) + expect(result.removed).to.equal(2) + expect(#result.hunks).to.equal(1) + end) + + it("should compute exact hunks for large diffs (no overflow)", function() + local lines = {} + for i = 1, 150 do + lines[i] = "line" .. i + end + local big = table.concat(lines, "\n") .. "\n" + local result = DiffUtil.diffLines("", big) + expect(result.added).to.equal(150) + expect(result.removed).to.equal(0) + expect(#result.hunks).to.equal(1) + end) + + it("should use custom context lines", function() + local old = "A\nB\nC\nD\nE\n" + local new = "A\nB\nX\nD\nE\n" + -- C -> X with 1 context line + local result = DiffUtil.diffLines(old, new, 1) + expect(#result.hunks).to.equal(1) + local hunk = result.hunks[1] + -- context B, remove C, add X, context D = 4 lines + local lineCount = #hunk.lines + expect(lineCount).to.equal(4) + end) + end) + + describe("EditType", function() + it("should expose frozen edit type constants", function() + expect(DiffUtil.EditType.Equal).to.equal(0) + expect(DiffUtil.EditType.Delete).to.equal(1) + expect(DiffUtil.EditType.Insert).to.equal(2) + end) + end) +end diff --git a/plugin/src/PatchTree.lua b/plugin/src/PatchTree.lua index 2ede49c9..700c3812 100644 --- a/plugin/src/PatchTree.lua +++ b/plugin/src/PatchTree.lua @@ -11,10 +11,10 @@ local Packages = Rojo.Packages local Log = require(Packages.Log) +local ChangeMetadata = require(Plugin.ChangeMetadata) local Config = require(Plugin.Config) local Timer = require(Plugin.Timer) local Types = require(Plugin.Types) -local WhitespaceUtil = require(Plugin.WhitespaceUtil) local decodeValue = require(Plugin.Reconciler.decodeValue) local getProperty = require(Plugin.Reconciler.getProperty) @@ -251,9 +251,6 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) -- Gather detail text local changeList, changeInfo = nil, nil - local totalLineChanges = 0 - local whitespaceLineChanges = 0 - local hasSourceChange = false if next(change.changedProperties) or change.changedName then changeList = {} @@ -290,13 +287,6 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) end end - -- Check if this is a Source property - count line differences - if prop == "Source" and currentSuccess and incomingSuccess then - hasSourceChange = true - totalLineChanges, whitespaceLineChanges = - WhitespaceUtil.CountLineDifferences(currentValue, incomingValue) - end - addProp( prop, if currentSuccess then currentValue else "[Error]", @@ -304,18 +294,8 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) ) end - -- Count non-Source property changes - local propCount = changeIndex - (if hasSourceChange then 1 else 0) - - changeInfo = { - -- Line changes for Source (nil if no Source change) - lineChanges = if hasSourceChange and totalLineChanges > 0 then totalLineChanges else nil, - isWhitespaceOnly = hasSourceChange - and totalLineChanges > 0 - and totalLineChanges == whitespaceLineChanges, - -- Property changes (non-Source: Name, Attributes, etc.) - propChanges = if propCount > 0 then propCount else nil, - } + -- Compute structured change metadata via ChangeMetadata module + changeInfo = ChangeMetadata.compute(changeList) -- Sort changes and add header table.sort(changeList, function(a, b) @@ -377,6 +357,9 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) tree:buildAncestryNodes(previousId, ancestryIds, patch, instanceMap) + -- Compute line count metadata for removed scripts + local changeInfo = ChangeMetadata.computeForRemoval(instance) + -- Add this node to tree -- Default to nil (unselected) - user must explicitly choose Push/Pull/Skip local nodeId = instanceMap.fromInstances[instance] or HttpService:GenerateGUID(false) @@ -387,6 +370,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) className = instance.ClassName, name = instance.Name, instance = instance, + changeInfo = changeInfo, defaultSelection = nil, }) end @@ -444,9 +428,8 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) addProp(prop, if success then incomingValue else select(2, next(incoming))) end - changeInfo = { - edits = changeIndex, - } + -- Compute structured change metadata via ChangeMetadata module + changeInfo = ChangeMetadata.computeForAddition(changeList) -- Sort changes and add header table.sort(changeList, function(a, b) @@ -570,10 +553,10 @@ function PatchTree.updateMetadata(tree, patch, instanceMap, unappliedPatch) Log.trace(" Marked property as warning: {}.{}", node.name, property) end - node.changeInfo = { - edits = (node.changeInfo.edits or (#node.changeList - 1)) - warnings, - failed = if warnings > 0 then warnings else nil, - } + -- Preserve existing metadata fields (linesAdded, etc.) and add failure tracking + node.changeInfo = table.clone(node.changeInfo) + node.changeInfo.edits = (node.changeInfo.edits or (#node.changeList - 1)) - warnings + node.changeInfo.failed = if warnings > 0 then warnings else nil end for failedAdditionId in unappliedPatch.added do local node = tree:getNode(failedAdditionId) @@ -598,9 +581,11 @@ function PatchTree.updateMetadata(tree, patch, instanceMap, unappliedPatch) Log.trace(" Marked property as warning: {}.{}", node.name, change[1]) end - node.changeInfo = { - failed = node.changeInfo.edits or (#node.changeList - 1), - } + -- Preserve existing metadata fields and mark all edits as failed + local totalEdits = node.changeInfo.edits or (#node.changeList - 1) + node.changeInfo = table.clone(node.changeInfo) + node.changeInfo.failed = totalEdits + node.changeInfo.edits = nil end for _, failedRemovalIdOrInstance in unappliedPatch.removed do local failedRemovalId = if Types.RbxId(failedRemovalIdOrInstance) diff --git a/plugin/src/WhitespaceUtil.lua b/plugin/src/WhitespaceUtil.lua deleted file mode 100644 index 63ac7374..00000000 --- a/plugin/src/WhitespaceUtil.lua +++ /dev/null @@ -1,111 +0,0 @@ ---[[ - Utility for detecting whitespace-only changes in source code. - Uses LCS (Longest Common Subsequence) based diff algorithm for accurate - change detection that properly handles insertions and deletions. -]] - -local WhitespaceUtil = {} - -local function SplitLines(str: string): { string } - local lines = {} - for line in string.gmatch(str .. "\n", "([^\n]*)\n") do - table.insert(lines, line) - end - return lines -end - -local function NormalizeLine(line: string): string - return string.gsub(string.gsub(line, "\r+$", ""), "[ \t]+$", "") -end - --- Computes the length of the Longest Common Subsequence between two line arrays --- If normalize is true, lines are compared after whitespace normalization -local function ComputeLcsLength(linesA: { string }, linesB: { string }, normalize: boolean): number - local m, n = #linesA, #linesB - if m == 0 or n == 0 then - return 0 - end - - -- Precompute comparison lines (normalized if requested) - local compareA, compareB = linesA, linesB - if normalize then - compareA = table.create(m) - compareB = table.create(n) - for i = 1, m do - compareA[i] = NormalizeLine(linesA[i]) - end - for j = 1, n do - compareB[j] = NormalizeLine(linesB[j]) - end - end - - -- Space-optimized DP for LCS - -- prev[j+1] = LCS length for linesA[1..i-1] and linesB[1..j] - -- curr[j+1] = LCS length for linesA[1..i] and linesB[1..j] - local prev = table.create(n + 1, 0) - local curr = table.create(n + 1, 0) - - for i = 1, m do - for j = 1, n do - if compareA[i] == compareB[j] then - curr[j + 1] = prev[j] + 1 - else - curr[j + 1] = math.max(prev[j + 1], curr[j]) - end - end - -- Swap rows and reset curr - prev, curr = curr, prev - for j = 1, n + 1 do - curr[j] = 0 - end - end - - return prev[n + 1] -end - --- Counts lines that differ between two strings using LCS-based diff --- Returns: totalDiff, whitespaceDiff (lines that only differ in whitespace) --- Returns 10000 for totalDiff if exceeds 100 (frontend displays as "100+") --- whitespaceDiff is 10000 only if ALL checked differences were whitespace-only -function WhitespaceUtil.CountLineDifferences(current: string, incoming: string): (number, number) - if type(current) ~= "string" or type(incoming) ~= "string" then - return 0, 0 - end - - local currentLines = SplitLines(current) - local incomingLines = SplitLines(incoming) - local m, n = #currentLines, #incomingLines - - -- Quick check: if size difference alone exceeds limit - local minPossibleDiff = math.abs(m - n) - if minPossibleDiff > 100 then - return 10000, 0 - end - - -- Compute LCS with exact matching - -- totalDiff = deletions + insertions = (m - lcs) + (n - lcs) - local exactLcs = ComputeLcsLength(currentLines, incomingLines, false) - local totalDiff = (m - exactLcs) + (n - exactLcs) - - if totalDiff > 100 then - -- Compute normalized LCS to determine if changes are whitespace-only - local normalizedLcs = ComputeLcsLength(currentLines, incomingLines, true) - local normalizedDiff = (m - normalizedLcs) + (n - normalizedLcs) - return 10000, if normalizedDiff == 0 then 10000 else 0 - end - - if totalDiff == 0 then - return 0, 0 - end - - -- Compute LCS with normalized matching to find whitespace-only changes - local normalizedLcs = ComputeLcsLength(currentLines, incomingLines, true) - local normalizedDiff = (m - normalizedLcs) + (n - normalizedLcs) - - -- whitespaceDiff = changes that disappear after normalization - local whitespaceDiff = totalDiff - normalizedDiff - - return totalDiff, whitespaceDiff -end - -return WhitespaceUtil