Add standalone web build & decomp.me integration

This commit is contained in:
Luke Street
2025-03-06 21:53:12 -07:00
parent 4050ec4d95
commit 60dc1d6afe
34 changed files with 1761 additions and 271 deletions
+113
View File
@@ -0,0 +1,113 @@
.instruction-list {
flex: 1 1 auto;
}
.instruction-row {
display: flex;
flex-direction: row;
gap: 0.5em;
height: var(--list-row-height);
}
.instruction-cell {
flex: 1 0 0;
font-family: var(--code-font-family);
font-weight: var(--code-font-weight);
font-size: var(--code-font-size);
text-wrap: nowrap;
white-space: pre;
overflow: hidden;
padding-left: 0.5em;
}
.highlightable {
cursor: pointer;
}
.highlighted {
color: white;
background-color: #aa8b00;
}
.diff-any {
background-color: rgba(255, 255, 255, 0.02);
}
.segment-dim {
color: var(--line-number-foreground);
}
.segment-bright {
color: var(--color-bright);
}
.segment-replace {
color: var(--color-blue);
}
.segment-delete {
color: var(--color-red);
}
.segment-insert {
color: var(--color-green);
}
.rotation0 {
color: light-dark(
/* hsv(0° 60% 80%) */
rgb(205, 82, 82),
magenta
);
}
.rotation1 {
color: light-dark(
/* hsv(40° 60% 80%) */
rgb(205, 164, 82),
cyan
);
}
.rotation2 {
color: light-dark(
/* hsv(80° 60% 80%) */
rgb(164, 205, 82),
rgb(0, 212, 0)
);
}
.rotation3 {
color: light-dark(
/* hsv(120° 60% 80%) */
rgb(82, 205, 82),
red
);
}
.rotation4 {
color: light-dark(
/* hsv(160° 60% 80%) */
rgb(82, 205, 164),
rgb(103, 106, 255)
);
}
.rotation5 {
color: light-dark(
/* hsv(200° 60% 80%) */
rgb(82, 164, 205),
lightpink
);
}
.rotation6 {
color: light-dark(
/* hsv(240° 60% 80%) */
rgb(82, 82, 205),
lightcyan
);
}
.rotation7 {
color: light-dark(
/* hsv(280° 60% 80%) */
rgb(164, 82, 205),
lightgreen
);
}
.rotation8 {
color: light-dark(
/* hsv(320° 60% 80%) */
rgb(205, 82, 164),
grey
);
}
+472
View File
@@ -0,0 +1,472 @@
import headerStyles from '../common/Header.module.css';
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 { FixedSizeList, areEqual } from 'react-window';
import type { ListChildComponentProps } from 'react-window';
import { useShallow } from 'zustand/react/shallow';
import TooltipShared from '../common/TooltipShared';
import {
buildDiffConfig,
runBuild,
useAppStore,
useExtensionStore,
} from '../state';
import {
type HighlightState,
highlightColumn,
highlightMatches,
updateHighlight,
} from '../util/highlight';
import { percentClass, useFontSize } from '../util/util';
const ROTATION_CLASSES = [
styles.rotation0,
styles.rotation1,
styles.rotation2,
styles.rotation3,
styles.rotation4,
styles.rotation5,
styles.rotation6,
styles.rotation7,
styles.rotation8,
];
const AsmCell = ({
obj,
config,
symbol,
row,
column,
highlight: highlightState,
setHighlight,
}: {
obj: diff.ObjectDiff | undefined;
config: diff.DiffConfig;
symbol: display.SectionDisplaySymbol | null;
row: number;
column: number;
highlight: HighlightState;
setHighlight: (highlight: HighlightState) => void;
}) => {
if (!obj || !symbol) {
return <div className={styles.instructionCell} />;
}
const highlight = highlightColumn(highlightState, column);
const out: React.ReactNode[] = [];
const insRow = display.displayInstructionRow(obj, symbol, row, config);
let index = 0;
for (const segment of insRow.segments) {
let className: string | undefined;
switch (segment.color.tag) {
case 'normal':
break;
case 'dim':
className = styles.segmentDim;
break;
case 'bright':
className = styles.segmentBright;
break;
case 'replace':
className = styles.segmentReplace;
break;
case 'delete':
className = styles.segmentDelete;
break;
case 'insert':
className = styles.segmentInsert;
break;
case 'rotating':
className =
ROTATION_CLASSES[segment.color.val % ROTATION_CLASSES.length];
break;
default:
console.warn('Unknown color type', segment.color);
break;
}
const t = segment.text;
let text = '';
let postText = ''; // unhighlightable text after the token
let isToken = false;
switch (t.tag) {
case 'basic':
text = t.val;
break;
case 'line':
text = t.val.toString(10);
break;
case 'address':
text = t.val.toString(16);
postText = ':';
isToken = true;
break;
case 'opcode':
text = t.val.mnemonic;
isToken = true;
break;
case 'signed':
if (t.val < 0) {
text = `-0x${(-t.val).toString(16)}`;
} else {
text = `0x${t.val.toString(16)}`;
}
isToken = true;
break;
case 'unsigned':
text = `0x${t.val.toString(16)}`;
isToken = true;
break;
case 'opaque':
text = t.val;
isToken = true;
break;
case 'branch-dest':
text = t.val.toString(16);
isToken = true;
break;
case 'symbol':
text = t.val.demangledName || t.val.name;
isToken = true;
break;
case 'addend':
if (t.val < 0) {
text = `-0x${(-t.val).toString(16)}`;
} else {
text = `+0x${t.val.toString(16)}`;
}
break;
case 'spacing':
text = ' '.repeat(t.val);
break;
case 'eol':
continue;
default:
console.warn('Unknown text type', t);
break;
}
out.push(
<span
key={index}
className={clsx(className, {
[styles.highlightable]: isToken,
[styles.highlighted]: highlightMatches(highlight, t),
})}
onClick={(e) => {
if (isToken) {
setHighlight(updateHighlight(highlightState, t, column));
e.stopPropagation();
}
}}
>
{text}
</span>,
);
index++;
if (postText) {
out.push(
<span key={index} className={className}>
{postText}
</span>,
);
index++;
}
if (segment.padTo > text.length + postText.length) {
const spacing = ' '.repeat(segment.padTo - text.length - postText.length);
out.push(<span key={index}>{spacing}</span>);
index++;
}
}
const classes = [styles.instructionCell];
if (insRow.diffKind !== 'none') {
classes.push(styles.diffAny);
}
if (!out.length) {
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)}
>
{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;
};
const AsmRow = memo(
({
index,
style,
data: { result, config, left, right, highlight, setHighlight },
}: ListChildComponentProps<ItemData>) => {
return (
<div
className={styles.instructionRow}
style={style}
onClick={() => {
// Clear highlight on background click
setHighlight({ left: null, right: null });
}}
onMouseDown={(e) => {
// Prevent double click text selection
if (e.detail > 1) {
e.preventDefault();
}
}}
>
<AsmCell
obj={result.left}
config={config}
symbol={left}
row={index}
column={0}
highlight={highlight}
setHighlight={setHighlight}
/>
<AsmCell
obj={result.right}
config={config}
symbol={right}
row={index}
column={1}
highlight={highlight}
setHighlight={setHighlight}
/>
</div>
);
},
areEqual,
);
const createItemData = memoizeOne(
(
result: diff.DiffResult,
left: display.SectionDisplaySymbol | null,
right: display.SectionDisplaySymbol | 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 config = buildDiffConfig(null);
const matchPercent = rightSymbol?.matchPercent;
return {
itemCount,
symbolName,
result,
config,
matchPercent,
left,
leftSymbol,
right,
rightSymbol,
highlight,
setHighlight,
};
},
);
const SymbolLabel = ({
symbol,
}: {
symbol: display.SymbolDisplay | null;
}) => {
if (!symbol) {
return (
<span className={clsx(headerStyles.label, headerStyles.missing)}>
Missing
</span>
);
}
const displayName = symbol.demangledName || symbol.name;
return (
<span
className={clsx(headerStyles.label, headerStyles.emphasized)}
title={displayName}
>
{displayName}
</span>
);
};
const FunctionView = ({
diff,
left,
right,
}: {
diff: diff.DiffResult;
left: display.SectionDisplaySymbol | null;
right: display.SectionDisplaySymbol | 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(
useShallow((state) => ({
highlight: state.highlight,
setSelectedSymbol: state.setSelectedSymbol,
setSymbolScrollOffset: state.setSymbolScrollOffset,
setHighlight: state.setHighlight,
})),
);
const itemData = createItemData(diff, left, right, highlight, setHighlight);
const initialScrollOffset = useMemo(
() =>
useAppStore.getState().getUnitState(currentUnitName).symbolScrollOffsets[
itemData.symbolName
] || 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}>
<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>
</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
View File
@@ -0,0 +1,20 @@
.container {
flex: 1 1 0;
padding: 0 1em;
overflow: auto;
}
.header {
margin: 0.5em 0;
}
.category-header {
margin: 0.5em;
}
.property {
margin: 0.5em 1em;
display: flex;
flex-flow: row;
align-items: center;
}
+135
View File
@@ -0,0 +1,135 @@
import headerStyles from '../common/Header.module.css';
import styles from './SettingsView.module.css';
import { version as wasmVersion } from 'objdiff-wasm';
import {
CONFIG_SCHEMA,
type ConfigPropertyBoolean,
type ConfigPropertyChoice,
} from '../../shared/config';
import {
inVsCode,
openSettings,
setConfigProperty,
useAppStore,
useExtensionStore,
} from '../state';
const BooleanProperty = ({
property,
value,
}: { property: ConfigPropertyBoolean; value: boolean }) => (
<div className={styles.property} title={property.description}>
<input
type="checkbox"
id={property.id}
checked={value}
onChange={(e) => {
const value = e.target.checked;
if (value === property.default) {
setConfigProperty(property.id, undefined);
} else {
setConfigProperty(property.id, value);
}
}}
/>
<label htmlFor={property.id}>{property.name}</label>
</div>
);
const ChoiceProperty = ({
property,
value,
}: { property: ConfigPropertyChoice; value: string }) => (
<div className={styles.property} title={property.description}>
<label htmlFor={property.id}>{property.name}</label>
<select
id={property.id}
defaultValue={value}
onChange={(e) => {
const value = e.target.value;
if (value === property.default) {
setConfigProperty(property.id, undefined);
} else {
setConfigProperty(property.id, value);
}
}}
>
{property.items.map((item) => (
<option key={item.value} value={item.value}>
{item.name}
</option>
))}
</select>
</div>
);
const SettingsView = () => {
const setCurrentView = useAppStore((state) => state.setCurrentView);
const configProperties = useExtensionStore((state) => state.configProperties);
const items = [];
for (const group of CONFIG_SCHEMA.groups) {
items.push(
<h2 className={styles.categoryHeader} key={group.id}>
{group.name}
</h2>,
);
for (const id of group.properties) {
const property = CONFIG_SCHEMA.properties.find((p) => p.id === id);
if (!property) {
continue;
}
const value = configProperties[property.id] ?? property.default;
switch (property.type) {
case 'boolean':
items.push(
<BooleanProperty
key={property.id}
property={property}
value={value as boolean}
/>,
);
break;
case 'choice':
items.push(
<ChoiceProperty
key={property.id}
property={property}
value={value as string}
/>,
);
break;
}
}
}
return (
<>
<div className={headerStyles.header}>
<button title="Back" onClick={() => setCurrentView('main')}>
<span className="codicon codicon-chevron-left" />
</button>
{inVsCode && (
<button onClick={() => openSettings()}>Open in Editor</button>
)}
</div>
<div className={styles.container}>
<h1 className={styles.header}>About</h1>
{window.webviewProps?.extensionVersion && (
<p>
<strong>Extension version:</strong>{' '}
{window.webviewProps.extensionVersion}
</p>
)}
<p>
<strong>objdiff version:</strong> {wasmVersion()}
</p>
<h1 className={styles.header}>Settings</h1>
{items}
</div>
</>
);
};
export default SettingsView;
+76
View File
@@ -0,0 +1,76 @@
.symbols {
flex: 1 1 0;
display: flex;
flex-flow: row;
overflow: auto;
}
.symbol-list {
flex: 1 1 0;
margin: 0;
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);
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;
}
.flag-global {
color: var(--color-green);
}
.flag-weak {
color: inherit;
}
.flag-common {
color: var(--color-blue);
}
.symbol-name {
color: var(--color-bright);
}
.no-object {
color: var(--color-blue);
font-family: var(--code-font-family);
font-weight: var(--code-font-weight);
font-size: var(--code-font-size);
text-wrap: nowrap;
white-space: pre;
}
+471
View File
@@ -0,0 +1,471 @@
import headerStyles from '../common/Header.module.css';
import styles from './SymbolsView.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 {
FixedSizeList,
type ListChildComponentProps,
areEqual,
} from 'react-window';
import { useShallow } from 'zustand/react/shallow';
import TooltipShared from '../common/TooltipShared';
import {
type UnitScrollOffsets,
runBuild,
setCurrentUnit,
useAppStore,
useExtensionStore,
} from '../state';
import { percentClass, useFontSize } from '../util/util';
const SectionRow = ({
section,
style,
onClick,
}: {
section: SectionData;
style?: React.CSSProperties;
onClick?: React.MouseEventHandler<HTMLDivElement>;
}) => {
let percentElem = null;
if (section.matchPercent != null) {
percentElem = (
<>
{' ('}
<span className={percentClass(section.matchPercent)}>
{Math.floor(section.matchPercent).toFixed(0)}%
</span>
{')'}
</>
);
}
return (
<div
className={clsx(styles.symbolListRow, styles.section, {
[styles.collapsed]: section.collapsed,
})}
style={style}
onClick={onClick}
>
{section.name} ({section.size.toString(16)}){percentElem}
</div>
);
};
const SymbolRow = ({
obj,
section,
symbolRef,
side,
style,
}: {
obj: diff.ObjectDiff;
section: SectionData;
symbolRef: display.SectionDisplaySymbol;
side: keyof UnitScrollOffsets;
style?: React.CSSProperties;
}) => {
const setSelectedSymbol = useAppStore((state) => state.setSelectedSymbol);
const symbol = display.displaySymbol(obj, symbolRef);
const flags = [];
if (symbol.flags.global) {
flags.push(
<span key="g" className={styles.flagGlobal}>
g
</span>,
);
}
if (symbol.flags.weak) {
flags.push(
<span key="w" className={styles.flagWeak}>
w
</span>,
);
}
if (symbol.flags.local) {
flags.push(
<span key="l" className={styles.flagLocal}>
l
</span>,
);
}
if (symbol.flags.common) {
flags.push(
<span key="c" className={styles.flagCommon}>
c
</span>,
);
}
let flagsElem = null;
if (flags.length > 0) {
flagsElem = <>[{flags}] </>;
}
let percentElem = null;
if (symbol.matchPercent != null) {
percentElem = (
<>
{'('}
<span className={percentClass(symbol.matchPercent)}>
{Math.floor(symbol.matchPercent).toFixed(0)}%
</span>
{') '}
</>
);
}
const tooltipContent: SymbolTooltipContent = {
symbolRef,
side,
};
return (
<div
className={clsx(styles.symbolListRow, styles.symbol)}
style={style}
onClick={() => {
setSelectedSymbol(
{
symbolName: symbol.name,
sectionName: section.name,
},
{
symbolName: symbol.name,
sectionName: section.name,
},
);
}}
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)}
>
{flagsElem}
{percentElem}
<span className={styles.symbolName}>
{symbol.demangledName || symbol.name}
</span>
</div>
);
};
type SymbolTooltipContent = {
symbolRef: display.SectionDisplaySymbol;
side: keyof UnitScrollOffsets;
};
type SectionData = display.SectionDisplay & { collapsed: boolean };
type ItemData = {
obj: diff.ObjectDiff | undefined;
itemCount: number;
sections: SectionData[];
side: keyof UnitScrollOffsets;
setSectionCollapsed: (section: string, collapsed: boolean) => void;
};
const SymbolListRow = memo(
({
index,
style,
data: { obj, sections, side, setSectionCollapsed },
}: ListChildComponentProps<ItemData>) => {
if (!obj) {
return null;
}
let currentIndex = 0;
for (const section of sections) {
if (currentIndex === index) {
return (
<SectionRow
section={section}
style={style}
onClick={() => setSectionCollapsed(section.id, !section.collapsed)}
/>
);
}
currentIndex++;
if (section.collapsed) {
continue;
}
if (index < currentIndex + section.symbols.length) {
const symbolRef = section.symbols[index - currentIndex];
return (
<SymbolRow
obj={obj}
section={section}
symbolRef={symbolRef}
side={side}
style={style}
/>
);
}
currentIndex += section.symbols.length;
}
return null;
},
areEqual,
);
const createItemDataFn = (
obj: diff.ObjectDiff | undefined,
collapsedSections: Record<string, boolean>,
search: string | null,
side: keyof UnitScrollOffsets,
setSectionCollapsed: (section: string, collapsed: boolean) => void,
): ItemData => {
if (!obj) {
return {
obj,
itemCount: 0,
sections: [],
side,
setSectionCollapsed,
};
}
const displaySections = display.displaySections(
obj,
{
mapping: undefined,
regex: search ?? undefined,
},
{
showHiddenSymbols: false,
showMappedSymbols: false,
reverseFnOrder: false,
},
);
let itemCount = 0;
const sections: SectionData[] = [];
for (const section of displaySections) {
itemCount++;
if (search !== null && section.symbols.length === 0) {
continue;
}
if (collapsedSections[section.id]) {
sections.push({
...section,
symbols: [],
collapsed: true,
});
continue;
}
itemCount += section.symbols.length;
sections.push({
...section,
collapsed: false,
});
}
return {
obj,
itemCount,
sections,
side,
setSectionCollapsed,
};
};
const createItemDataLeft = memoizeOne(createItemDataFn);
const createItemDataRight = memoizeOne(createItemDataFn);
const SymbolsView = ({ diff }: { diff: diff.DiffResult }) => {
const { buildRunning, currentUnit, hasProjectConfig } = useExtensionStore(
useShallow((state) => ({
buildRunning: state.buildRunning,
currentUnit: state.currentUnit,
hasProjectConfig: state.projectConfig != null,
})),
);
const currentUnitName = currentUnit?.name || '';
const {
collapsedSections,
search,
setUnitSectionCollapsed,
setUnitScrollOffset,
setUnitSearch,
setCurrentView,
} = useAppStore(
useShallow((state) => {
const unit = state.getUnitState(currentUnitName);
return {
collapsedSections: unit.collapsedSections,
search: unit.search,
setUnitSectionCollapsed: state.setUnitSectionCollapsed,
setUnitScrollOffset: state.setUnitScrollOffset,
setUnitSearch: state.setUnitSearch,
setCurrentView: state.setCurrentView,
};
}),
);
const initialScrollOffsets = useMemo(
() => useAppStore.getState().getUnitState(currentUnitName).scrollOffsets,
[currentUnitName],
);
const setLeftSectionCollapsed = useMemo(
() => (section: string, collapsed: boolean) => {
setUnitSectionCollapsed(currentUnitName, section, 'left', collapsed);
},
[currentUnitName, setUnitSectionCollapsed],
);
const setRightSectionCollapsed = useMemo(
() => (section: string, collapsed: boolean) => {
setUnitSectionCollapsed(currentUnitName, section, 'right', collapsed);
},
[currentUnitName, setUnitSectionCollapsed],
);
const renderList = (
height: number,
width: number,
itemData: ItemData,
side: keyof UnitScrollOffsets,
) => {
if (!itemData.obj) {
return (
<div className={clsx(styles.symbolList, styles.noObject)}>
No object configured
</div>
);
}
return (
<FixedSizeList
key={`${side}-${currentUnitName}`}
className={styles.symbolList}
height={height - 1}
itemCount={itemData.itemCount}
itemSize={itemSize}
width={width / 2}
itemData={itemData}
overscanCount={20}
onScroll={(e) => {
if (currentUnitName) {
setUnitScrollOffset(currentUnitName, side, e.scrollOffset);
}
}}
initialScrollOffset={initialScrollOffsets[side]}
>
{SymbolListRow}
</FixedSizeList>
);
};
const itemSize = useFontSize() * 1.33;
const leftItemData = createItemDataLeft(
diff.left,
collapsedSections.left,
search,
'left',
setLeftSectionCollapsed,
);
const rightItemData = createItemDataRight(
diff.right,
collapsedSections.right,
search,
'right',
setRightSectionCollapsed,
);
const unitNameRow = (
<div className={headerStyles.row}>
<span className={clsx(headerStyles.label, headerStyles.emphasized)}>
{currentUnitName}
</span>
</div>
);
const filterRow = (
<div className={headerStyles.row}>
<input
type="text"
placeholder="Filter symbols"
value={search || ''}
onChange={(e) => {
const value = e.target.value;
setUnitSearch(currentUnitName, value);
}}
/>
</div>
);
const settingsRow = (
<div className={headerStyles.row}>
<button title="Settings" onClick={() => setCurrentView('settings')}>
<span className="codicon codicon-settings-gear" />
</button>
</div>
);
return (
<>
<div className={headerStyles.header}>
<div className={headerStyles.column}>
<div className={headerStyles.row}>
{hasProjectConfig ? (
<button
title="Back"
onClick={() => setCurrentUnit(null)}
disabled={buildRunning}
>
<span className="codicon codicon-chevron-left" />
</button>
) : null}
<span className={headerStyles.label}>Target object</span>
</div>
{currentUnitName ? unitNameRow : filterRow}
</div>
<div className={headerStyles.column}>
<div className={headerStyles.row}>
{hasProjectConfig && (
<button
title="Build"
onClick={() => runBuild()}
disabled={buildRunning}
>
<span className="codicon codicon-refresh" />
</button>
)}
<span className={headerStyles.label}>Base object</span>
</div>
{currentUnitName ? filterRow : settingsRow}
</div>
</div>
<div className={styles.symbols}>
<AutoSizer className={styles.symbols}>
{({ height, width }) => (
<>
{renderList(height, width, leftItemData, 'left')}
{renderList(height, width, rightItemData, 'right')}
</>
)}
</AutoSizer>
</div>
<TooltipShared
id="symbol-tooltip"
callback={(content) => {
const data: SymbolTooltipContent = JSON.parse(content);
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;
}
return display.symbolHover(obj, data.symbolRef);
}}
/>
</>
);
};
export default SymbolsView;
+58
View File
@@ -0,0 +1,58 @@
.units {
flex: 1 1 0;
display: flex;
flex-flow: row;
overflow: hidden;
}
.row {
display: flex;
cursor: pointer;
user-select: none;
height: var(--list-row-height);
padding-left: 0.5em;
font-family: var(--code-font-family, monospace);
font-weight: var(--code-font-weight, normal);
font-size: var(--code-font-size);
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;
}
.toggle {
display: inline-block;
height: var(--list-row-height);
width: 1em;
margin-right: 0.25em;
}
.unit {
.label {
margin-left: 0.4em;
}
}
.complete {
color: var(--color-green);
}
.incomplete {
color: var(--color-red);
}
.collapsed {
opacity: 0.75;
}
+228
View File
@@ -0,0 +1,228 @@
import styles from './UnitsView.module.css';
import clsx from 'clsx';
import memoizeOne from 'memoize-one';
import { memo, useMemo } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import {
FixedSizeList,
type ListChildComponentProps,
areEqual,
} from 'react-window';
import { useShallow } from 'zustand/react/shallow';
import type { ProjectConfig, Unit } from '../../shared/config';
import headerStyles from '../common/Header.module.css';
import {
quickPickUnit,
setCurrentUnit,
useAppStore,
useExtensionStore,
} from '../state';
import { useFontSize } from '../util/util';
type DirectoryItem = {
type: 'directory';
indent: number;
label: string;
id: string;
collapsed: boolean;
};
type UnitItem = {
type: 'unit';
indent: number;
label: string;
id: string;
unit: Unit;
};
type Item = DirectoryItem | UnitItem;
type ItemData = {
unitsCount: number;
items: Item[];
};
const UnitRow = memo(
({ index, style, data: { items } }: ListChildComponentProps<ItemData>) => {
const setCollapsedUnit = useAppStore((state) => state.setCollapsedUnit);
const item = items[index];
const classes = [styles.row];
if (item.type === 'unit') {
classes.push(styles.unit);
if (item.unit.metadata?.complete !== undefined) {
if (item.unit.metadata.complete) {
classes.push(styles.complete);
} else {
classes.push(styles.incomplete);
}
}
} else {
// classes.push(styles.directory);
if (item.collapsed) {
classes.push(styles.collapsed);
}
}
const indentItems = [];
for (let i = 0; i < item.indent; i++) {
indentItems.push(<span key={i} className={styles.indent} />);
}
if (item.type === 'directory') {
indentItems.push(
<span
key="toggle"
className={clsx(
styles.toggle,
'codicon',
item.collapsed ? 'codicon-chevron-right' : 'codicon-chevron-down',
)}
/>,
);
}
return (
<div
className={clsx(classes)}
style={style}
onClick={() => {
if (item.type === 'unit') setCurrentUnit(item.unit);
else setCollapsedUnit(item.id, !item.collapsed);
}}
>
{indentItems}
<span className={styles.label}>{item.label}</span>
</div>
);
},
areEqual,
);
type TreeItem = DirectoryItem & { children: (TreeItem | UnitItem)[] };
function pushTreeItems(item: TreeItem | UnitItem, out: Item[]) {
if (item.type === 'directory') {
out.push(item);
if (!item.collapsed) {
for (const child of item.children) {
pushTreeItems(child, out);
}
}
} else if (item.type === 'unit') {
out.push(item);
}
}
const createItemData = memoizeOne(
(
config: ProjectConfig | null,
collapsedUnits: Record<string, boolean>,
): ItemData => {
const units = config?.units ?? [];
const map = new Map<string, TreeItem>();
const rootItems = [];
for (const unit of units) {
const path = unit.name || '';
const split = path.split('/');
let parent: TreeItem | null = null;
for (let i = 0; i < split.length - 1; i++) {
const name = split[i];
const key = split.slice(0, i + 1).join('/');
let item = map.get(key);
if (!item) {
item = {
type: 'directory',
indent: i,
label: name,
id: key,
collapsed: !!collapsedUnits[key],
children: [],
};
if (parent) {
parent.children.push(item);
} else {
rootItems.push(item);
}
map.set(key, item);
}
parent = item;
}
const unitItem: UnitItem = {
type: 'unit',
indent: split.length - 1,
label: split[split.length - 1],
id: path,
unit,
};
if (parent) {
parent.children.push(unitItem);
} else {
rootItems.push(unitItem);
}
}
const items: Item[] = [];
for (const item of rootItems) {
pushTreeItems(item, items);
}
return { unitsCount: units.length, items };
},
);
const UnitsView = () => {
const config = useExtensionStore((state) => state.projectConfig);
const { collapsedUnits, setCurrentView, setUnitsScrollOffset } = useAppStore(
useShallow((state) => ({
collapsedUnits: state.collapsedUnits,
setCurrentView: state.setCurrentView,
setUnitsScrollOffset: state.setUnitsScrollOffset,
})),
);
const initialScrollOffset = useMemo(
() => useAppStore.getState().unitsScrollOffset,
[],
);
const itemSize = useFontSize() * 1.33;
const itemData = createItemData(config, collapsedUnits);
return (
<>
<div className={headerStyles.header}>
<div className={headerStyles.column}>
<div className={headerStyles.row}>
<button onClick={() => setCurrentUnit('source')}>
Current File
</button>
<button onClick={() => quickPickUnit()}>Quick Pick</button>
<button title="Settings" onClick={() => setCurrentView('settings')}>
<span className="codicon codicon-settings-gear" />
</button>
</div>
<div className={headerStyles.row}>
<span className={headerStyles.label}>
{itemData.unitsCount} unit{itemData.unitsCount === 1 ? '' : 's'}
</span>
</div>
</div>
</div>
<div className={styles.units}>
<AutoSizer>
{({ height, width }) => (
<FixedSizeList
height={height}
itemCount={itemData.items.length}
itemSize={itemSize}
width={width}
itemData={itemData}
overscanCount={20}
onScroll={(e) => {
setUnitsScrollOffset(e.scrollOffset);
}}
initialScrollOffset={initialScrollOffset}
>
{UnitRow}
</FixedSizeList>
)}
</AutoSizer>
</div>
</>
);
};
export default UnitsView;