Add symbol and instruction context menus

This commit is contained in:
Luke Street
2025-03-11 21:46:44 -06:00
parent df258e40bd
commit ec5d3481ff
8 changed files with 406 additions and 60 deletions
+1 -2
View File
@@ -20,11 +20,10 @@
"extension:publish": "pnpm run extension:build && vsce publish --no-dependencies"
},
"dependencies": {
"@protobuf-ts/runtime": "^2.9.4",
"@vscode/codicons": "^0.0.36",
"clsx": "^2.1.1",
"memoize-one": "^6.0.0",
"objdiff-wasm": "3.0.0-beta.1",
"objdiff-wasm": "3.0.0-beta.3",
"picomatch": "^4.0.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+5 -13
View File
@@ -8,9 +8,6 @@ importers:
.:
dependencies:
'@protobuf-ts/runtime':
specifier: ^2.9.4
version: 2.9.4
'@vscode/codicons':
specifier: ^0.0.36
version: 0.0.36
@@ -21,8 +18,8 @@ importers:
specifier: ^6.0.0
version: 6.0.0
objdiff-wasm:
specifier: 3.0.0-beta.1
version: 3.0.0-beta.1
specifier: 3.0.0-beta.3
version: 3.0.0-beta.3
picomatch:
specifier: ^4.0.2
version: 4.0.2
@@ -428,9 +425,6 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@protobuf-ts/runtime@2.9.4':
resolution: {integrity: sha512-vHRFWtJJB/SiogWDF0ypoKfRIZ41Kq+G9cEFj6Qm1eQaAhJ1LDFvgZ7Ja4tb3iLOQhz0PaoPnnOijF1qmEqTxg==}
'@rsbuild/core@1.2.14':
resolution: {integrity: sha512-G8AqvCHBhs8Yt7pOQuS5YYjdYozp436ohaLr1iAMB/Jw01VNFh4u1tpShudRnw3NPmHhJ82wBaEu1zNaJ0VsKg==}
engines: {node: '>=16.7.0'}
@@ -1406,8 +1400,8 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
objdiff-wasm@3.0.0-beta.1:
resolution: {integrity: sha512-1x97N/QUCeQJxm/ZcI+Frp1Wxsi0heKZQHR2kQNhpwcCvt84DainLIJ+4nH6DOU6U4eAxkUVPKvPbW1pAPqZZA==}
objdiff-wasm@3.0.0-beta.3:
resolution: {integrity: sha512-StNnXtstWdM8Bc2eWxTJhC/9pdQj2GZaAeL2KXASZzXvmwzGqWxaW+RbZ3z+tOfQAgOR4QUQnnjLzpXdQA/Rlw==}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
@@ -2180,8 +2174,6 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
'@protobuf-ts/runtime@2.9.4': {}
'@rsbuild/core@1.2.14':
dependencies:
'@rspack/core': 1.2.5(@swc/helpers@0.5.15)
@@ -3229,7 +3221,7 @@ snapshots:
dependencies:
boolbase: 1.0.0
objdiff-wasm@3.0.0-beta.1: {}
objdiff-wasm@3.0.0-beta.3: {}
object-inspect@1.13.4: {}
+4 -4
View File
@@ -18,6 +18,7 @@ const webConfig: RsbuildConfig = {
},
},
html: {
scriptLoading: 'module',
title: 'objdiff',
},
plugins: [pluginReact(), pluginTypeCheck(), pluginTypedCSSModules()],
@@ -67,11 +68,8 @@ const extensionConfig: RsbuildConfig = {
inlineStyles: true,
legalComments: 'none',
},
// <script defer> doesn't work with inline scripts,
// so we need to move the scripts to the body.
html: {
inject: 'body',
scriptLoading: 'blocking',
scriptLoading: 'module',
title: 'objdiff',
},
plugins: [
@@ -129,8 +127,10 @@ const PROJECT_ROOT = '../prime';
// Mock API middleware for development.
const apiMiddleware: RequestHandler = (req, res, next) => {
// Permit cross-origin embedding for decomp.me.
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
if (!req.url || !req.headers.host || req.method !== 'GET') {
return next();
}
+18
View File
@@ -103,6 +103,24 @@
light-dark(#cecece, #3c3c3c)
);
--menu-border: var(--vscode-menu-border, light-dark(#cecece, #454545));
--menu-foreground: var(--vscode-menu-foreground, light-dark(#3b3b3b, #ccc));
--menu-background: var(--vscode-menu-background, light-dark(#fff, #1f1f1f));
--menu-selection-foreground: var(--vscode-menu-selectionForeground, #fff);
--menu-selection-background: var(
--vscode-menu-selectionBackground,
light-dark(#005fb8, #0078d4)
);
--menu-separator-background: var(
--vscode-menu-separatorBackground,
light-dark(#d4d4d4, #454545)
);
--widget-shadow: var(
--vscode-widget-shadow,
light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.36))
);
color-scheme: light dark;
font-synthesis: none;
+52
View File
@@ -0,0 +1,52 @@
.context-menu {
z-index: 99;
outline: 1px solid var(--menu-border);
border-radius: 5px;
color: var(--menu-foreground);
background-color: var(--menu-background);
box-shadow: 0 2px 8px var(--widget-shadow);
padding: 4px 0;
max-width: min(500px, 80vw);
}
.context-menu-item {
font-family: var(--code-font-family);
font-weight: var(--code-font-weight);
font-size: var(--code-font-size);
cursor: pointer;
height: 1.5em;
color: var(--menu-foreground);
flex: 1 1 auto;
display: flex;
align-items: center;
position: relative;
margin: 0 4px;
border-radius: 4px;
padding: 0 0.25em;
overflow: hidden;
display: flex;
white-space: nowrap;
&:hover {
background-color: var(--button-hover-background-color);
}
&:active {
background-color: var(--button-active-background-color);
}
}
.context-menu-item-label {
flex: none;
}
.context-menu-item-value {
flex: 0 auto;
overflow: hidden;
text-overflow: ellipsis;
color: var(--color-bright);
}
.context-menu-separator {
border-color: var(--color-muted);
}
+225
View File
@@ -0,0 +1,225 @@
import type { display } from 'objdiff-wasm';
import styles from './ContextMenu.module.css';
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
export type ContextMenuCallback<T> = (
e: React.MouseEvent<HTMLElement>,
data: T,
) => void;
export type ContextMenuState<T> = Readonly<{
visible: boolean;
clickPosition: { x: number; y: number };
position: { x: number; y: number };
target: HTMLElement;
data: T;
}>;
type ContextMenuProps<T> = React.PropsWithChildren<{
className?: string;
render?: (state: ContextMenuState<T>, close: () => void) => React.ReactNode;
}>;
export function createContextMenu<T>(): {
ContextMenuProvider: React.FC<ContextMenuProps<T>>;
useContextMenu: () => ContextMenuCallback<T>;
} {
const Context = createContext<ContextMenuCallback<T>>((e) => {
e.preventDefault();
});
return {
ContextMenuProvider: ({
children,
className,
render,
}: ContextMenuProps<T>) => {
const elemRef = useRef<HTMLDivElement | null>(null);
const [state, setState] = useState<ContextMenuState<T> | null>(null);
const callback = useCallback<ContextMenuCallback<T>>((e, data) => {
e.preventDefault();
setState({
visible: false,
clickPosition: { x: e.clientX, y: e.clientY },
position: { x: e.clientX, y: e.clientY },
target: e.target as HTMLElement,
data,
});
}, []);
const close = useCallback(() => {
setState(null);
}, []);
const closeIfOutside = useCallback(
(e: Event) => {
if (elemRef.current && !elemRef.current.contains(e.target as Node)) {
close();
}
},
[close],
);
useEffect(() => {
const clickOptions = { capture: true };
const scrollOptions = { capture: true, passive: true };
const resizeOptions = { passive: true };
document.addEventListener('click', closeIfOutside, clickOptions);
document.addEventListener('mousedown', closeIfOutside, clickOptions);
document.addEventListener('scroll', closeIfOutside, scrollOptions);
window.addEventListener('resize', close, resizeOptions);
return () => {
document.removeEventListener('click', closeIfOutside, clickOptions);
document.removeEventListener(
'mousedown',
closeIfOutside,
clickOptions,
);
document.removeEventListener('scroll', closeIfOutside, {
capture: true,
});
window.removeEventListener('resize', close);
};
}, [closeIfOutside, close]);
// Close context menu if target element is removed
useEffect(() => {
if (state?.target?.parentNode) {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
// biome-ignore lint/complexity/noForEach: NodeList
mutation.removedNodes.forEach((node) => {
if (node === state.target) {
close();
}
});
}
});
observer.observe(state.target.parentNode, {
childList: true,
});
return () => {
observer.disconnect();
};
}
}, [state?.target, close]);
// biome-ignore lint/correctness/useExhaustiveDependencies: on purpose
useEffect(() => {
const tooltip = elemRef.current;
if (!state?.clickPosition || !tooltip) {
return;
}
const rect = tooltip.getBoundingClientRect();
let x = state.clickPosition.x;
let y = state.target.getBoundingClientRect().bottom;
if (x < 10) {
x = 10;
} else if (x + rect.width > window.innerWidth - 10) {
x = window.innerWidth - 10 - rect.width;
}
if (y + rect.height > window.innerHeight - 10) {
y = window.innerHeight - 10 - rect.height;
}
setState((prev) => ({
...prev!,
position: { x, y },
visible: true,
}));
}, [state?.clickPosition]);
let tooltip: React.ReactNode = null;
if (state?.position) {
const children = render?.(state, close);
if (children) {
tooltip = (
<div
ref={elemRef}
className={
styles.contextMenu + (className ? ` ${className}` : '')
}
style={{
position: 'fixed',
top: state.position.y,
left: state.position.x,
visibility: state.visible ? 'visible' : 'hidden',
}}
>
{children}
</div>
);
}
}
return (
<>
<Context.Provider value={callback}>{children}</Context.Provider>
{tooltip}
</>
);
},
useContextMenu: () => useContext(Context),
};
}
export function renderContextItems(
items: display.ContextItem[],
close: () => void,
): React.ReactNode {
return items.map((item, i) => {
const key = `${item.tag}-${i}`;
switch (item.tag) {
case 'copy':
return (
<div
key={key}
className={styles.contextMenuItem}
onClick={() => {
navigator.clipboard.writeText(item.val.value).then(
() => {
close();
},
(e) => {
console.warn('Failed to copy:', e);
},
);
}}
>
<span className={styles.contextMenuItemLabel}>Copy "</span>
<span className={styles.contextMenuItemValue}>
{item.val.value}
</span>
<span className={styles.contextMenuItemLabel}>
"{item.val.label ? ` (${item.val.label})` : ''}
</span>
</div>
);
case 'navigate':
return (
<div
key={key}
className={styles.contextMenuItem}
onClick={() => {
// TODO
}}
>
{item.val.label}
</div>
);
case 'separator':
return <hr key={key} className={styles.contextMenuSeparator} />;
default:
return null;
}
});
}
+59 -22
View File
@@ -9,6 +9,7 @@ import AutoSizer from 'react-virtualized-auto-sizer';
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,
@@ -36,6 +37,11 @@ const ROTATION_CLASSES = [
styles.rotation8,
];
const { ContextMenuProvider, useContextMenu } = createContextMenu<{
column: number;
row: number;
}>();
const AsmCell = ({
obj,
config,
@@ -53,6 +59,7 @@ const AsmCell = ({
highlight: HighlightState;
setHighlight: (highlight: HighlightState) => void;
}) => {
const onContextMenu = useContextMenu();
if (!obj || !symbol) {
return <div className={styles.instructionCell} />;
}
@@ -197,6 +204,7 @@ const AsmCell = ({
className={clsx(classes)}
data-tooltip-id="instruction-tooltip"
data-tooltip-content={JSON.stringify(tooltipContent)}
onContextMenu={(e) => onContextMenu(e, { column, row })}
>
{out}
</div>
@@ -413,28 +421,57 @@ const FunctionView = ({
</div>
</div>
<div className={styles.instructionList}>
<AutoSizer>
{({ height, width }) => (
<FixedSizeList
height={height}
itemCount={itemData.itemCount}
itemSize={itemSize}
width={width}
itemData={itemData}
overscanCount={20}
onScroll={(e) => {
setSymbolScrollOffset(
currentUnitName,
itemData.symbolName,
e.scrollOffset,
);
}}
initialScrollOffset={initialScrollOffset}
>
{AsmRow}
</FixedSizeList>
)}
</AutoSizer>
<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}
itemSize={itemSize}
width={width}
itemData={itemData}
overscanCount={20}
onScroll={(e) => {
setSymbolScrollOffset(
currentUnitName,
itemData.symbolName,
e.scrollOffset,
);
}}
initialScrollOffset={initialScrollOffset}
>
{AsmRow}
</FixedSizeList>
)}
</AutoSizer>
</ContextMenuProvider>
</div>
<TooltipShared
id="instruction-tooltip"
+42 -19
View File
@@ -12,6 +12,7 @@ import {
areEqual,
} from 'react-window';
import { useShallow } from 'zustand/react/shallow';
import { createContextMenu, renderContextItems } from '../common/ContextMenu';
import TooltipShared from '../common/TooltipShared';
import {
type UnitScrollOffsets,
@@ -22,6 +23,14 @@ import {
} from '../state';
import { percentClass, useFontSize } from '../util/util';
type SymbolTooltipContent = {
symbolRef: display.SectionDisplaySymbol;
side: keyof UnitScrollOffsets;
};
const { ContextMenuProvider, useContextMenu } =
createContextMenu<SymbolTooltipContent>();
const SectionRow = ({
section,
style,
@@ -70,6 +79,7 @@ const SymbolRow = ({
style?: React.CSSProperties;
}) => {
const setSelectedSymbol = useAppStore((state) => state.setSelectedSymbol);
const onContextMenu = useContextMenu();
const symbol = display.displaySymbol(obj, symbolRef);
const flags = [];
if (symbol.flags.global) {
@@ -136,14 +146,11 @@ const SymbolRow = ({
},
);
}}
data-vscode-context={JSON.stringify({
contextType: 'symbol',
preventDefaultContextMenuItems: true,
symbolName: symbol.name,
symbolDemangledName: symbol.demangledName,
})}
data-tooltip-id="symbol-tooltip"
data-tooltip-content={JSON.stringify(tooltipContent)}
onContextMenu={(e) => {
onContextMenu(e, tooltipContent);
}}
>
{flagsElem}
{percentElem}
@@ -154,11 +161,6 @@ const SymbolRow = ({
);
};
type SymbolTooltipContent = {
symbolRef: display.SectionDisplaySymbol;
side: keyof UnitScrollOffsets;
};
type SectionData = display.SectionDisplay & { collapsed: boolean };
type ItemData = {
@@ -434,14 +436,35 @@ const SymbolsView = ({ diff }: { diff: diff.DiffResult }) => {
</div>
</div>
<div className={styles.symbols}>
<AutoSizer className={styles.symbols}>
{({ height, width }) => (
<>
{renderList(height, width, leftItemData, 'left')}
{renderList(height, width, rightItemData, 'right')}
</>
)}
</AutoSizer>
<ContextMenuProvider
render={({ data }, close) => {
let obj: diff.ObjectDiff | undefined;
switch (data.side) {
case 'left':
obj = diff.left;
break;
case 'right':
obj = diff.right;
break;
default:
break;
}
if (!obj) {
return null;
}
const items = display.symbolContext(obj, data.symbolRef);
return renderContextItems(items, close);
}}
>
<AutoSizer className={styles.symbols}>
{({ height, width }) => (
<>
{renderList(height, width, leftItemData, 'left')}
{renderList(height, width, rightItemData, 'right')}
</>
)}
</AutoSizer>
</ContextMenuProvider>
</div>
<TooltipShared
id="symbol-tooltip"