From be714dbd179b7312239e789a525b547f26890e4a Mon Sep 17 00:00:00 2001 From: Luke Street Date: Sun, 7 Sep 2025 20:05:56 -0600 Subject: [PATCH] Implement data diffing & update to objdiff v3.2.0 --- package.json | 4 +- pnpm-lock.yaml | 10 +- webview/common/ContextMenu.tsx | 3 + webview/common/TooltipShared.module.css | 20 +- webview/common/TooltipShared.tsx | 33 ++- webview/views/DataView.module.css | 49 ++++ webview/views/DataView.tsx | 329 ++++++++++++++++++++++++ webview/views/DiffView.tsx | 188 ++++++++++++-- 8 files changed, 596 insertions(+), 40 deletions(-) create mode 100644 webview/views/DataView.module.css create mode 100644 webview/views/DataView.tsx diff --git a/package.json b/package.json index 0b7401d..fa03d5d 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "displayName": "objdiff", "description": "A local diffing tool for decompilation projects", "publisher": "decomp-dev", - "version": "3.1.1", + "version": "3.2.0", "repository": { "type": "git", "url": "https://github.com/encounter/objdiff-web" @@ -24,7 +24,7 @@ "@vscode/codicons": "^0.0.36", "clsx": "^2.1.1", "memoize-one": "^6.0.0", - "objdiff-wasm": "3.1.1", + "objdiff-wasm": "3.2.0", "picomatch": "^4.0.2", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 211232f..cca7182 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^6.0.0 version: 6.0.0 objdiff-wasm: - specifier: 3.1.1 - version: 3.1.1 + specifier: 3.2.0 + version: 3.2.0 picomatch: specifier: ^4.0.2 version: 4.0.2 @@ -1613,8 +1613,8 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - objdiff-wasm@3.1.1: - resolution: {integrity: sha512-OXe1nmsxmYbXUSgHzZx1/bL/TLsSySR6S94MHAn9MEIWkaqGb+YkOK901NNBledbylMM/0llchl2FVgT54N3vw==} + objdiff-wasm@3.2.0: + resolution: {integrity: sha512-y6DXIOPtXPZMl/hxYYyks27ljtB0K5d51Mhx3K0yHddYW+558TYReo47lY9Ls8G9N/i1dAHWnYzpTAdCv/P0CQ==} object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} @@ -3795,7 +3795,7 @@ snapshots: dependencies: boolbase: 1.0.0 - objdiff-wasm@3.1.1: {} + objdiff-wasm@3.2.0: {} object-inspect@1.13.4: {} diff --git a/webview/common/ContextMenu.tsx b/webview/common/ContextMenu.tsx index 0ea9670..b5fe8a3 100644 --- a/webview/common/ContextMenu.tsx +++ b/webview/common/ContextMenu.tsx @@ -177,6 +177,9 @@ export function renderContextItems( items: display.ContextItem[], close: () => void, ): React.ReactNode { + if (items.length === 0) { + return null; + } return items.map((item, i) => { const key = `${item.tag}-${i}`; switch (item.tag) { diff --git a/webview/common/TooltipShared.module.css b/webview/common/TooltipShared.module.css index d4e4ac1..bfcb336 100644 --- a/webview/common/TooltipShared.module.css +++ b/webview/common/TooltipShared.module.css @@ -12,13 +12,29 @@ } .hover-item-label { - color: var(--color-blue); + color: var(--color-bright); } .hover-item-value { - color: var(--color-bright); + color: var(--foreground); } .hover-item-separator { border-color: var(--color-muted); } + +.label-color-special { + color: var(--color-blue); +} + +.label-color-delete { + color: var(--color-red); +} + +.label-color-insert { + color: var(--color-green); +} + +.value-color-emphasized { + color: var(--color-bright); +} diff --git a/webview/common/TooltipShared.tsx b/webview/common/TooltipShared.tsx index e043cdb..576507e 100644 --- a/webview/common/TooltipShared.tsx +++ b/webview/common/TooltipShared.tsx @@ -38,7 +38,7 @@ export function createTooltip(): { const parsedContent = JSON.parse(content) as T; return callback(parsedContent); }, [callback, content]); - if (!items) { + if (!items || items.length === 0) { return null; } return ; @@ -57,23 +57,46 @@ const TooltipContent = ({ items }: { items: display.HoverItem[] }) => { for (const [i, item] of items.entries()) { let inner: React.ReactNode; switch (item.tag) { - case 'text': + case 'text': { + let labelClass: string | null = null; + let valueClass: string | null = null; + switch (item.val.color) { + case 'special': + labelClass = styles.labelColorSpecial; + break; + case 'insert': + labelClass = styles.labelColorInsert; + break; + case 'delete': + labelClass = styles.labelColorDelete; + break; + case 'emphasized': + valueClass = styles.valueColorEmphasized; + break; + default: + break; + } if (item.val.label) { inner = ( <> - + {item.val.label} {': '} - {item.val.value} + + {item.val.value} + ); } else { inner = ( - {item.val.value} + + {item.val.value} + ); } break; + } case 'separator': inner =
; break; diff --git a/webview/views/DataView.module.css b/webview/views/DataView.module.css new file mode 100644 index 0000000..3cc7b50 --- /dev/null +++ b/webview/views/DataView.module.css @@ -0,0 +1,49 @@ +.data-row { + display: flex; + align-items: center; + font-family: var(--vscode-editor-font-family, monospace); + font-size: var(--vscode-editor-font-size, 13px); + line-height: 1.5; + white-space: pre; + cursor: default; + padding: 0 8px; +} + +.combined-row { + display: flex; + width: 100%; +} + +.data-row:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.address { + color: var(--vscode-descriptionForeground); + margin-right: 8px; +} + +.hex-bytes { + margin-right: 8px; +} + +.separator { + color: var(--vscode-descriptionForeground); + margin: 0 4px; +} + +.ascii-chars { + color: var(--vscode-foreground); +} + +.replace { + color: var(--color-blue); +} + +.delete { + color: var(--color-red); +} + +.insert { + color: var(--color-green); +} diff --git a/webview/views/DataView.tsx b/webview/views/DataView.tsx new file mode 100644 index 0000000..1c6ff58 --- /dev/null +++ b/webview/views/DataView.tsx @@ -0,0 +1,329 @@ +import styles from './DataView.module.css'; + +import { type diff, display } from 'objdiff-wasm'; +import { memo, useCallback, useMemo } from 'react'; +import { FixedSizeList, areEqual } from 'react-window'; +import type { ListChildComponentProps, ListOnScrollProps } from 'react-window'; +import { useShallow } from 'zustand/react/shallow'; +import { createContextMenu } from '../common/ContextMenu'; +import { createTooltip } from '../common/TooltipShared'; +import { useAppStore, useExtensionStore } from '../state'; +import { useFontSize } from '../util/util'; + +const BYTES_PER_ROW = 16; + +export type DataTooltipContent = { + column: number; + row: number; +}; + +export const { Tooltip: DataTooltip, useTooltip: useDataTooltip } = + createTooltip(); + +export const { + ContextMenuProvider: DataContextMenuProvider, + useContextMenu: useDataContextMenu, +} = createContextMenu(); + +const DataRow = ({ + obj, + symbol, + row, + column, +}: { + obj: diff.ObjectDiff | undefined; + symbol: display.SymbolDisplay | null; + row: number; + column: number; +}) => { + const onContextMenu = useDataContextMenu(); + const tooltipContent: DataTooltipContent = useMemo( + () => ({ + column, + row, + }), + [column, row], + ); + const tooltipProps = useDataTooltip(tooltipContent); + const onContextMenuMemo = useCallback( + (e: React.MouseEvent) => onContextMenu(e, tooltipContent), + [onContextMenu, tooltipContent], + ); + + if (!obj || !symbol) { + return null; + } + + const dataRow = display.displayDataRow(obj, symbol.info.id, row); + const out: React.ReactNode[] = []; + + // Display address + out.push( + + {dataRow.address.toString(16).padStart(8, '0')}: + , + ); + + // Display hex bytes + let byteIndex = 0; + const hexBytes: React.ReactNode[] = []; + const asciiChars: React.ReactNode[] = []; + + for (const diff of dataRow.segments) { + let className: string | undefined; + switch (diff.kind) { + case 'none': + break; + case 'replace': + className = styles.replace; + break; + case 'delete': + className = styles.delete; + break; + case 'insert': + className = styles.insert; + break; + } + + // Check for relocations that overlap with this diff segment + const diffStartAddr = dataRow.address + BigInt(byteIndex); + const diffEndAddr = diffStartAddr + BigInt(diff.size); + const relocsForDiff = dataRow.relocations.filter((r) => { + const relocStart = r.address; + const relocEnd = r.address + BigInt(r.size); + // Check if relocation overlaps with this diff segment + return relocStart < diffEndAddr && relocEnd > diffStartAddr; + }); + + if (diff.data.length === 0) { + // Empty data (deletion on other side - show as blank spaces) + for (let i = 0; i < diff.size; i++) { + hexBytes.push({' '}); + asciiChars.push( ); + byteIndex++; + if (byteIndex % 8 === 0 && byteIndex < BYTES_PER_ROW) { + hexBytes.push( ); + } + } + } else { + // Display actual bytes + for (let i = 0; i < diff.data.length; i++) { + const byte = diff.data[i]; + const currentAddress = dataRow.address + BigInt(byteIndex); + const reloc = relocsForDiff.find( + (r) => + r.address <= currentAddress && + currentAddress < r.address + BigInt(r.size), + ); + + let byteClassName = className; + let byteText = byte.toString(16).padStart(2, '0'); + + if (reloc) { + if (byte === 0) { + byteText = '??'; + } + if (reloc.kind !== 'none') { + switch (reloc.kind) { + case 'replace': + byteClassName = styles.replace; + break; + case 'delete': + byteClassName = styles.delete; + break; + case 'insert': + byteClassName = styles.insert; + break; + } + } + } + + hexBytes.push( + + {byteText}{' '} + , + ); + + // ASCII representation + const c = String.fromCharCode(byte); + const asciiChar = + c.charCodeAt(0) >= 32 && c.charCodeAt(0) < 127 ? c : '.'; + asciiChars.push( + + {asciiChar} + , + ); + + byteIndex++; + if (byteIndex % 8 === 0 && byteIndex < BYTES_PER_ROW) { + hexBytes.push( ); + } + } + } + } + + // Pad to full row width if needed + while (byteIndex < BYTES_PER_ROW) { + hexBytes.push({' '}); + asciiChars.push( ); + byteIndex++; + if (byteIndex % 8 === 0 && byteIndex < BYTES_PER_ROW) { + hexBytes.push( ); + } + } + + out.push( + + {hexBytes} + , + ); + out.push( + + {' '} + , + ); + out.push( + + {asciiChars} + , + ); + + return ( +
+ {out} +
+ ); +}; + +const DataRowMemo = memo(DataRow); + +// Row renderer for combined view +const CombinedRowRenderer = memo( + ({ + index, + style, + data, + }: ListChildComponentProps<{ + leftObj: diff.ObjectDiff | undefined; + leftSymbol: display.SymbolDisplay | null; + rightObj: diff.ObjectDiff | undefined; + rightSymbol: display.SymbolDisplay | null; + }>) => { + const { leftObj, leftSymbol, rightObj, rightSymbol } = data; + return ( +
+
+ +
+
+ +
+
+ ); + }, + areEqual, +); + +export const DataList = ({ + height, + width, + diff, + leftSymbol, + rightSymbol, +}: { + height: number; + width: number; + diff: diff.DiffResult; + leftSymbol: display.SymbolDisplay | null; + rightSymbol: display.SymbolDisplay | null; +}) => { + const { currentUnit } = useExtensionStore( + useShallow((state) => ({ + currentUnit: state.currentUnit, + })), + ); + const { setSymbolScrollOffset } = useAppStore( + useShallow((state) => ({ + setSymbolScrollOffset: state.setSymbolScrollOffset, + })), + ); + const fontSize = useFontSize(); + + const leftCount = leftSymbol?.rowCount ?? 0; + const rightCount = rightSymbol?.rowCount ?? 0; + const rowCount = Math.max(leftCount, rightCount); + + // Get symbol name for scroll persistence + const symbolName = useMemo(() => { + if (leftSymbol && diff.left) { + return leftSymbol.info.name; + } + if (rightSymbol && diff.right) { + return rightSymbol.info.name; + } + return ''; + }, [diff, leftSymbol, rightSymbol]); + + const itemData = useMemo( + () => ({ + leftObj: diff.left, + leftSymbol, + rightObj: diff.right, + rightSymbol, + }), + [diff, leftSymbol, rightSymbol], + ); + + const currentUnitName = currentUnit?.name || ''; + + // Get initial scroll offset + const initialScrollOffset = useMemo( + () => + useAppStore.getState().getUnitState(currentUnitName).symbolScrollOffsets[ + symbolName + ] || 0, + [currentUnitName, symbolName], + ); + + // Handle scroll events to persist position + const onScrollMemo = useCallback( + (e: ListOnScrollProps) => { + setSymbolScrollOffset(currentUnitName, symbolName, e.scrollOffset); + }, + [currentUnitName, symbolName, setSymbolScrollOffset], + ); + + if ((!diff.left || !leftSymbol) && (!diff.right || !rightSymbol)) { + return null; + } + + return ( + + {CombinedRowRenderer} + + ); +}; + +export default DataList; diff --git a/webview/views/DiffView.tsx b/webview/views/DiffView.tsx index fded97e..1dd1e5a 100644 --- a/webview/views/DiffView.tsx +++ b/webview/views/DiffView.tsx @@ -23,6 +23,12 @@ import { useExtensionStore, } from '../state'; import { percentClass } from '../util/util'; +import { + DataContextMenuProvider, + DataList, + DataTooltip, + type DataTooltipContent, +} from './DataView'; import { InstructionContextMenuProvider, InstructionList, @@ -47,6 +53,12 @@ type ColumnViewAsm = { symbol: display.SymbolDisplay; }; +type ColumnViewData = { + type: 'data'; + obj: diff.ObjectDiff; + symbol: display.SymbolDisplay; +}; + type ColumnViewSymbols = { type: 'symbols'; mappingSymbol: number | null; @@ -60,6 +72,7 @@ type ColumnView = | ColumnViewNone | ColumnViewBuildStatus | ColumnViewAsm + | ColumnViewData | ColumnViewSymbols; export const resolveSymbol = ( @@ -140,22 +153,49 @@ const DiffView = ({ } } - if (leftSymbol && rightSymbol) { - // Joint function view + let leftColumnType: ColumnView['type'] | null = null; + let rightColumnType: ColumnView['type'] | null = null; + if (leftSymbol && leftSuccess) { + switch (leftSymbol.info.sectionKind) { + case 'code': + leftColumnType = 'asm'; + break; + case 'data': + leftColumnType = 'data'; + break; + default: + break; + } + } + if (rightSymbol && rightSuccess) { + switch (rightSymbol.info.sectionKind) { + case 'code': + rightColumnType = 'asm'; + break; + case 'data': + rightColumnType = 'data'; + break; + default: + break; + } + } + + if (leftSymbol && leftColumnType && rightSymbol && rightColumnType) { + // Joint view leftColumnView = { - type: 'asm', + type: leftColumnType, obj: result.diff!.left!, symbol: leftSymbol, }; rightColumnView = { - type: 'asm', + type: rightColumnType, obj: result.diff!.right!, symbol: rightSymbol, }; - } else if (leftSymbol) { - // Left function view only + } else if (leftSymbol && leftColumnType) { + // Left view only leftColumnView = { - type: 'asm', + type: leftColumnType, obj: result.diff!.left!, symbol: leftSymbol, }; @@ -166,10 +206,10 @@ const DiffView = ({ mappingSymbol: leftSymbol.info.id, }; } - } else if (rightSymbol) { - // Right function view only + } else if (rightSymbol && rightColumnType) { + // Right view only rightColumnView = { - type: 'asm', + type: rightColumnType, obj: result.diff!.right!, symbol: rightSymbol, }; @@ -258,6 +298,32 @@ const DiffView = ({ [result.diff, leftSymbol, rightSymbol, diffConfig], ); + const dataContextMenuRender: ContextMenuRender = + useCallback( + ({ data }, close) => { + let obj: diff.ObjectDiff | undefined; + let symbol: number | undefined; + switch (data.column) { + case 0: + obj = result.diff?.left; + symbol = leftSymbol?.info.id; + break; + case 1: + obj = result.diff?.right; + symbol = rightSymbol?.info.id; + break; + default: + break; + } + if (!obj || symbol === undefined) { + return null; + } + const items = display.dataContext(obj, symbol, data.row); + return renderContextItems(items, close); + }, + [result.diff, leftSymbol, rightSymbol], + ); + const instructionTooltipCallback: TooltipCallback = useCallback( (data) => { @@ -283,6 +349,30 @@ const DiffView = ({ [result.diff, leftSymbol, rightSymbol, diffConfig], ); + const dataTooltipCallback: TooltipCallback = useCallback( + (data) => { + let obj: diff.ObjectDiff | undefined; + let symbol: number | undefined; + switch (data.column) { + case 0: + obj = result.diff?.left; + symbol = leftSymbol?.info.id; + break; + case 1: + obj = result.diff?.right; + symbol = rightSymbol?.info.id; + break; + default: + break; + } + if (!obj || symbol === undefined) { + return null; + } + return display.dataHover(obj, symbol, data.row); + }, + [result.diff, leftSymbol, rightSymbol], + ); + const [showMappedSymbols, setShowMappedSymbols] = useState(false); return ( @@ -299,19 +389,21 @@ const DiffView = ({
- - {({ height, width }) => ( - - )} - + + + {({ height, width }) => ( + + )} + +
@@ -325,6 +417,11 @@ const DiffView = ({ delayShow={500} callback={instructionTooltipCallback} /> + ); }; @@ -480,7 +577,12 @@ const DiffViewHeader = ({ /> ); - if (leftColumnView.type !== 'asm' && rightColumnView.type !== 'asm') { + if ( + leftColumnView.type !== 'asm' && + rightColumnView.type !== 'asm' && + leftColumnView.type !== 'data' && + rightColumnView.type !== 'data' + ) { const unitNameRow = ( {currentUnitName} @@ -532,7 +634,7 @@ const DiffViewHeader = ({ } const matchPercent = - leftColumnView.type === 'asm' + leftColumnView.type === 'asm' || leftColumnView.type === 'data' ? leftColumnView.symbol.matchPercent : undefined; @@ -616,7 +718,8 @@ const SymbolLabel = ({ currentUnitName: string; }) => { switch (view.type) { - case 'asm': { + case 'asm': + case 'data': { const displayName = view.symbol.info.demangledName || view.symbol.info.name; return ( @@ -705,6 +808,19 @@ const DiffViewContent = ({ ); } + if (leftColumnView.type === 'data' && rightColumnView.type === 'data') { + // Render joint data view + return ( + + ); + } + let leftColumn = null; let rightColumn = null; if (leftColumnView.type === 'symbols') { @@ -733,6 +849,16 @@ const DiffViewContent = ({ rightSymbol={null} /> ); + } else if (leftColumnView.type === 'data') { + leftColumn = ( + + ); } else if (leftColumnView.type === 'buildStatus') { leftColumn = ( ); + } else if (rightColumnView.type === 'data') { + rightColumn = ( + + ); } else if (rightColumnView.type === 'buildStatus') { rightColumn = (