Add symbol mapping & diff UI improvements

This commit is contained in:
Luke Street
2025-05-27 22:15:56 -06:00
parent 047c28560f
commit 3a86f14bfa
22 changed files with 2872 additions and 1330 deletions
+19 -19
View File
@@ -24,34 +24,34 @@
"@vscode/codicons": "^0.0.36",
"clsx": "^2.1.1",
"memoize-one": "^6.0.0",
"objdiff-wasm": "3.0.0-beta.8",
"objdiff-wasm": "3.0.0-beta.9",
"picomatch": "^4.0.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-tooltip": "^5.28.0",
"react-virtualized-auto-sizer": "^1.0.24",
"react-window": "^1.8.10",
"shescape": "^2.1.1",
"zustand": "^5.0.2"
"react-tooltip": "^5.28.1",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "^1.8.11",
"shescape": "^2.1.3",
"zustand": "^5.0.4"
},
"devDependencies": {
"@biomejs/biome": "^1.9.3",
"@rsbuild/core": "^1.1.8",
"@rsbuild/plugin-react": "^1.0.7",
"@rsbuild/plugin-type-check": "^1.1.0",
"@biomejs/biome": "^1.9.4",
"@rsbuild/core": "^1.3.20",
"@rsbuild/plugin-react": "^1.3.1",
"@rsbuild/plugin-type-check": "^1.2.2",
"@rsbuild/plugin-typed-css-modules": "^1.0.2",
"@types/node": "^22.10.2",
"@types/picomatch": "^3.0.1",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@types/node": "^22.15.18",
"@types/picomatch": "^3.0.2",
"@types/react": "^18.3.21",
"@types/react-dom": "^18.3.7",
"@types/react-window": "^1.8.8",
"@types/vscode": "^1.96.0",
"@types/vscode": "^1.100.0",
"@types/vscode-webview": "^1.57.5",
"@vscode/test-cli": "^0.0.10",
"@vscode/test-electron": "^2.4.1",
"@vscode/vsce": "^3.2.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^3.4.0",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
},
"main": "./dist/extension.js",
"files": [
+972 -278
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -27,6 +27,7 @@ export type StateMessage = {
leftObject?: ArrayBuffer | null;
rightObject?: ArrayBuffer | null;
projectConfig?: ProjectConfig | null;
diffLabel?: string | null;
};
// decomp.me colors
+1 -3
View File
@@ -15,9 +15,7 @@
"allowImportingTsExtensions": true,
/* type checking */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
"strict": true
},
"include": ["src", "webview", "shared"]
}
+8
View File
@@ -49,6 +49,14 @@
--vscode-list-hoverBackground,
light-dark(#f2f2f2, #2a2d2e)
);
--list-row-selection-background: var(
--vscode-list-inactiveSelectionBackground,
light-dark(#e4e6f1, #37373d)
);
--list-row-highlight-background: var(
--vscode-list-activeSelectionBackground,
light-dark(#e8e8e8, #04395e)
);
--line-number-foreground: var(--vscode-editorLineNumber-foreground, #6e7681);
+67 -36
View File
@@ -1,50 +1,77 @@
import './App.css';
import type { diff, display } from 'objdiff-wasm';
import type { diff } from 'objdiff-wasm';
import { useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useDiff } from './diff';
import { useAppStore, useExtensionStore } from './state';
import type { SymbolRefByName } from './state';
import FunctionView from './views/FunctionView';
import DiffView from './views/DiffView';
import SettingsView from './views/SettingsView';
import SymbolsView from './views/SymbolsView';
import UnitsView from './views/UnitsView';
const findSymbol = (
obj: diff.ObjectDiff | undefined,
symbolRef: SymbolRefByName | null,
): display.SectionDisplaySymbol | null => {
if (!obj || !symbolRef) {
return null;
}
const idx = obj.findSymbol(
symbolRef.symbolName,
symbolRef.sectionName ?? undefined,
);
if (idx !== undefined) {
return {
symbol: idx,
isMappingSymbol: false,
};
}
return null;
};
const App = () => {
const { buildRunning, result, config, ready } = useExtensionStore(
const {
buildRunning,
configProperties,
currentUnit,
leftStatus,
rightStatus,
leftObject,
rightObject,
config,
ready,
} = useExtensionStore(
useShallow((state) => ({
buildRunning: state.buildRunning,
result: state.result,
configProperties: state.configProperties,
currentUnit: state.currentUnit,
leftStatus: state.leftStatus,
rightStatus: state.rightStatus,
leftObject: state.leftObject,
rightObject: state.rightObject,
config: state.projectConfig,
ready: state.ready,
})),
);
const { leftSymbolRef, rightSymbolRef, currentView } = useAppStore(
useShallow((state) => ({
const { leftSymbolRef, rightSymbolRef, currentView, mappings } = useAppStore(
useShallow((state) => {
const unitState = state.getUnitState(currentUnit?.name ?? '');
return {
leftSymbolRef: state.leftSymbol,
rightSymbolRef: state.rightSymbol,
currentView: state.currentView,
})),
mappings: unitState?.mappings,
};
}),
);
const mappingConfig = useMemo(() => {
const result: diff.MappingConfig = {
mappings: mappings == null ? [] : Object.entries(mappings),
selectingLeft: undefined,
selectingRight: undefined,
};
if (leftSymbolRef && !rightSymbolRef) {
result.selectingRight = leftSymbolRef.symbolName;
result.mappings = result.mappings.filter(
([left, _]) => left !== leftSymbolRef.symbolName,
);
}
if (!leftSymbolRef && rightSymbolRef) {
result.selectingLeft = rightSymbolRef.symbolName;
result.mappings = result.mappings.filter(
([_, right]) => right !== rightSymbolRef.symbolName,
);
}
return result;
}, [leftSymbolRef, rightSymbolRef, mappings]);
const result = useDiff({
leftStatus,
rightStatus,
leftObject,
rightObject,
configProperties,
mappingConfig,
});
if (!ready) {
// Uses panel background color to avoid flashing
@@ -53,16 +80,20 @@ const App = () => {
switch (currentView) {
case 'main':
if (result) {
const leftSymbol = findSymbol(result.left, leftSymbolRef);
const rightSymbol = findSymbol(result.right, rightSymbolRef);
if (leftSymbol || rightSymbol) {
if (
result.leftStatus ||
result.rightStatus ||
result.diff.left ||
result.diff.right
) {
return (
<FunctionView diff={result} left={leftSymbol} right={rightSymbol} />
<DiffView
result={result}
leftSymbolRef={leftSymbolRef}
rightSymbolRef={rightSymbolRef}
/>
);
}
return <SymbolsView diff={result} />;
}
if (buildRunning) {
return (
+11 -14
View File
@@ -23,9 +23,14 @@ export type ContextMenuState<T> = Readonly<{
data: T;
}>;
type ContextMenuProps<T> = React.PropsWithChildren<{
export type ContextMenuRender<T> = (
state: ContextMenuState<T>,
close: () => void,
) => React.ReactNode;
export type ContextMenuProps<T> = React.PropsWithChildren<{
className?: string;
render?: (state: ContextMenuState<T>, close: () => void) => React.ReactNode;
render?: ContextMenuRender<T>;
}>;
export function createContextMenu<T>(): {
@@ -55,9 +60,7 @@ export function createContextMenu<T>(): {
});
}, []);
const close = useCallback(() => {
setState(null);
}, []);
const close = useCallback(() => setState(null), []);
const closeIfOutside = useCallback(
(e: Event) => {
@@ -108,9 +111,7 @@ export function createContextMenu<T>(): {
observer.observe(state.target.parentNode, {
childList: true,
});
return () => {
observer.disconnect();
};
return () => observer.disconnect();
}
}, [state?.target, close]);
@@ -186,12 +187,8 @@ export function renderContextItems(
className={styles.contextMenuItem}
onClick={() => {
navigator.clipboard.writeText(item.val.value).then(
() => {
close();
},
(e) => {
console.warn('Failed to copy:', e);
},
() => close(),
(e) => console.warn('Failed to copy:', e),
);
}}
>
+5
View File
@@ -42,3 +42,8 @@
.spacer {
flex: 1 1 0;
}
.input {
display: flex;
align-items: center;
}
+53 -2
View File
@@ -1,9 +1,52 @@
import styles from './TooltipShared.module.css';
import type { display } from 'objdiff-wasm';
import React, { useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import { Tooltip } from 'react-tooltip';
type TooltipProps = {
'data-tooltip-id': string;
'data-tooltip-content': string;
};
export type TooltipCallback<T> = (content: T) => display.HoverItem[] | null;
export function createTooltip<T>(): {
Tooltip: React.FC<{
callback: TooltipCallback<T>;
}>;
useTooltip: (content: T) => TooltipProps;
} {
const id = generateRandomString(10);
return {
Tooltip: ({ callback }) => {
const callbackMemo = useCallback(
(content: string) => {
if (!content) {
return null;
}
const parsedContent = JSON.parse(content) as T;
return callback(parsedContent);
},
[callback],
);
return <TooltipShared id={id} callback={callbackMemo} />;
},
useTooltip: (content: T) =>
// useMemo(
// () => ({
// 'data-tooltip-id': id,
// 'data-tooltip-content': JSON.stringify(content),
// }),
// [content],
// ),
({
'data-tooltip-id': id,
'data-tooltip-content': JSON.stringify(content),
}),
};
}
const TooltipShared = ({
id,
callback,
@@ -74,4 +117,12 @@ const TooltipContent = ({ items }: { items: display.HoverItem[] }) => {
const TooltipContentMemo = React.memo(TooltipContent);
export default TooltipShared;
function generateRandomString(length: number): string {
const characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return result;
}
+82
View File
@@ -0,0 +1,82 @@
import { diff } from 'objdiff-wasm';
import { useMemo } from 'react';
import type { ConfigProperties } from '../shared/config';
import type { BuildStatus } from '../shared/messages';
import { buildDiffConfig } from './state';
export type DiffInput = {
leftStatus: BuildStatus | null;
rightStatus: BuildStatus | null;
leftObject: ArrayBuffer | null;
rightObject: ArrayBuffer | null;
configProperties: ConfigProperties;
mappingConfig: diff.MappingConfig;
};
export type DiffOutput = {
leftStatus: BuildStatus | null;
rightStatus: BuildStatus | null;
diff: diff.DiffResult | null;
lastBuilt: number | null;
isMapping: boolean;
};
export const useDiff = ({
leftStatus,
rightStatus,
leftObject,
rightObject,
configProperties,
mappingConfig,
}: DiffInput) =>
useMemo(() => {
const start = performance.now();
const diffConfig = buildDiffConfig(configProperties);
let left: diff.Object | undefined;
let right: diff.Object | undefined;
try {
left =
leftObject?.byteLength && leftStatus?.success
? diff.Object.parse(new Uint8Array(leftObject), diffConfig)
: undefined;
} catch (e) {
leftStatus = {
success: false,
cmdline: leftStatus?.cmdline ?? '',
stdout: '',
stderr: `Failed to parse left object: ${e}`,
};
}
try {
right =
rightObject?.byteLength && rightStatus?.success
? diff.Object.parse(new Uint8Array(rightObject), diffConfig)
: undefined;
} catch (e) {
rightStatus = {
success: false,
cmdline: rightStatus?.cmdline ?? '',
stdout: '',
stderr: `Failed to parse right object: ${e}`,
};
}
const result = diff.runDiff(left, right, diffConfig, mappingConfig);
const end = performance.now();
console.debug('Diff time:', end - start, 'ms');
return {
leftStatus,
rightStatus,
diff: result,
lastBuilt: Date.now(),
isMapping:
mappingConfig.selectingLeft != null ||
mappingConfig.selectingRight != null,
};
}, [
leftStatus,
rightStatus,
leftObject,
rightObject,
configProperties,
mappingConfig,
]);
+62 -18
View File
@@ -36,6 +36,11 @@ function sendMessage(data: InboundMessage) {
let resolvedProjectConfig: ProjectConfig | null = null;
async function fetchFile(path: string): Promise<Response> {
if (!path) {
return Promise.resolve(
new Response(null, { status: 404, statusText: 'Not Found' }),
);
}
const search = new URLSearchParams();
search.set('path', path);
const response = await fetch(`/api/get?${search.toString()}`);
@@ -59,6 +64,54 @@ if (serializedConfigProperties) {
configProperties = JSON.parse(serializedConfigProperties);
}
async function fetchUnitFiles(unit: Unit, out: StateMessage): Promise<void> {
const leftPromise = fetchFile(unit.target_path ?? '')
.then((r) => r.arrayBuffer())
.then(
(o) => {
out.leftObject = o;
out.leftStatus = {
success: true,
cmdline: '',
stdout: '',
stderr: '',
};
},
(e) => {
out.leftObject = null;
out.leftStatus = {
success: false,
cmdline: '',
stdout: '',
stderr: `Failed to fetch object: ${e}`,
};
},
);
const rightPromise = fetchFile(unit.base_path ?? '')
.then((r) => r.arrayBuffer())
.then(
(o) => {
out.rightObject = o;
out.rightStatus = {
success: true,
cmdline: '',
stdout: '',
stderr: '',
};
},
(e) => {
out.rightObject = null;
out.rightStatus = {
success: false,
cmdline: '',
stdout: '',
stderr: `Failed to fetch object: ${e}`,
};
},
);
await Promise.all([leftPromise, rightPromise]);
}
async function handleMessage(msg: OutboundMessage): Promise<void> {
switch (msg.type) {
case 'ready': {
@@ -70,17 +123,14 @@ async function handleMessage(msg: OutboundMessage): Promise<void> {
buildRunning: false,
configProperties,
currentUnit: null,
leftStatus: null,
rightStatus: null,
leftObject: null,
rightObject: null,
projectConfig: resolvedProjectConfig,
};
if (lastUnit) {
out.leftObject = await fetchFile(lastUnit.target_path ?? '').then((r) =>
r.arrayBuffer(),
);
out.rightObject = await fetchFile(lastUnit.base_path ?? '').then((r) =>
r.arrayBuffer(),
);
await fetchUnitFiles(lastUnit, out);
}
sendMessage(out);
break;
@@ -90,16 +140,13 @@ async function handleMessage(msg: OutboundMessage): Promise<void> {
const out: StateMessage = {
type: 'state',
buildRunning: false,
leftStatus: null,
rightStatus: null,
leftObject: null,
rightObject: null,
};
if (lastUnit) {
out.leftObject = await fetchFile(lastUnit.target_path ?? '').then((r) =>
r.arrayBuffer(),
);
out.rightObject = await fetchFile(lastUnit.base_path ?? '').then((r) =>
r.arrayBuffer(),
);
await fetchUnitFiles(lastUnit, out);
}
sendMessage(out);
break;
@@ -115,17 +162,14 @@ async function handleMessage(msg: OutboundMessage): Promise<void> {
type: 'state',
buildRunning: false,
currentUnit: unit,
leftStatus: null,
rightStatus: null,
leftObject: null,
rightObject: null,
};
if (unit) {
sendMessage({ type: 'state', buildRunning: true });
out.leftObject = await fetchFile(unit.target_path ?? '').then((r) =>
r.arrayBuffer(),
);
out.rightObject = await fetchFile(unit.base_path ?? '').then((r) =>
r.arrayBuffer(),
);
await fetchUnitFiles(unit, out);
}
lastUnit = unit;
localStorage.setItem('lastUnit', JSON.stringify(unit));
+41 -118
View File
@@ -33,20 +33,16 @@ export type SymbolRefByName = {
sectionName: string | null;
};
export type UnitScrollOffsets = {
left: number;
right: number;
};
export type UnitCollapsedSections = {
left: Record<string, boolean>;
right: Record<string, boolean>;
};
export type Side = 'left' | 'right';
export type UnitScrollOffsets = { [key in Side]: number };
export type UnitCollapsedSections = { [key in Side]: Record<string, boolean> };
export type UnitState = {
scrollOffsets: UnitScrollOffsets;
symbolScrollOffsets: Record<string, number>;
collapsedSections: UnitCollapsedSections;
search: string | null;
mappings: Record<string, string>;
};
const defaultUnitState: UnitState = {
scrollOffsets: { left: 0, right: 0 },
@@ -56,6 +52,7 @@ const defaultUnitState: UnitState = {
right: {},
},
search: null,
mappings: {},
};
export type CurrentView = 'main' | 'settings';
@@ -68,7 +65,7 @@ export interface AppState {
currentView: CurrentView;
collapsedUnits: Record<string, boolean>;
getUnitState(unit: string): UnitState;
getUnitState(unit: string | null | undefined): UnitState;
setSelectedSymbol: (
leftSymbol: SymbolRefByName | null,
rightSymbol: SymbolRefByName | null,
@@ -91,6 +88,11 @@ export interface AppState {
) => void;
setUnitSearch: (unit: string, search: string | null) => void;
setUnitsScrollOffset: (offset: number) => void;
setUnitMapping: (
unit: string,
left: string | null | undefined,
right: string | null | undefined,
) => void;
setHighlight: (highlight: HighlightState) => void;
setCurrentView: (view: CurrentView) => void;
setCollapsedUnit: (unit: string, collapsed: boolean) => void;
@@ -123,7 +125,9 @@ export const useAppStore = create<AppState>((set) => {
collapsedUnits: {},
getUnitState(unit) {
return this.unitStates[unit] ?? defaultUnitState;
return unit == null
? defaultUnitState
: (this.unitStates[unit] ?? defaultUnitState);
},
setSelectedSymbol: (leftSymbol, rightSymbol) =>
set({ leftSymbol, rightSymbol }),
@@ -160,6 +164,23 @@ export const useAppStore = create<AppState>((set) => {
search,
})),
setUnitsScrollOffset: (offset) => set({ unitsScrollOffset: offset }),
setUnitMapping: (unit, left, right) =>
setUnitState(unit, (state) => {
const newMappings = Object.fromEntries(
Object.entries(state.mappings || {}).filter(
([k, v]) =>
(left == null || k !== left) && (right == null || v !== right),
),
);
if (left != null && right != null && left !== right) {
// Only add new mapping if both sides are defined and different
newMappings[left] = right;
}
return {
...state,
mappings: newMappings,
};
}),
setHighlight: (highlight: HighlightState) => set({ highlight }),
setCurrentView: (currentView) => set({ currentView }),
setCollapsedUnit: (unit, collapsed) =>
@@ -178,14 +199,11 @@ export type ExtensionState = {
currentUnit: Unit | null;
leftStatus: BuildStatus | null;
rightStatus: BuildStatus | null;
leftObject: diff.Object | null;
rightObject: diff.Object | null;
result: diff.DiffResult | null;
lastBuilt: number | null;
leftObject: ArrayBuffer | null;
rightObject: ArrayBuffer | null;
projectConfig: ProjectConfig | null;
diffLabel: string | null;
ready: boolean;
setResult: (result: diff.DiffResult | null | undefined) => void;
};
export const useExtensionStore = create(
subscribeWithSelector<ExtensionState>((set) => ({
@@ -197,18 +215,9 @@ export const useExtensionStore = create(
rightStatus: null,
leftObject: null,
rightObject: null,
result: null,
lastBuilt: null,
projectConfig: null,
diffLabel: null,
ready: false,
setResult: (result: diff.DiffResult | null | undefined) => {
if (result === undefined) {
set({ lastBuilt: Date.now() });
} else {
set({ result, lastBuilt: Date.now() });
}
},
})),
);
@@ -275,7 +284,8 @@ subscriptions.push(
k !== 'setUnitsScrollOffset' &&
k !== 'setHighlight' &&
k !== 'setCurrentView' &&
k !== 'setCollapsedUnit'
k !== 'setCollapsedUnit' &&
k !== 'setUnitMapping'
) {
serialized[k] = state[k] as any;
}
@@ -330,117 +340,30 @@ export function buildDiffConfig(
return config;
}
// Run diff when objects or config properties change
subscriptions.push(
useExtensionStore.subscribe(
(state) => ({
leftObject: state.leftObject,
rightObject: state.rightObject,
configProperties: state.configProperties,
setResult: state.setResult,
}),
(
{ leftObject, rightObject, configProperties, setResult },
{
leftObject: prevLeftObject,
rightObject: prevRightObject,
configProperties: prevConfigProperties,
},
) => {
if (leftObject == null && rightObject == null) {
setResult(null);
} else if (
configProperties === prevConfigProperties &&
leftObject?.hash() === prevLeftObject?.hash() &&
rightObject?.hash() === prevRightObject?.hash()
) {
// Nothing changed, but update build time
setResult(undefined);
} else {
const start = performance.now();
const diffConfig = buildDiffConfig(configProperties);
const result = diff.runDiff(
leftObject ?? undefined,
rightObject ?? undefined,
diffConfig,
);
const end = performance.now();
console.debug('Diff time:', end - start, 'ms');
setResult(result);
}
},
{ equalityFn: shallow },
),
);
const handleMessage = (event: MessageEvent) => {
const message = event.data as InboundMessage;
if (message.type === 'state') {
console.log('Received state message', message);
const newState: Partial<ExtensionState> = {
...message,
leftObject: undefined,
rightObject: undefined,
result: undefined,
ready: true,
};
let diffConfig: diff.DiffConfig | null = null;
if (message.leftObject !== undefined) {
if (message.leftObject == null) {
newState.leftObject = null;
} else {
if (diffConfig == null) {
diffConfig = buildDiffConfig(message.configProperties);
}
try {
newState.leftObject = diff.Object.parse(
new Uint8Array(message.leftObject),
diffConfig,
);
// Backwards compatibility for decomp.me
if (message.leftObject && !message.leftStatus) {
newState.leftStatus = {
success: true,
cmdline: '',
stdout: '',
stderr: '',
};
} catch (e) {
newState.leftObject = null;
newState.leftStatus = {
success: false,
cmdline: '',
stdout: 'Failed to parse object',
stderr: e instanceof Error ? e.message : String(e),
};
}
}
}
if (message.rightObject !== undefined) {
if (message.rightObject == null) {
newState.rightObject = null;
} else {
if (diffConfig == null) {
diffConfig = buildDiffConfig(message.configProperties);
}
try {
newState.rightObject = diff.Object.parse(
new Uint8Array(message.rightObject),
diffConfig,
);
if (message.rightObject && !message.rightStatus) {
newState.rightStatus = {
success: true,
cmdline: '',
stdout: '',
stderr: '',
};
} catch (e) {
newState.rightObject = null;
newState.rightStatus = {
success: false,
cmdline: '',
stdout: 'Failed to parse object',
stderr: e instanceof Error ? e.message : String(e),
};
}
}
}
for (const k in newState) {
const key = k as keyof typeof newState;
+23
View File
@@ -0,0 +1,23 @@
.content {
flex: 1 1 0;
display: flex;
flex-flow: row;
}
.column {
flex: 1 1 0;
margin: 0;
padding: 0;
overflow: auto;
}
.no-object {
color: var(--color-blue);
font-family: var(--code-font-family);
font-weight: var(--code-font-weight);
font-size: var(--code-font-size);
font-variant-ligatures: var(--code-font-variant-ligatures);
text-wrap: nowrap;
white-space: pre;
}
File diff suppressed because it is too large Load Diff
-2
View File
@@ -5,8 +5,6 @@
.instruction-row {
display: flex;
flex-direction: row;
gap: 0.5em;
height: var(--list-row-height);
}
.instruction-cell {
+60 -174
View File
@@ -4,26 +4,20 @@ import styles from './FunctionView.module.css';
import clsx from 'clsx';
import memoizeOne from 'memoize-one';
import { type diff, display } from 'objdiff-wasm';
import { memo, useMemo } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { memo, useCallback, useMemo } from 'react';
import { FixedSizeList, areEqual } from 'react-window';
import type { ListChildComponentProps } from 'react-window';
import { useShallow } from 'zustand/react/shallow';
import { createContextMenu, renderContextItems } from '../common/ContextMenu';
import TooltipShared from '../common/TooltipShared';
import {
buildDiffConfig,
runBuild,
useAppStore,
useExtensionStore,
} from '../state';
import { createTooltip } from '../common/TooltipShared';
import { buildDiffConfig, useAppStore, useExtensionStore } from '../state';
import {
type HighlightState,
highlightColumn,
highlightMatches,
updateHighlight,
} from '../util/highlight';
import { percentClass, useFontSize } from '../util/util';
import { useFontSize } from '../util/util';
const ROTATION_CLASSES = [
styles.rotation0,
@@ -37,10 +31,20 @@ const ROTATION_CLASSES = [
styles.rotation8,
];
const { ContextMenuProvider, useContextMenu } = createContextMenu<{
export type InstructionTooltipContent = {
column: number;
row: number;
}>();
};
export const {
Tooltip: InstructionTooltip,
useTooltip: useInstructionTooltip,
} = createTooltip<InstructionTooltipContent>();
export const {
ContextMenuProvider: InstructionContextMenuProvider,
useContextMenu: useInstructionContextMenu,
} = createContextMenu<InstructionTooltipContent>();
const AsmCell = ({
obj,
@@ -53,15 +57,28 @@ const AsmCell = ({
}: {
obj: diff.ObjectDiff | undefined;
config: diff.DiffConfig;
symbol: display.SectionDisplaySymbol | null;
symbol: display.SymbolRef | null;
row: number;
column: number;
highlight: HighlightState;
setHighlight: (highlight: HighlightState) => void;
}) => {
const onContextMenu = useContextMenu();
const onContextMenu = useInstructionContextMenu();
const tooltipContent: InstructionTooltipContent = useMemo(
() => ({
column,
row,
}),
[column, row],
);
const tooltipProps = useInstructionTooltip(tooltipContent);
const onContextMenuMemo = useCallback(
(e: React.MouseEvent<HTMLElement>) => onContextMenu(e, tooltipContent),
[onContextMenu, tooltipContent],
);
if (!obj || !symbol) {
return <div className={styles.instructionCell} />;
return null;
}
const highlight = highlightColumn(highlightState, column);
@@ -198,33 +215,24 @@ const AsmCell = ({
return <div className={clsx(classes)} />;
}
const tooltipContent: InstructionTooltipContent = { column, row };
return (
<div
className={clsx(classes)}
data-tooltip-id="instruction-tooltip"
data-tooltip-content={JSON.stringify(tooltipContent)}
onContextMenu={(e) => onContextMenu(e, { column, row })}
onContextMenu={onContextMenuMemo}
{...tooltipProps}
>
{out}
</div>
);
};
type InstructionTooltipContent = {
column: number;
row: number;
};
type ItemData = {
itemCount: number;
symbolName: string;
result: diff.DiffResult;
config: diff.DiffConfig;
matchPercent?: number;
left: display.SectionDisplaySymbol | null;
leftSymbol: display.SymbolDisplay | null;
right: display.SectionDisplaySymbol | null;
rightSymbol: display.SymbolDisplay | null;
highlight: HighlightState;
setHighlight: (highlight: HighlightState) => void;
@@ -234,7 +242,7 @@ const AsmRow = memo(
({
index,
style,
data: { result, config, left, right, highlight, setHighlight },
data: { result, config, leftSymbol, rightSymbol, highlight, setHighlight },
}: ListChildComponentProps<ItemData>) => {
return (
<div
@@ -254,7 +262,7 @@ const AsmRow = memo(
<AsmCell
obj={result.left}
config={config}
symbol={left}
symbol={leftSymbol?.info.id ?? null}
row={index}
column={0}
highlight={highlight}
@@ -263,7 +271,7 @@ const AsmRow = memo(
<AsmCell
obj={result.right}
config={config}
symbol={right}
symbol={rightSymbol?.info.id ?? null}
row={index}
column={1}
highlight={highlight}
@@ -278,20 +286,16 @@ const AsmRow = memo(
const createItemData = memoizeOne(
(
result: diff.DiffResult,
left: display.SectionDisplaySymbol | null,
right: display.SectionDisplaySymbol | null,
leftSymbol: display.SymbolDisplay | null,
rightSymbol: display.SymbolDisplay | null,
highlight: HighlightState,
setHighlight: (highlight: HighlightState) => void,
): ItemData => {
const leftSymbol = left ? display.displaySymbol(result.left!, left) : null;
const rightSymbol = right
? display.displaySymbol(result.right!, right)
: null;
const itemCount = Math.max(
leftSymbol?.rowCount || 0,
rightSymbol?.rowCount || 0,
);
const symbolName = leftSymbol?.name || rightSymbol?.name || '';
const symbolName = leftSymbol?.info.name || rightSymbol?.info.name || '';
const config = buildDiffConfig(null);
const matchPercent = rightSymbol?.matchPercent;
return {
@@ -300,9 +304,7 @@ const createItemData = memoizeOne(
result,
config,
matchPercent,
left,
leftSymbol,
right,
rightSymbol,
highlight,
setHighlight,
@@ -322,7 +324,7 @@ const SymbolLabel = ({
</span>
);
}
const displayName = symbol.demangledName || symbol.name;
const displayName = symbol.info.demangledName || symbol.info.name;
return (
<span
className={clsx(headerStyles.label, headerStyles.emphasized)}
@@ -333,36 +335,35 @@ const SymbolLabel = ({
);
};
const FunctionView = ({
export const InstructionList = ({
height,
width,
diff,
left,
right,
leftSymbol,
rightSymbol,
}: {
height: number;
width: number;
diff: diff.DiffResult;
left: display.SectionDisplaySymbol | null;
right: display.SectionDisplaySymbol | null;
leftSymbol: display.SymbolDisplay | null;
rightSymbol: display.SymbolDisplay | null;
}) => {
const { buildRunning, currentUnit, lastBuilt, hasProjectConfig } =
useExtensionStore(
useShallow((state) => ({
buildRunning: state.buildRunning,
currentUnit: state.currentUnit,
lastBuilt: state.lastBuilt,
hasProjectConfig: state.projectConfig != null,
})),
);
const currentUnitName = currentUnit?.name || '';
const { highlight, setSelectedSymbol, setSymbolScrollOffset, setHighlight } =
useAppStore(
const currentUnit = useExtensionStore((state) => state.currentUnit);
const { highlight, setSymbolScrollOffset, setHighlight } = useAppStore(
useShallow((state) => ({
highlight: state.highlight,
setSelectedSymbol: state.setSelectedSymbol,
setSymbolScrollOffset: state.setSymbolScrollOffset,
setHighlight: state.setHighlight,
})),
);
const itemData = createItemData(diff, left, right, highlight, setHighlight);
const itemData = createItemData(
diff,
leftSymbol,
rightSymbol,
highlight,
setHighlight,
);
const currentUnitName = currentUnit?.name || '';
const initialScrollOffset = useMemo(
() =>
useAppStore.getState().getUnitState(currentUnitName).symbolScrollOffsets[
@@ -370,87 +371,8 @@ const FunctionView = ({
] || 0,
[currentUnitName, itemData.symbolName],
);
const itemSize = useFontSize() * 1.33;
return (
<>
<div className={headerStyles.header}>
<div className={headerStyles.column}>
<div className={headerStyles.row}>
<button title="Back" onClick={() => setSelectedSymbol(null, null)}>
<span className="codicon codicon-chevron-left" />
</button>
</div>
<div className={headerStyles.row}>
<SymbolLabel symbol={itemData.leftSymbol} />
</div>
</div>
<div className={headerStyles.column}>
<div className={headerStyles.row}>
{hasProjectConfig && (
<button
title="Build"
onClick={() => runBuild()}
disabled={buildRunning}
>
<span className="codicon codicon-refresh" />
</button>
)}
{lastBuilt && (
<span className={headerStyles.label}>
Last built: {new Date(lastBuilt).toLocaleTimeString('en-US')}
</span>
)}
</div>
<div className={headerStyles.row}>
{itemData.matchPercent !== undefined && (
<>
<span
className={clsx(
headerStyles.label,
percentClass(itemData.matchPercent),
)}
>
{Math.floor(itemData.matchPercent).toFixed(0)}%
</span>
{' | '}
</>
)}
<SymbolLabel symbol={itemData.rightSymbol} />
</div>
</div>
</div>
<div className={styles.instructionList}>
<ContextMenuProvider
render={({ data }, close) => {
let obj: diff.ObjectDiff | undefined;
let symbol: display.SectionDisplaySymbol | undefined;
switch (data.column) {
case 0:
obj = diff.left;
symbol = itemData.left ?? undefined;
break;
case 1:
obj = diff.right;
symbol = itemData.right ?? undefined;
break;
default:
break;
}
if (!obj || !symbol) {
return null;
}
const items = display.instructionContext(
obj,
symbol,
data.row,
itemData.config,
);
return renderContextItems(items, close);
}}
>
<AutoSizer>
{({ height, width }) => (
<FixedSizeList
height={height}
itemCount={itemData.itemCount}
@@ -469,41 +391,5 @@ const FunctionView = ({
>
{AsmRow}
</FixedSizeList>
)}
</AutoSizer>
</ContextMenuProvider>
</div>
<TooltipShared
id="instruction-tooltip"
callback={(content) => {
const data: InstructionTooltipContent = JSON.parse(content);
let obj: diff.ObjectDiff | undefined;
let symbol: display.SectionDisplaySymbol | undefined;
switch (data.column) {
case 0:
obj = diff.left;
symbol = itemData.left ?? undefined;
break;
case 1:
obj = diff.right;
symbol = itemData.right ?? undefined;
break;
default:
break;
}
if (!obj || !symbol) {
return null;
}
return display.instructionHover(
obj,
symbol,
data.row,
itemData.config,
);
}}
/>
</>
);
};
export default FunctionView;
+20 -35
View File
@@ -11,41 +11,6 @@
padding: 0;
}
.symbol-list-row {
cursor: pointer;
user-select: none;
height: var(--list-row-height);
font-family: var(--code-font-family);
font-weight: var(--code-font-weight);
font-size: var(--code-font-size);
font-variant-ligatures: var(--code-font-variant-ligatures);
text-wrap: nowrap;
white-space: pre;
&:hover {
background-color: var(--list-row-hover-background);
}
}
.section {
padding-left: 0.5em;
&::before {
content: "▼ ";
}
&.collapsed {
opacity: 0.75;
&::before {
content: "▶ ";
}
}
}
.symbol {
padding-left: 2em;
}
.flag-local {
color: inherit;
}
@@ -62,6 +27,10 @@
color: var(--color-blue);
}
.flag-hidden {
color: var(--color-muted);
}
.symbol-name {
color: var(--color-bright);
}
@@ -76,3 +45,19 @@
text-wrap: nowrap;
white-space: pre;
}
.selected {
background-color: var(--list-row-selection-background) !important;
}
.highlighted {
background-color: var(--list-row-highlight-background) !important;
}
.selected.highlighted {
background-color: color-mix(
in srgb,
var(--list-row-selection-background) 50%,
var(--list-row-highlight-background) 50%
) !important;
}
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
.row {
display: flex;
cursor: pointer;
user-select: none;
height: var(--list-row-height);
padding-left: 0.5em;
font-family: var(--code-font-family);
font-weight: var(--code-font-weight);
font-size: var(--code-font-size);
font-variant-ligatures: var(--code-font-variant-ligatures);
text-wrap: nowrap;
white-space: pre;
&:hover {
background-color: var(--list-row-hover-background);
}
}
.indent {
display: inline-block;
height: var(--list-row-height);
width: 0;
border-right: 1px solid var(--panel-separator);
margin-left: 0.5em;
margin-right: 0.5em;
}
.indent-highlighted {
border-right-color: var(--color-muted);
}
.toggle {
display: inline-block;
height: var(--list-row-height);
width: 1em;
margin-right: 0.25em;
}
.collapsed {
color: color-mix(in srgb, var(--foreground) 75%, transparent);
}
+215
View File
@@ -0,0 +1,215 @@
import clsx from 'clsx';
import type { ListChildComponentProps } from 'react-window';
import styles from './TreeView.module.css';
export type NodeInner<T> = {
id: string;
indent: number;
path: string[];
data: T;
};
export type BranchNode<T> = NodeInner<T> & {
type: 'branch';
collapsed: boolean;
};
export type LeafNode<T> = NodeInner<T> & {
type: 'leaf';
};
export type Node<B, L> = BranchNode<B> | LeafNode<L>;
export type TreeData<B, L> = {
leafCount: number;
nodes: Node<B, L>[];
highlightedPath: string | null;
setHighlightedPath: (id: string | null) => void;
};
export type TreeRowProps<B, L> = ListChildComponentProps<TreeData<B, L>> & {
rowProps?: React.HTMLProps<HTMLDivElement> | null;
render: (item: Node<B, L>) => React.ReactNode;
getClasses?: (item: Node<B, L>) => string[];
onLeafClick?: (item: LeafNode<L>) => void;
onHover?: (item: Node<B, L>) => void;
setBranchCollapsed?: (id: string, collapsed: boolean) => void;
};
export function TreeRow<B, L>({
index,
style,
data: { nodes, highlightedPath, setHighlightedPath },
rowProps,
render,
getClasses,
onLeafClick,
onHover,
setBranchCollapsed,
}: TreeRowProps<B, L>) {
const node = nodes[index];
const classes = [styles.row];
if (node.type === 'branch' && node.collapsed) {
classes.push(styles.collapsed);
}
classes.push(...(getClasses?.(node) ?? []));
const indentItems = [];
for (let i = 0; i < node.indent; i++) {
indentItems.push(
<span
key={i}
className={clsx(
styles.indent,
node.path[i] === highlightedPath && styles.indentHighlighted,
)}
/>,
);
}
if (node.type === 'branch') {
indentItems.push(
<span
key="toggle"
className={clsx(
styles.toggle,
'codicon',
node.collapsed ? 'codicon-chevron-right' : 'codicon-chevron-down',
)}
/>,
);
}
return (
<div
className={clsx(classes)}
style={style}
{...rowProps}
onClick={() => {
if (node.type === 'leaf') {
onLeafClick?.(node);
} else {
const collapsed = !node.collapsed;
setBranchCollapsed?.(node.id, collapsed);
setHighlightedPath(
collapsed ? node.path[node.path.length - 1] : node.id,
);
}
}}
onMouseEnter={() => {
onHover?.(node);
setHighlightedPath(
node.type === 'leaf' || node.collapsed
? node.path[node.path.length - 1]
: node.id,
);
}}
>
{indentItems}
{render(node)}
</div>
);
}
type NodeWithChildren<B, L> = BranchNode<B> & {
children: (NodeWithChildren<B, L> | LeafNode<L>)[];
};
export type SimpleTreeNodeData = { label: string };
export type SimpleTreeData<L> = TreeData<
SimpleTreeNodeData,
L & SimpleTreeNodeData
>;
// Build a simple tree structure by splitting the path of each item
// into components (splitting on '/') and creating a tree node for
// each component.
export function buildSimpleTree<L>(
items: L[],
getPath: (item: L) => string,
collapsed: Record<string, boolean>,
highlightedPath: string | null,
setHighlightedPath: (id: string | null) => void,
): SimpleTreeData<L> {
type SimpleTreeNode = NodeWithChildren<
SimpleTreeNodeData,
L & SimpleTreeNodeData
>;
const map = new Map<string, SimpleTreeNode>();
const rootNodes = [];
for (const item of items) {
const path = getPath(item);
const split = path.split('/');
let parent: SimpleTreeNode | null = null;
for (let i = 0; i < split.length - 1; i++) {
const name = split[i];
const dirPath = split.slice(0, i + 1);
const key = dirPath.join('/');
let node = map.get(key);
if (!node) {
node = {
type: 'branch',
id: key,
indent: i,
collapsed: !!collapsed[key],
children: [],
path: buildPath(dirPath),
data: { label: name },
};
if (parent) {
parent.children.push(node);
} else {
rootNodes.push(node);
}
map.set(key, node);
}
parent = node;
}
const node: LeafNode<L & SimpleTreeNodeData> = {
type: 'leaf',
id: path,
indent: split.length - 1,
path: buildPath(split),
data: {
...item,
label: split[split.length - 1],
},
};
if (parent) {
parent.children.push(node);
} else {
rootNodes.push(node);
}
}
const nodes: SimpleTreeData<L>['nodes'] = [];
for (const node of rootNodes) {
pushNodes(node, nodes);
}
return {
leafCount: items.length,
nodes,
highlightedPath,
setHighlightedPath,
};
}
function pushNodes<B, L>(
item: NodeWithChildren<B, L> | LeafNode<L>,
out: Node<B, L>[],
) {
if (item.type === 'branch') {
out.push(item);
if (!item.collapsed) {
for (const child of item.children) {
pushNodes(child, out);
}
}
} else if (item.type === 'leaf') {
out.push(item);
}
}
function buildPath(split: string[]) {
const path = [];
for (let i = 0; i < split.length - 1; i++) {
path.push(split.slice(0, i + 1).join('/'));
}
return path;
}

Some files were not shown because too many files have changed in this diff Show More