diff --git a/.esbuild.config.js b/.esbuild.config.js index 7d3a585..f4d0324 100644 --- a/.esbuild.config.js +++ b/.esbuild.config.js @@ -46,12 +46,23 @@ build({ tsconfig: "./tsconfig.json", bundle: true, format: "cjs", - external: ["vscode", "fs"], + external: ["vscode", "fs", "worker_threads"], minify, platform: "browser", outfile: "dist/web/extension.js", }); +build({ + entryPoints: ["shared/diffWorker.ts"], + tsconfig: "./tsconfig.json", + bundle: true, + format: "cjs", + external: ["vscode", "worker_threads"], + minify, + platform: "browser", + outfile: "dist/diffWorker.js", +}); + // Build the data inspector build({ entryPoints: ["media/data_inspector/inspector.ts"], diff --git a/media/editor/dataDisplay.tsx b/media/editor/dataDisplay.tsx index 6c3569a..2118867 100644 --- a/media/editor/dataDisplay.tsx +++ b/media/editor/dataDisplay.tsx @@ -3,6 +3,7 @@ import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRecoilValue, useSetRecoilState } from "recoil"; +import { HexDecorator } from "../../shared/decorators"; import { EditRangeOp, HexDocumentEditOp } from "../../shared/hexDocumentModel"; import { CopyFormat, @@ -14,8 +15,8 @@ import { Range } from "../../shared/util/range"; import { PastePopup } from "./copyPaste"; import _style from "./dataDisplay.css"; import { - FocusedElement, dataCellCls, + FocusedElement, useDisplayContext, useIsFocused, useIsHovered, @@ -31,9 +32,11 @@ import { clsx, getAsciiCharacter, getScrollDimensions, + HexDecoratorStyles, parseHexDigit, throwOnUndefinedAccessInDev, } from "./util"; +import { binarySearch } from "../../shared/util/binarySearch"; const style = throwOnUndefinedAccessInDev(_style); @@ -428,8 +431,9 @@ const LoadingDataRows: React.FC = props => ( ); const DataPageContents: React.FC = props => { - const pageSelector = select.editedDataPages(props.pageNo); - const [data] = useLastAsyncRecoilValue(pageSelector); + const decorators = useRecoilValue(select.decoratorsPage(props.pageNo)); + const dataPageSelector = select.editedDataPages(props.pageNo); + const [data] = useLastAsyncRecoilValue(dataPageSelector); return ( <> @@ -443,6 +447,7 @@ const DataPageContents: React.FC = props => { width={props.columnWidth} showDecodedText={props.showDecodedText} isRowWithInsertDataCell={isRowWithInsertDataCell} + decorators={decorators} /> ))} @@ -688,7 +693,8 @@ const DataRowContents: React.FC<{ showDecodedText: boolean; rawBytes: Uint8Array; isRowWithInsertDataCell: boolean; -}> = ({ offset, width, showDecodedText, rawBytes, isRowWithInsertDataCell }) => { + decorators: HexDecorator[]; +}> = ({ offset, width, showDecodedText, rawBytes, isRowWithInsertDataCell, decorators }) => { let memoValue = ""; const ctx = useDisplayContext(); for (const byte of rawBytes) { @@ -698,9 +704,21 @@ const DataRowContents: React.FC<{ const { bytes, chars } = useMemo(() => { const bytes: React.ReactChild[] = []; const chars: React.ReactChild[] = []; + const searcher = binarySearch(d => d.range.end); + let j = searcher(offset, decorators); for (let i = 0; i < width; i++) { const boffset = offset + i; const value = rawBytes[i]; + let decorator: HexDecorator | undefined = undefined; + // Searches for the decorator, if any. Leverages the fact that + // the decorators are sorted by range. + while (j < decorators.length && decorators[j].range.start <= boffset) { + if (boffset >= decorators[j].range.start && boffset < decorators[j].range.end) { + decorator = decorators[j]; + break; + } + j++; + } if (value === undefined) { if (isRowWithInsertDataCell && !ctx.isReadonly) { @@ -723,7 +741,14 @@ const DataRowContents: React.FC<{ } bytes.push( - + {value.toString(16).padStart(2, "0").toUpperCase()} , ); @@ -736,7 +761,10 @@ const DataRowContents: React.FC<{ offset={boffset} isChar={true} isAppend={false} - className={char === undefined ? style.nonGraphicChar : undefined} + className={clsx( + char === undefined ? style.nonGraphicChar : undefined, + decorator !== undefined && HexDecoratorStyles[decorator.type], + )} value={value} > {char === undefined ? "." : char} diff --git a/media/editor/state.ts b/media/editor/state.ts index d2aced1..bad9444 100644 --- a/media/editor/state.ts +++ b/media/editor/state.ts @@ -3,7 +3,14 @@ *--------------------------------------------------------*/ import { atom, DefaultValue, selector, selectorFamily } from "recoil"; -import { buildEditTimeline, HexDocumentEdit, readUsingRanges } from "../../shared/hexDocumentModel"; +import { HexDecorator, HexDecoratorType } from "../../shared/decorators"; +import { + buildEditTimeline, + HexDocumentEdit, + HexDocumentEditOp, + HexDocumentEmptyInsertEdit, + readUsingRanges, +} from "../../shared/hexDocumentModel"; import { FromWebviewMessage, InspectorLocation, @@ -15,6 +22,7 @@ import { ToWebviewMessage, } from "../../shared/protocol"; import { deserializeEdits, serializeEdits } from "../../shared/serialization"; +import { binarySearch } from "../../shared/util/binarySearch"; import { Range } from "../../shared/util/range"; import { clamp } from "./util"; @@ -103,6 +111,11 @@ export const codeSettings = selector({ get: ({ get }) => get(readyQuery).codeSettings, }); +export const decorators = selector({ + key: "decorators", + get: ({ get }) => get(readyQuery).decorators, +}); + export const showReadonlyWarningForEl = atom({ key: "showReadonlyWarningForEl", default: null, @@ -136,7 +149,7 @@ export const fileSize = selector({ key: "fileSize", get: ({ get }) => { const initial = get(diskFileSize); - const sizeDelta = get(unsavedEditTimeline).sizeDelta; + const sizeDelta = get(unsavedAndDecoratorEditTimeline).sizeDelta; return initial === undefined ? initial : initial + sizeDelta; }, }); @@ -372,13 +385,41 @@ export const unsavedEditTimeline = selector({ }, }); +const emptyDecoratorEdits = selector({ + key: "emptyDecoratorEdits", + get: ({ get }) => { + return get(decorators) + .filter(record => record.type === HexDecoratorType.Empty) + .map(value => { + return { + op: HexDocumentEditOp.EmptyInsert, + offset: value.range.start, + length: value.range.end - value.range.start, + } as HexDocumentEmptyInsertEdit; + }); + }, +}); + +/** + * Creates the edit timeline for the unsaved edits and empty decorators. + */ +export const unsavedAndDecoratorEditTimeline = selector({ + key: "unsavedAndDecoratorEditTimeline", + get: ({ get }) => { + return buildEditTimeline([ + ...get(edits).slice(get(unsavedEditIndex)), + ...get(emptyDecoratorEdits), + ]); + }, +}); + export const editedDataPages = selectorFamily({ key: "editedDataPages", get: (pageNumber: number) => async ({ get }) => { const pageSize = get(dataPageSize); - const { ranges } = get(unsavedEditTimeline); + const { ranges } = get(unsavedAndDecoratorEditTimeline); const target = new Uint8Array(pageSize); const it = readUsingRanges( { @@ -414,6 +455,25 @@ export const editedDataPages = selectorFamily({ }, }); +/** Returns the decorators in a page */ +export const decoratorsPage = selectorFamily({ + key: "decoratorsPage", + get: + (pageNumber: number) => + async ({ get }) => { + const allDecorators = get(decorators); + if (allDecorators.length === 0) { + return []; + } + const pageSize = get(dataPageSize); + const searcherByEnd = binarySearch(decorator => decorator.range.end); + const startIndex = searcherByEnd(pageSize * pageNumber, allDecorators); + const searcherByStart = binarySearch(d => d.range.start); + const endIndex = searcherByStart(pageSize * pageNumber + pageSize+1, allDecorators); + return allDecorators.slice(startIndex, endIndex); + }, +}); + const rawDataPages = selectorFamily({ key: "rawDataPages", get: diff --git a/media/editor/util.css b/media/editor/util.css index bb06f71..dbc67e7 100644 --- a/media/editor/util.css +++ b/media/editor/util.css @@ -5,3 +5,29 @@ width: 100px; height: 100px; } + +/* Decorators */ + +.diff-insert { + background-color: rgba(156, 204, 44, 0.4); + filter: opacity(100%); +} + +.diff-delete { + background-color: rgba(255, 0, 0, 0.4); +} + +.diff-empty { + background-image: linear-gradient( + -45deg, + var(--vscode-diffEditor-diagonalFill) 12.5%, + #0000 12.5%, + #0000 50%, + var(--vscode-diffEditor-diagonalFill) 50%, + var(--vscode-diffEditor-diagonalFill) 62.5%, + #0000 62.5%, + #0000 100% + ); + background-size: 8px 8px; + color: transparent !important; +} diff --git a/media/editor/util.ts b/media/editor/util.ts index b26788a..79d2cea 100644 --- a/media/editor/util.ts +++ b/media/editor/util.ts @@ -3,6 +3,7 @@ // Assorted helper functions +import { HexDecoratorType } from "../../shared/decorators"; import { Range } from "../../shared/util/range"; import _style from "./util.css"; @@ -180,3 +181,9 @@ export const getScrollDimensions = (() => { return value; }; })(); + +export const HexDecoratorStyles: { [key in HexDecoratorType]: string } = { + [HexDecoratorType.Insert]: style.diffInsert, + [HexDecoratorType.Delete]: style.diffDelete, + [HexDecoratorType.Empty]: style.diffEmpty, +}; diff --git a/package-lock.json b/package-lock.json index 40aef37..136bc30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@vscode/codicons": "0.0.27", "@vscode/extension-telemetry": "0.6.2", "cockatiel": "^3.1.2", + "diff": "^5.2.0", "js-base64": "^3.7.2", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -22,6 +23,7 @@ }, "devDependencies": { "@types/chai": "^4.3.0", + "@types/diff": "^5.2.1", "@types/mocha": "^9.0.0", "@types/node": "^18.11.9", "@types/react": "^17.0.38", @@ -1220,6 +1222,12 @@ "integrity": "sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw==", "dev": true }, + "node_modules/@types/diff": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-5.2.1.tgz", + "integrity": "sha512-uxpcuwWJGhe2AR1g8hD9F5OYGCqjqWnBUQFD8gMZsDbv8oPHzxJF6iMO6n8Tk0AdzlxoaaoQhOYlIg/PukVU8g==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2065,10 +2073,10 @@ } }, "node_modules/diff": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", - "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", - "dev": true, + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } @@ -3464,6 +3472,16 @@ "url": "https://opencollective.com/mochajs" } }, + "node_modules/mocha/node_modules/diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/mocha/node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -5112,6 +5130,12 @@ "integrity": "sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw==", "dev": true }, + "@types/diff": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-5.2.1.tgz", + "integrity": "sha512-uxpcuwWJGhe2AR1g8hD9F5OYGCqjqWnBUQFD8gMZsDbv8oPHzxJF6iMO6n8Tk0AdzlxoaaoQhOYlIg/PukVU8g==", + "dev": true + }, "@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -5687,10 +5711,9 @@ "dev": true }, "diff": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", - "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", - "dev": true + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==" }, "dir-glob": { "version": "3.0.1", @@ -6666,6 +6689,12 @@ "yargs-unparser": "2.0.0" }, "dependencies": { + "diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "dev": true + }, "escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", diff --git a/package.json b/package.json index 78e3c54..2320fe5 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,11 @@ } } ], + "configurationDefaults": { + "workbench.editorAssociations": { + "{hexdiff}:/**/*.*": "hexEditor.hexedit" + } + }, "customEditors": [ { "viewType": "hexEditor.hexedit", @@ -132,6 +137,11 @@ "command": "hexEditor.copyOffsetAsDec", "category": "%name%", "title": "%hexEditor.copyOffsetAsDec%" + }, + { + "command": "hexEditor.compareSelected", + "category": "%name%", + "title": "%hexEditor.compareSelected%" } ], "viewsContainers": { @@ -163,6 +173,10 @@ { "command": "hexEditor.switchEditMode", "when": "hexEditor:isActive" + }, + { + "command": "hexEditor.compareSelected", + "when": "false" } ], "editor/title": [ @@ -188,6 +202,13 @@ "command": "hexEditor.copyAs", "group": "9_cutcopypaste" } + ], + "explorer/context": [ + { + "command": "hexEditor.compareSelected", + "group": "3_compare", + "when": "listDoubleSelection" + } ] }, "keybindings": [ @@ -217,6 +238,7 @@ }, "devDependencies": { "@types/chai": "^4.3.0", + "@types/diff": "^5.2.1", "@types/mocha": "^9.0.0", "@types/node": "^18.11.9", "@types/react": "^17.0.38", @@ -239,6 +261,7 @@ "@vscode/codicons": "0.0.27", "@vscode/extension-telemetry": "0.6.2", "cockatiel": "^3.1.2", + "diff": "^5.2.0", "js-base64": "^3.7.2", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/package.nls.json b/package.nls.json index 76932ba..6f3b712 100644 --- a/package.nls.json +++ b/package.nls.json @@ -16,5 +16,6 @@ "hexEditor.switchEditMode": "Switch Edit Mode", "hexEditor.copyOffsetAsDec": "Copy Offset as Decimal", "hexEditor.copyOffsetAsHex": "Copy Offset as Hex", + "hexEditor.compareSelected": "Compare Selected in HexEditor (Experimental)", "dataInspectorView": "Data Inspector" } diff --git a/shared/decorators.ts b/shared/decorators.ts new file mode 100644 index 0000000..3ee0348 --- /dev/null +++ b/shared/decorators.ts @@ -0,0 +1,10 @@ +export enum HexDecoratorType { + Insert, + Delete, + Empty, +} + +export interface HexDecorator { + type: HexDecoratorType; + range: { start: number; end: number }; +} diff --git a/shared/diffWorker.ts b/shared/diffWorker.ts new file mode 100644 index 0000000..a4e3e1c --- /dev/null +++ b/shared/diffWorker.ts @@ -0,0 +1,39 @@ +import { DiffMessageType, FromDiffWorkerMessage, ToDiffWorkerMessage } from "./diffWorkerProtocol"; +import { MessageHandler } from "./protocol"; +import { MyersDiff } from "./util/myers"; + +function onMessage(message: ToDiffWorkerMessage): undefined | FromDiffWorkerMessage { + switch (message.type) { + case DiffMessageType.DiffDecoratorRequest: + const script = MyersDiff.lcs(message.original, message.modified); + const decorators = MyersDiff.toDecorator(script); + return { + type: DiffMessageType.DiffDecoratorResponse, + original: decorators.original, + modified: decorators.modified, + }; + } +} + +try { + // Web worker + const messageHandler = new MessageHandler( + async message => onMessage(message), + message => postMessage(message), + ); + onmessage = e => messageHandler.handleMessage(e.data); +} catch { + // node worker + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { parentPort } = require("worker_threads") as typeof import("worker_threads"); + if (parentPort) { + const messageHandler = new MessageHandler( + async message => onMessage(message), + message => parentPort.postMessage(message), + ); + parentPort.on("message", e => { + messageHandler.handleMessage(e); + }); + } +} diff --git a/shared/diffWorkerProtocol.ts b/shared/diffWorkerProtocol.ts new file mode 100644 index 0000000..50372f0 --- /dev/null +++ b/shared/diffWorkerProtocol.ts @@ -0,0 +1,31 @@ +import { HexDecorator } from "./decorators"; +import { MessageHandler } from "./protocol"; + +export type DiffExtensionHostMessageHandler = MessageHandler< + ToDiffWorkerMessage, + FromDiffWorkerMessage +>; +export type DiffWorkerMessageHandler = MessageHandler; + +export type ToDiffWorkerMessage = DiffDecoratorsRequestMessage; +export type FromDiffWorkerMessage = DiffDecoratorResponseMessage; +export enum DiffMessageType { + // #region to diffworker + DiffDecoratorRequest, + // #endregion + // #region from diff worker + DiffDecoratorResponse, + // #endregion +} + +export interface DiffDecoratorsRequestMessage { + type: DiffMessageType.DiffDecoratorRequest; + original: Uint8Array; + modified: Uint8Array; +} + +export interface DiffDecoratorResponseMessage { + type: DiffMessageType.DiffDecoratorResponse; + original: HexDecorator[]; + modified: HexDecorator[]; +} diff --git a/shared/hexDiffModel.ts b/shared/hexDiffModel.ts new file mode 100644 index 0000000..b4b4059 --- /dev/null +++ b/shared/hexDiffModel.ts @@ -0,0 +1,99 @@ +import { bulkhead } from "cockatiel"; +import * as vscode from "vscode"; +import { HexDecorator } from "./decorators"; +import { + DiffDecoratorResponseMessage, + DiffExtensionHostMessageHandler, + DiffMessageType, +} from "./diffWorkerProtocol"; +import { HexDocumentModel } from "./hexDocumentModel"; +export type HexDiffModelBuilder = typeof HexDiffModel.Builder.prototype; + +export class HexDiffModel { + /** Guard to make sure only one computation operation happens */ + private readonly saveGuard = bulkhead(1, Infinity); + private decorators?: { original: HexDecorator[]; modified: HexDecorator[] }; + + constructor( + private readonly originalModel: HexDocumentModel, + private readonly modifiedModel: HexDocumentModel, + private readonly messageHandler: DiffExtensionHostMessageHandler, + ) {} + + public async computeDecorators(uri: vscode.Uri): Promise { + return this.saveGuard.execute(async () => { + if (this.decorators === undefined) { + //TODO: Add a warning if the file sizes are too large? + const oSize = await this.originalModel.sizeWithEdits(); + const mSize = await this.modifiedModel.sizeWithEdits(); + if (oSize === undefined || mSize === undefined) { + throw new Error(vscode.l10n.t("HexEditor Diff: Failed to get file sizes.")); + } + + const oArray = new Uint8Array(oSize); + const mArray = new Uint8Array(mSize); + await this.originalModel.readInto(0, oArray); + await this.modifiedModel.readInto(0, mArray); + const decorators = await this.messageHandler.sendRequest( + { + type: DiffMessageType.DiffDecoratorRequest, + original: oArray, + modified: mArray, + }, + [oArray.buffer, mArray.buffer], + ); + this.decorators = decorators; + } + return uri.toString() === this.originalModel.uri.toString() + ? this.decorators.original + : this.decorators.modified; + }); + } + + /** + * Class to coordinate the creation of HexDiffModel + * with both HexDocumentModels + */ + static Builder = class { + private original: { + promise: Promise; + resolve: (model: HexDocumentModel) => void; + }; + private modified: { + promise: Promise; + resolve: (model: HexDocumentModel) => void; + }; + + private built?: HexDiffModel; + + constructor(private readonly messageHandler: DiffExtensionHostMessageHandler) { + let promise: Promise; + let res: (model: HexDocumentModel) => void; + + promise = new Promise(resolve => (res = resolve)); + this.original = { promise: promise, resolve: res! }; + promise = new Promise(resolve => (res = resolve)); + this.modified = { promise: promise, resolve: res! }; + } + + public setModel(side: "original" | "modified", document: HexDocumentModel) { + if (side === "original") { + this.original.resolve(document); + } else { + this.modified.resolve(document); + } + return this; + } + + public async build() { + const [original, modified] = await Promise.all([ + this.original.promise, + this.modified.promise, + ]); + if (this.built === undefined) { + this.built = new HexDiffModel(original, modified, this.messageHandler); + } + return this.built; + } + }; +} diff --git a/shared/hexDocumentModel.ts b/shared/hexDocumentModel.ts index c0355fd..22acc46 100644 --- a/shared/hexDocumentModel.ts +++ b/shared/hexDocumentModel.ts @@ -11,6 +11,7 @@ export const enum HexDocumentEditOp { Insert, Delete, Replace, + EmptyInsert, } export interface GenericHexDocumentEdit { @@ -35,6 +36,19 @@ export interface HexDocumentInsertEdit extends GenericHexDocumentEdit { value: Uint8Array; } +/** + * Similar to {@link HexDocumentInsertEdit} but only intended to be used + * when comparing files. Unlike {@link HexDocumentInsertEdit}, this allows us + * to align files without creating unnecessary Uint8Arrays. + * + * note: this is only a visual edit, so shouldn't be treated as a normal + * edit (that's why HexDocumentEdit does not include it) + */ +export interface HexDocumentEmptyInsertEdit extends GenericHexDocumentEdit { + op: HexDocumentEditOp.EmptyInsert; + length: number; +} + export type HexDocumentEdit = | HexDocumentInsertEdit | HexDocumentDeleteEdit @@ -74,6 +88,7 @@ export const enum EditRangeOp { Read, Skip, Insert, + EmptyInsert, } export type EditRange = @@ -82,7 +97,8 @@ export type EditRange = /** Skip starting at "offset" in the edited version of the file */ | { op: EditRangeOp.Skip; editIndex: number; offset: number } /** Insert "value" at the "offset" in th edited version of the file */ - | { op: EditRangeOp.Insert; editIndex: number; offset: number; value: Uint8Array }; + | { op: EditRangeOp.Insert; editIndex: number; offset: number; value: Uint8Array } + | { op: EditRangeOp.EmptyInsert; editIndex: number; offset: number; length: number }; export interface IEditTimeline { /** Instructions on how to read the file, in order. */ @@ -332,6 +348,21 @@ export async function* readUsingRanges( continue; } + if (range.op === EditRangeOp.EmptyInsert) { + const readLast = range.offset + range.length - fromOffset; + if (readLast <= 0) { + continue; + } + const toYield = + readLast < range.length + ? buf.fill(0, -readLast).subarray(-readLast) + : buf.fill(0, -range.length).subarray(-range.length); + if (toYield.length > 0) { + yield toYield; + } + continue; + } + if (range.op === EditRangeOp.Insert) { const readLast = range.offset + range.value.length - fromOffset; if (readLast <= 0) { @@ -363,7 +394,9 @@ export async function* readUsingRanges( } } -export const buildEditTimeline = (edits: readonly HexDocumentEdit[]): IEditTimeline => { +export const buildEditTimeline = ( + edits: readonly (HexDocumentEdit | HexDocumentEmptyInsertEdit)[], +): IEditTimeline => { // Serialize all edits to a single, continuous "timeline", which we'll // iterate through in order to read data and yield bytes. const ranges: EditRange[] = [{ op: EditRangeOp.Read, editIndex: -1, roffset: 0, offset: 0 }]; @@ -389,7 +422,7 @@ export const buildEditTimeline = (edits: readonly HexDocumentEdit[]): IEditTimel before: { op: EditRangeOp.Skip, editIndex, offset: split.offset }, after: { op: EditRangeOp.Skip, editIndex, offset: split.offset + atByte }, }; - } else { + } else if (split.op === EditRangeOp.Insert) { return { before: { op: EditRangeOp.Insert, @@ -404,6 +437,21 @@ export const buildEditTimeline = (edits: readonly HexDocumentEdit[]): IEditTimel value: split.value.subarray(atByte), }, }; + } else { + return { + before: { + op: EditRangeOp.EmptyInsert, + editIndex, + offset: split.offset, + length: atByte, + }, + after: { + op: EditRangeOp.EmptyInsert, + editIndex, + offset: split.offset + atByte, + length: split.length - atByte, + }, + }; } }; @@ -426,16 +474,18 @@ export const buildEditTimeline = (edits: readonly HexDocumentEdit[]): IEditTimel } const split = ranges[i]; - if (edit.op === HexDocumentEditOp.Insert) { + if (edit.op === HexDocumentEditOp.Insert || edit.op === HexDocumentEditOp.EmptyInsert) { const { before, after } = getSplit(editIndex, split, edit.offset - split.offset); ranges.splice( i, 1, before, - { op: EditRangeOp.Insert, editIndex, offset: edit.offset, value: edit.value }, + edit.op === HexDocumentEditOp.Insert + ? { op: EditRangeOp.Insert, editIndex, offset: edit.offset, value: edit.value } + : { op: EditRangeOp.EmptyInsert, editIndex, offset: edit.offset, length: edit.length }, after, ); - shiftAfter(i + 2, edit.value.length); + shiftAfter(i + 2, edit.op === HexDocumentEditOp.Insert ? edit.value.length : edit.length); } else if (edit.op === HexDocumentEditOp.Delete || edit.op === HexDocumentEditOp.Replace) { const { before } = getSplit(editIndex, split, edit.offset - split.offset); let until = searcher(edit.offset + edit.previous.length, ranges); diff --git a/shared/protocol.ts b/shared/protocol.ts index 3be1d03..0cff2c9 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -2,6 +2,7 @@ * Copyright (C) Microsoft Corporation. All rights reserved. *--------------------------------------------------------*/ +import { HexDecorator } from "./decorators"; import { HexDocumentEditOp } from "./hexDocumentModel"; import { ISerializedEdits } from "./serialization"; @@ -80,6 +81,7 @@ export interface ReadyResponseMessage { isReadonly: boolean; isLargeFile: boolean; editMode: HexDocumentEditOp.Insert | HexDocumentEditOp.Replace; + decorators: HexDecorator[]; } export interface SetEditModeMessage { @@ -310,18 +312,21 @@ export class MessageHandler { constructor( public messageHandler: (msg: TFrom) => Promise, - private readonly postMessage: (msg: WebviewMessage) => void, + private readonly postMessage: (msg: WebviewMessage, transfer?: Transferable[]) => void, ) {} /** Sends a request without waiting for a response */ - public sendEvent(body: TTo): void { - this.postMessage({ body, messageId: this.messageIdCounter++ }); + public sendEvent(body: TTo, transfer?: Transferable[]): void { + this.postMessage({ body, messageId: this.messageIdCounter++ }, transfer); } /** Sends a request that expects a response */ - public sendRequest(msg: TTo): Promise { + public sendRequest( + msg: TTo, + transfer?: Transferable[], + ): Promise { const id = this.messageIdCounter++; - this.postMessage({ body: msg, messageId: id }); + this.postMessage({ body: msg, messageId: id }, transfer); return new Promise((resolve, reject) => { this.pendingMessages.set(id, { resolve: resolve as (msg: TFrom) => void, reject }); }); diff --git a/shared/util/myers.ts b/shared/util/myers.ts new file mode 100644 index 0000000..33c6e52 --- /dev/null +++ b/shared/util/myers.ts @@ -0,0 +1,49 @@ +import { ArrayChange, diffArrays } from "diff"; +import { HexDecorator, HexDecoratorType } from "../decorators"; +import { Range } from "./range"; + +/** + * O(d^2) implementation + */ +export class MyersDiff { + public static lcs(original: Uint8Array, modified: Uint8Array) { + // the types in @types/diff are incomplete. + const changes: ArrayChange[] | undefined = diffArrays( + original as any, + modified as any, + ); + return changes; + } + + public static toDecorator(script: ArrayChange[]) { + const out: { + original: HexDecorator[]; + modified: HexDecorator[]; + } = { original: [], modified: [] }; + let offset = 0; + for (const change of script) { + const r = new Range(offset, offset + change.count!); + if (change.removed) { + out.original.push({ + type: HexDecoratorType.Delete, + range: r, + }); + out.modified.push({ + type: HexDecoratorType.Empty, + range: r, + }); + } else if (change.added) { + out.original.push({ + type: HexDecoratorType.Empty, + range: r, + }); + out.modified.push({ + type: HexDecoratorType.Insert, + range: r, + }); + } + offset += change.count!; + } + return out; + } +} diff --git a/shared/util/uri.ts b/shared/util/uri.ts new file mode 100644 index 0000000..49dc2af --- /dev/null +++ b/shared/util/uri.ts @@ -0,0 +1,44 @@ +export interface HexEditorUriQuery { + baseAddress?: string; + token?: string; + side?: "modified" | "original"; +} + +/** + * Utility function to convert a Uri query string into a map + */ +export function parseQuery(queryString: string): HexEditorUriQuery { + const queries: HexEditorUriQuery = {}; + if (queryString) { + const pairs = (queryString[0] === "?" ? queryString.substr(1) : queryString).split("&"); + for (const q of pairs) { + const pair = q.split("="); + const name = pair.shift() as keyof HexEditorUriQuery; + if (name) { + const value = pair.join("="); + if (name === "side") { + if (value === "modified" || value === "original" || value === undefined) { + queries.side = value; + } + } else { + queries[name] = value; + } + } + } + } + return queries; +} + +/** + * Forms a valid HexEditor Query to be used in vscode.Uri + */ +export function formQuery(queries: HexEditorUriQuery): string { + const query: string[] = []; + for (const q in queries) { + const queryValue = queries[q as keyof HexEditorUriQuery]; + if (queryValue !== undefined && queryValue !== "") { + query.push(`${q}=${queryValue}`); + } + } + return query.join("&"); +} diff --git a/src/compareSelected.ts b/src/compareSelected.ts new file mode 100644 index 0000000..1175220 --- /dev/null +++ b/src/compareSelected.ts @@ -0,0 +1,34 @@ +import * as vscode from "vscode"; +import { formQuery, parseQuery } from "../shared/util/uri"; + +const uuidGenerator = () => { + let uuid = 0; + return () => (uuid++).toString(); +}; +const uuid = uuidGenerator(); + +// Initializes our custom editor with diff capabilities +// @see https://github.com/microsoft/vscode/issues/97683 +// @see https://github.com/microsoft/vscode/issues/138525 +export const openCompareSelected = (originalFile: vscode.Uri, modifiedFile: vscode.Uri) => { + const token = uuid(); + const diffOriginalUri = originalFile.with({ + scheme: "hexdiff", + query: formQuery({ + ...parseQuery(originalFile.query), + side: "original", + token: token, + }), + }); + + const diffModifiedUri = modifiedFile.with({ + scheme: "hexdiff", + query: formQuery({ + ...parseQuery(originalFile.query), + side: "modified", + token: token, + }), + }); + + vscode.commands.executeCommand("vscode.diff", diffOriginalUri, diffModifiedUri); +}; diff --git a/src/extension.ts b/src/extension.ts index ca96d7f..717d783 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,11 +4,14 @@ import TelemetryReporter from "@vscode/extension-telemetry"; import * as vscode from "vscode"; import { HexDocumentEditOp } from "../shared/hexDocumentModel"; +import { openCompareSelected } from "./compareSelected"; import { copyAs } from "./copyAs"; import { DataInspectorView } from "./dataInspectorView"; import { showGoToOffset } from "./goToOffset"; +import { HexDiffFSProvider } from "./hexDiffFS"; import { HexEditorProvider } from "./hexEditorProvider"; import { HexEditorRegistry } from "./hexEditorRegistry"; +import { prepareLazyInitDiffWorker } from "./initWorker"; import { showSelectBetweenOffsets } from "./selectBetweenOffsets"; import StatusEditMode from "./statusEditMode"; import StatusFocus from "./statusFocus"; @@ -37,8 +40,12 @@ function reopenWithHexEditor() { } } -export function activate(context: vscode.ExtensionContext): void { - const registry = new HexEditorRegistry(); +export async function activate(context: vscode.ExtensionContext) { + // Prepares the worker to be lazily initialized + const initWorker = prepareLazyInitDiffWorker(context.extensionUri, workerDispose => + context.subscriptions.push(workerDispose), + ); + const registry = new HexEditorRegistry(initWorker); // Register the data inspector as a separate view on the side const dataInspectorProvider = new DataInspectorView(context.extensionUri, registry); const configValues = readConfigFromPackageJson(context.extension); @@ -81,7 +88,6 @@ export function activate(context: vscode.ExtensionContext): void { } }); - const switchEditModeCommand = vscode.commands.registerCommand("hexEditor.switchEditMode", () => { if (registry.activeDocument) { registry.activeDocument.editMode = @@ -109,6 +115,20 @@ export function activate(context: vscode.ExtensionContext): void { } }); + const compareSelectedCommand = vscode.commands.registerCommand( + "hexEditor.compareSelected", + async (...args) => { + if (args.length !== 2 && !(args[1] instanceof Array)) { + return; + } + const [leftFile, rightFile] = args[1]; + if (!(leftFile instanceof vscode.Uri && rightFile instanceof vscode.Uri)) { + return; + } + openCompareSelected(leftFile, rightFile); + }, + ); + context.subscriptions.push(new StatusEditMode(registry)); context.subscriptions.push(new StatusFocus(registry)); context.subscriptions.push(new StatusHoverAndSelection(registry)); @@ -119,6 +139,12 @@ export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push(openWithCommand); context.subscriptions.push(telemetryReporter); context.subscriptions.push(copyOffsetAsDec, copyOffsetAsHex); + context.subscriptions.push(compareSelectedCommand); + context.subscriptions.push( + vscode.workspace.registerFileSystemProvider("hexdiff", new HexDiffFSProvider(), { + isCaseSensitive: typeof process !== 'undefined' && process.platform !== 'win32' && process.platform !== 'darwin', + }), + ); context.subscriptions.push( HexEditorProvider.register(context, telemetryReporter, dataInspectorProvider, registry), ); diff --git a/src/fileSystemAdaptor.ts b/src/fileSystemAdaptor.ts index fdf31cb..12396a6 100644 --- a/src/fileSystemAdaptor.ts +++ b/src/fileSystemAdaptor.ts @@ -24,7 +24,7 @@ export const accessFile = async ( // try to use native file access for local files to allow large files to be handled efficiently // todo@connor4312/lramos: push forward extension host API for this. - if (uri.scheme === "file") { + if (uri.scheme === "file" || uri.scheme === "hexdiff") { try { // eslint-disable @typescript-eslint/no-var-requires const fs = require("fs"); @@ -42,7 +42,8 @@ export const accessFile = async ( : !(fileStats.mode & 0o002); // other if (fileStats.isFile()) { - return new NativeFileAccessor(uri, isReadonly, fs); + // Diff is readonly since the diff is only computed at the beginning once + return new NativeFileAccessor(uri, uri.scheme === "hexdiff" ? true : isReadonly, fs); } } catch { // probably not node.js, or file does not exist diff --git a/src/hexDiffFS.ts b/src/hexDiffFS.ts new file mode 100644 index 0000000..65d0ce4 --- /dev/null +++ b/src/hexDiffFS.ts @@ -0,0 +1,60 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import * as vscode from "vscode"; + +/** changes our scheme to file so we can use workspace.fs */ +function toFileUri(uri: vscode.Uri) { + return uri.with({ scheme: "file" }); +} +// Workaround to open our files in diff mode. Used by both web and node +// to create a diff model, but the methods are only used in web whereas +// in node we use node's fs. +export class HexDiffFSProvider implements vscode.FileSystemProvider { + readDirectory( + uri: vscode.Uri, + ): [string, vscode.FileType][] | Thenable<[string, vscode.FileType][]> { + throw new Error("Method not implemented."); + } + createDirectory(uri: vscode.Uri): void | Thenable { + throw new Error("Method not implemented."); + } + readFile(uri: vscode.Uri): Uint8Array | Thenable { + return vscode.workspace.fs.readFile(toFileUri(uri)); + } + writeFile( + uri: vscode.Uri, + content: Uint8Array, + options: { readonly create: boolean; readonly overwrite: boolean }, + ): void | Thenable { + return vscode.workspace.fs.writeFile(toFileUri(uri), content); + } + + delete(uri: vscode.Uri, options: { readonly recursive: boolean }): void | Thenable { + return vscode.workspace.fs.delete(toFileUri(uri), options); + } + rename( + oldUri: vscode.Uri, + newUri: vscode.Uri, + options: { readonly overwrite: boolean }, + ): void | Thenable { + throw new Error("Method not implemented"); + } + copy?( + source: vscode.Uri, + destination: vscode.Uri, + options: { readonly overwrite: boolean }, + ): void | Thenable { + throw new Error("Method not implemented."); + } + private _emitter = new vscode.EventEmitter(); + onDidChangeFile: vscode.Event = this._emitter.event; + public watch( + uri: vscode.Uri, + options: { readonly recursive: boolean; readonly excludes: readonly string[] }, + ): vscode.Disposable { + return new vscode.Disposable(() => {}); + } + stat(uri: vscode.Uri): vscode.FileStat | Thenable { + return vscode.workspace.fs.stat(toFileUri(uri)); + } +} diff --git a/src/hexDocument.ts b/src/hexDocument.ts index 7402d1e..5fb7341 100644 --- a/src/hexDocument.ts +++ b/src/hexDocument.ts @@ -3,13 +3,16 @@ import TelemetryReporter from "@vscode/extension-telemetry"; import * as vscode from "vscode"; +import { HexDecorator } from "../shared/decorators"; import { FileAccessor } from "../shared/fileAccessor"; +import { HexDiffModel, HexDiffModelBuilder } from "../shared/hexDiffModel"; import { HexDocumentEdit, HexDocumentEditOp, HexDocumentEditReference, HexDocumentModel, } from "../shared/hexDocumentModel"; +import { parseQuery } from "../shared/util/uri"; import { Backup } from "./backup"; import { Disposable } from "./dispose"; import { accessFile } from "./fileSystemAdaptor"; @@ -27,6 +30,7 @@ export class HexDocument extends Disposable implements vscode.CustomDocument { uri: vscode.Uri, { backupId, untitledDocumentData }: vscode.CustomDocumentOpenContext, telemetryReporter: TelemetryReporter, + diffModelBuilder: HexDiffModelBuilder | undefined, ): Promise<{ document: HexDocument; accessor: FileAccessor }> { const accessor = await accessFile(uri, untitledDocumentData); const model = new HexDocumentModel({ @@ -38,9 +42,9 @@ export class HexDocument extends Disposable implements vscode.CustomDocument { : undefined, }); - const queries = HexDocument.parseQuery(uri.query); - const baseAddress: number = queries["baseAddress"] - ? HexDocument.parseHexOrDecInt(queries["baseAddress"]) + const queries = parseQuery(uri.query); + const baseAddress: number = queries.baseAddress + ? HexDocument.parseHexOrDecInt(queries.baseAddress) : 0; const fileSize = await accessor.getSize(); @@ -55,7 +59,13 @@ export class HexDocument extends Disposable implements vscode.CustomDocument { (vscode.workspace.getConfiguration().get("hexeditor.maxFileSize") as number) * 1000000; const isLargeFile = !backupId && !accessor.supportsIncremetalAccess && (fileSize ?? 0) > maxFileSize; - return { document: new HexDocument(model, isLargeFile, baseAddress), accessor }; + + const diffModel = + queries.side && diffModelBuilder + ? await diffModelBuilder.setModel(queries.side, model).build() + : undefined; + + return { document: new HexDocument(model, isLargeFile, baseAddress, diffModel), accessor }; } // Last save time @@ -73,6 +83,7 @@ export class HexDocument extends Disposable implements vscode.CustomDocument { private model: HexDocumentModel, public readonly isLargeFile: boolean, public readonly baseAddress: number, + private diffModel?: HexDiffModel, ) { super(); } @@ -95,7 +106,24 @@ export class HexDocument extends Disposable implements vscode.CustomDocument { public get uri(): vscode.Uri { return vscode.Uri.parse(this.model.uri); } - + + /** + * Reads decorators from models, returning an array of all + * decorators. + */ + public async readDecorators(): Promise { + if (this.diffModel) { + try { + return await this.diffModel.computeDecorators(this.uri); + } catch (e: unknown) { + vscode.window.showErrorMessage( + e instanceof Error ? e.message : vscode.l10n.t("Unknown Error in HexEditor Diff"), + ); + } + } + return []; + } + /** * Reads data including unsaved edits from the model, returning an iterable * of Uint8Array chunks. @@ -336,24 +364,6 @@ export class HexDocument extends Disposable implements vscode.CustomDocument { }; } - /** - * Utility function to convert a Uri query string into a map - */ - private static parseQuery(queryString: string): { [key: string]: string } { - const queries: { [key: string]: string } = {}; - if (queryString) { - const pairs = (queryString[0] === "?" ? queryString.substr(1) : queryString).split("&"); - for (const q of pairs) { - const pair = q.split("="); - const name = pair.shift(); - if (name) { - queries[name] = pair.join("="); - } - } - } - return queries; - } - /** * Utility function to parse a number. Only hex and decimal supported */ diff --git a/src/hexEditorProvider.ts b/src/hexEditorProvider.ts index 8cae05e..46b6c2f 100644 --- a/src/hexEditorProvider.ts +++ b/src/hexEditorProvider.ts @@ -69,13 +69,16 @@ export class HexEditorProvider implements vscode.CustomEditorProvider { + const diff = this._registry.getDiff(uri); + const { document, accessor } = await HexDocument.create( uri, openContext, this._telemetryReporter, + diff.builder, ); const disposables: vscode.Disposable[] = []; - + disposables.push(diff); disposables.push( document.onDidRevert(async () => { const replaceFileSize = (await document.size()) ?? null; @@ -334,6 +337,7 @@ export class HexEditorProvider implements vscode.CustomEditorProvider>(); + private readonly diffsBuilder = new Map< + string, + { refCount: number; value: HexDiffModelBuilder } + >(); private onChangeEmitter = new vscode.EventEmitter(); private _activeDocument?: HexDocument; @@ -32,7 +39,7 @@ export class HexEditorRegistry extends Disposable { return (this._activeDocument && this.docs.get(this._activeDocument)) || EMPTY; } - constructor() { + constructor(private readonly initDiffWorker: () => DiffExtensionHostMessageHandler) { super(); this._register(vscode.window.tabGroups.onDidChangeTabs(this.onChangedTabs, this)); this._register(vscode.window.tabGroups.onDidChangeTabGroups(this.onChangedTabs, this)); @@ -68,6 +75,40 @@ export class HexEditorRegistry extends Disposable { }; } + /** returns a diff model using the file uri */ + public getDiff(uri: vscode.Uri): { + builder: HexDiffModelBuilder | undefined; + dispose: () => void; + } { + const { token } = parseQuery(uri.query); + if (token === undefined) { + return { builder: undefined, dispose: () => {} }; + } + // Lazily initializes the diff worker, if it isn't + // iniitalized already + const messageHandler = this.initDiffWorker(); + + // Creates a new diff model + if (!this.diffsBuilder.has(token)) { + this.diffsBuilder.set(token, { + refCount: 0, + value: new HexDiffModel.Builder(messageHandler), + }); + } + const builder = this.diffsBuilder.get(token)!; + builder.refCount++; + + return { + builder: builder.value, + dispose: () => { + builder.refCount--; + if (builder.refCount === 0) { + this.diffsBuilder.delete(token); + } + }, + }; + } + private onChangedTabs() { const input = vscode.window.tabGroups.activeTabGroup.activeTab?.input; const uri = input instanceof vscode.TabInputCustom ? input.uri : undefined; diff --git a/src/initWorker.ts b/src/initWorker.ts new file mode 100644 index 0000000..ba95876 --- /dev/null +++ b/src/initWorker.ts @@ -0,0 +1,62 @@ +import * as vscode from "vscode"; +import { + DiffExtensionHostMessageHandler, + FromDiffWorkerMessage, + ToDiffWorkerMessage, +} from "../shared/diffWorkerProtocol"; +import { MessageHandler } from "../shared/protocol"; + +/** Prepares diff worker to be lazily initialized and instantiated once*/ +export function prepareLazyInitDiffWorker( + extensionUri: vscode.Uri, + addDispose: (dispose: vscode.Disposable) => void, +) { + let messageHandler: DiffExtensionHostMessageHandler; + return () => { + if (!messageHandler) { + const { msgHandler, dispose } = initDiffWorker(extensionUri); + messageHandler = msgHandler; + addDispose({ dispose: dispose }); + } + return messageHandler; + }; +} + +/** Initializes the diff worker */ +function initDiffWorker(extensionUri: vscode.Uri): { + msgHandler: DiffExtensionHostMessageHandler; + dispose: () => void; +} { + let worker: Worker; + const workerFilePath = vscode.Uri.joinPath(extensionUri, "dist", "diffWorker.js").toString(); + + try { + worker = new Worker(workerFilePath); + } catch { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { Worker } = require("worker_threads") as typeof import("worker_threads"); + const nodeWorker = new Worker(new URL(workerFilePath)); + // Web and node js have different worker interfaces, so we share a function + // to initialize both workers the same way. + const ref = nodeWorker.addListener; + (nodeWorker as any).addEventListener = ref; + worker = nodeWorker as any; + } + + const workerMessageHandler = new MessageHandler( + // Always return undefined as the diff worker + // does not request anything from extension host + async () => undefined, + // worker.postMessage's transfer parameter type looks to be wrong because + // it should be set as optional. + (message, transfer) => worker.postMessage(message, transfer!), + ); + + worker.addEventListener("message", e => + // e.data is used in web worker and e is used in node js worker + e.data + ? workerMessageHandler.handleMessage(e.data) + : workerMessageHandler.handleMessage(e as any), + ); + return { msgHandler: workerMessageHandler, dispose: () => worker.terminate() }; +}