From dce2189cc08c4b3e93eaeda17d19af068900ed74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Silva?= <136352470+tomilho@users.noreply.github.com> Date: Sat, 10 Aug 2024 05:45:03 +0100 Subject: [PATCH] HexEditor diff (#522) * feat: simple decorators Applies custom css styles on data cells by specifying a decorator style and its offset range. * refactor: move parseQuery to its own util file * feat: compareSelected command Opens two selected files into hex diff view. Works with any file extension by changing the file scheme. * synchronizes hex documents creation for diffing * feat: basic Myers diff Introduces the O(d^2) space version and its decorators. With this diff algorithm, the diffs should be more insightful compared to using a simple per-offset comparison. * Adds alignment in original file Inserted bytes are show in the original file by a stripe data cell. * fix: negative k-index and wrong alignment * optimize: use binary search for page decorators Finds the lower and upper bound of the decorators that fit in the page, instead of using .filter(). In addition to that, it also removes the need to re-initialize the decorator's ranges. * Makes diff read-only Done to simplify diff, because recomputing diffs after a file change is too complex to tackle at the moment. * refactor: use diff package for myers diff Although still O(d^2), it improves memory usage in other ways, provides some optimization for edge cases and has some useful built-in options to exit on large diffs. * fix: diff failing to open in web * perf: moved myers diff into a web-only worker * adds node js worker * adds worker transferable objects * Use binary search in data row content * removes web-only code * fix: decorator's binary search gives wrong index * Adds lazy init & better diff lifecycle --- .esbuild.config.js | 13 ++++- media/editor/dataDisplay.tsx | 40 ++++++++++++--- media/editor/state.ts | 66 ++++++++++++++++++++++-- media/editor/util.css | 26 ++++++++++ media/editor/util.ts | 7 +++ package-lock.json | 45 +++++++++++++--- package.json | 23 +++++++++ package.nls.json | 1 + shared/decorators.ts | 10 ++++ shared/diffWorker.ts | 39 ++++++++++++++ shared/diffWorkerProtocol.ts | 31 +++++++++++ shared/hexDiffModel.ts | 99 ++++++++++++++++++++++++++++++++++++ shared/hexDocumentModel.ts | 62 +++++++++++++++++++--- shared/protocol.ts | 15 ++++-- shared/util/myers.ts | 49 ++++++++++++++++++ shared/util/uri.ts | 44 ++++++++++++++++ src/compareSelected.ts | 34 +++++++++++++ src/extension.ts | 32 ++++++++++-- src/fileSystemAdaptor.ts | 5 +- src/hexDiffFS.ts | 60 ++++++++++++++++++++++ src/hexDocument.ts | 56 +++++++++++--------- src/hexEditorProvider.ts | 6 ++- src/hexEditorRegistry.ts | 43 +++++++++++++++- src/initWorker.ts | 62 ++++++++++++++++++++++ 24 files changed, 809 insertions(+), 59 deletions(-) create mode 100644 shared/decorators.ts create mode 100644 shared/diffWorker.ts create mode 100644 shared/diffWorkerProtocol.ts create mode 100644 shared/hexDiffModel.ts create mode 100644 shared/util/myers.ts create mode 100644 shared/util/uri.ts create mode 100644 src/compareSelected.ts create mode 100644 src/hexDiffFS.ts create mode 100644 src/initWorker.ts diff --git a/.esbuild.config.js b/.esbuild.config.js index 7d3a5851..f4d0324f 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 6c3569a4..21188672 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 d2aced1f..bad9444d 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 bb06f714..dbc67e73 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 b26788ab..79d2cea2 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 40aef374..136bc30c 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 78e3c54b..2320fe55 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 76932ba2..6f3b7123 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 00000000..3ee0348f --- /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 00000000..a4e3e1c1 --- /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 00000000..50372f09 --- /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 00000000..b4b40590 --- /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 c0355fd8..22acc460 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 3be1d03b..0cff2c93 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 00000000..33c6e52d --- /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 00000000..49dc2afa --- /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 00000000..1175220a --- /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 ca96d7ff..717d783f 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 fdf31cba..12396a67 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 00000000..65d0ce44 --- /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 7402d1e9..5fb73419 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 8cae05e2..46b6c2f2 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 00000000..ba95876e --- /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() }; +}