rojo/.cursor/plans/git-style_change_metadata_9d8c8345.plan.md
Joe 9b832c4637 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.
2026-02-08 16:12:40 -06:00

13 KiB

name overview todos isProject
Git-style change metadata Extract ad-hoc change calculation from PatchTree into a dedicated ChangeMetadata module, update WhitespaceUtil to return separate added/removed line counts, and display git-style "-M +N" tags in DomLabel (grayed when whitespace-only).
id content status
create-diff-util Replace WhitespaceUtil with DiffUtil -- Myers diff algorithm (line-level), returns { added, removed, isWhitespaceOnly, overflow } completed
id content status
diff-util-spec Create DiffUtil.spec.lua with comprehensive tests (empty, identical, pure add/remove, mixed, whitespace-only, overflow, edge cases) completed
id content status
create-change-metadata Create plugin/src/ChangeMetadata.lua module with compute() function that takes changeList and returns metadata struct completed
id content status
update-patch-tree Refactor PatchTree.build() and updateMetadata() to use ChangeMetadata.compute() instead of inline calculation completed
id content status
update-dom-label Update DomLabel to display git-style -M/+N tags (red/green), grayed when whitespace-only completed
id content status
diff-bar Add GitHub-style mini diff bar (5 colored segments) next to the line change tags in DomLabel completed
id content status
tooltips Add hover tooltips to change tags with human-readable detail text completed
id content status
restyle-props Restyle the Props tag to be visually consistent with the new git-style tags completed
id content status
update-theme Update Theme.lua Diff colors to use the new hardcoded values (#F21717, completed
false

Git-Style Change Metadata Module

Current State

Change calculation is scattered across multiple files with an ad-hoc data flow:

  • WhitespaceUtil.lua uses LCS-based algorithm, returns a combined (totalDiff, whitespaceDiff) tuple -- no split between added/removed lines
  • PatchTree.lua lines 252-318 builds changeInfo inline during tree construction -- intermixed with changeList building, hard to follow
  • DomLabel.lua lines 519-528 displays "Lines 5" or "Whitespace 3" -- not git-style
flowchart LR
  WT["WhitespaceUtil LCS"] -->|"(totalDiff, wsDiff)"| PT["PatchTree.build()"]
  PT -->|"ad-hoc changeInfo"| DL["DomLabel"]
  DL -->|"'Lines 5' or 'Whitespace 3'"| UI["UI Tags"]

Target State

flowchart LR
  WT["DiffUtil Myers"] -->|"LineDiffResult struct"| CM["ChangeMetadata"]
  PT["PatchTree.build()"] -->|"changeList"| CM
  CM -->|"ChangeMetadata struct"| DL["DomLabel"]
  DL -->|"'-3 +5' or gray '-3 +5'"| UI["UI Tags"]

Changes

1. Replace WhitespaceUtil with DiffUtil (Myers diff)

Delete WhitespaceUtil.lua and create plugin/src/DiffUtil.lua with a proper line-level Myers diff algorithm -- the same algorithm git uses internally.

Goal: exact match with GitHub's commit diff counts. The -3 +5 numbers must be identical to what GitHub shows in the file header of a commit diff. This means matching git diff behavior exactly:

  • A modified line counts as 1 deletion + 1 insertion (old line removed, new line added) -- NOT as 1 "edit"
  • Line splitting on \n (same as git)
  • Lines compared byte-for-byte (no normalization for the primary count)
  • Trailing newline handling: a missing final \n does NOT create a phantom line difference (match git's \ No newline at end of file behavior)

Algorithm: Myers O(ND) diff (see "An O(ND) Difference Algorithm and Its Variations", Myers 1986) -- the exact algorithm git uses internally. The existing StringDiff.lua already has a character-level Myers bisect implementation (_bisect). DiffUtil adapts this for line-level diffing:

  1. Split both strings into line arrays on \n (strip trailing empty line from final \n to avoid phantom diffs)
  2. Run Myers diff on line arrays (each line is an atomic unit, compared as whole strings)
  3. Walk the edit script: right moves = deletions (removed), down moves = insertions (added), diagonal = equal
  4. The resulting removed and added counts are exactly what git diff --stat reports and what GitHub displays
  5. For whitespace detection: run a second pass with normalized lines (trailing whitespace/\r stripped). If normalized diff has 0 changes, isWhitespaceOnly = true

API:

-- Lightweight: just counts (for display tags)
function DiffUtil.countLineDifferences(current: string, incoming: string): LineDiffResult

type LineDiffResult = {
    added: number,
    removed: number,
    isWhitespaceOnly: boolean,
    overflow: boolean,  -- true when line count diff > 100 (early bail, no exact split)
}

-- Full diff: returns edit script grouped into hunks (for future merge dialogue)
function DiffUtil.diffLines(current: string, incoming: string, contextLines: number?): DiffResult

type DiffLine = {
    type: "context" | "add" | "remove",
    content: string,
}

type DiffHunk = {
    oldStart: number,     -- 1-indexed line in old version
    oldCount: number,     -- lines from old version in this hunk
    newStart: number,     -- 1-indexed line in new version
    newCount: number,     -- lines from new version in this hunk
    lines: {DiffLine},    -- ordered lines with type markers
}

type DiffResult = {
    added: number,
    removed: number,
    isWhitespaceOnly: boolean,
    overflow: boolean,
    hunks: {DiffHunk},    -- contiguous change groups with context (default 3 lines, matching git)
}

Both functions share the same internal Myers diff engine. countLineDifferences is the fast path that discards the edit script after counting. diffLines retains it and groups into hunks with contextLines (default 3, matching git). diffLines is not consumed by anything yet -- it exists so the future merge dialogue has a ready-made hunk source.

Performance: Keep the existing early-bail when math.abs(#currentLines - #incomingLines) > 100 to avoid expensive computation on massive diffs. Myers is O(ND) where D is the number of differences, so it's fast for similar files (common case).

Test file: Create plugin/src/DiffUtil.spec.lua (TestEZ pattern, same as PatchSet.spec.lua) covering:

  • Identical strings (0 added, 0 removed)
  • Empty to non-empty (pure additions)
  • Non-empty to empty (pure removals)
  • Mixed changes (some lines added, some removed, some unchanged)
  • Whitespace-only changes (trailing spaces, tabs, mixed)
  • Non-whitespace changes (isWhitespaceOnly = false)
  • Overflow threshold (>100 line diff)
  • Edge cases: single line, empty strings, nil/non-string inputs
  • Accuracy: verify exact added/removed counts against known diffs that can be cross-checked with git diff --stat
  • Modified lines: confirm a changed line counts as 1 removal + 1 addition (not 1 edit)
  • Trailing newline: no phantom diff from presence/absence of final \n
  • diffLines hunks: verify hunk boundaries, context lines (default 3), oldStart/newStart/oldCount/newCount match git's unified diff format
  • diffLines hunks: adjacent changes merge into single hunk, separated changes produce separate hunks

2. Create ChangeMetadata module

New file: plugin/src/ChangeMetadata.lua

Provides ChangeMetadata.compute(changeList) which:

  • Takes the already-built changeList array of {prop, currentValue, incomingValue} entries
  • Scans for Source property to compute line diff via DiffUtil
  • Counts non-Source property changes
  • Returns a clean struct with both summary (for display) and detail (for future merge dialogue):
type PropertyDetail = {
    name: string,
    oldValue: any?,       -- nil for added instances
    newValue: any?,       -- nil for removed instances
    valueType: string,    -- typeof() result, for display/merge type dispatch
    isWarning: boolean?,  -- set later by updateMetadata
}

type ChangeMetadata = {
    -- Summary (drives the display tags and diff bar)
    linesAdded: number?,       -- nil if no Source change
    linesRemoved: number?,     -- nil if no Source change
    isWhitespaceOnly: boolean?, -- true only when ALL Source changes are whitespace
    overflow: boolean?,        -- true when diff exceeds 100 lines threshold
    propChanges: number?,      -- count of non-Source property changes
    failed: number?,           -- set later by updateMetadata
    edits: number?,            -- total edits (used by added instances and failure tracking)

    -- Detail (for future merge dialogue -- not consumed yet)
    hunks: {DiffHunk}?,        -- Source diff hunks from DiffUtil.diffLines(), nil if no Source change
    propertyDetails: {PropertyDetail}?, -- structured property changes with old/new values and types
}

ChangeMetadata.compute() populates both the summary fields (used now) and the detail fields (used later). The hunks come from DiffUtil.diffLines() when a Source change is present. The propertyDetails array is built from the changeList entries, giving the merge dialogue structured access to each property's old/new value and type without re-decoding.

3. Update PatchTree -- use ChangeMetadata module

In PatchTree.lua:

  • Remove the inline metadata calculation (lines 253-318 simplified)
  • After building changeList, call ChangeMetadata.compute(changeList) to get the metadata struct
  • Keep changeList construction where it is (it's tree-building logic)
  • Update updateMetadata() to work with the new struct fields (it currently sets edits and failed, which remain in the struct)

4. Update DomLabel -- git-style display

In DomLabel.lua:

  • Replace the single "Lines X" / "Whitespace X" tag (lines 519-528) with two separate tags:
    • -M tag using theme.Diff.Remove (red) -- shown first
    • +N tag using theme.Diff.Add (green) -- shown second
  • Each tag individually picks its color via if isWhitespaceOnly then theme.Diff.WhitespaceOnly else theme.Diff.Remove (and same pattern for Add). isWhitespaceOnly is only true when the entire Source change is whitespace -- so both go gray together naturally, but the code for each tag is independent
  • When overflow is true, fall back to a single 100+ tag (current behavior, since we don't have exact split)
  • Suppress a tag if its count is 0 (e.g., pure additions show only +5, no -0)

4a. Update Theme.lua with new diff colors

In Theme.lua, update the Diff section to add/replace colors used by the new tags:

  • Diff.Add = Color3.fromHex("47CB7C") (green, for +N tags and bar segments)
  • Diff.Remove = Color3.fromHex("F21717") (red, for -M tags and bar segments)
  • Diff.Property = Color3.fromHex("FFC601") (yellow, for 2P tag)

These replace the existing magenta placeholders for Diff.Add/Diff.Remove and add a new Diff.Property entry. DomLabel references these theme values instead of hardcoding hex colors.

5. Add mini diff bar (GitHub-style)

Add a small 5-segment colored bar next to the line change tags, like GitHub's --stat view. Each segment is a small square frame; the ratio of red-to-green segments reflects the ratio of removals-to-additions.

  • A single fixed-width bar (e.g., 40x8px) with a UICorner for rounded ends
  • Inside: a horizontal UIListLayout with child Frames sized proportionally using UDim2.fromScale(ratio, 1) where ratio = count / total
    • Red section (theme.Diff.Remove) = removed / total
    • Green section (theme.Diff.Add) = added / total
    • Yellow section (theme.Diff.Property) = propChanges / total
    • Total = removed + added + propChanges
  • Sections with 0 count are omitted (no zero-width frames)
  • For whitespace-only line changes: the red/green sections become gray (theme.Diff.WhitespaceOnly), yellow section stays yellow if props exist
  • For overflow (100+): skip the bar (no meaningful ratio)
  • Placed after all tags: -3 +5 3P <bar> via the existing UIListLayout
  • Clip children on the outer bar frame so the proportional fills stay within the rounded corners

6. Add hover tooltips to change tags

Add TooltipText or a custom tooltip on hover for each change tag:

  • Line tags: "N lines removed" / "N lines added" (when whitespace-only: "N lines removed (whitespace only)" / "N lines added (whitespace only)")
  • Overflow: "More than 100 lines changed"
  • Props tag: "N property changes (excluding Source)"
  • Failed tag: "N changes failed to apply"

Implementation: Build a custom Tooltip component -- a small floating TextLabel that appears on MouseEnter of the tag, positioned above/below the tag. This gives full control over styling and positioning. Wrap the existing ChangeTag function component to accept an optional tooltipText prop and manage hover state internally.

7. Restyle Props tag for consistency

Change the "Props 2" tag to use the compact suffix format 2P in yellow (theme.Diff.Property), matching the style of the -3 / +5 tags. This makes the full tag row read naturally as a single stat line: -3 +5 2P -- removals, additions, property changes. The P suffix distinguishes it from line changes while keeping it compact and visually consistent.