├── .editorconfig ├── .esbuild.config.js ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── pr.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── build └── pipeline.yml ├── dist └── inspector.css ├── hex-editor.gif ├── icon.png ├── media ├── data_inspector │ ├── dataInspector.ts │ └── inspector.ts └── editor │ ├── copyPaste.css │ ├── copyPaste.tsx │ ├── css.d.ts │ ├── dataDisplay.css │ ├── dataDisplay.tsx │ ├── dataDisplayContext.css │ ├── dataDisplayContext.tsx │ ├── dataInspector.css │ ├── dataInspector.tsx │ ├── dataInspectorProperties.tsx │ ├── findWidget.css │ ├── findWidget.tsx │ ├── hexEdit.css │ ├── hexEdit.tsx │ ├── hooks.ts │ ├── readonlyWarning.tsx │ ├── scrollContainer.css │ ├── scrollContainer.tsx │ ├── settings.css │ ├── settings.tsx │ ├── state.ts │ ├── strings.ts │ ├── svg.d.ts │ ├── util.css │ ├── util.ts │ ├── virtualScrollContainer.css │ ├── virtualScrollContainer.tsx │ ├── vscodeUi.css │ └── vscodeUi.tsx ├── package-lock.json ├── package.json ├── package.nls.json ├── panel-icon.svg ├── shared ├── decorators.ts ├── diffWorker.ts ├── diffWorkerProtocol.ts ├── fileAccessor.ts ├── hexDiffModel.ts ├── hexDocumentModel.ts ├── protocol.ts ├── serialization.ts ├── strings.ts └── util │ ├── binarySearch.ts │ ├── memoize.ts │ ├── myers.ts │ ├── once.ts │ ├── range.ts │ ├── uint8ArrayMap.ts │ └── uri.ts ├── src ├── backup.ts ├── compareSelected.ts ├── copyAs.ts ├── dataInspectorView.ts ├── dispose.ts ├── extension.ts ├── fileSystemAdaptor.ts ├── goToOffset.ts ├── hexDiffFS.ts ├── hexDocument.ts ├── hexEditorProvider.ts ├── hexEditorRegistry.ts ├── initWorker.ts ├── literalSearch.ts ├── searchProvider.ts ├── searchRequest.ts ├── selectBetweenOffsets.ts ├── statusEditMode.ts ├── statusFocus.ts ├── statusHoverAndSelection.ts ├── test │ ├── backup.test.ts │ ├── hexDocumentModel.test.ts │ ├── index.ts │ ├── literalSearch.test.ts │ ├── range.test.ts │ ├── runTest.js │ ├── searchRequest.test.ts │ └── util.ts └── util.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Tab indentation 5 | [*] 6 | indent_style = tab 7 | trim_trailing_whitespace = true 8 | 9 | # The indent size used in the `package.json` file cannot be changed 10 | [{*.yml,*.yaml,package.json}] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.esbuild.config.js: -------------------------------------------------------------------------------- 1 | const esbuild = require("esbuild"); 2 | const svgr = require("esbuild-plugin-svgr"); 3 | const css = require("esbuild-css-modules-plugin"); 4 | 5 | const watch = process.argv.includes("--watch"); 6 | const minify = !watch || process.argv.includes("--minify"); 7 | const defineProd = process.argv.includes("--defineProd"); 8 | 9 | function build(options) { 10 | (async () => { 11 | if (watch) { 12 | const context = await esbuild.context(options); 13 | await context.watch(); 14 | } else { 15 | await esbuild.build(options); 16 | } 17 | })().catch(() => process.exit(1)); 18 | } 19 | 20 | // Build the editor provider 21 | build({ 22 | entryPoints: ["src/extension.ts"], 23 | tsconfig: "./tsconfig.json", 24 | bundle: true, 25 | external: ["vscode"], 26 | sourcemap: watch, 27 | minify, 28 | platform: "node", 29 | outfile: "dist/extension.js", 30 | }); 31 | 32 | // Build the test cases 33 | build({ 34 | entryPoints: ["src/test/index.ts"], 35 | tsconfig: "./tsconfig.json", 36 | bundle: true, 37 | external: ["vscode", "mocha", "chai"], 38 | sourcemap: watch, 39 | minify, 40 | platform: "node", 41 | outfile: "dist/test.js", 42 | }); 43 | 44 | build({ 45 | entryPoints: ["src/extension.ts"], 46 | tsconfig: "./tsconfig.json", 47 | bundle: true, 48 | format: "cjs", 49 | external: ["vscode", "fs", "worker_threads"], 50 | minify, 51 | platform: "browser", 52 | outfile: "dist/web/extension.js", 53 | }); 54 | 55 | build({ 56 | entryPoints: ["shared/diffWorker.ts"], 57 | tsconfig: "./tsconfig.json", 58 | bundle: true, 59 | format: "cjs", 60 | external: ["vscode", "worker_threads"], 61 | minify, 62 | platform: "browser", 63 | outfile: "dist/diffWorker.js", 64 | }); 65 | 66 | // Build the data inspector 67 | build({ 68 | entryPoints: ["media/data_inspector/inspector.ts"], 69 | tsconfig: "./tsconfig.json", 70 | bundle: true, 71 | external: ["vscode"], 72 | sourcemap: watch ? "inline" : false, 73 | minify, 74 | platform: "browser", 75 | outfile: "dist/inspector.js", 76 | }); 77 | 78 | // Build the webview editors 79 | build({ 80 | entryPoints: ["media/editor/hexEdit.tsx"], 81 | tsconfig: "./tsconfig.json", 82 | bundle: true, 83 | external: ["vscode"], 84 | sourcemap: watch, 85 | minify, 86 | platform: "browser", 87 | outfile: "dist/editor.js", 88 | define: defineProd 89 | ? { 90 | "process.env.NODE_ENV": defineProd ? '"production"' : '"development"', 91 | } 92 | : undefined, 93 | plugins: [svgr(), css({ v2: true, filter: /\.css$/i })], 94 | }); 95 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.d.ts 3 | *.js -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "commonjs": true, 5 | "es6": true, 6 | "jest": true, 7 | "browser": true 8 | }, 9 | "extends": [ 10 | "eslint:recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "globals": { 14 | "Atomics": "readonly", 15 | "SharedArrayBuffer": "readonly" 16 | }, 17 | "parser": "@typescript-eslint/parser", 18 | "parserOptions": { 19 | "ecmaVersion": 2018 20 | }, 21 | "plugins": [ 22 | "@typescript-eslint/eslint-plugin" 23 | ], 24 | "rules": { 25 | "no-console": "off", 26 | "no-var": 1, 27 | "no-case-declarations": 0, 28 | "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "(^_)|(^h$)" }], 29 | "@typescript-eslint/no-explicit-any": 0, 30 | "@typescript-eslint/camelcase": 0, 31 | "@typescript-eslint/no-non-null-assertion": 0, 32 | "object-curly-spacing": [ 33 | 2, 34 | "always" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [20.x] 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | cache: "npm" 22 | - run: npm install 23 | - run: npm run compile --if-present 24 | - run: npm run lint 25 | - run: xvfb-run -a npm run test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | .vscode-test/ 4 | *.vsix 5 | dist 6 | !dist/inspector.css 7 | .DS_Store 8 | dist/web 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "runtimeExecutable": "${execPath}", 13 | "debugWebviews": true, 14 | "rendererDebugOptions": { 15 | "sourceMapPathOverrides": { 16 | // hack-around for https://github.com/evanw/esbuild/issues/2595 17 | "../media/editor/*": "/*" 18 | } 19 | }, 20 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 21 | "outFiles": ["${workspaceFolder}/dist/**/*.js"] 22 | }, 23 | { 24 | "name": "Run Web Extension", 25 | "type": "pwa-extensionHost", 26 | "debugWebWorkerHost": true, 27 | "trace": true, 28 | "request": "launch", 29 | "args": [ 30 | "--extensionDevelopmentPath=${workspaceFolder}", 31 | "--extensionDevelopmentKind=web", 32 | "--crash-reporter-directory=${workspaceFolder}" 33 | ], 34 | "outFiles": ["${workspaceFolder}/dist/web/**/*.js"], 35 | "preLaunchTask": "npm: compile" 36 | }, 37 | { 38 | "name": "Extension Tests", 39 | "type": "extensionHost", 40 | "request": "launch", 41 | "runtimeExecutable": "${execPath}", 42 | "args": [ 43 | "--extensionDevelopmentPath=${workspaceFolder}", 44 | "--extensionTestsPath=${workspaceFolder}/dist/test.js" 45 | ], 46 | "outFiles": ["${workspaceFolder}/dist/**/*.js"] 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "files.exclude": { 5 | "out": false // set this to true to hide the "out" folder with the compiled JS files 6 | }, 7 | "search.exclude": { 8 | "out": true // set this to false to include "out" folder in search results 9 | }, 10 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 11 | "typescript.tsc.autoDetect": "off", 12 | "typescript.tsdk": "node_modules\\typescript\\lib", 13 | "files.eol": "\n", 14 | "typescript.preferences.quoteStyle": "double", 15 | "editor.formatOnSave": true, 16 | "[typescriptreact]": { 17 | "editor.defaultFormatter": "esbenp.prettier-vscode" 18 | }, 19 | "[typescript]": { 20 | "editor.defaultFormatter": "esbenp.prettier-vscode" 21 | }, 22 | "[css]": { 23 | "editor.defaultFormatter": "esbenp.prettier-vscode" 24 | }, 25 | "editor.codeActionsOnSave": { 26 | "source.organizeImports": "explicit" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | node_modules/** 4 | out 5 | media/** 6 | src/** 7 | .gitignore 8 | .github 9 | vsc-extension-quickstart.md 10 | **/tsconfig.json 11 | **/.eslintrc.json 12 | **/*.map 13 | **/*.ts 14 | .esbuild.config.js 15 | .eslintignore 16 | *.gif 17 | build 18 | *.tgz 19 | *.vsix 20 | 21 | # We need the codicon node module included for the fonts + icons to work 22 | !node_modules/@vscode/codicons/dist 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Release Notes 2 | 3 | ## 1.11.1 - November 2, 2024 4 | 5 | - Add a setting to configure the default "Copy As..." format, thanks to [@Antecer via #540](https://github.com/microsoft/vscode-hexeditor/pull/540) 6 | - Fix a display issue causing a blank editor [@Hexa3333 via #548](https://github.com/microsoft/vscode-hexeditor/pull/548) 7 | 8 | ## 1.11.0 - November 1, 2024 9 | 10 | - Fix: ctrl+g scroll state being lost when restoring editor [#545](https://github.com/microsoft/vscode-hexeditor/pull/545) 11 | - Fix: retain selection state when restoring webview [#544](https://github.com/microsoft/vscode-hexeditor/pull/544) 12 | - Fix: not able to edit empty files [#543](https://github.com/microsoft/vscode-hexeditor/pull/543) 13 | - Fix: correctly show big-endian utf-16 chars [#542](https://github.com/microsoft/vscode-hexeditor/pull/542) 14 | - Add an experimental diff mode for the hex editor, thanks to [@tomilho via #522](https://github.com/microsoft/vscode-hexeditor/pull/522) 15 | - Add a "Copy As..." action, thanks to [@lorsanta via #498](https://github.com/microsoft/vscode-hexeditor/pull/498) 16 | - Add a UUID/GUID mode in the data inspector, thanks to [@jogo- via #500](https://github.com/microsoft/vscode-hexeditor/pull/500) 17 | 18 | ## 1.10.0 - April 22, 2024 19 | 20 | - Fix bug in saving of restored editors, thanks to [@tomilho via #513](https://github.com/microsoft/vscode-hexeditor/pull/513) 21 | - Add hovered byte status bar entry, thanks to [@tomilho via #502](https://github.com/microsoft/vscode-hexeditor/pull/502) 22 | - Add insert mode, thanks to [@tomilho via #503](https://github.com/microsoft/vscode-hexeditor/pull/503) 23 | 24 | ## 1.9.14 - February 22, 2024 25 | - Add ULEB128 and SLEB128 support in data inspector, thanks to [@jogo- via #488](https://github.com/microsoft/vscode-hexeditor/pull/488) 26 | - Add display of status offset and selection count in hexadecimal, thanks to [@jogo- via #486](https://github.com/microsoft/vscode-hexeditor/pull/486) 27 | - Add ASCII character in data inspector, thanks to [@jogo- via #483](https://github.com/microsoft/vscode-hexeditor/pull/483) 28 | - Fix order of unsigned before signed int64 in data inspector, thanks to [@jogo- via #482](https://github.com/microsoft/vscode-hexeditor/pull/482) 29 | 30 | ## 1.9.13 - February 2, 2024 31 | - Fix plugin description, thanks to [@deitry via #480](https://github.com/microsoft/vscode-hexeditor/pull/480) 32 | - Fix listener leak when closing files 33 | - Fix close hex editors when corresponding files are deleted 34 | - Fix regex in binary files by using ascii for regex matches 35 | - Fix re-run search if a file is reloaded from disk 36 | - Add Localization to this extension using the Localization pipeline 37 | - Fix slight selection bugs 38 | - Fix improve range selection logic, support delete 39 | - Add select between offsets feature, thanks to [@IngilizAdam via #470](https://github.com/microsoft/vscode-hexeditor/pull/470) 40 | - Add common cjk encoding support in data inspector, thanks to [@liudonghua123 via #465](https://github.com/microsoft/vscode-hexeditor/pull/465) 41 | - Fix dispose all disposables in openCustomDocument, thanks to [@lorsanta via #453](https://github.com/microsoft/vscode-hexeditor/pull/453) 42 | - Add float16 and bfloat16 support in data inspector, thanks to [@lorsanta via #451](https://github.com/microsoft/vscode-hexeditor/pull/451) 43 | 44 | ## 1.9.12 - July 27, 2023 45 | - Fix the selection count now updated when switching between tab groups, thanks to [@lorsanta via #449](https://github.com/microsoft/vscode-hexeditor/pull/449) 46 | - Fix scrolling to the top when hit home key, thanks to [@lorsanta via #448](https://github.com/microsoft/vscode-hexeditor/pull/448) 47 | - Fix editor failing to open read-only files, thanks to [@tomilho via #437](https://github.com/microsoft/vscode-hexeditor/pull/437) 48 | 49 | ## 1.9.11 - January 25, 2023 50 | - Octal representation of the selected byte in the data inspector, thanks to [@brabli via #410](https://github.com/microsoft/vscode-hexeditor/pull/410) 51 | 52 | ## 1.9.10 - January 4, 2023 53 | - Add a badge indicating offset and selection size, thanks to [@MoralCode via #401](https://github.com/microsoft/vscode-hexeditor/pull/401) 54 | - Used a smaller page size when requesting debug memory 55 | - Fixed many selection bugs 56 | - Improved scroll/display performance 57 | - Made a change to respect `editor.scrollBeyondLastLine` 58 | - Aligned loading indicator style to match the rest of VS Code 59 | 60 | ## 1.9.9 - October 6, 2022 61 | - Fixed a bug where the custom and native selection could be shown at the same time in the main hex view 62 | - Binary type added to data inspector, thanks to [@jwr12135 via #370](https://github.com/microsoft/vscode-hexeditor/pull/370) 63 | 64 | ## 1.9.8 - July 28, 2022 65 | - Fixed bug causing binary search to be incorrect, thanks to [@varblane via #367](https://github.com/microsoft/vscode-hexeditor/pull/367) 66 | - Open active file in Hex Editor now works with non-text editors 67 | 68 | ## 1.9.7 - June 15, 2022 69 | - Fixed bug causing bytes at page boundaries to be incorrect 70 | - Fixed data overlapping in the data inspector 71 | 72 | ## 1.9.6 - April 21, 2022 73 | - Fixed go to offset not working correctly 74 | - Changed default decoding of decoded text to ASCII 75 | 76 | ## 1.9.5 - February 18, 2022 77 | - Data inspector location is now configurable via the `hexeditor.inspectorType` setting. 78 | 79 | ## 1.9.4 - January 27, 2022 80 | - Fixed bug with copy and paste not working 81 | 82 | ## 1.9.3 - January 13, 2022 83 | - Files of any size can now be opened without issue (when operating locally) 84 | - Find menu has been improved and aligns better with the VS Code UI 85 | - Layout columns and decoded text views are now configurable 86 | - Support viewing and editing memory of programs debugged in VS Code 87 | 88 | ## 1.8.2 - July 27, 2021 89 | - Fix web compatibility due to incorrect bundle format 90 | 91 | ## 1.8.1 - July 26, 2021 92 | - Even smaller bundle size 93 | - Upgrade telemetry module for transparent telemetry logging in output channel 94 | 95 | ## 1.8.0 - July 22, 2021 96 | - Fix bug preventing opening of large files 97 | - Switch from webpack -> esbuild 98 | - Reduce bundle size 99 | - Fix file watcher on non-file path files 100 | 101 | ## 1.7.1 - June 18, 2021 102 | - Fix bug preventing search from working 103 | 104 | ## 1.7.0 - June 4, 2021 105 | - Support virtual workspaces 106 | - Support untrusted workspaces 107 | - Fixed invalid content security policy preventing codicon loading 108 | - Updated to latest node modules 109 | 110 | ## 1.6.0 - April 28, 2021 111 | - Improved find widget UI 112 | - Fix scaling issues with larger font sizes 113 | - Adoption of workspace trust API 114 | - Fixed bug regarding place holder characters, thanks to [@whpac via #282](https://github.com/microsoft/vscode-hexeditor/pull/282) 115 | 116 | ## 1.5.0 - April 5, 2021 117 | - Better trackpad scrolling 118 | - New hex editor panel icon 119 | - Tidying up of data inspector UI 120 | - Additional setting to define default endianness, thanks to [@natecraddock via #215](https://github.com/microsoft/vscode-hexeditor/pull/215) 121 | 122 | ## 1.4.0 - February 4, 2021 123 | - Move data inspector to its own hex panel 124 | - Restyle search to look more like the normal VS Code widget 125 | - Add preliminary support for untitled files 126 | - Fixed a bug with selections not updating the data inspector 127 | 128 | ## 1.3.0 - September 8, 2020 129 | - Allow extensions to configure the starting address for a file. See https://github.com/microsoft/vscode-hexeditor/pull/170 for details. 130 | 131 | ## 1.2.0 - July 23, 2020 132 | - Simple File Watching implementation, editor will now respond to changes on disk outside of editor 133 | - Support for copy and paste 134 | - Support for Find with text regex, and hex wildcards (i.e FF ?? EE) 135 | - Support for multi select, along with drag, drop, and keyboard selection improvements thank to [@jeanp413 via #92](https://github.com/microsoft/vscode-hexeditor/pull/92) for helping with that 136 | - Fixed a bug with num pad not working inside the hex editor 137 | - Fixed a bug with incorrect UTF-8 decoding inside the data inspector 138 | 139 | ## 1.1.0 - June 30, 2020 140 | - Added simple editing support for hex and decoded text 141 | - Fixed a bug preventing files over 18MB from being opened 142 | - Added more keyboard navigation support via PgUp, PgDown, Ctrl + End/Home, and End/Home. 143 | - Fixed a bug with empty files not rendering correctly 144 | - Scroll position is now retained upon switching tabs 145 | 146 | ## 1.0.1 - June 11, 2020 147 | - Add instructions to the README on how to use the extension 148 | - Add an Open with HexEditor command 149 | 150 | ## 1.0.0 - June 8, 2020 151 | - Hex Editor initial release 152 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | This project has adopted the Microsoft Open Source Code of Conduct. For more information see the Code of Conduct FAQ or contact opencode@microsoft.com with any additional questions or comments. -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Visual Studio Code Hex Editor 2 | There are many ways to contribute to the Visual Studio Code Hex Editor project: logging bugs, submitting pull requests, reporting issues, and creating suggestions. 3 | 4 | After cloning and building the repo, check out the [issues list](https://github.com/microsoft/vscode-hexeditor/issues?q=is%3Aissue+is%3Aopen+). 5 | 6 | 7 | ### Getting the sources 8 | 9 | First, fork the VS Code Hex Editor repository so that you can make a pull request. Then, clone your fork locally: 10 | 11 | ``` 12 | git clone https://github.com/<<>>/vscode-hexeditor.git 13 | ``` 14 | 15 | Occasionally you will want to merge changes in the upstream repository (the official code repo) with your fork. 16 | 17 | ``` 18 | cd vscode-hexeditor 19 | git checkout main 20 | git pull https://github.com/microsoft/vscode-hexeditor.git main 21 | ``` 22 | 23 | Manage any merge conflicts, commit them, and then push them to your fork. 24 | 25 | ## Prerequisites 26 | 27 | In order to download necessary tools, clone the repository, and install dependencies through npm, you need network access. 28 | 29 | You'll need the following tools: 30 | 31 | - [Git](https://git-scm.com) 32 | - [Node.JS](https://nodejs.org/en/), **x64**, version `>= 12.x` 33 | 34 | ``` 35 | cd vscode-hexeditor 36 | npm install 37 | ``` 38 | 39 | ## Build and Run 40 | 41 | After cloning the extension and running `npm install` execute `npm run watch` to initiate esbuild's file watcher and then use the debugger in VS Code to execute "Run Extension". 42 | 43 | ### Linting 44 | We use [eslint](https://eslint.org/) for linting our sources. You can run eslint across the sources by calling `npm run lint` from a terminal or command prompt. 45 | To lint the source as you make changes you can install the [eslint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint). 46 | 47 | ## Work Branches 48 | Even if you have push rights on the Microsoft/vscode-hexeditor repository, you should create a personal fork and create feature branches there when you need them. This keeps the main repository clean and your personal workflow cruft out of sight. 49 | 50 | ## Pull Requests 51 | Before we can accept a pull request from you, you'll need to sign a [Contributor License Agreement (CLA)](https://cla.opensource.microsoft.com/microsoft/vscode-hexeditor). It is an automated process and you only need to do it once. 52 | 53 | To enable us to quickly review and accept your pull requests, always create one pull request per issue and [link the issue in the pull request](https://github.com/blog/957-introducing-issue-mentions). Never merge multiple requests in one unless they have the same root cause. Be sure to keep code changes as small as possible. Avoid pure formatting changes to code that has not been modified otherwise. Pull requests should contain tests whenever possible. 54 | 55 | ## Suggestions 56 | We're also interested in your feedback for the future of the hex editor. You can submit a suggestion or feature request through the issue tracker. To make this process more effective, we're asking that these include more information to help define them more clearly. 57 | 58 | ## Discussion Etiquette 59 | 60 | In order to keep the conversation clear and transparent, please limit discussion to English and keep things on topic with the issue. Be considerate to others and try to be courteous and professional at all times. 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Microsoft Corporation. 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A custom editor extension for Visual Studio Code which provides a hex editor for viewing and manipulating files in their raw hexadecimal representation. 2 | 3 | ## Features 4 | 5 | - Opening files as hex 6 | - A data inspector for viewing the hex values as various different data types 7 | - Editing with undo, redo, copy, and paste support 8 | - Find and replace 9 | 10 | ![User opens a text file named release.txt and switches to the hex editor via command palette. The user then navigates and edits the document](https://raw.githubusercontent.com/microsoft/vscode-hexeditor/main/hex-editor.gif) 11 | 12 | ## How to Use 13 | 14 | There are three ways to open a file in the hex editor: 15 | 16 | 1. Right click a file -> Open With -> Hex Editor 17 | 2. Trigger the command palette (F1) -> Open File using Hex Editor 18 | 3. Trigger the command palette (F1) -> Reopen With -> Hex Editor 19 | 20 | The hex editor can be set as the default editor for certain file types by using the `workbench.editorAssociations` setting. For example, this would associate all files with extensions `.hex` and `.ini` to use the hex editor by default: 21 | 22 | ```json 23 | "workbench.editorAssociations": { 24 | "*.hex": "hexEditor.hexedit", 25 | "*.ini": "hexEditor.hexedit" 26 | }, 27 | ``` 28 | 29 | ## Configuring the Data Inspector 30 | 31 | By default, the data inspector is shown just to the right of the data grid (or decoded text if enabled), but it can be configured (via the `hexeditor.inspectorType` setting) to instead show up while hovering over a data cell. 32 | 33 | Another option is to give the data inspector a dedicated activity bar entry on the left (by setting `hexeditor.inspectorType` to `sidebar`) that appears when the hex editor is opened, causing the explorer or whatever sidebar you had opened to be hidden. If preferred, the hex editor view can be dragged into another view by dragging the ⬡ icon onto one of the other views. This can be used in combination with the `hexeditor.dataInspector.autoReveal` setting to avoid revealing the sidebar containing the data inspector altogether. 34 | 35 | ## Known Issues 36 | 37 | To track existing issues or report a new one, please visit the GitHub Issues page at https://github.com/microsoft/vscode-hexeditor/issues 38 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). 40 | 41 | -------------------------------------------------------------------------------- /build/pipeline.yml: -------------------------------------------------------------------------------- 1 | name: $(Date:yyyyMMdd)$(Rev:.r) 2 | 3 | trigger: 4 | branches: 5 | include: 6 | - main 7 | 8 | pr: none 9 | 10 | resources: 11 | repositories: 12 | - repository: templates 13 | type: github 14 | name: microsoft/vscode-engineering 15 | ref: main 16 | endpoint: Monaco 17 | 18 | parameters: 19 | - name: publishExtension 20 | displayName: 🚀 Publish Extension 21 | type: boolean 22 | default: false 23 | 24 | extends: 25 | template: azure-pipelines/extension/stable.yml@templates 26 | parameters: 27 | l10nSourcePaths: ./src 28 | buildSteps: 29 | - script: npm ci 30 | displayName: Install dependencies 31 | 32 | - script: npm run compile 33 | displayName: Compile 34 | 35 | - script: npm run lint 36 | displayName: Run ESLint 37 | - script: xvfb-run -a npm test 38 | displayName: Run Tests 39 | 40 | tsa: 41 | config: 42 | areaPath: "Visual Studio Code Miscellaneous Extensions" 43 | serviceTreeID: "c8cb03c6-176e-40dd-90a5-518de08666dc" 44 | enabled: true 45 | 46 | publishExtension: ${{ parameters.publishExtension }} 47 | -------------------------------------------------------------------------------- /dist/inspector.css: -------------------------------------------------------------------------------- 1 | input { 2 | border-radius: 0; 3 | padding-left: 4px; 4 | padding-right: 4px; 5 | font-family: var(--vscode-editor-font-family); 6 | background-color: var(--vscode-input-background); 7 | border-width: 0; 8 | width: 100%; 9 | color: var(--vscode-input-foreground); 10 | } 11 | 12 | :focus { 13 | outline-color: var(--vscode-focusBorder) !important; 14 | } 15 | 16 | .endian-select { 17 | padding-top: 10px; 18 | } 19 | 20 | body { 21 | font-family: var(--vscode-font-family); 22 | font-weight: var(--vscode-editor-font-weight); 23 | } 24 | 25 | .grid-container { 26 | position: sticky; 27 | top: 20px; 28 | display: grid; 29 | grid-template-columns: 0fr 1fr; 30 | padding-top: 3px; 31 | align-items: center; 32 | } 33 | 34 | .grid-item { 35 | text-align: left; 36 | white-space: nowrap; 37 | } 38 | 39 | .grid-item:nth-child(2n) { 40 | padding-left: 15px; 41 | min-width: 100px; /* Prevent scroll bar unless really small */ 42 | 43 | /* Prevent special characters shifting the input around */ 44 | line-height: 1; 45 | vertical-align: middle; 46 | } 47 | 48 | select { 49 | background-color: var(--vscode-dropdown-background); 50 | border: var(--vscode-dropdown-border); 51 | color: var(--vscode-dropdown-foreground); 52 | min-width: 100px; 53 | } 54 | -------------------------------------------------------------------------------- /hex-editor.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vscode-hexeditor/7d97ac4003059cdefc8f533a6ad7381fe8ae0435/hex-editor.gif -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vscode-hexeditor/7d97ac4003059cdefc8f533a6ad7381fe8ae0435/icon.png -------------------------------------------------------------------------------- /media/data_inspector/dataInspector.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | import { inspectableTypes } from "../editor/dataInspectorProperties"; 5 | 6 | /** 7 | * Gets (building if necessary) the input elements for each inspectable 8 | * type, in order. 9 | */ 10 | const getInputElements = (() => { 11 | let inputs: HTMLInputElement[] | undefined; 12 | 13 | return () => { 14 | if (inputs) { 15 | return inputs; 16 | } 17 | 18 | const container = document.querySelector("#data-inspector .grid-container") as HTMLElement; 19 | const existingChild = container.firstElementChild; 20 | inputs = []; 21 | 22 | for (const { label } of inspectableTypes) { 23 | const labelGridItem = document.createElement("div"); 24 | labelGridItem.className = "grid-item"; 25 | const labelEl = labelGridItem.appendChild(document.createElement("label")); 26 | labelEl.htmlFor = `inspect-${label}`; 27 | labelEl.textContent = label; 28 | 29 | const inputGridItem = document.createElement("div"); 30 | inputGridItem.className = "grid-item"; 31 | const inputEl = inputGridItem.appendChild(document.createElement("input")); 32 | inputEl.id = `inspect-${label}`; 33 | inputEl.type = "text"; 34 | inputEl.disabled = true; 35 | inputEl.readOnly = true; 36 | inputEl.autocomplete = "off"; 37 | inputEl.spellcheck = false; 38 | 39 | container.insertBefore(labelGridItem, existingChild); 40 | container.insertBefore(inputGridItem, existingChild); 41 | inputs.push(inputEl); 42 | } 43 | 44 | return inputs; 45 | }; 46 | })(); 47 | 48 | /** 49 | * @description Builds input elemenets and labels for the data inspector. 50 | */ 51 | export const buildDataInspectorUi = () => { 52 | getInputElements(); 53 | }; 54 | 55 | /** 56 | * @description Clears the data inspector back to its default state 57 | */ 58 | export function clearDataInspector(): void { 59 | for (const element of getInputElements()) { 60 | element.disabled = true; 61 | element.value = ""; 62 | } 63 | } 64 | 65 | /** 66 | * @description Giving an ArrayBuffer object and what endianness, populates the data inspector 67 | * @param {ByteData} arrayBuffer The ArrayBuffer object to represent on the data inspector 68 | * @param {boolean} littleEndian Wether the data inspector is in littleEndian or bigEndian mode 69 | */ 70 | export function populateDataInspector(arrayBuffer: ArrayBuffer, littleEndian: boolean): void { 71 | const dv = new DataView(arrayBuffer); 72 | const inputElements = getInputElements(); 73 | for (let i = 0; i < inputElements.length; i++) { 74 | const element = inputElements[i]; 75 | const { convert, minBytes } = inspectableTypes[i]; 76 | if (dv.byteLength < minBytes) { 77 | element.disabled = true; 78 | element.value = "End of File"; 79 | } else { 80 | element.disabled = false; 81 | element.value = convert(dv, littleEndian); 82 | } 83 | } 84 | } 85 | 86 | // This is bound to the on change event for the select which decides to render big or little endian 87 | /** 88 | * @description Handles when the user changes the dropdown for whether they want little or big endianness 89 | * @param {ByteData} arrayBuffer The ArrayBuffer object to represent on the data inspector 90 | */ 91 | export function changeEndianness(arrayBuffer: ArrayBuffer): void { 92 | const littleEndian = 93 | (document.getElementById("endianness") as HTMLInputElement).value === "little"; 94 | populateDataInspector(arrayBuffer, littleEndian); 95 | } 96 | -------------------------------------------------------------------------------- /media/data_inspector/inspector.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | import { 5 | buildDataInspectorUi, 6 | changeEndianness, 7 | clearDataInspector, 8 | populateDataInspector, 9 | } from "./dataInspector"; 10 | 11 | declare const acquireVsCodeApi: any; 12 | export const vscode = acquireVsCodeApi(); 13 | let currentByteData: ArrayBuffer; 14 | 15 | // Self executing anonymous function 16 | // This is the main entry point 17 | ((): void => { 18 | buildDataInspectorUi(); 19 | 20 | // Handle messages which are sent to the inspector 21 | window.addEventListener("message", async e => { 22 | switch (e.data.method) { 23 | case "update": 24 | currentByteData = e.data.data; 25 | populateDataInspector( 26 | currentByteData, 27 | (document.getElementById("endianness") as HTMLSelectElement).value === "little", 28 | ); 29 | return; 30 | case "clear": 31 | clearDataInspector(); 32 | return; 33 | } 34 | }); 35 | 36 | // Signal to VS Code that the webview is initialized. 37 | // On the inspector side we currently don't do anything with this message 38 | vscode.postMessage({ type: "ready" }); 39 | })(); 40 | 41 | // Bind an event listener to detect when the user changes the endinaness 42 | document 43 | .getElementById("endianness") 44 | ?.addEventListener("change", () => changeEndianness(currentByteData)); 45 | -------------------------------------------------------------------------------- /media/editor/copyPaste.css: -------------------------------------------------------------------------------- 1 | .radio-list { 2 | display: flex; 3 | margin-bottom: 12px; 4 | } 5 | 6 | .radio-list > * { 7 | margin-right: 8px; 8 | } 9 | 10 | .radio-container { 11 | display: flex; 12 | align-items: center; 13 | } 14 | 15 | .button-wrap { 16 | display: flex; 17 | width: 100%; 18 | justify-content: center; 19 | } 20 | -------------------------------------------------------------------------------- /media/editor/copyPaste.tsx: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license 3 | 4 | import * as base64 from "js-base64"; 5 | import React, { useCallback, useMemo, useState } from "react"; 6 | import { MessageType, PasteMode } from "../../shared/protocol"; 7 | import _style from "./copyPaste.css"; 8 | import { useUniqueId } from "./hooks"; 9 | import { messageHandler } from "./state"; 10 | import { strings } from "./strings"; 11 | import { throwOnUndefinedAccessInDev } from "./util"; 12 | import { VsButton, VsWidgetPopover } from "./vscodeUi"; 13 | 14 | const style = throwOnUndefinedAccessInDev(_style); 15 | 16 | const enum Encoding { 17 | Base64 = "base64", 18 | Utf8 = "utf-8", 19 | Hex = "hex", 20 | } 21 | 22 | const encodings = [Encoding.Utf8, Encoding.Base64, Encoding.Hex]; 23 | 24 | const encodingLabel: { [key in Encoding]: string } = { 25 | [Encoding.Base64]: "Base64", 26 | [Encoding.Utf8]: "UTF-8", 27 | [Encoding.Hex]: "Hex", 28 | }; 29 | 30 | const isData: { [key in Encoding]: (data: string) => boolean } = { 31 | [Encoding.Base64]: d => base64.isValid(d), 32 | [Encoding.Utf8]: () => true, 33 | [Encoding.Hex]: () => true, 34 | }; 35 | 36 | const decode: { [key in Encoding]: (data: string) => Uint8Array } = { 37 | [Encoding.Base64]: d => base64.toUint8Array(d), 38 | [Encoding.Utf8]: d => new TextEncoder().encode(d), 39 | [Encoding.Hex]: d => Uint8Array.from(d.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16))), 40 | }; 41 | 42 | const EncodingOption: React.FC<{ 43 | value: Encoding; 44 | enabled: boolean; 45 | checked: boolean; 46 | onChecked: (encoding: Encoding) => void; 47 | }> = ({ value, enabled, checked, onChecked }) => { 48 | const id = useUniqueId(); 49 | return ( 50 |
51 | { 59 | if (evt.target.checked) { 60 | onChecked(value); 61 | } 62 | }} 63 | /> 64 | 65 |
66 | ); 67 | }; 68 | 69 | const InsertionOption: React.FC<{ 70 | value: PasteMode; 71 | label: string; 72 | checked: boolean; 73 | onChecked: (encoding: PasteMode) => void; 74 | }> = ({ value, label, checked, onChecked }) => { 75 | const id = useUniqueId(); 76 | return ( 77 |
78 | { 85 | if (evt.target.checked) { 86 | onChecked(value); 87 | } 88 | }} 89 | /> 90 | 91 |
92 | ); 93 | }; 94 | 95 | export const PastePopup: React.FC<{ 96 | context?: { target: HTMLElement; data: string; offset: number }; 97 | hide: () => void; 98 | }> = ({ context, hide }) => { 99 | const [encoding, setEncoding] = useState(Encoding.Utf8); 100 | const [mode, setMode] = useState(PasteMode.Replace); 101 | const decoded: Uint8Array | Error = useMemo(() => { 102 | try { 103 | return context ? decode[encoding](context.data) : new Uint8Array(); 104 | } catch (e) { 105 | return e as Error; 106 | } 107 | }, [context, encoding]); 108 | 109 | const decodedValid = decoded instanceof Uint8Array; 110 | 111 | const doReplace = useCallback(() => { 112 | if (decoded instanceof Uint8Array && context) { 113 | messageHandler.sendEvent({ 114 | type: MessageType.DoPaste, 115 | data: decoded, 116 | mode, 117 | offset: context.offset, 118 | }); 119 | hide(); 120 | } 121 | }, [decoded, mode, hide, context?.offset]); 122 | 123 | return ( 124 | 125 |
126 | {strings.pasteAs}: 127 | {encodings.map(e => ( 128 | 135 | ))} 136 |
137 |
138 | {strings.pasteMode}: 139 | 145 | 151 |
152 |
153 | 154 | {decodedValid ? ( 155 | <> 156 | {mode === PasteMode.Replace ? strings.replace : strings.insert} {decoded.length}{" "} 157 | {strings.bytes} 158 | 159 | ) : ( 160 | strings.encodingError 161 | )} 162 | 163 |
164 |
165 | ); 166 | }; 167 | -------------------------------------------------------------------------------- /media/editor/css.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.css" { 2 | const classMap: Record; 3 | export default classMap; 4 | } 5 | -------------------------------------------------------------------------------- /media/editor/dataDisplay.css: -------------------------------------------------------------------------------- 1 | .header { 2 | font-weight: bold; 3 | color: var(--vscode-editorLineNumber-activeForeground); 4 | white-space: nowrap; 5 | display: flex; 6 | align-items: center; 7 | } 8 | 9 | .address { 10 | font-family: var(--vscode-editor-font-family); 11 | color: var(--vscode-editorLineNumber-foreground); 12 | text-transform: uppercase; 13 | line-height: var(--cell-size); 14 | } 15 | 16 | .data-cell-group { 17 | padding: 0 calc(var(--cell-size) / 4); 18 | display: inline-flex; 19 | cursor: default; 20 | user-select: text; 21 | } 22 | 23 | .non-graphic-char { 24 | color: var(--vscode-tab-unfocusedInactiveForeground); 25 | } 26 | 27 | .data-cell-hovered { 28 | background: var(--vscode-editor-hoverHighlightBackground); 29 | } 30 | 31 | .data-cell-replace { 32 | outline-offset: 1px; 33 | outline: var(--vscode-editorCursor-foreground) 2px solid; 34 | z-index: 100; 35 | } 36 | 37 | .data-cell-append { 38 | color: var(--vscode-button-secondaryForeground); 39 | background-color: var(--vscode-button-secondaryBackground); 40 | cursor: pointer; 41 | } 42 | 43 | .data-cell-insert-middle, 44 | .data-cell-insert-before { 45 | position: relative; 46 | outline: 2px solid transparent; 47 | } 48 | 49 | @keyframes blink { 50 | to { 51 | visibility: hidden; 52 | } 53 | } 54 | 55 | .data-cell-insert-before::before, 56 | .data-cell-insert-middle::before { 57 | content: ""; 58 | position: absolute; 59 | top: -2px; 60 | bottom: -2px; 61 | width: 2px; 62 | background-color: var(--vscode-editorCursor-foreground); 63 | animation: blink 1s steps(5, start) infinite; 64 | } 65 | 66 | @media (prefers-reduced-motion) { 67 | .data-cell-insert-before::before, 68 | .data-cell-insert-middle::before { 69 | animation: none; 70 | } 71 | } 72 | 73 | .data-cell-insert-before::before { 74 | left: -1px; 75 | } 76 | 77 | .data-cell-insert-middle::before { 78 | left: 50%; 79 | } 80 | 81 | .data-cell-selected { 82 | background: var(--vscode-editor-selectionBackground); 83 | color: var(--vscode-editor-selectionForeground); 84 | } 85 | 86 | .data-cell-selected-hovered { 87 | background: var(--vscode-editor-inactiveSelectionBackground); 88 | color: inherit; 89 | } 90 | 91 | .data-cell-unsaved { 92 | background: var(--vscode-minimapGutter-modifiedBackground); 93 | } 94 | 95 | .data-display { 96 | position: sticky; 97 | inset: 0; 98 | height: 0px; 99 | } 100 | 101 | .data-inspector-wrap { 102 | position: absolute; 103 | top: var(--cell-size); 104 | font-weight: normal; 105 | z-index: 2; 106 | line-height: var(--cell-size); 107 | left: calc(var(--cell-size) / 4); 108 | right: var(--scrollbar-width); 109 | overflow: hidden; 110 | } 111 | 112 | .data-inspector-wrap dl { 113 | gap: 0 0.4rem !important; 114 | } 115 | 116 | .data-page { 117 | position: absolute; 118 | left: 0; 119 | top: 0; 120 | } 121 | 122 | .data-row { 123 | position: absolute; 124 | left: 0; 125 | top: 0; 126 | display: flex; 127 | } 128 | 129 | .data-cell-char { 130 | width: calc(var(--cell-size) * 0.7) !important; 131 | } 132 | -------------------------------------------------------------------------------- /media/editor/dataDisplayContext.css: -------------------------------------------------------------------------------- 1 | .data-cell { 2 | font-family: var(--vscode-editor-font-family); 3 | width: var(--cell-size); 4 | height: var(--cell-size); 5 | line-height: var(--cell-size); 6 | text-align: center; 7 | display: inline-block; 8 | user-select: none; 9 | } -------------------------------------------------------------------------------- /media/editor/dataInspector.css: -------------------------------------------------------------------------------- 1 | .types { 2 | display: grid; 3 | gap: 0.3rem 1rem; 4 | align-items: center; 5 | margin: 0; 6 | max-width: calc(100vw - 30px); 7 | } 8 | 9 | .types dd { 10 | 11 | font-family: var(--vscode-editor-font-family); 12 | user-select: auto; 13 | } 14 | 15 | .types dd, .types dl { 16 | margin: 0; 17 | } 18 | 19 | .endianness-toggle-container { 20 | display: flex; 21 | align-items: center; 22 | } 23 | 24 | .endianness-toggle-container input { 25 | margin: 0 0.3rem 0 0; 26 | } 27 | -------------------------------------------------------------------------------- /media/editor/dataInspector.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense, useEffect, useMemo, useState } from "react"; 2 | import { useRecoilValue } from "recoil"; 3 | import { Endianness } from "../../shared/protocol"; 4 | import { FocusedElement, getDataCellElement, useDisplayContext } from "./dataDisplayContext"; 5 | import _style from "./dataInspector.css"; 6 | import { inspectableTypes } from "./dataInspectorProperties"; 7 | import { useFileBytes, usePersistedState } from "./hooks"; 8 | import * as select from "./state"; 9 | import { strings } from "./strings"; 10 | import { throwOnUndefinedAccessInDev } from "./util"; 11 | import { VsTooltipPopover } from "./vscodeUi"; 12 | 13 | const style = throwOnUndefinedAccessInDev(_style); 14 | 15 | /** Component that shows a data inspector when bytes are hovered. */ 16 | export const DataInspectorHover: React.FC = () => { 17 | const ctx = useDisplayContext(); 18 | const [inspected, setInspected] = useState(); 19 | const anchor = useMemo(() => inspected && getDataCellElement(inspected), [inspected]); 20 | 21 | useEffect(() => { 22 | let hoverTimeout: NodeJS.Timeout | undefined; 23 | 24 | const disposable = ctx.onDidHover(target => { 25 | if (hoverTimeout) { 26 | clearTimeout(hoverTimeout); 27 | hoverTimeout = undefined; 28 | } 29 | if (target && ctx.isSelecting === undefined) { 30 | setInspected(undefined); 31 | hoverTimeout = setTimeout(() => setInspected(target), 500); 32 | } 33 | }); 34 | 35 | return () => disposable.dispose(); 36 | }, []); 37 | 38 | if (!inspected || !anchor) { 39 | return null; 40 | } 41 | 42 | return ( 43 | setInspected(undefined)} visible={true}> 44 | 45 | 46 | 47 | 48 | ); 49 | }; 50 | 51 | /** Data inspector view shown to the right hand side of the hex editor. */ 52 | export const DataInspectorAside: React.FC<{ onInspecting?(isInspecting: boolean): void }> = ({ 53 | onInspecting, 54 | }) => { 55 | const ctx = useDisplayContext(); 56 | const [inspected, setInspected] = useState(ctx.focusedElement); 57 | 58 | useEffect(() => { 59 | const disposable = ctx.onDidFocus(focused => { 60 | if (!inspected) { 61 | onInspecting?.(true); 62 | } 63 | if (focused) { 64 | setInspected(focused); 65 | } 66 | }); 67 | return () => disposable.dispose(); 68 | }, []); 69 | 70 | if (!inspected) { 71 | return null; 72 | } 73 | 74 | return ( 75 | 76 | 77 | 78 | ); 79 | }; 80 | 81 | const lookahead = 8; 82 | 83 | /** Inner contents of the data inspector, reused between the hover and aside inspector views. */ 84 | const InspectorContents: React.FC<{ 85 | offset: number; 86 | columns: number; 87 | }> = ({ offset, columns }) => { 88 | const defaultEndianness = useRecoilValue(select.editorSettings).defaultEndianness; 89 | const [endianness, setEndianness] = usePersistedState("endianness", defaultEndianness); 90 | const target = useFileBytes(offset, lookahead); 91 | const dv = new DataView(target.buffer); 92 | const le = endianness === Endianness.Little; 93 | 94 | return ( 95 | <> 96 |
97 | {inspectableTypes.map(({ label, convert, minBytes }) => ( 98 | 99 |
{label}
100 |
101 | {target.length < minBytes ? ( 102 | End of File 103 | ) : ( 104 | convert(dv, le) 105 | )} 106 |
107 |
108 | ))} 109 |
110 | 111 | 112 | ); 113 | }; 114 | 115 | /** Controlled checkbox that toggles between little and big endian. */ 116 | const EndiannessToggle: React.FC<{ 117 | endianness: Endianness; 118 | setEndianness: (e: Endianness) => void; 119 | }> = ({ endianness, setEndianness }) => ( 120 |
121 | setEndianness(evt.target.checked ? Endianness.Little : Endianness.Big)} 126 | /> 127 | 128 |
129 | ); 130 | -------------------------------------------------------------------------------- /media/editor/dataInspectorProperties.tsx: -------------------------------------------------------------------------------- 1 | /** Reads a GUID/UUID at offset 0 from the buffer. (RFC 4122) */ 2 | const getGUID = (arrayBuffer: ArrayBuffer, le: boolean) => { 3 | const buf = new Uint8Array(arrayBuffer); 4 | 5 | const indices = le 6 | ? [3, 2, 1, 0, 5, 4, 7, 6, 8, 9, 10, 11, 12, 13, 14, 15] 7 | : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; 8 | const parts = indices.map(index => buf[index].toString(16).padStart(2, "0").toUpperCase()); 9 | const guid = `{${parts[0]}${parts[1]}${parts[2]}${parts[3]}-${parts[4]}${parts[5]}-${parts[6]}${parts[7]}-${parts[8]}${parts[9]}-${parts[10]}${parts[11]}${parts[12]}${parts[13]}${parts[14]}${parts[15]}}`; 10 | 11 | return guid; 12 | }; 13 | 14 | /** Reads a ULEB128 at offset 0 from the buffer. */ 15 | const getULEB128 = (arrayBuffer: ArrayBuffer) => { 16 | const buf = new Uint8Array(arrayBuffer); 17 | 18 | let result = 0n; 19 | let shift = 0n; 20 | let index = 0; 21 | while (true) { 22 | if (shift > 128n || index >= buf.length) { 23 | return ""; 24 | } 25 | const byte: bigint = BigInt(buf[index++]); 26 | result |= (byte & 0x7fn) << shift; 27 | if ((0x80n & byte) === 0n) { 28 | return result; 29 | } 30 | shift += 7n; 31 | } 32 | }; 33 | 34 | /** Reads a SLEB128 at offset 0 from the buffer. */ 35 | const getSLEB128 = (arrayBuffer: ArrayBuffer) => { 36 | const buf = new Uint8Array(arrayBuffer); 37 | 38 | let result = 0n; 39 | let shift = 0n; 40 | let index = 0; 41 | while (true) { 42 | if (shift > 128n || index >= buf.length) { 43 | return ""; 44 | } 45 | const byte: bigint = BigInt(buf[index++]); 46 | result |= (byte & 0x7fn) << shift; 47 | shift += 7n; 48 | if ((0x80n & byte) === 0n) { 49 | if (shift < 128n && (byte & 0x40n) !== 0n) { 50 | result |= ~0n << shift; 51 | return result; 52 | } 53 | return result; 54 | } 55 | } 56 | }; 57 | 58 | /** Reads a uint24 at offset 0 from the buffer. */ 59 | const getUint24 = (arrayBuffer: ArrayBuffer, le: boolean) => { 60 | const buf = new Uint8Array(arrayBuffer); 61 | return le ? buf[0] | (buf[1] << 8) | (buf[2] << 16) : (buf[0] << 16) | (buf[1] << 8) | buf[2]; 62 | }; 63 | 64 | const getFloat16 = (exponentWidth: number, significandPrecision: number) => { 65 | const exponentMask = (2 ** exponentWidth - 1) << significandPrecision; 66 | const fractionMask = 2 ** significandPrecision - 1; 67 | 68 | const exponentBias = 2 ** (exponentWidth - 1) - 1; 69 | const exponentMin = 1 - exponentBias; 70 | 71 | return (arrayBuffer: ArrayBuffer, le: boolean) => { 72 | const buf = new Uint8Array(arrayBuffer); 73 | const uint16 = le ? buf[0] | (buf[1] << 8) : (buf[0] << 8) | buf[1]; 74 | 75 | const e = (uint16 & exponentMask) >> significandPrecision; 76 | const f = uint16 & fractionMask; 77 | const sign = uint16 >> 15 ? -1 : 1; 78 | 79 | if (e === 0) { 80 | return sign * 2 ** exponentMin * (f / 2 ** significandPrecision); 81 | } else if (e === 2 ** exponentWidth - 1) { 82 | return f ? NaN : sign * Infinity; 83 | } 84 | 85 | return sign * 2 ** (e - exponentBias) * (1 + f / 2 ** significandPrecision); 86 | }; 87 | }; 88 | 89 | export interface IInspectableType { 90 | /** Readable label for the type */ 91 | label: string; 92 | /** Minimum number of bytes needed to accurate disable this type */ 93 | minBytes: number; 94 | /** Shows the representation of the type from the data view */ 95 | convert(dv: DataView, littleEndian: boolean): string; 96 | } 97 | 98 | const inspectTypesBuilder: IInspectableType[] = [ 99 | { label: "binary", minBytes: 1, convert: dv => dv.getUint8(0).toString(2).padStart(8, "0") }, 100 | 101 | { label: "octal", minBytes: 1, convert: dv => dv.getUint8(0).toString(8).padStart(3, "0") }, 102 | 103 | { label: "uint8", minBytes: 1, convert: dv => dv.getUint8(0).toString() }, 104 | { label: "int8", minBytes: 1, convert: dv => dv.getInt8(0).toString() }, 105 | 106 | { label: "uint16", minBytes: 2, convert: (dv, le) => dv.getUint16(0, le).toString() }, 107 | { label: "int16", minBytes: 2, convert: (dv, le) => dv.getInt16(0, le).toString() }, 108 | 109 | { label: "uint24", minBytes: 3, convert: (dv, le) => getUint24(dv.buffer, le).toString() }, 110 | { 111 | label: "int24", 112 | minBytes: 3, 113 | convert: (dv, le) => { 114 | const uint = getUint24(dv.buffer, le); 115 | const isNegative = !!(uint & 0x800000); 116 | return String(isNegative ? -(0xffffff - uint + 1) : uint); 117 | }, 118 | }, 119 | 120 | { label: "uint32", minBytes: 4, convert: (dv, le) => dv.getUint32(0, le).toString() }, 121 | { label: "int32", minBytes: 4, convert: (dv, le) => dv.getInt32(0, le).toString() }, 122 | 123 | { label: "uint64", minBytes: 8, convert: (dv, le) => dv.getBigUint64(0, le).toString() }, 124 | { label: "int64", minBytes: 8, convert: (dv, le) => dv.getBigInt64(0, le).toString() }, 125 | 126 | { label: "ULEB128", minBytes: 1, convert: dv => getULEB128(dv.buffer).toString() }, 127 | { label: "SLEB128", minBytes: 1, convert: dv => getSLEB128(dv.buffer).toString() }, 128 | 129 | { 130 | label: "float16", 131 | minBytes: 2, 132 | convert: (dv, le) => getFloat16(5, 10)(dv.buffer, le).toString(), 133 | }, 134 | { 135 | label: "bfloat16", 136 | minBytes: 2, 137 | convert: (dv, le) => getFloat16(8, 7)(dv.buffer, le).toString(), 138 | }, 139 | 140 | { label: "float32", minBytes: 4, convert: (dv, le) => dv.getFloat32(0, le).toString() }, 141 | { label: "float64", minBytes: 8, convert: (dv, le) => dv.getFloat64(0, le).toString() }, 142 | 143 | { label: "GUID", minBytes: 16, convert: (dv, le) => getGUID(dv.buffer, le) }, 144 | ]; 145 | 146 | const addTextDecoder = (encoding: string, minBytes: number, bigEndianAlt?: string) => { 147 | try { 148 | new TextDecoder(encoding); // throws if encoding is now supported 149 | } catch { 150 | return; 151 | } 152 | 153 | if (bigEndianAlt) { 154 | try { 155 | new TextDecoder(bigEndianAlt); // throws if encoding is now supported 156 | } catch { 157 | bigEndianAlt = undefined; 158 | } 159 | } 160 | 161 | inspectTypesBuilder.push({ 162 | label: encoding.toUpperCase(), 163 | minBytes, 164 | convert: (dv, le) => { 165 | const utf8 = new TextDecoder(!le && bigEndianAlt ? bigEndianAlt : encoding).decode(dv.buffer); 166 | for (const char of utf8) return char; 167 | return utf8; 168 | }, 169 | }); 170 | }; 171 | 172 | addTextDecoder("ascii", 1); 173 | addTextDecoder("utf-8", 1); 174 | addTextDecoder("utf-16", 2, "utf-16be"); 175 | addTextDecoder("gb18030", 2); 176 | addTextDecoder("big5", 2); 177 | addTextDecoder("iso-2022-kr", 2); 178 | addTextDecoder("shift-jis", 2); 179 | 180 | export const inspectableTypes: readonly IInspectableType[] = inspectTypesBuilder; 181 | -------------------------------------------------------------------------------- /media/editor/findWidget.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | position: absolute; 3 | top: 0; 4 | right: 28px; 5 | width: 50%; 6 | max-width: 420px; 7 | transform: translateY(-100%); 8 | transition: transform 300ms; 9 | border: 1px solid var(--vscode-contrastBorder); 10 | color: var(--vscode-editorWidget-foreground); 11 | background: var(--vscode-editorWidget-background); 12 | padding: 2px; 13 | z-index: 1; 14 | display: flex; 15 | } 16 | 17 | .input-row { 18 | display: flex; 19 | align-items: center; 20 | justify-content: start; 21 | margin: 2px 0; 22 | } 23 | 24 | .result-badge { 25 | margin: 0 0 0 3px; 26 | padding: 2px 0 0 2px; 27 | min-width: 69px; 28 | font-size: 0.9em; 29 | white-space: nowrap; 30 | } 31 | 32 | .result-badge a { 33 | cursor: pointer; 34 | } 35 | 36 | .visible { 37 | transform: translateY(0); 38 | box-shadow: 0 0 8px 2px var(--vscode-widget-shadow); 39 | } 40 | 41 | .replace-toggle { 42 | align-self: stretch; 43 | height: initial !important; 44 | margin: 1px !important; 45 | padding: 0 !important; 46 | } 47 | 48 | .controls-container { 49 | width: 0; 50 | flex-grow: 1; 51 | } 52 | 53 | .text-field { 54 | flex-grow: 1; 55 | } 56 | -------------------------------------------------------------------------------- /media/editor/hexEdit.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | width: 100vw; 5 | height: 100vh; 6 | } 7 | 8 | :global(body) { 9 | margin: 0; 10 | padding: 0; 11 | font-size: var(--vscode-editor-font-size); 12 | } 13 | 14 | :global(html) { 15 | padding: 0; 16 | overflow: hidden; 17 | } 18 | -------------------------------------------------------------------------------- /media/editor/hexEdit.tsx: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | import React, { Suspense, useLayoutEffect, useMemo } from "react"; 5 | import { render } from "react-dom"; 6 | import { RecoilRoot, useRecoilState, useRecoilValue, useSetRecoilState } from "recoil"; 7 | import { InspectorLocation } from "../../shared/protocol"; 8 | import { DataHeader } from "./dataDisplay"; 9 | import { DataDisplayContext, DisplayContext } from "./dataDisplayContext"; 10 | import { DataInspectorHover } from "./dataInspector"; 11 | import { FindWidget } from "./findWidget"; 12 | import _style from "./hexEdit.css"; 13 | import { useTheme } from "./hooks"; 14 | import { ReadonlyWarning } from "./readonlyWarning"; 15 | import { ScrollContainer } from "./scrollContainer"; 16 | import { SettingsGear } from "./settings"; 17 | import * as select from "./state"; 18 | import { strings } from "./strings"; 19 | import { throwOnUndefinedAccessInDev } from "./util"; 20 | import { VsProgressIndicator } from "./vscodeUi"; 21 | 22 | const style = throwOnUndefinedAccessInDev(_style); 23 | 24 | const Root: React.FC = () => { 25 | const setDimensions = useSetRecoilState(select.dimensions); 26 | const theme = useTheme(); 27 | 28 | useLayoutEffect(() => { 29 | const applyDimensions = () => 30 | setDimensions({ 31 | width: window.innerWidth, 32 | height: window.innerHeight, 33 | rowPxHeight: parseInt(theme["editor-font-size"]) + 8, 34 | }); 35 | 36 | window.addEventListener("resize", applyDimensions); 37 | applyDimensions(); 38 | return () => window.removeEventListener("resize", applyDimensions); 39 | }, [theme]); 40 | 41 | return ( 42 | }> 43 | 44 | 45 | ); 46 | }; 47 | 48 | const Editor: React.FC = () => { 49 | const dimensions = useRecoilValue(select.dimensions); 50 | const setEdit = useSetRecoilState(select.edits); 51 | const isReadonly = useRecoilValue(select.isReadonly); 52 | const inspectorLocation = useRecoilValue(select.dataInspectorLocation); 53 | const ctx = useMemo(() => new DisplayContext(setEdit, isReadonly), []); 54 | 55 | const isLargeFile = useRecoilValue(select.isLargeFile); 56 | const [bypassLargeFilePrompt, setBypassLargeFile] = useRecoilState(select.bypassLargeFilePrompt); 57 | 58 | if (isLargeFile && !bypassLargeFilePrompt) { 59 | return ( 60 |
61 |

62 | {strings.openLargeFileWarning}{" "} 63 | setBypassLargeFile(true)}> 64 | {strings.openAnyways} 65 | 66 |

67 |
68 | ); 69 | } 70 | 71 | return ( 72 | 73 |
77 | 78 | 79 | 80 | 81 | 82 | {inspectorLocation === InspectorLocation.Hover && } 83 |
84 |
85 | ); 86 | }; 87 | 88 | render( 89 | 90 | 91 | , 92 | document.body, 93 | ); 94 | -------------------------------------------------------------------------------- /media/editor/hooks.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------- 2 | * Copyright (C) Microsoft Corporation. All rights reserved. 3 | *--------------------------------------------------------*/ 4 | 5 | import React, { 6 | DependencyList, 7 | useEffect, 8 | useLayoutEffect, 9 | useMemo, 10 | useRef, 11 | useState, 12 | } from "react"; 13 | import { RecoilValue, useRecoilValue, useRecoilValueLoadable } from "recoil"; 14 | import { ColorMap, observeColors, parseColors } from "vscode-webview-tools"; 15 | import * as select from "./state"; 16 | 17 | export const useTheme = (): ColorMap => { 18 | const [colors, setColors] = useState(parseColors()); 19 | useEffect(() => observeColors(setColors), []); 20 | return colors; 21 | }; 22 | 23 | /** 24 | * Like useEffect, but only runs when its inputs change, not on the first render. 25 | */ 26 | export const useLazyEffect = ( 27 | fn: () => void | (() => void), 28 | inputs: React.DependencyList, 29 | ): void => { 30 | const isFirst = useRef(true); 31 | useEffect(() => { 32 | if (!isFirst.current) { 33 | return fn(); 34 | } 35 | 36 | isFirst.current = false; 37 | }, inputs); 38 | }; 39 | 40 | /** 41 | * Like useState, but also persists changes to the VS Code webview API. 42 | */ 43 | export const usePersistedState = ( 44 | key: string, 45 | defaultValue: T, 46 | ): [T, React.Dispatch>] => { 47 | const [value, setValue] = useState(select.getWebviewState(key, defaultValue)); 48 | 49 | useLazyEffect(() => { 50 | select.setWebviewState(key, value); 51 | }, [value]); 52 | 53 | return [value, setValue]; 54 | }; 55 | 56 | /** 57 | * An effect-priority hook that invokes the function when the value changes. 58 | */ 59 | export const useOnChange = (value: T, fn: (value: T, previous: T) => void): void => { 60 | const previous = useRef(value); 61 | useEffect(() => { 62 | if (value !== previous.current) { 63 | fn(value, previous.current); 64 | previous.current = value; 65 | } 66 | }, [value]); 67 | }; 68 | 69 | let idCounter = 0; 70 | 71 | /** Creates a unique ID for use in the DOM */ 72 | export const useUniqueId = (prefix = "uniqueid-"): string => 73 | useMemo(() => `${prefix}${idCounter++}`, [prefix]); 74 | 75 | const zeroRect: DOMRectReadOnly = new DOMRect(); 76 | 77 | /** Uses the measured DOM size of the element, watching for resizes. */ 78 | export const useSize = (target: React.RefObject): DOMRectReadOnly => { 79 | const [size, setSize] = useState(zeroRect); 80 | 81 | const observer = useMemo( 82 | () => 83 | new ResizeObserver(entry => { 84 | if (entry.length) { 85 | setSize(entry[0].target.getBoundingClientRect()); 86 | } 87 | }), 88 | [], 89 | ); 90 | 91 | useLayoutEffect(() => { 92 | if (!target.current) { 93 | return; 94 | } 95 | 96 | const el = target.current; 97 | setSize(el.getBoundingClientRect()); 98 | observer.observe(el); 99 | return () => observer.unobserve(el); 100 | }, [target.current]); 101 | 102 | return size; 103 | }; 104 | 105 | export const useLastAsyncRecoilValue = (value: RecoilValue): [value: T, isStale: boolean] => { 106 | const loadable = useRecoilValueLoadable(value); 107 | const lastValue = useRef<{ value: T; key: string; isStale: boolean }>(); 108 | switch (loadable.state) { 109 | case "hasValue": 110 | lastValue.current = { value: loadable.contents, isStale: false, key: value.key }; 111 | break; 112 | case "loading": 113 | if (lastValue.current?.key !== value.key) { 114 | throw loadable.contents; // throwing a promise will trigger 115 | } else { 116 | lastValue.current.isStale = true; 117 | } 118 | break; 119 | case "hasError": 120 | throw loadable.contents; 121 | default: 122 | throw new Error(`Unknown loadable state ${JSON.stringify(loadable)}`); 123 | } 124 | 125 | return [lastValue.current.value, lastValue.current.isStale]; 126 | }; 127 | 128 | export const useGlobalHandler = ( 129 | name: string, 130 | handler: (evt: T) => void, 131 | deps: DependencyList = [], 132 | ) => { 133 | useEffect(() => { 134 | const l = (evt: Event) => handler(evt as unknown as T); 135 | window.addEventListener(name, l); 136 | return () => window.removeEventListener(name, l); 137 | }, deps); 138 | }; 139 | 140 | /** 141 | * Hook that returns up to "count" bytes at the offset in the file. 142 | * @param offset The byte offset in the file 143 | * @param count The number of bytes to read 144 | * @param useLastAsync Whether to use stale bytes if new ones are being edited 145 | * in, as opposed to allowing the component to Suspend. 146 | */ 147 | export const useFileBytes = (offset: number, count: number, useLastAsync = false) => { 148 | const dataPageSize = useRecoilValue(select.dataPageSize); 149 | if (count > dataPageSize) { 150 | throw new Error("Cannot useFileBytes() with a count larger than the page size"); 151 | } 152 | 153 | // We have to select both the 'start' and 'end' page since the data might 154 | // span across multiple. (We enforce the count is never larger than a page 155 | // size, so 2 is all we need.) 156 | const startPageNo = Math.floor(offset / dataPageSize); 157 | const startPageStartsAt = startPageNo * dataPageSize; 158 | const endPageNo = Math.floor((offset + count) / dataPageSize); 159 | const endPageStartsAt = endPageNo * dataPageSize; 160 | 161 | const startPageSelector = select.editedDataPages(startPageNo); 162 | const endPageSelector = select.editedDataPages(endPageNo); 163 | 164 | const startPage = useLastAsync 165 | ? useLastAsyncRecoilValue(startPageSelector)[0] 166 | : useRecoilValue(startPageSelector); 167 | const endPage = useLastAsync 168 | ? useLastAsyncRecoilValue(endPageSelector)[0] 169 | : useRecoilValue(endPageSelector); 170 | const target = useMemo(() => new Uint8Array(count), [count]); 171 | 172 | for (let i = 0; i < count; i++) { 173 | const value = 174 | offset + i >= endPageStartsAt 175 | ? endPage[offset + i - endPageStartsAt] 176 | : startPage[offset + i - startPageStartsAt]; 177 | if (value === undefined) { 178 | return target.subarray(0, i); 179 | } 180 | 181 | target[i] = value; 182 | } 183 | 184 | return target; 185 | }; 186 | -------------------------------------------------------------------------------- /media/editor/readonlyWarning.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect } from "react"; 2 | import { useRecoilState } from "recoil"; 3 | import * as select from "./state"; 4 | import { strings } from "./strings"; 5 | import { VsTooltipPopover } from "./vscodeUi"; 6 | 7 | export const ReadonlyWarning: React.FC = () => { 8 | const [anchor, setAnchor] = useRecoilState(select.showReadonlyWarningForEl); 9 | const hide = useCallback(() => setAnchor(null), []); 10 | 11 | useEffect(() => { 12 | if (!anchor) { 13 | return; 14 | } 15 | 16 | const timeout = setTimeout(() => setAnchor(null), 3000000); 17 | return () => clearTimeout(timeout); 18 | }); 19 | 20 | return ( 21 | 22 | {strings.readonlyWarning} 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /media/editor/scrollContainer.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | flex-grow: 1; 3 | flex-basis: 0; 4 | } 5 | -------------------------------------------------------------------------------- /media/editor/scrollContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useRef, useState } from "react"; 2 | import { useRecoilState, useRecoilValue } from "recoil"; 3 | import { Range } from "../../shared/util/range"; 4 | import { DataDisplay } from "./dataDisplay"; 5 | import _style from "./scrollContainer.css"; 6 | import * as select from "./state"; 7 | import { throwOnUndefinedAccessInDev } from "./util"; 8 | import { VirtualScrollContainer } from "./virtualScrollContainer"; 9 | 10 | const style = throwOnUndefinedAccessInDev(_style); 11 | 12 | /** 13 | * "Overscroll" of data that the hex editor will try to load. For example, if 14 | * this is set to 2, then two additional window heights of data will be loaded 15 | * before and after the currently displayed data. 16 | */ 17 | const loadThreshold = 0.5; 18 | 19 | export const ScrollContainer: React.FC = () => { 20 | const dimension = useRecoilValue(select.dimensions); 21 | const columnWidth = useRecoilValue(select.columnWidth); 22 | const fileSize = useRecoilValue(select.fileSize); 23 | const { scrollBeyondLastLine } = useRecoilValue(select.codeSettings); 24 | const [bounds, setBounds] = useRecoilState(select.scrollBounds); 25 | const [offset, setOffset] = useRecoilState(select.offset); 26 | const previousOffset = useRef(); 27 | 28 | const [scrollTop, setScrollTop] = useState(0); 29 | 30 | const expandBoundsToContain = useCallback( 31 | (newOffset: number) => { 32 | const windowSize = select.getDisplayedBytes(dimension, columnWidth); 33 | 34 | // Expand the scroll bounds if the new position is too close to the 35 | // start or end of the selection, based on the loadThreshold. 36 | setBounds(old => { 37 | if (newOffset - old.start < windowSize * loadThreshold && old.start > 0) { 38 | return new Range(Math.max(0, old.start - windowSize), old.end); 39 | } else if (old.end - newOffset < windowSize * (1 + loadThreshold)) { 40 | return new Range(old.start, Math.min(fileSize ?? Infinity, old.end + windowSize)); 41 | } else { 42 | return old; 43 | } 44 | }); 45 | }, 46 | [dimension, columnWidth, fileSize], 47 | ); 48 | 49 | useEffect(() => { 50 | if (previousOffset.current === offset) { 51 | return; 52 | } 53 | 54 | expandBoundsToContain(offset); 55 | setScrollTop(dimension.rowPxHeight * (offset / columnWidth)); 56 | }, [offset]); 57 | 58 | // If scrolling slowly, an individual scroll event might not be able to move 59 | // to a new offset. This stores the "unused" scroll amount. 60 | const accumulatedScroll = useRef(0); 61 | 62 | const onScroll = useCallback( 63 | (scrollTop: number) => { 64 | // On scroll, figure out the offset displayed at the new position. 65 | scrollTop += accumulatedScroll.current; 66 | const rowNumber = Math.floor(scrollTop / dimension.rowPxHeight); 67 | accumulatedScroll.current = scrollTop - rowNumber * dimension.rowPxHeight; 68 | const newOffset = rowNumber * columnWidth; 69 | const newScrollTop = rowNumber * dimension.rowPxHeight; 70 | previousOffset.current = newOffset; 71 | setOffset(newOffset); 72 | expandBoundsToContain(newOffset); 73 | setScrollTop(newScrollTop); 74 | }, 75 | [dimension, columnWidth, expandBoundsToContain], 76 | ); 77 | 78 | const extraScroll = scrollBeyondLastLine ? dimension.height / 2 : 0; 79 | 80 | return ( 81 | 88 | 89 | 90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /media/editor/settings.css: -------------------------------------------------------------------------------- 1 | .gear { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | } 6 | 7 | .content { 8 | display: grid; 9 | grid-template-columns: 1fr auto; 10 | grid-gap: 0.5em; 11 | align-items: center; 12 | } 13 | -------------------------------------------------------------------------------- /media/editor/settings.tsx: -------------------------------------------------------------------------------- 1 | import SettingsGearIcon from "@vscode/codicons/src/icons/settings-gear.svg"; 2 | import React, { useState } from "react"; 3 | import { useRecoilState } from "recoil"; 4 | import _style from "./settings.css"; 5 | import * as select from "./state"; 6 | import { strings } from "./strings"; 7 | import { throwOnUndefinedAccessInDev } from "./util"; 8 | import { VsIconButton, VsTextField, VsWidgetPopover } from "./vscodeUi"; 9 | 10 | const style = throwOnUndefinedAccessInDev(_style); 11 | 12 | export const SettingsGear: React.FC = () => { 13 | const [isOpen, setIsOpen] = useState(false); 14 | const [anchor, setAnchor] = useState(null); 15 | 16 | return ( 17 | <> 18 | setIsOpen(!isOpen)} 22 | ref={setAnchor} 23 | > 24 | 25 | 26 | setIsOpen(false)} visible={isOpen}> 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | const SettingsContent: React.FC = () => ( 34 |
35 | 36 | 37 |
38 | ); 39 | 40 | const TextCheckbox: React.FC = () => { 41 | const [settings, updateSettings] = useRecoilState(select.editorSettings); 42 | 43 | return ( 44 | <> 45 | 46 | updateSettings(s => ({ ...s, showDecodedText: evt.target.checked }))} 51 | /> 52 | 53 | ); 54 | }; 55 | 56 | const ColumnWidth: React.FC = () => { 57 | const [settings, updateSettings] = useRecoilState(select.editorSettings); 58 | 59 | const updateColumnWidth = (evt: React.ChangeEvent) => { 60 | updateSettings(s => { 61 | const colWidth = isNaN(evt.target.valueAsNumber) ? 1 : Math.max(evt.target.valueAsNumber, 1); 62 | const newSetting = { ...s, columnWidth: Math.min(colWidth, 32) }; 63 | return newSetting; 64 | }); 65 | }; 66 | 67 | return ( 68 | <> 69 | 70 | 79 | 80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /media/editor/strings.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------- 2 | * Copyright (C) Microsoft Corporation. All rights reserved. 3 | *--------------------------------------------------------*/ 4 | 5 | import { ILocalizedStrings } from "../../shared/strings"; 6 | 7 | declare const LOC_STRINGS: ILocalizedStrings; 8 | 9 | export const strings = LOC_STRINGS; 10 | -------------------------------------------------------------------------------- /media/editor/svg.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | import React from "react"; 3 | const Component: React.ComponentType>; 4 | export = Component; 5 | } 6 | -------------------------------------------------------------------------------- /media/editor/util.css: -------------------------------------------------------------------------------- 1 | .scrollbar { 2 | position: absolute; 3 | visibility: hidden; 4 | overflow: scroll; 5 | width: 100px; 6 | height: 100px; 7 | } 8 | 9 | /* Decorators */ 10 | 11 | .diff-insert { 12 | background-color: rgba(156, 204, 44, 0.4); 13 | filter: opacity(100%); 14 | } 15 | 16 | .diff-delete { 17 | background-color: rgba(255, 0, 0, 0.4); 18 | } 19 | 20 | .diff-empty { 21 | background-image: linear-gradient( 22 | -45deg, 23 | var(--vscode-diffEditor-diagonalFill) 12.5%, 24 | #0000 12.5%, 25 | #0000 50%, 26 | var(--vscode-diffEditor-diagonalFill) 50%, 27 | var(--vscode-diffEditor-diagonalFill) 62.5%, 28 | #0000 62.5%, 29 | #0000 100% 30 | ); 31 | background-size: 8px 8px; 32 | color: transparent !important; 33 | } 34 | -------------------------------------------------------------------------------- /media/editor/util.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | // Assorted helper functions 5 | 6 | import { HexDecoratorType } from "../../shared/decorators"; 7 | import { Range } from "../../shared/util/range"; 8 | import _style from "./util.css"; 9 | 10 | /** 11 | * Wraps the object in another object that throws when accessing undefined properties. 12 | */ 13 | export const throwOnUndefinedAccessInDev = (value: T): T => { 14 | if (process.env.NODE_ENV === "production") { 15 | return value; // check that react does too, and esbuild defines 16 | } 17 | 18 | return new Proxy(value, { 19 | get: (target, prop) => { 20 | if (prop in target) { 21 | return (target as any)[prop]; 22 | } 23 | 24 | throw new Error(`Accessing undefined property ${String(prop)}`); 25 | }, 26 | }); 27 | }; 28 | 29 | const style = throwOnUndefinedAccessInDev(_style); 30 | 31 | export const isMac = navigator.userAgent.indexOf("Mac OS X") >= 0; 32 | 33 | /** 34 | * Returns truthy classes passed in as parameters joined into a class string. 35 | */ 36 | export const clsx = (...classes: (string | false | undefined | null)[]): string | undefined => { 37 | let out: undefined | string; 38 | for (const cls of classes) { 39 | if (cls) { 40 | out = out ? `${out} ${cls}` : cls; 41 | } 42 | } 43 | 44 | return out; 45 | }; 46 | 47 | /** 48 | * @description Checks if the given number is in any of the ranges 49 | * @param {number} num The number to use when checking the ranges 50 | * @param {Range[]} ranges The ranges to check the number against 51 | * @returns {boolean} True if the number is in any of the ranges, false otherwise 52 | */ 53 | export function withinAnyRange(num: number, ranges: Range[]): boolean { 54 | for (const range of ranges) { 55 | if (range.includes(num)) { 56 | return true; 57 | } 58 | } 59 | return false; 60 | } 61 | 62 | /** 63 | * @description Creates a list of ranges containing the non renderable 8 bit char codes 64 | * @returns {Range[]} The ranges which represent the non renderable 8 bit char codes 65 | */ 66 | export function generateCharacterRanges(): Range[] { 67 | const ranges: Range[] = []; 68 | ranges.push(new Range(0, 32)); 69 | ranges.push(new Range(127)); 70 | return ranges; 71 | } 72 | 73 | const nonPrintableAsciiRange = generateCharacterRanges(); 74 | 75 | /** 76 | * Gets the ascii character for the byte, if it's printable. 77 | * @returns 78 | */ 79 | export const getAsciiCharacter = (byte: number): string | undefined => { 80 | if (withinAnyRange(byte, nonPrintableAsciiRange)) { 81 | return undefined; 82 | } else { 83 | return String.fromCharCode(byte); 84 | } 85 | }; 86 | 87 | /** 88 | * Returns `x` clamped between the provided lower and upper bounds. 89 | */ 90 | export const clamp = (lower: number, x: number, upper: number): number => 91 | Math.max(lower, Math.min(upper, x)); 92 | 93 | /** 94 | * Parses the string as hex. Non-hex characters will be treated as 0. 95 | */ 96 | export const hexDecode = (str: string): Uint8Array => { 97 | const value = new Uint8Array(Math.ceil(str.length / 2)); 98 | for (let i = 0; i < str.length; i += 2) { 99 | value[i >>> 1] = ((parseHexDigit(str[i]) || 0) << 4) | (parseHexDigit(str[i + 1]) || 0); 100 | } 101 | 102 | return value; 103 | }; 104 | 105 | export const isHexString = (s: string): boolean => { 106 | for (const char of s) { 107 | if (parseHexDigit(char) === undefined) { 108 | return false; 109 | } 110 | } 111 | 112 | return true; 113 | }; 114 | 115 | export const parseHexDigit = (s: string): number | undefined => { 116 | switch (s) { 117 | case "0": 118 | return 0; 119 | case "1": 120 | return 1; 121 | case "2": 122 | return 2; 123 | case "3": 124 | return 3; 125 | case "4": 126 | return 4; 127 | case "5": 128 | return 5; 129 | case "6": 130 | return 6; 131 | case "7": 132 | return 7; 133 | case "8": 134 | return 8; 135 | case "9": 136 | return 9; 137 | case "a": 138 | return 10; 139 | case "A": 140 | return 10; 141 | case "b": 142 | return 11; 143 | case "B": 144 | return 11; 145 | case "c": 146 | return 12; 147 | case "C": 148 | return 12; 149 | case "d": 150 | return 13; 151 | case "D": 152 | return 13; 153 | case "e": 154 | return 14; 155 | case "E": 156 | return 14; 157 | case "f": 158 | return 15; 159 | case "F": 160 | return 15; 161 | default: 162 | return undefined; 163 | } 164 | }; 165 | 166 | /** Calculates the dimensions of the browser scrollbar */ 167 | export const getScrollDimensions = (() => { 168 | let value: { width: number; height: number } | undefined; 169 | return () => { 170 | if (value !== undefined) { 171 | return value; 172 | } 173 | 174 | const el = document.createElement("div"); 175 | el.classList.add(style.scrollbar); 176 | document.body.appendChild(el); 177 | const width = el.offsetWidth - el.clientWidth; 178 | const height = el.offsetHeight - el.clientHeight; 179 | document.body.removeChild(el); 180 | value = { width, height }; 181 | return value; 182 | }; 183 | })(); 184 | 185 | export const HexDecoratorStyles: { [key in HexDecoratorType]: string } = { 186 | [HexDecoratorType.Insert]: style.diffInsert, 187 | [HexDecoratorType.Delete]: style.diffDelete, 188 | [HexDecoratorType.Empty]: style.diffEmpty, 189 | }; 190 | -------------------------------------------------------------------------------- /media/editor/virtualScrollContainer.css: -------------------------------------------------------------------------------- 1 | .handle { 2 | background: var(--vscode-scrollbarSlider-background); 3 | flex-grow: 1; 4 | transform-origin: 0 0; 5 | } 6 | 7 | .dragging .handle { 8 | background: var(--vscode-scrollbarSlider-hoverBackground); 9 | } 10 | 11 | .scrollbar-container { 12 | position: absolute; 13 | top: 0; 14 | right: 0; 15 | bottom: 0; 16 | display: flex; 17 | flex-direction: column; 18 | } 19 | 20 | .scrollbar-container:hover .handle { 21 | background: var(--vscode-scrollbarSlider-hoverBackground); 22 | } 23 | 24 | .interaction-blocker { 25 | position: fixed; 26 | top: 0; 27 | left: 0; 28 | right: 0; 29 | bottom: 0; 30 | z-index: 1; 31 | } 32 | 33 | .container { 34 | position: relative; 35 | } 36 | -------------------------------------------------------------------------------- /media/editor/virtualScrollContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { useSize } from "./hooks"; 3 | import { clamp, clsx, getScrollDimensions, throwOnUndefinedAccessInDev } from "./util"; 4 | import _style from "./virtualScrollContainer.css"; 5 | 6 | const style = throwOnUndefinedAccessInDev(_style); 7 | 8 | /** 9 | * Generic virtual scroll container. We use this instead 10 | * of native scrolling because native browser scrolling has a limited scroll 11 | * height, which we run into easily for larger files. 12 | * 13 | * Note that, unlike traditional scroll elements, this doesn't have a 14 | * scrollHeight but rather a scrollStart and scrollEnd (given in px). This is 15 | * used for dynamically expanding scroll bounds while keeping the position. 16 | */ 17 | export const VirtualScrollContainer: React.FC<{ 18 | className?: string; 19 | scrollStart: number; 20 | scrollEnd: number; 21 | scrollTop: number; 22 | minHandleHeight?: number; 23 | onScroll(top: number): void; 24 | }> = ({ 25 | className, 26 | children, 27 | scrollStart, 28 | scrollEnd, 29 | minHandleHeight = 20, 30 | scrollTop, 31 | onScroll, 32 | }) => { 33 | const wrapperRef = useRef(null); 34 | // Set when the scroll handle is being dragged. startY is the original pageY 35 | // positon of the cursor. offset how far down the scroll handle the cursor was. 36 | const [drag, setDrag] = useState<{ startY: number; offset: number }>(); 37 | const size = useSize(wrapperRef); 38 | 39 | const scrollHeight = scrollEnd - scrollStart; 40 | const visible = scrollHeight > size.height; 41 | 42 | let scrollStyle: React.CSSProperties | undefined; 43 | let handleTop: number; 44 | let handleHeight: number; 45 | if (visible) { 46 | // We use transform rather than top/height here since it's cheaper to 47 | // rerender. The height is the greatest of either the min handle height or 48 | // the proportion of the total data that the current window is displaying. 49 | handleHeight = Math.max(minHandleHeight, (size.height * size.height) / scrollHeight); 50 | // Likewise, the distance from the top is how far through the scrollHeight 51 | // the current scrollTop is--adjusting for the handle height to keep it on screen. 52 | handleTop = 53 | Math.min(1, (scrollTop - scrollStart) / (scrollHeight - size.height)) * 54 | (size.height - handleHeight); 55 | scrollStyle = { 56 | opacity: 1, 57 | pointerEvents: "auto", 58 | transform: `translateY(${handleTop}px) scaleY(${handleHeight / size.height})`, 59 | }; 60 | } 61 | 62 | /** Clamps the `newScrollTop` witihn the valid scrollable region. */ 63 | const clampScroll = (newScrollTop: number) => 64 | clamp(scrollStart, newScrollTop, scrollEnd - size.height); 65 | 66 | /** Handler for a mouse move to position "pageY" with the given scrubber offset. */ 67 | const onScrollWithOffset = (pageY: number, offset: number) => { 68 | // This is just the `handleTop` assignment from above solved for the 69 | // scrollTop where handleTop = `pageY - offset - size.top`. 70 | const newScrollTop = 71 | scrollStart + 72 | ((pageY - offset - size.top) / (size.height - handleHeight)) * (scrollHeight - size.height); 73 | onScroll(clampScroll(newScrollTop)); 74 | }; 75 | 76 | const onWheel = (evt: React.WheelEvent) => { 77 | if (!evt.defaultPrevented) { 78 | onScroll(clampScroll(scrollTop + evt.deltaY)); 79 | } 80 | }; 81 | 82 | const onHandleMouseDown = (evt: React.MouseEvent) => { 83 | if (evt.defaultPrevented) { 84 | return; 85 | } 86 | 87 | setDrag({ 88 | startY: evt.pageY, 89 | // offset is how far down the scroll handle the cursor is 90 | offset: clamp(0, evt.pageY - handleTop - size.top, handleHeight), 91 | }); 92 | evt.preventDefault(); 93 | }; 94 | 95 | const onBarMouseDown = (evt: React.MouseEvent) => { 96 | if (evt.defaultPrevented) { 97 | return; 98 | } 99 | 100 | // Start scrolling and set the offset to by the middle of the scrollbar. 101 | // Start dragging if we aren't already. 102 | onScrollWithOffset(evt.pageY, handleHeight / 2); 103 | setDrag(d => d || { startY: evt.pageY, offset: handleHeight / 2 }); 104 | evt.preventDefault(); 105 | }; 106 | 107 | // Effect that adds a drag overlay and global mouse listeners while scrubbing on the scrollbar. 108 | useEffect(() => { 109 | if (!drag) { 110 | return; 111 | } 112 | 113 | const blocker = document.createElement("div"); 114 | blocker.classList.add(style.interactionBlocker); 115 | document.body.appendChild(blocker); 116 | 117 | const onMove = (evt: MouseEvent) => { 118 | if (!evt.buttons) { 119 | setDrag(undefined); 120 | } else { 121 | onScrollWithOffset(evt.pageY, drag.offset); 122 | } 123 | }; 124 | 125 | const onUp = () => setDrag(undefined); 126 | document.addEventListener("mousemove", onMove); 127 | document.addEventListener("mouseup", onUp); 128 | 129 | return () => { 130 | document.body.removeChild(blocker); 131 | document.removeEventListener("mousemove", onMove); 132 | document.removeEventListener("mouseup", onUp); 133 | }; 134 | }, [drag, scrollHeight, size.height]); 135 | 136 | return ( 137 |
138 | {children} 139 |
149 |
155 |
156 |
157 | ); 158 | }; 159 | -------------------------------------------------------------------------------- /media/editor/vscodeUi.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --icon-button-size: 22px; 3 | --icon-button-margin: 3px; 4 | } 5 | .vs-text-field-group-inner { 6 | position: relative; 7 | } 8 | 9 | .vs-text-field-group-inner > input { 10 | width: 100%; 11 | box-sizing: border-box; 12 | } 13 | 14 | .vs-text-field-group-buttons { 15 | position: absolute; 16 | top: 0; 17 | right: 0; 18 | bottom: 0; 19 | display: flex; 20 | align-items: center; 21 | } 22 | 23 | .vs-text-field-error-message { 24 | display: none; 25 | position: absolute; 26 | top: 100%; 27 | left: 0; 28 | right: 0; 29 | padding: .4em; 30 | font-size: 12px; 31 | line-height: 17px; 32 | margin-top: -1px; 33 | word-wrap: break-word; 34 | border: 1px solid var(--vscode-inputValidation-errorBorder); 35 | color: var(--vscode-inputValidation-errorForeground); 36 | background: var(--vscode-inputValidation-errorBackground); 37 | z-index: 1; 38 | } 39 | 40 | .text-field-wrapper { 41 | position: relative; 42 | display: flex; 43 | } 44 | 45 | .text-field-wrapper:focus-within .vs-text-field-error-message { 46 | display: block; 47 | } 48 | 49 | .vs-text-field-inner { 50 | background: var(--vscode-input-background); 51 | border: 1px solid var(--vscode-input-border, transparent); 52 | color: var(--vscode-input-foreground); 53 | padding: 2px 4px; 54 | width: 0; 55 | flex-grow: 1; 56 | } 57 | 58 | .vs-text-field-inner::placeholder { 59 | color: var(--vscode-input-placeholderForeground); 60 | } 61 | 62 | .vs-text-field-inner:focus { 63 | outline: 0 !important; 64 | border-color: var(--vscode-focusBorder); 65 | } 66 | 67 | .vs-text-field-error { 68 | border-color: var(--vscode-inputValidation-errorBorder) !important; 69 | } 70 | 71 | .vs-progress-indicator { 72 | position: absolute; 73 | top: 0; 74 | left: 0; 75 | right: 0; 76 | height: 2px; 77 | pointer-events: none; 78 | overflow: hidden; 79 | z-index: 1; 80 | } 81 | 82 | .vs-progress-indicator::before { 83 | content: ""; 84 | position: absolute; 85 | inset: 0; 86 | width: 2%; 87 | animation-name: progress; 88 | animation-duration: 4s; 89 | animation-iteration-count: infinite; 90 | animation-timing-function: linear; 91 | transform: translate3d(0px, 0px, 0px); 92 | background: var(--vscode-progressBar-background); 93 | } 94 | 95 | @keyframes progress { 96 | from { transform: translateX(0%) scaleX(1) } 97 | 50% { transform: translateX(2500%) scaleX(3) } 98 | to { transform: translateX(4900%) scaleX(1) } 99 | } 100 | 101 | .vs-button { 102 | background: var(--vscode-button-background); 103 | color: var(--vscode-button-foreground); 104 | border: 1px solid var(--vscode-button-border); 105 | padding: 0 calc(var(--icon-button-margin) + var(--icon-button-size)); 106 | font-family: var(--vscode-font-family); 107 | cursor: pointer; 108 | padding: 6px 11px; 109 | } 110 | 111 | .vs-button:hover { 112 | background: var(--vscode-button-hoverBackground); 113 | } 114 | 115 | .vs-button:active { 116 | background: var(--vscode-button-background); 117 | } 118 | 119 | .vs-button:focus { 120 | outline: 1px solid var(--vscode-focusBorder); 121 | } 122 | 123 | .vs-button:disabled { 124 | opacity: 0.5; 125 | cursor: default; 126 | } 127 | 128 | .vs-icon-button-inner { 129 | background: transparent; 130 | width: 22px; 131 | height: 22px; 132 | padding: 4px; 133 | border-radius: 5px; 134 | display: flex; 135 | flex: initial; 136 | margin-left: var(--icon-button-margin); 137 | cursor: pointer; 138 | display: flex; 139 | align-items: center; 140 | justify-content: center; 141 | border: 0; 142 | color: var(--vscode-icon-foreground); 143 | } 144 | 145 | .vs-icon-button-inner[disabled] { 146 | opacity: 0.3; 147 | background: transparent !important; 148 | cursor: auto; 149 | } 150 | 151 | .vs-icon-button-inner:hover { 152 | background: var(--vscode-toolbar-hoverBackground); 153 | } 154 | 155 | .vs-icon-button-inner[aria-checked="true"] { 156 | background: var(--vscode-inputOption-activeBackground); 157 | outline: 1px solid var(--vscode-inputOption-activeBorder); 158 | color: var(--vscode-inputOption-activeForeground); 159 | } 160 | 161 | .vs-icon-button-inner:focus { 162 | outline: 1px solid var(--vscode-focusBorder); 163 | } 164 | 165 | .popover { 166 | position: absolute; 167 | z-index: 1; 168 | } 169 | 170 | .popover-hidden { 171 | opacity: 0; 172 | pointer-events: none; 173 | } 174 | 175 | .tooltip-popover { 176 | background: var(--vscode-editorWidget-background); 177 | color: var(--vscode-editorWidget-foreground); 178 | border: 1px solid var(--vscode-editorWidget-border); 179 | padding: 0.5em; 180 | box-shadow: 0 0 8px 2px var(--vscode-widget-shadow); 181 | transition: 0.2s opacity; 182 | animation: fadeIn linear 100ms; 183 | overflow: hidden; 184 | } 185 | 186 | @keyframes fadeIn { 187 | from { opacity: 0; } 188 | to { opacity: 1; } 189 | } 190 | 191 | .tooltip-arrow { 192 | position: absolute; 193 | top: 0; 194 | left: 0; 195 | } 196 | 197 | .tooltip-arrow svg { 198 | display: block; 199 | } 200 | 201 | .tooltip-arrow polygon:first-child { 202 | fill: var(--vscode-editorWidget-border); 203 | } 204 | 205 | .tooltip-arrow polygon:last-child { 206 | fill: var(--vscode-editorWidget-background); 207 | } 208 | 209 | .widget-popover { 210 | padding-right: calc(0.8em + var(--icon-button-size)); 211 | transition: none; 212 | } 213 | 214 | .widget-popover-closer { 215 | position: absolute; 216 | top: 0.5em; 217 | right: 0.5em; 218 | } 219 | -------------------------------------------------------------------------------- /media/editor/vscodeUi.tsx: -------------------------------------------------------------------------------- 1 | import { VirtualElement } from "@popperjs/core"; 2 | import Close from "@vscode/codicons/src/icons/close.svg"; 3 | import React, { KeyboardEvent, useEffect, useState } from "react"; 4 | import ReactDOM from "react-dom"; 5 | import { usePopper } from "react-popper"; 6 | import { useGlobalHandler } from "./hooks"; 7 | import { clsx, throwOnUndefinedAccessInDev } from "./util"; 8 | import _style from "./vscodeUi.css"; 9 | 10 | const style = throwOnUndefinedAccessInDev(_style); 11 | 12 | export const VsTextFieldGroup = React.forwardRef< 13 | HTMLInputElement, 14 | { 15 | buttons: number; 16 | outerClassName?: string; 17 | error?: string; 18 | } & React.InputHTMLAttributes 19 | >(({ buttons, children, outerClassName, ...props }, ref) => ( 20 |
21 | 26 |
{children}
27 |
28 | )); 29 | 30 | export const VsTextField = React.forwardRef< 31 | HTMLInputElement, 32 | { error?: string } & React.InputHTMLAttributes 33 | >(({ error, className, ...props }, ref) => ( 34 |
35 | 40 | {error &&
{error}
} 41 |
42 | )); 43 | 44 | export const VsProgressIndicator: React.FC = () =>
; 45 | 46 | export const iconButtonSize = 22; 47 | const iconButtonMargin = 3; 48 | 49 | export const VsButton: React.FC> = ({ 50 | children, 51 | ...props 52 | }) => ( 53 | 56 | ); 57 | 58 | const VsIconButtonInner = React.forwardRef< 59 | HTMLButtonElement, 60 | React.ButtonHTMLAttributes 61 | >((props, ref) => ( 62 | 65 | )); 66 | 67 | export const VsIconButton = React.forwardRef< 68 | HTMLButtonElement, 69 | { title: string } & React.ButtonHTMLAttributes 70 | >((props, ref) => ( 71 | 72 | )); 73 | 74 | export const VsIconCheckbox: React.FC<{ 75 | checked: boolean; 76 | title: string; 77 | onToggle: (checked: boolean) => void; 78 | }> = ({ checked, title, onToggle, children }) => ( 79 | onToggle(!checked)} 84 | > 85 | {children} 86 | 87 | ); 88 | 89 | export interface IPopoverProps { 90 | anchor: Element | VirtualElement | null; 91 | className?: string; 92 | focusable?: boolean; 93 | visible: boolean; 94 | hide: () => void; 95 | onClickOutside?: () => void; 96 | role?: string; 97 | arrow?: { className: string; size: number }; 98 | } 99 | 100 | const PopoverArrow: React.FC<{ size: number } & React.SVGProps> = ({ 101 | size: h, 102 | ...props 103 | }) => { 104 | const w = h * 1.5; 105 | return ( 106 | 107 | 108 | 109 | 110 | ); 111 | }; 112 | 113 | export const Popover: React.FC = ({ 114 | anchor, 115 | visible, 116 | className, 117 | children, 118 | focusable = true, 119 | arrow, 120 | hide, 121 | ...props 122 | }) => { 123 | const [popperElement, setPopperElement] = useState(null); 124 | const [arrowElement, setArrowElement] = useState(null); 125 | const { styles, attributes } = usePopper( 126 | anchor, 127 | popperElement, 128 | arrow && { 129 | modifiers: [ 130 | { 131 | name: "arrow", 132 | options: { element: arrowElement }, 133 | }, 134 | ], 135 | }, 136 | ); 137 | useEffect(() => { 138 | if (visible && focusable) { 139 | popperElement?.focus(); 140 | } 141 | }, [visible]); 142 | 143 | useGlobalHandler( 144 | "mousedown", 145 | evt => { 146 | if (evt.target instanceof Element && !popperElement?.contains(evt.target)) { 147 | hide(); 148 | } 149 | }, 150 | [hide, popperElement], 151 | ); 152 | 153 | return ReactDOM.createPortal( 154 |
164 |
165 | {children} 166 |
167 | {arrow && ( 168 |
174 | 175 |
176 | )} 177 |
, 178 | document.body, 179 | ); 180 | }; 181 | 182 | const tooltipArrow = { size: 8, className: style.tooltipArrow }; 183 | 184 | export const tooltipArrowSize = tooltipArrow.size; 185 | 186 | export const VsTooltipPopover: React.FC = props => { 187 | useGlobalHandler( 188 | "keydown", 189 | evt => { 190 | if (evt.key === "Escape") { 191 | props.hide(); 192 | } 193 | }, 194 | [props.hide], 195 | ); 196 | 197 | return ( 198 | 205 | {props.children} 206 | 207 | ); 208 | }; 209 | 210 | export const VsWidgetPopover: React.FC = props => ( 211 | 212 | 213 | 214 | 215 | {props.children} 216 | 217 | ); 218 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hexeditor", 3 | "displayName": "%name%", 4 | "description": "%description%", 5 | "version": "1.11.1", 6 | "aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", 7 | "publisher": "ms-vscode", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/microsoft/vscode-hexeditor" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/microsoft/vscode-hexeditor/issues" 14 | }, 15 | "license": "MIT", 16 | "engines": { 17 | "vscode": "^1.89.0-insider" 18 | }, 19 | "categories": [ 20 | "Other" 21 | ], 22 | "icon": "icon.png", 23 | "activationEvents": [], 24 | "main": "./dist/extension.js", 25 | "browser": "./dist/web/extension.js", 26 | "l10n": "./l10n", 27 | "capabilities": { 28 | "untrustedWorkspaces": { 29 | "supported": true 30 | }, 31 | "virtualWorkspaces": true 32 | }, 33 | "contributes": { 34 | "configuration": [ 35 | { 36 | "title": "%name%", 37 | "properties": { 38 | "hexeditor.inspectorType": { 39 | "type": "string", 40 | "enum": [ 41 | "aside", 42 | "hover", 43 | "sidebar" 44 | ], 45 | "default": "aside", 46 | "description": "%hexeditor.inspectorType%" 47 | }, 48 | "hexeditor.maxFileSize": { 49 | "type": "number", 50 | "minimum": 0, 51 | "default": 10, 52 | "description": "%hexeditor.maxFileSize%" 53 | }, 54 | "hexeditor.dataInspector.autoReveal": { 55 | "type": "boolean", 56 | "default": true, 57 | "description": "%hexeditor.dataInspector.autoReveal%" 58 | }, 59 | "hexeditor.defaultEndianness": { 60 | "type": "string", 61 | "default": "little", 62 | "enum": [ 63 | "little", 64 | "big" 65 | ], 66 | "description": "%hexeditor.defaultEndianness%" 67 | }, 68 | "hexeditor.columnWidth": { 69 | "type": "integer", 70 | "default": 16, 71 | "minimum": 1, 72 | "maximum": 512, 73 | "description": "%hexeditor.columnWidth%" 74 | }, 75 | "hexeditor.copyType": { 76 | "type": "string", 77 | "enum": [ 78 | "Hex Octets", 79 | "Hex", 80 | "Literal", 81 | "UTF-8", 82 | "C", 83 | "Go", 84 | "Java", 85 | "JSON", 86 | "Base64" 87 | ], 88 | "default": "Hex Octets", 89 | "description": "%hexeditor.copyType%" 90 | }, 91 | "hexeditor.showDecodedText": { 92 | "type": "boolean", 93 | "default": true, 94 | "description": "%hexeditor.showDecodedText%" 95 | }, 96 | "hexeditor.showOpenFileButton": { 97 | "type": "boolean", 98 | "default": false, 99 | "description": "%hexeditor.showOpenFileButton%" 100 | } 101 | } 102 | } 103 | ], 104 | "configurationDefaults": { 105 | "workbench.editorAssociations": { 106 | "{hexdiff}:/**/*.*": "hexEditor.hexedit" 107 | } 108 | }, 109 | "customEditors": [ 110 | { 111 | "viewType": "hexEditor.hexedit", 112 | "displayName": "%name%", 113 | "selector": [ 114 | { 115 | "filenamePattern": "*" 116 | } 117 | ], 118 | "priority": "option" 119 | } 120 | ], 121 | "commands": [ 122 | { 123 | "command": "hexEditor.openFile", 124 | "category": "%name%", 125 | "title": "%hexEditor.openFile%", 126 | "icon": "$(file-binary)" 127 | }, 128 | { 129 | "command": "hexEditor.goToOffset", 130 | "category": "%name%", 131 | "title": "%hexEditor.goToOffset%" 132 | }, 133 | { 134 | "command": "hexEditor.selectBetweenOffsets", 135 | "category": "%name%", 136 | "title": "%hexEditor.selectBetweenOffsets%" 137 | }, 138 | { 139 | "command": "hexEditor.copyAs", 140 | "category": "%name%", 141 | "title": "%hexEditor.copyAs%" 142 | }, 143 | { 144 | "command": "hexEditor.switchEditMode", 145 | "category": "%name%", 146 | "title": "%hexEditor.switchEditMode%" 147 | }, 148 | { 149 | "command": "hexEditor.copyOffsetAsHex", 150 | "category": "%name%", 151 | "title": "%hexEditor.copyOffsetAsHex%" 152 | }, 153 | { 154 | "command": "hexEditor.copyOffsetAsDec", 155 | "category": "%name%", 156 | "title": "%hexEditor.copyOffsetAsDec%" 157 | }, 158 | { 159 | "command": "hexEditor.compareSelected", 160 | "category": "%name%", 161 | "title": "%hexEditor.compareSelected%" 162 | } 163 | ], 164 | "viewsContainers": { 165 | "activitybar": [ 166 | { 167 | "id": "hexExplorer", 168 | "title": "%name%", 169 | "icon": "panel-icon.svg", 170 | "when": "hexEditor:showSidebarInspector" 171 | } 172 | ] 173 | }, 174 | "views": { 175 | "hexExplorer": [ 176 | { 177 | "type": "webview", 178 | "id": "hexEditor.dataInspectorView", 179 | "name": "%dataInspectorView%", 180 | "when": "hexEditor:showSidebarInspector" 181 | } 182 | ] 183 | }, 184 | "menus": { 185 | "commandPalette": [ 186 | { 187 | "command": "hexEditor.goToOffset", 188 | "when": "hexEditor:isActive" 189 | }, 190 | { 191 | "command": "hexEditor.switchEditMode", 192 | "when": "hexEditor:isActive" 193 | }, 194 | { 195 | "command": "hexEditor.compareSelected", 196 | "when": "false" 197 | } 198 | ], 199 | "editor/title": [ 200 | { 201 | "command": "hexEditor.openFile", 202 | "group": "navigation@1", 203 | "when": "activeEditor && config.hexeditor.showOpenFileButton" 204 | } 205 | ], 206 | "webview/context": [ 207 | { 208 | "command": "hexEditor.copyOffsetAsDec", 209 | "group": "9_cutcopypaste", 210 | "when": "hexEditor:isActive" 211 | }, 212 | { 213 | "command": "hexEditor.copyOffsetAsHex", 214 | "group": "9_cutcopypaste", 215 | "when": "hexEditor:isActive" 216 | }, 217 | { 218 | "when": "webviewId == 'hexEditor.hexedit'", 219 | "command": "hexEditor.copyAs", 220 | "group": "9_cutcopypaste" 221 | } 222 | ], 223 | "explorer/context": [ 224 | { 225 | "command": "hexEditor.compareSelected", 226 | "group": "3_compare@30", 227 | "when": "listDoubleSelection" 228 | } 229 | ] 230 | }, 231 | "keybindings": [ 232 | { 233 | "command": "hexEditor.goToOffset", 234 | "key": "ctrl+g", 235 | "when": "activeCustomEditorId == hexEditor.hexedit" 236 | }, 237 | { 238 | "command": "hexEditor.copyAs", 239 | "key": "alt+ctrl+c", 240 | "when": "activeCustomEditorId == hexEditor.hexedit" 241 | }, 242 | { 243 | "command": "hexEditor.switchEditMode", 244 | "key": "Insert", 245 | "when": "hexEditor:isActive" 246 | } 247 | ] 248 | }, 249 | "scripts": { 250 | "compile": "tsc --noEmit && node .esbuild.config.js", 251 | "lint": "eslint src --ext ts", 252 | "fmt": "prettier --write \"{src,media,shared}/**/*.{ts,tsx}\" && npm run lint -- --fix", 253 | "watch": "node .esbuild.config.js --watch", 254 | "test": "tsc --noEmit && node ./src/test/runTest.js" 255 | }, 256 | "devDependencies": { 257 | "@types/chai": "^4.3.0", 258 | "@types/diff": "^5.2.1", 259 | "@types/mocha": "^9.0.0", 260 | "@types/node": "^18.11.9", 261 | "@types/react": "^17.0.38", 262 | "@types/react-dom": "^17.0.11", 263 | "@types/vscode": "^1.74.0", 264 | "@typescript-eslint/eslint-plugin": "^6.20.0", 265 | "@typescript-eslint/parser": "^6.20.0", 266 | "@vscode/test-electron": "^2.3.9", 267 | "chai": "^4.3.4", 268 | "esbuild": "^0.19.0", 269 | "esbuild-css-modules-plugin": "^3.1.0", 270 | "esbuild-plugin-svgr": "^2.1.0", 271 | "eslint": "^8.56.0", 272 | "mocha": "^11.1.0", 273 | "prettier": "^3.2.4", 274 | "typescript": "^5.3.3" 275 | }, 276 | "dependencies": { 277 | "@popperjs/core": "^2.11.6", 278 | "@vscode/codicons": "0.0.27", 279 | "@vscode/extension-telemetry": "0.6.2", 280 | "cockatiel": "^3.1.2", 281 | "diff": "^5.2.0", 282 | "js-base64": "^3.7.2", 283 | "react": "^18.2.0", 284 | "react-dom": "^18.2.0", 285 | "react-popper": "^2.3.0", 286 | "recoil": "^0.7.7", 287 | "vscode-webview-tools": "^0.1.1" 288 | }, 289 | "prettier": { 290 | "printWidth": 100, 291 | "tabWidth": 2, 292 | "arrowParens": "avoid" 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /package.nls.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Hex Editor", 3 | "description": "Allows viewing and editing files in a hex editor", 4 | 5 | "hexeditor.inspectorType": "Where the data inspector should be shown in the hex editor.", 6 | "hexeditor.maxFileSize": "The max file size (in MB) that the editor will try to open before warning you.", 7 | "hexeditor.dataInspector.autoReveal": "Whether to auto reveal the data inspector when the hex editor is opened.", 8 | "hexeditor.defaultEndianness": "The endianness selected when loading the editor.", 9 | "hexeditor.columnWidth": "The number of bytes per row to show in the editor.", 10 | "hexeditor.showDecodedText": "Whether decoded text should be shown in the editor.", 11 | "hexeditor.showOpenFileButton": "Show Hex Editor button in editor menu.", 12 | "hexEditor.openFile": "Open Active File in Hex Editor", 13 | "hexEditor.goToOffset": "Go To Offset", 14 | "hexEditor.selectBetweenOffsets": "Select Between Offsets", 15 | "hexEditor.copyAs": "Copy As...", 16 | "hexeditor.copyType": "Sets the default format in which bytes are copied", 17 | "hexEditor.switchEditMode": "Switch Edit Mode", 18 | "hexEditor.copyOffsetAsDec": "Copy Offset as Decimal", 19 | "hexEditor.copyOffsetAsHex": "Copy Offset as Hex", 20 | "hexEditor.compareSelected": "Compare Selected in HexEditor (Experimental)", 21 | "dataInspectorView": "Data Inspector" 22 | } 23 | -------------------------------------------------------------------------------- /panel-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /shared/decorators.ts: -------------------------------------------------------------------------------- 1 | export enum HexDecoratorType { 2 | Insert, 3 | Delete, 4 | Empty, 5 | } 6 | 7 | export interface HexDecorator { 8 | type: HexDecoratorType; 9 | range: { start: number; end: number }; 10 | } 11 | -------------------------------------------------------------------------------- /shared/diffWorker.ts: -------------------------------------------------------------------------------- 1 | import { DiffMessageType, FromDiffWorkerMessage, ToDiffWorkerMessage } from "./diffWorkerProtocol"; 2 | import { MessageHandler } from "./protocol"; 3 | import { MyersDiff } from "./util/myers"; 4 | 5 | function onMessage(message: ToDiffWorkerMessage): undefined | FromDiffWorkerMessage { 6 | switch (message.type) { 7 | case DiffMessageType.DiffDecoratorRequest: 8 | const script = MyersDiff.lcs(message.original, message.modified); 9 | const decorators = MyersDiff.toDecorator(script); 10 | return { 11 | type: DiffMessageType.DiffDecoratorResponse, 12 | original: decorators.original, 13 | modified: decorators.modified, 14 | }; 15 | } 16 | } 17 | 18 | try { 19 | // Web worker 20 | const messageHandler = new MessageHandler( 21 | async message => onMessage(message), 22 | message => postMessage(message), 23 | ); 24 | onmessage = e => messageHandler.handleMessage(e.data); 25 | } catch { 26 | // node worker 27 | 28 | // eslint-disable-next-line @typescript-eslint/no-var-requires 29 | const { parentPort } = require("worker_threads") as typeof import("worker_threads"); 30 | if (parentPort) { 31 | const messageHandler = new MessageHandler( 32 | async message => onMessage(message), 33 | message => parentPort.postMessage(message), 34 | ); 35 | parentPort.on("message", e => { 36 | messageHandler.handleMessage(e); 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /shared/diffWorkerProtocol.ts: -------------------------------------------------------------------------------- 1 | import { HexDecorator } from "./decorators"; 2 | import { MessageHandler } from "./protocol"; 3 | 4 | export type DiffExtensionHostMessageHandler = MessageHandler< 5 | ToDiffWorkerMessage, 6 | FromDiffWorkerMessage 7 | >; 8 | export type DiffWorkerMessageHandler = MessageHandler; 9 | 10 | export type ToDiffWorkerMessage = DiffDecoratorsRequestMessage; 11 | export type FromDiffWorkerMessage = DiffDecoratorResponseMessage; 12 | export enum DiffMessageType { 13 | // #region to diffworker 14 | DiffDecoratorRequest, 15 | // #endregion 16 | // #region from diff worker 17 | DiffDecoratorResponse, 18 | // #endregion 19 | } 20 | 21 | export interface DiffDecoratorsRequestMessage { 22 | type: DiffMessageType.DiffDecoratorRequest; 23 | original: Uint8Array; 24 | modified: Uint8Array; 25 | } 26 | 27 | export interface DiffDecoratorResponseMessage { 28 | type: DiffMessageType.DiffDecoratorResponse; 29 | original: HexDecorator[]; 30 | modified: HexDecorator[]; 31 | } 32 | -------------------------------------------------------------------------------- /shared/fileAccessor.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------- 2 | * Copyright (C) Microsoft Corporation. All rights reserved. 3 | *--------------------------------------------------------*/ 4 | 5 | import type * as vscode from "vscode"; 6 | 7 | export interface FileWriteOp { 8 | offset: number; 9 | data: Uint8Array; 10 | } 11 | 12 | export interface FileAccessor { 13 | readonly uri: string; 14 | /** 15 | * Whether this accessor works with the file incrementally under the hood. 16 | * If false, it may be necessary to warn the user before they open very large files. 17 | */ 18 | readonly supportsIncremetalAccess?: boolean; 19 | 20 | /** 21 | * Whether this file is read-only. 22 | */ 23 | readonly isReadonly?: boolean; 24 | 25 | /** 26 | * Preferred page size of the accessor. 27 | */ 28 | readonly pageSize: number; 29 | 30 | /** Implements a file watcher. */ 31 | watch(onDidChange: () => void, onDidDelete: () => void): vscode.Disposable; 32 | /** Calculates the size of the associated document. Undefined if unbounded */ 33 | getSize(): Promise; 34 | /** Reads bytes at the given offset from the file, returning the number of read bytes. */ 35 | read(offset: number, target: Uint8Array): Promise; 36 | /** Bulk updates data in the file. */ 37 | writeBulk(ops: readonly FileWriteOp[]): Promise; 38 | /** Updates the file by replacing it with the contents of the stream. */ 39 | writeStream( 40 | stream: AsyncIterable, 41 | cancellation?: vscode.CancellationToken, 42 | ): Promise; 43 | /** Signalled when a full reload is requested. Cached data should be forgotten. */ 44 | invalidate?(): void; 45 | /** Disposes of the accessor. */ 46 | dispose(): void; 47 | } 48 | -------------------------------------------------------------------------------- /shared/hexDiffModel.ts: -------------------------------------------------------------------------------- 1 | import { bulkhead } from "cockatiel"; 2 | import * as vscode from "vscode"; 3 | import { HexDecorator } from "./decorators"; 4 | import { 5 | DiffDecoratorResponseMessage, 6 | DiffExtensionHostMessageHandler, 7 | DiffMessageType, 8 | } from "./diffWorkerProtocol"; 9 | import { HexDocumentModel } from "./hexDocumentModel"; 10 | export type HexDiffModelBuilder = typeof HexDiffModel.Builder.prototype; 11 | 12 | export class HexDiffModel { 13 | /** Guard to make sure only one computation operation happens */ 14 | private readonly saveGuard = bulkhead(1, Infinity); 15 | private decorators?: { original: HexDecorator[]; modified: HexDecorator[] }; 16 | 17 | constructor( 18 | private readonly originalModel: HexDocumentModel, 19 | private readonly modifiedModel: HexDocumentModel, 20 | private readonly messageHandler: DiffExtensionHostMessageHandler, 21 | ) {} 22 | 23 | public async computeDecorators(uri: vscode.Uri): Promise { 24 | return this.saveGuard.execute(async () => { 25 | if (this.decorators === undefined) { 26 | //TODO: Add a warning if the file sizes are too large? 27 | const oSize = await this.originalModel.sizeWithEdits(); 28 | const mSize = await this.modifiedModel.sizeWithEdits(); 29 | if (oSize === undefined || mSize === undefined) { 30 | throw new Error(vscode.l10n.t("HexEditor Diff: Failed to get file sizes.")); 31 | } 32 | 33 | const oArray = new Uint8Array(oSize); 34 | const mArray = new Uint8Array(mSize); 35 | await this.originalModel.readInto(0, oArray); 36 | await this.modifiedModel.readInto(0, mArray); 37 | const decorators = await this.messageHandler.sendRequest( 38 | { 39 | type: DiffMessageType.DiffDecoratorRequest, 40 | original: oArray, 41 | modified: mArray, 42 | }, 43 | [oArray.buffer, mArray.buffer], 44 | ); 45 | this.decorators = decorators; 46 | } 47 | return uri.toString() === this.originalModel.uri.toString() 48 | ? this.decorators.original 49 | : this.decorators.modified; 50 | }); 51 | } 52 | 53 | /** 54 | * Class to coordinate the creation of HexDiffModel 55 | * with both HexDocumentModels 56 | */ 57 | static Builder = class { 58 | private original: { 59 | promise: Promise; 60 | resolve: (model: HexDocumentModel) => void; 61 | }; 62 | private modified: { 63 | promise: Promise; 64 | resolve: (model: HexDocumentModel) => void; 65 | }; 66 | 67 | private built?: HexDiffModel; 68 | 69 | constructor(private readonly messageHandler: DiffExtensionHostMessageHandler) { 70 | let promise: Promise; 71 | let res: (model: HexDocumentModel) => void; 72 | 73 | promise = new Promise(resolve => (res = resolve)); 74 | this.original = { promise: promise, resolve: res! }; 75 | promise = new Promise(resolve => (res = resolve)); 76 | this.modified = { promise: promise, resolve: res! }; 77 | } 78 | 79 | public setModel(side: "original" | "modified", document: HexDocumentModel) { 80 | if (side === "original") { 81 | this.original.resolve(document); 82 | } else { 83 | this.modified.resolve(document); 84 | } 85 | return this; 86 | } 87 | 88 | public async build() { 89 | const [original, modified] = await Promise.all([ 90 | this.original.promise, 91 | this.modified.promise, 92 | ]); 93 | if (this.built === undefined) { 94 | this.built = new HexDiffModel(original, modified, this.messageHandler); 95 | } 96 | return this.built; 97 | } 98 | }; 99 | } 100 | -------------------------------------------------------------------------------- /shared/protocol.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------- 2 | * Copyright (C) Microsoft Corporation. All rights reserved. 3 | *--------------------------------------------------------*/ 4 | 5 | import { HexDecorator } from "./decorators"; 6 | import { HexDocumentEditOp } from "./hexDocumentModel"; 7 | import { ISerializedEdits } from "./serialization"; 8 | 9 | export const enum MessageType { 10 | //#region to webview 11 | ReadyResponse, 12 | ReadRangeResponse, 13 | SearchProgress, 14 | SetEdits, 15 | SetEditMode, 16 | Saved, 17 | ReloadFromDisk, 18 | StashDisplayedOffset, 19 | GoToOffset, 20 | SetHoveredByte, 21 | SetFocusedByte, 22 | SetFocusedByteRange, 23 | SetSelectedCount, 24 | PopDisplayedOffset, 25 | DeleteAccepted, 26 | TriggerCopyAs, 27 | //#endregion 28 | //#region from webview 29 | ReadyRequest, 30 | OpenDocument, 31 | ReadRangeRequest, 32 | MakeEdits, 33 | RequestDeletes, 34 | SearchRequest, 35 | CancelSearch, 36 | ClearDataInspector, 37 | SetInspectByte, 38 | UpdateEditorSettings, 39 | DoPaste, 40 | DoCopy, 41 | //#endregion 42 | } 43 | 44 | export interface WebviewMessage { 45 | messageId: number; 46 | inReplyTo?: number; 47 | body: T; 48 | } 49 | 50 | export const enum Endianness { 51 | Big = "big", 52 | Little = "little", 53 | } 54 | 55 | export const enum InspectorLocation { 56 | Hover = "hover", 57 | Aside = "aside", 58 | Sidebar = "sidebar", 59 | } 60 | 61 | export interface IEditorSettings { 62 | copyType: CopyFormat; 63 | showDecodedText: boolean; 64 | columnWidth: number; 65 | inspectorType: InspectorLocation; 66 | defaultEndianness: Endianness; 67 | } 68 | 69 | export interface ICodeSettings { 70 | scrollBeyondLastLine: boolean; 71 | } 72 | 73 | export interface ReadyResponseMessage { 74 | type: MessageType.ReadyResponse; 75 | initialOffset: number; 76 | pageSize: number; 77 | edits: ISerializedEdits; 78 | editorSettings: IEditorSettings; 79 | codeSettings: ICodeSettings; 80 | unsavedEditIndex: number; 81 | fileSize: number | undefined; 82 | isReadonly: boolean; 83 | isLargeFile: boolean; 84 | editMode: HexDocumentEditOp.Insert | HexDocumentEditOp.Replace; 85 | decorators: HexDecorator[]; 86 | } 87 | 88 | export interface SetEditModeMessage { 89 | type: MessageType.SetEditMode; 90 | mode: HexDocumentEditOp.Insert | HexDocumentEditOp.Replace; 91 | } 92 | 93 | export interface ReadRangeResponseMessage { 94 | type: MessageType.ReadRangeResponse; 95 | data: ArrayBuffer; 96 | } 97 | 98 | export interface SearchResult { 99 | from: number; 100 | to: number; 101 | previous: Uint8Array; 102 | } 103 | 104 | export interface SearchResultsWithProgress { 105 | results: SearchResult[]; 106 | progress: number; 107 | capped?: boolean; 108 | outdated?: boolean; 109 | } 110 | 111 | export interface SearchProgressMessage { 112 | type: MessageType.SearchProgress; 113 | data: SearchResultsWithProgress; 114 | } 115 | 116 | /** Notifies the document is saved, any pending edits should be flushed */ 117 | export interface SavedMessage { 118 | type: MessageType.Saved; 119 | unsavedEditIndex: number; 120 | } 121 | 122 | /** Notifies that the underlying file is changed. Webview should throw away and re-request state. */ 123 | export interface ReloadMessage { 124 | type: MessageType.ReloadFromDisk; 125 | } 126 | 127 | /** Sets the edits that should be applied to the document */ 128 | export interface SetEditsMessage { 129 | type: MessageType.SetEdits; 130 | edits: ISerializedEdits; 131 | replaceFileSize?: number | null; 132 | appendOnly?: boolean; 133 | } 134 | 135 | /** Sets the displayed offset. */ 136 | export interface GoToOffsetMessage { 137 | type: MessageType.GoToOffset; 138 | offset: number; 139 | } 140 | 141 | /** Focuses a byte in the editor. */ 142 | export interface SetFocusedByteMessage { 143 | type: MessageType.SetFocusedByte; 144 | offset: number; 145 | } 146 | 147 | /** Focuses a byte range in the editor. */ 148 | export interface SetFocusedByteRangeMessage { 149 | type: MessageType.SetFocusedByteRange; 150 | startingOffset: number; 151 | endingOffset: number; 152 | } 153 | 154 | /** sets the count of selected bytes. */ 155 | export interface SetSelectedCountMessage { 156 | type: MessageType.SetSelectedCount; 157 | selected: number; 158 | focused?: number; 159 | } 160 | 161 | /** Sets the hovered byte in the editor */ 162 | export interface SetHoveredByteMessage { 163 | type: MessageType.SetHoveredByte; 164 | hovered?: number; 165 | } 166 | 167 | /** Saves the current offset shown in the editor. */ 168 | export interface StashDisplayedOffsetMessage { 169 | type: MessageType.StashDisplayedOffset; 170 | } 171 | 172 | /** Restored a stashed offset. */ 173 | export interface PopDisplayedOffsetMessage { 174 | type: MessageType.PopDisplayedOffset; 175 | } 176 | 177 | /** Acks a deletion request. */ 178 | export interface DeleteAcceptedMessage { 179 | type: MessageType.DeleteAccepted; 180 | } 181 | 182 | export const enum CopyFormat { 183 | HexOctets = "Hex Octets", 184 | Hex = "Hex", 185 | Literal = "Literal", 186 | Utf8 = "UTF-8", 187 | C = "C", 188 | Go = "Go", 189 | Java = "Java", 190 | JSON = "JSON", 191 | Base64 = "Base64", 192 | } 193 | 194 | export interface TriggerCopyAsMessage { 195 | type: MessageType.TriggerCopyAs; 196 | format: CopyFormat; 197 | } 198 | 199 | export type ToWebviewMessage = 200 | | ReadyResponseMessage 201 | | ReadRangeResponseMessage 202 | | SearchProgressMessage 203 | | SavedMessage 204 | | ReloadMessage 205 | | GoToOffsetMessage 206 | | SetEditsMessage 207 | | SetFocusedByteMessage 208 | | SetFocusedByteRangeMessage 209 | | SetEditModeMessage 210 | | PopDisplayedOffsetMessage 211 | | StashDisplayedOffsetMessage 212 | | DeleteAcceptedMessage 213 | | TriggerCopyAsMessage; 214 | 215 | export interface OpenDocumentMessage { 216 | type: MessageType.OpenDocument; 217 | } 218 | 219 | export interface ReadRangeMessage { 220 | type: MessageType.ReadRangeRequest; 221 | offset: number; 222 | bytes: number; 223 | } 224 | 225 | export interface MakeEditsMessage { 226 | type: MessageType.MakeEdits; 227 | edits: ISerializedEdits; 228 | } 229 | 230 | export type LiteralSearchQuery = { literal: (Uint8Array | "*")[] }; 231 | 232 | export type RegExpSearchQuery = { re: string }; 233 | 234 | export interface SearchRequestMessage { 235 | type: MessageType.SearchRequest; 236 | query: LiteralSearchQuery | RegExpSearchQuery; 237 | cap: number | undefined; 238 | caseSensitive: boolean; 239 | } 240 | 241 | export interface CancelSearchMessage { 242 | type: MessageType.CancelSearch; 243 | } 244 | 245 | export interface ClearDataInspectorMessage { 246 | type: MessageType.ClearDataInspector; 247 | } 248 | 249 | export interface SetInspectByteMessage { 250 | type: MessageType.SetInspectByte; 251 | offset: number; 252 | } 253 | 254 | export interface ReadyRequestMessage { 255 | type: MessageType.ReadyRequest; 256 | } 257 | 258 | export interface UpdateEditorSettings { 259 | type: MessageType.UpdateEditorSettings; 260 | editorSettings: IEditorSettings; 261 | } 262 | 263 | export const enum PasteMode { 264 | Insert = "insert", 265 | Replace = "replace", 266 | } 267 | 268 | export interface PasteMessage { 269 | type: MessageType.DoPaste; 270 | offset: number; 271 | data: Uint8Array; 272 | mode: PasteMode; 273 | } 274 | 275 | export interface CopyMessage { 276 | type: MessageType.DoCopy; 277 | selections: [from: number, to: number][]; 278 | format: CopyFormat; 279 | } 280 | 281 | export interface RequestDeletesMessage { 282 | type: MessageType.RequestDeletes; 283 | deletes: { start: number; end: number }[]; 284 | } 285 | 286 | export type FromWebviewMessage = 287 | | OpenDocumentMessage 288 | | ReadRangeMessage 289 | | MakeEditsMessage 290 | | SearchRequestMessage 291 | | CancelSearchMessage 292 | | ClearDataInspectorMessage 293 | | SetInspectByteMessage 294 | | SetSelectedCountMessage 295 | | SetHoveredByteMessage 296 | | ReadyRequestMessage 297 | | UpdateEditorSettings 298 | | PasteMessage 299 | | CopyMessage 300 | | RequestDeletesMessage; 301 | 302 | export type ExtensionHostMessageHandler = MessageHandler; 303 | export type WebviewMessageHandler = MessageHandler; 304 | 305 | /** 306 | * Helper for postMessage-based RPC. 307 | */ 308 | export class MessageHandler { 309 | private messageIdCounter = 0; 310 | private readonly pendingMessages = new Map< 311 | number, 312 | { resolve: (msg: TFrom) => void; reject: (err: Error) => void } 313 | >(); 314 | 315 | constructor( 316 | public messageHandler: (msg: TFrom) => Promise, 317 | private readonly postMessage: (msg: WebviewMessage, transfer?: Transferable[]) => void, 318 | ) {} 319 | 320 | /** Sends a request without waiting for a response */ 321 | public sendEvent(body: TTo, transfer?: Transferable[]): void { 322 | this.postMessage({ body, messageId: this.messageIdCounter++ }, transfer); 323 | } 324 | 325 | /** Sends a request that expects a response */ 326 | public sendRequest( 327 | msg: TTo, 328 | transfer?: Transferable[], 329 | ): Promise { 330 | const id = this.messageIdCounter++; 331 | this.postMessage({ body: msg, messageId: id }, transfer); 332 | return new Promise((resolve, reject) => { 333 | this.pendingMessages.set(id, { resolve: resolve as (msg: TFrom) => void, reject }); 334 | }); 335 | } 336 | 337 | /** Sends a reply in response to a previous request */ 338 | private sendReply(inReplyTo: WebviewMessage, reply: TTo): void { 339 | this.postMessage({ 340 | body: reply, 341 | messageId: this.messageIdCounter++, 342 | inReplyTo: inReplyTo.messageId, 343 | }); 344 | } 345 | 346 | /** Should be called when a postMessage is received */ 347 | public handleMessage(message: WebviewMessage): void { 348 | if (message.inReplyTo !== undefined) { 349 | this.pendingMessages.get(message.inReplyTo)?.resolve(message.body); 350 | this.pendingMessages.delete(message.inReplyTo); 351 | } else { 352 | Promise.resolve(this.messageHandler(message.body)).then( 353 | reply => reply && this.sendReply(message, reply), 354 | ); 355 | } 356 | } 357 | } 358 | -------------------------------------------------------------------------------- /shared/serialization.ts: -------------------------------------------------------------------------------- 1 | import { HexDocumentEdit, HexDocumentEditOp } from "./hexDocumentModel"; 2 | import { Uint8ArrayMap } from "./util/uint8ArrayMap"; 3 | 4 | export interface ISerializedEdits { 5 | edits: readonly unknown[]; 6 | data: Uint8Array; 7 | } 8 | 9 | /** 10 | * Serializes edits for transfer, see vscode#137757. 11 | * 12 | * Normally each edit has its own stored buffer inside of it, but this is 13 | * problematic. This modifies it so that there's a single Uint8Array and each 14 | * edit points to a region in that array for transportation. 15 | */ 16 | export const serializeEdits = (edits: readonly HexDocumentEdit[]): ISerializedEdits => { 17 | let allocOffset = 0; 18 | const allocTable = new Uint8ArrayMap(); 19 | const allocOrReuse = (buf: Uint8Array) => { 20 | const offset = allocTable.set(buf, () => { 21 | const offset = allocOffset; 22 | allocOffset += buf.length; 23 | return offset; 24 | }); 25 | 26 | return { offset, len: buf.length }; 27 | }; 28 | 29 | const newEdits: unknown[] = []; 30 | for (const edit of edits) { 31 | if (edit.op === HexDocumentEditOp.Insert) { 32 | newEdits.push({ ...edit, value: allocOrReuse(edit.value) }); 33 | } else if (edit.op === HexDocumentEditOp.Delete) { 34 | newEdits.push({ ...edit, previous: allocOrReuse(edit.previous) }); 35 | } else { 36 | newEdits.push({ 37 | ...edit, 38 | previous: allocOrReuse(edit.previous), 39 | value: allocOrReuse(edit.value), 40 | }); 41 | } 42 | } 43 | 44 | const data = new Uint8Array(allocOffset); 45 | for (const [buf, offset] of allocTable.entries()) { 46 | data.set(buf, offset); 47 | } 48 | 49 | return { data, edits: newEdits }; 50 | }; 51 | 52 | /** Reverses {@link serializeEdits} */ 53 | export const deserializeEdits = ({ edits, data }: ISerializedEdits): HexDocumentEdit[] => { 54 | const unref = ({ offset, len }: { offset: number; len: number }) => 55 | data.slice(offset, offset + len); 56 | 57 | return edits.map((edit: any) => { 58 | if (edit.op === HexDocumentEditOp.Insert) { 59 | return { ...edit, value: unref(edit.value) }; 60 | } else if (edit.op === HexDocumentEditOp.Delete) { 61 | return { ...edit, previous: unref(edit.previous) }; 62 | } else { 63 | return { ...edit, previous: unref(edit.previous), value: unref(edit.value) }; 64 | } 65 | }); 66 | }; 67 | -------------------------------------------------------------------------------- /shared/strings.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------- 2 | * Copyright (C) Microsoft Corporation. All rights reserved. 3 | *--------------------------------------------------------*/ 4 | 5 | export interface ILocalizedStrings { 6 | pasteAs: string; 7 | pasteMode: string; 8 | replace: string; 9 | insert: string; 10 | bytes: string; 11 | encodingError: string; 12 | decodedText: string; 13 | loadingUpper: string; 14 | loadingDotDotDot: string; 15 | littleEndian: string; 16 | onlyHexChars: string; 17 | onlyHexCharsAndPlaceholders: string; 18 | toggleReplace: string; 19 | findBytes: string; 20 | findText: string; 21 | regexSearch: string; 22 | searchInBinaryMode: string; 23 | caseSensitive: string; 24 | cancelSearch: string; 25 | previousMatch: string; 26 | nextMatch: string; 27 | closeWidget: string; 28 | replaceSelectedMatch: string; 29 | replaceAllMatches: string; 30 | resultOverflow: string; 31 | resultCount: string; 32 | foundNResults: string; 33 | noResults: string; 34 | openLargeFileWarning: string; 35 | openAnyways: string; 36 | readonlyWarning: string; 37 | openSettings: string; 38 | showDecodedText: string; 39 | bytesPerRow: string; 40 | close: string; 41 | } 42 | 43 | export const placeholder1 = "%1%"; 44 | -------------------------------------------------------------------------------- /shared/util/binarySearch.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------- 2 | * Copyright (C) Microsoft Corporation. All rights reserved. 3 | *--------------------------------------------------------*/ 4 | 5 | export const binarySearch = 6 | (mapFn: (a: T) => number) => 7 | (value: number, nodes: readonly T[]): number => { 8 | let mid: number; 9 | let lo = 0; 10 | let hi = nodes.length - 1; 11 | 12 | while (lo <= hi) { 13 | mid = Math.floor((lo + hi) / 2); 14 | 15 | if (mapFn(nodes[mid]) >= value) { 16 | hi = mid - 1; 17 | } else { 18 | lo = mid + 1; 19 | } 20 | } 21 | 22 | return lo; 23 | }; 24 | -------------------------------------------------------------------------------- /shared/util/memoize.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------- 2 | * Copyright (C) Microsoft Corporation. All rights reserved. 3 | *--------------------------------------------------------*/ 4 | 5 | const unset = Symbol("unset"); 6 | 7 | /** 8 | * Simple memoizer that only remembers the last value it's been invoked with. 9 | */ 10 | export const memoizeLast = (fn: (arg: T) => R): ((arg: T) => R) => { 11 | let lastArg: T | typeof unset = unset; 12 | let lastReturn: R | undefined; 13 | 14 | return arg => { 15 | if (arg !== lastArg) { 16 | lastReturn = fn(arg); 17 | lastArg = arg; 18 | } 19 | 20 | return lastReturn!; 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /shared/util/myers.ts: -------------------------------------------------------------------------------- 1 | import { ArrayChange, diffArrays } from "diff"; 2 | import { HexDecorator, HexDecoratorType } from "../decorators"; 3 | import { Range } from "./range"; 4 | 5 | /** 6 | * O(d^2) implementation 7 | */ 8 | export class MyersDiff { 9 | public static lcs(original: Uint8Array, modified: Uint8Array) { 10 | // the types in @types/diff are incomplete. 11 | const changes: ArrayChange[] | undefined = diffArrays( 12 | original as any, 13 | modified as any, 14 | ); 15 | return changes; 16 | } 17 | 18 | public static toDecorator(script: ArrayChange[]) { 19 | const out: { 20 | original: HexDecorator[]; 21 | modified: HexDecorator[]; 22 | } = { original: [], modified: [] }; 23 | let offset = 0; 24 | for (const change of script) { 25 | const r = new Range(offset, offset + change.count!); 26 | if (change.removed) { 27 | out.original.push({ 28 | type: HexDecoratorType.Delete, 29 | range: r, 30 | }); 31 | out.modified.push({ 32 | type: HexDecoratorType.Empty, 33 | range: r, 34 | }); 35 | } else if (change.added) { 36 | out.original.push({ 37 | type: HexDecoratorType.Empty, 38 | range: r, 39 | }); 40 | out.modified.push({ 41 | type: HexDecoratorType.Insert, 42 | range: r, 43 | }); 44 | } 45 | offset += change.count!; 46 | } 47 | return out; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /shared/util/once.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------- 2 | * Copyright (C) Microsoft Corporation. All rights reserved. 3 | *--------------------------------------------------------*/ 4 | 5 | const unset = Symbol("unset"); 6 | 7 | /** 8 | * Simple memoizer that only remembers the last value it's been invoked with. 9 | */ 10 | export const once = (fn: () => R): { (): R; getValue(): R | undefined; forget(): void } => { 11 | let value: R | typeof unset = unset; 12 | const wrapped = () => { 13 | if (value === unset) { 14 | value = fn(); 15 | } 16 | 17 | return value; 18 | }; 19 | 20 | wrapped.getValue = () => { 21 | return value === unset ? undefined : value; 22 | }; 23 | 24 | wrapped.forget = () => { 25 | value = unset; 26 | }; 27 | 28 | return wrapped; 29 | }; 30 | -------------------------------------------------------------------------------- /shared/util/range.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------- 2 | * Copyright (C) Microsoft Corporation. All rights reserved. 3 | *--------------------------------------------------------*/ 4 | 5 | /** Direction for the {@link Range} */ 6 | export const enum RangeDirection { 7 | /** When the range was constructed, end >= start */ 8 | Ascending, 9 | /** When the range was constructed, start > end */ 10 | Descending, 11 | } 12 | 13 | /** 14 | * @description Class which represents a range of numbers. Ranges represent 15 | * a number range [start, end). They may be directional, as indicated by 16 | * the order of arguments in the constructor and reflected in the {@link direction}. 17 | */ 18 | 19 | export class Range { 20 | public readonly direction: RangeDirection; 21 | /** 22 | * Gets the number of integers in the range [start, end) 23 | */ 24 | public get size(): number { 25 | return this.end - this.start; 26 | } 27 | 28 | /** 29 | * Returns a range containing the single byte. 30 | */ 31 | public static single(byte: number): Range { 32 | return new Range(byte, byte + 1); 33 | } 34 | 35 | /** 36 | * Creates a new range representing [start, end], inclusive. 37 | */ 38 | public static inclusive(start: number, end: number): Range { 39 | return end >= start ? new Range(start, end + 1) : new Range(start + 1, end); 40 | } 41 | 42 | /** 43 | * @description Constructs a range object representing [start, end) 44 | * @param start Represents the start of the range 45 | * @param end Represents the end of the range 46 | * @param direction The direction of the range, inferred from 47 | * argument order if not provided. 48 | */ 49 | constructor( 50 | public readonly start: number, 51 | public readonly end: number = Number.MAX_SAFE_INTEGER, 52 | direction?: RangeDirection, 53 | ) { 54 | if (start < 0) { 55 | throw new Error("Cannot construct a range with a negative start"); 56 | } 57 | 58 | if (end < start) { 59 | [this.start, this.end] = [end, start]; 60 | direction ??= RangeDirection.Descending; 61 | } else { 62 | direction ??= RangeDirection.Ascending; 63 | } 64 | 65 | this.direction = direction; 66 | } 67 | /** 68 | * @desciption Tests if the given number if within the range 69 | * @param {number} num The number to test 70 | * @returns {boolean} True if the number is in the range, false otherwise 71 | */ 72 | public includes(num: number): boolean { 73 | return num >= this.start && num < this.end; 74 | } 75 | 76 | /** 77 | * Expands the range to include the given value, if it is not already 78 | * within the range. 79 | */ 80 | public expandToContain(value: number): Range { 81 | if (value < this.start) { 82 | return new Range(value, this.end, this.direction); 83 | } else if (value >= this.end) { 84 | return new Range(this.start, value + 1, this.direction); 85 | } else { 86 | return this; 87 | } 88 | } 89 | /** 90 | * Returns whether this range overlaps the other one. 91 | */ 92 | public overlaps(other: Range): boolean { 93 | return other.end > this.start && other.start < this.end; 94 | } 95 | /** 96 | * Returns one or more ranges representing ranges covered by exactly one of 97 | * this or the `otherRange`. 98 | */ 99 | public difference(otherRange: Range): Range[] { 100 | if (!this.overlaps(otherRange)) { 101 | return [this, otherRange]; 102 | } 103 | 104 | const delta: Range[] = []; 105 | if (this.start !== otherRange.start) { 106 | delta.push(new Range(otherRange.start, this.start)); 107 | } 108 | if (this.end !== otherRange.end) { 109 | delta.push(new Range(otherRange.end, this.end)); 110 | } 111 | 112 | return delta; 113 | } 114 | } 115 | 116 | /** 117 | * Takes a DisplayContext-style list of ranges, where each range overlap 118 | * toggles the selected state of the overlapped bytes, and returns the 119 | * positively-selected data. 120 | */ 121 | export function getRangeSelectionsFromStack(ranges: readonly Range[]) { 122 | const result: Range[] = []; 123 | const pending = new Set(ranges); 124 | const within = new Set(); 125 | let last = -1; 126 | while (pending.size || within.size) { 127 | let nextStart: Range | undefined; 128 | for (const range of pending) { 129 | if (!nextStart || nextStart.start > range.start) { 130 | nextStart = range; 131 | } 132 | } 133 | 134 | let nextEnd: Range | undefined; 135 | for (const range of within) { 136 | if (!nextEnd || nextEnd.end > range.end) { 137 | nextEnd = range; 138 | } 139 | } 140 | 141 | if (nextStart && (!nextEnd || nextStart.start < nextEnd.end)) { 142 | if (last !== -1 && within.size && within.size % 2 === 1 && last !== nextStart.start) { 143 | result.push(new Range(last, nextStart.start)); 144 | } 145 | last = nextStart.start; 146 | within.add(nextStart); 147 | pending.delete(nextStart); 148 | } else if (nextEnd) { 149 | if (within.size % 2 === 1 && last !== nextEnd.end) { 150 | result.push(new Range(last, nextEnd.end)); 151 | } 152 | last = nextEnd.end; 153 | within.delete(nextEnd); 154 | } 155 | } 156 | 157 | return result; 158 | } 159 | -------------------------------------------------------------------------------- /shared/util/uint8ArrayMap.ts: -------------------------------------------------------------------------------- 1 | const unwrap = (fn: (() => T) | T) => (typeof fn === "function" ? (fn as () => T)() : fn); 2 | 3 | /** Map of unique values keyed by uint8 array contents */ 4 | export class Uint8ArrayMap { 5 | private table = new Map(); 6 | 7 | public set(buf: Uint8Array, value: (() => T) | T): T { 8 | const hash = doHash(buf); 9 | const existing = this.table.get(hash); 10 | if (!existing) { 11 | const rec = { buf, value: unwrap(value) }; 12 | this.table.set(hash, [rec]); 13 | return rec.value; 14 | } 15 | 16 | for (const r of existing) { 17 | if (arrEquals(r.buf, buf)) { 18 | return r.value; 19 | } 20 | } 21 | 22 | const rec = { buf, value: unwrap(value) }; 23 | existing.push(rec); 24 | return rec.value; 25 | } 26 | 27 | public *entries(): IterableIterator<[Uint8Array, T]> { 28 | for (const entries of this.table.values()) { 29 | for (const { buf, value } of entries) { 30 | yield [buf, value]; 31 | } 32 | } 33 | } 34 | 35 | public *keys(): IterableIterator { 36 | for (const entries of this.table.values()) { 37 | for (const { buf } of entries) { 38 | yield buf; 39 | } 40 | } 41 | } 42 | 43 | public *values(): IterableIterator { 44 | for (const entries of this.table.values()) { 45 | for (const { value } of entries) { 46 | yield value; 47 | } 48 | } 49 | } 50 | } 51 | 52 | function arrEquals(a: Uint8Array, b: Uint8Array) { 53 | if (a.length !== b.length) { 54 | return false; 55 | } 56 | for (let i = 0; i < a.length; i++) { 57 | if (a[i] !== b[i]) { 58 | return false; 59 | } 60 | } 61 | 62 | return true; 63 | } 64 | 65 | /** Simple hash from vscode core */ 66 | function doHash(b: Uint8Array, hashVal = 0) { 67 | hashVal = numberHash(149417, hashVal); 68 | for (let i = 0, length = b.length; i < length; i++) { 69 | hashVal = numberHash(b[i], hashVal); 70 | } 71 | return hashVal; 72 | } 73 | 74 | function numberHash(val: number, initialHashVal: number): number { 75 | return ((initialHashVal << 5) - initialHashVal + val) | 0; // hashVal * 31 + ch, keep as int32 76 | } 77 | -------------------------------------------------------------------------------- /shared/util/uri.ts: -------------------------------------------------------------------------------- 1 | export interface HexEditorUriQuery { 2 | baseAddress?: string; 3 | token?: string; 4 | side?: "modified" | "original"; 5 | } 6 | 7 | /** 8 | * Utility function to convert a Uri query string into a map 9 | */ 10 | export function parseQuery(queryString: string): HexEditorUriQuery { 11 | const queries: HexEditorUriQuery = {}; 12 | if (queryString) { 13 | const pairs = (queryString[0] === "?" ? queryString.substr(1) : queryString).split("&"); 14 | for (const q of pairs) { 15 | const pair = q.split("="); 16 | const name = pair.shift() as keyof HexEditorUriQuery; 17 | if (name) { 18 | const value = pair.join("="); 19 | if (name === "side") { 20 | if (value === "modified" || value === "original" || value === undefined) { 21 | queries.side = value; 22 | } 23 | } else { 24 | queries[name] = value; 25 | } 26 | } 27 | } 28 | } 29 | return queries; 30 | } 31 | 32 | /** 33 | * Forms a valid HexEditor Query to be used in vscode.Uri 34 | */ 35 | export function formQuery(queries: HexEditorUriQuery): string { 36 | const query: string[] = []; 37 | for (const q in queries) { 38 | const queryValue = queries[q as keyof HexEditorUriQuery]; 39 | if (queryValue !== undefined && queryValue !== "") { 40 | query.push(`${q}=${queryValue}`); 41 | } 42 | } 43 | return query.join("&"); 44 | } 45 | -------------------------------------------------------------------------------- /src/backup.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------- 2 | * Copyright (C) Microsoft Corporation. All rights reserved. 3 | *--------------------------------------------------------*/ 4 | 5 | import * as base64 from "js-base64"; 6 | import * as vscode from "vscode"; 7 | import { HexDocumentEdit } from "../shared/hexDocumentModel"; 8 | 9 | const encoder = new TextEncoder(); 10 | const decoder = new TextDecoder(); 11 | 12 | export class Backup { 13 | constructor(private readonly uri: vscode.Uri) {} 14 | 15 | /** Writes the edits to the backup file. */ 16 | public async write(edits: readonly HexDocumentEdit[]): Promise { 17 | const serialized = JSON.stringify(edits, (_key, value) => 18 | value instanceof Uint8Array ? { $u8: base64.fromUint8Array(value) } : value, 19 | ); 20 | 21 | await vscode.workspace.fs.writeFile(this.uri, encoder.encode(serialized)); 22 | } 23 | 24 | /** Reads the edits from the backup file. */ 25 | public async read(): Promise { 26 | let serialized: string; 27 | try { 28 | serialized = decoder.decode(await vscode.workspace.fs.readFile(this.uri)); 29 | } catch { 30 | return []; 31 | } 32 | 33 | return JSON.parse(serialized, (_key, value) => { 34 | if (value && typeof value === "object" && "$u8" in value) { 35 | return base64.toUint8Array(value.$u8); 36 | } else { 37 | return value; 38 | } 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/compareSelected.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { formQuery, parseQuery } from "../shared/util/uri"; 3 | 4 | const uuidGenerator = () => { 5 | let uuid = 0; 6 | return () => (uuid++).toString(); 7 | }; 8 | const uuid = uuidGenerator(); 9 | 10 | // Initializes our custom editor with diff capabilities 11 | // @see https://github.com/microsoft/vscode/issues/97683 12 | // @see https://github.com/microsoft/vscode/issues/138525 13 | export const openCompareSelected = (originalFile: vscode.Uri, modifiedFile: vscode.Uri) => { 14 | const token = uuid(); 15 | const diffOriginalUri = originalFile.with({ 16 | scheme: "hexdiff", 17 | query: formQuery({ 18 | ...parseQuery(originalFile.query), 19 | side: "original", 20 | token: token, 21 | }), 22 | }); 23 | 24 | const diffModifiedUri = modifiedFile.with({ 25 | scheme: "hexdiff", 26 | query: formQuery({ 27 | ...parseQuery(originalFile.query), 28 | side: "modified", 29 | token: token, 30 | }), 31 | }); 32 | 33 | vscode.commands.executeCommand("vscode.diff", diffOriginalUri, diffModifiedUri); 34 | }; 35 | -------------------------------------------------------------------------------- /src/copyAs.ts: -------------------------------------------------------------------------------- 1 | import * as base64 from "js-base64"; 2 | import * as vscode from "vscode"; 3 | import { CopyFormat, ExtensionHostMessageHandler, MessageType } from "../shared/protocol"; 4 | 5 | interface QuickPickCopyFormat extends vscode.QuickPickItem { 6 | label: CopyFormat; 7 | } 8 | 9 | export const copyAsFormats: { [K in CopyFormat]: (buffer: Uint8Array, filename: string) => void } = 10 | { 11 | [CopyFormat.HexOctets]: copyAsHexOctets, 12 | [CopyFormat.Hex]: copyAsHex, 13 | [CopyFormat.Literal]: copyAsLiteral, 14 | [CopyFormat.Utf8]: copyAsText, 15 | [CopyFormat.C]: copyAsC, 16 | [CopyFormat.Go]: copyAsGo, 17 | [CopyFormat.Java]: copyAsJava, 18 | [CopyFormat.JSON]: copyAsJSON, 19 | [CopyFormat.Base64]: copyAsBase64, 20 | }; 21 | 22 | export const copyAs = async (messaging: ExtensionHostMessageHandler): Promise => { 23 | const formats: QuickPickCopyFormat[] = [ 24 | { label: CopyFormat.HexOctets }, 25 | { label: CopyFormat.Hex }, 26 | { label: CopyFormat.Literal }, 27 | { label: CopyFormat.Utf8 }, 28 | { label: CopyFormat.C }, 29 | { label: CopyFormat.Go }, 30 | { label: CopyFormat.Java }, 31 | { label: CopyFormat.JSON }, 32 | { label: CopyFormat.Base64 }, 33 | { label: "Configure HexEditor: Copy Type" as CopyFormat } 34 | ]; 35 | 36 | vscode.window.showQuickPick(formats).then(format => { 37 | if (format?.label == formats.at(-1)?.label) { 38 | vscode.commands.executeCommand('workbench.action.openSettings2', { query: '@id:hexeditor.copyType' }); 39 | } 40 | else if (format) { 41 | messaging.sendEvent({ type: MessageType.TriggerCopyAs, format: format["label"] }); 42 | } 43 | }); 44 | }; 45 | 46 | export function copyAsText(buffer: Uint8Array) { 47 | vscode.env.clipboard.writeText(new TextDecoder().decode(buffer)); 48 | } 49 | 50 | export function copyAsHexOctets(buffer: Uint8Array) { 51 | const hexString = Array.from(buffer, (b) => b.toString(16).toUpperCase().padStart(2, "0")).join(" ") 52 | vscode.env.clipboard.writeText(hexString) 53 | } 54 | 55 | export function copyAsHex(buffer: Uint8Array) { 56 | const hexString = Array.from(buffer, b => b.toString(16).padStart(2, "0")).join(""); 57 | vscode.env.clipboard.writeText(hexString); 58 | } 59 | 60 | export function copyAsLiteral(buffer: Uint8Array) { 61 | let encoded: string = ""; 62 | const hexString = Array.from(buffer, b => b.toString(16).padStart(2, "0")).join(""); 63 | const digits = hexString.match(/.{1,2}/g); 64 | if (digits) { 65 | encoded = "\\x" + digits.join("\\x"); 66 | } 67 | 68 | vscode.env.clipboard.writeText(encoded); 69 | } 70 | 71 | export function copyAsC(buffer: Uint8Array, filename: string) { 72 | const len = buffer.length; 73 | let content: string = `unsigned char ${filename}[${len}] =\n{`; 74 | 75 | for (let i = 0; i < len; ++i) { 76 | if (i % 8 == 0) { 77 | content += "\n\t"; 78 | } 79 | const byte = buffer[i].toString(16).padStart(2, "0"); 80 | content += `0x${byte}, `; 81 | } 82 | 83 | content += "\n};\n"; 84 | 85 | if (typeof process !== "undefined" && process.platform === "win32") { 86 | content = content.replace(/\n/g, "\r\n"); 87 | } 88 | 89 | vscode.env.clipboard.writeText(content); 90 | } 91 | 92 | export function copyAsGo(buffer: Uint8Array, filename: string) { 93 | const len = buffer.length; 94 | let content: string = `// ${filename} (${len} bytes)\n`; 95 | content += `var ${filename} = []byte{`; 96 | 97 | for (let i = 0; i < len; ++i) { 98 | if (i % 8 == 0) { 99 | content += "\n\t"; 100 | } 101 | const byte = buffer[i].toString(16).padStart(2, "0"); 102 | content += `0x${byte}, `; 103 | } 104 | 105 | content += "\n}\n"; 106 | 107 | if (typeof process !== "undefined" && process.platform === "win32") { 108 | content = content.replace(/\n/g, "\r\n"); 109 | } 110 | 111 | vscode.env.clipboard.writeText(content); 112 | } 113 | 114 | export function copyAsJava(buffer: Uint8Array, filename: string) { 115 | const len = buffer.length; 116 | let content: string = `byte ${filename}[] =\n{`; 117 | 118 | for (let i = 0; i < len; ++i) { 119 | if (i % 8 == 0) { 120 | content += "\n\t"; 121 | } 122 | const byte = buffer[i].toString(16).padStart(2, "0"); 123 | content += `0x${byte}, `; 124 | } 125 | 126 | content += "\n};\n"; 127 | 128 | if (typeof process !== "undefined" && process.platform === "win32") { 129 | content = content.replace(/\n/g, "\r\n"); 130 | } 131 | 132 | vscode.env.clipboard.writeText(content); 133 | } 134 | 135 | export function copyAsJSON(buffer: Uint8Array) { 136 | vscode.env.clipboard.writeText(JSON.stringify(buffer)); 137 | } 138 | 139 | export function copyAsBase64(buffer: Uint8Array) { 140 | vscode.env.clipboard.writeText(base64.fromUint8Array(buffer)); 141 | } 142 | -------------------------------------------------------------------------------- /src/dataInspectorView.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | import * as vscode from "vscode"; 5 | import { InspectorLocation } from "../shared/protocol"; 6 | import { Disposable } from "./dispose"; 7 | import { HexEditorRegistry } from "./hexEditorRegistry"; 8 | import { randomString } from "./util"; 9 | 10 | export class DataInspectorView extends Disposable implements vscode.WebviewViewProvider { 11 | public static readonly viewType = "hexEditor.dataInspectorView"; 12 | private _view?: vscode.WebviewView; 13 | private _lastMessage: unknown; 14 | 15 | constructor( 16 | private readonly _extensionURI: vscode.Uri, 17 | registry: HexEditorRegistry, 18 | ) { 19 | super(); 20 | this._register( 21 | registry.onDidChangeActiveDocument(doc => { 22 | const inspectorType = vscode.workspace.getConfiguration("hexeditor").get("inspectorType"); 23 | const shouldShow = inspectorType === InspectorLocation.Sidebar && !!doc; 24 | 25 | vscode.commands.executeCommand("setContext", "hexEditor:showSidebarInspector", shouldShow); 26 | if (shouldShow) { 27 | this.show({ autoReveal: true }); 28 | } 29 | }), 30 | ); 31 | } 32 | 33 | public resolveWebviewView( 34 | webviewView: vscode.WebviewView, 35 | _context: vscode.WebviewViewResolveContext, 36 | _token: vscode.CancellationToken, 37 | ): void { 38 | this._view = webviewView; 39 | webviewView.webview.options = { 40 | enableScripts: true, 41 | localResourceRoots: [this._extensionURI], 42 | }; 43 | webviewView.webview.html = this._getWebviewHTML(webviewView.webview); 44 | 45 | // Message handler for when the data inspector view sends messages back to the ext host 46 | webviewView.webview.onDidReceiveMessage(data => { 47 | if (data.type === "ready") webviewView.show(); 48 | }); 49 | 50 | // Once the view is disposed of we don't want to keep a reference to it anymore 51 | this._view.onDidDispose(() => (this._view = undefined)); 52 | 53 | // If the webview just became visible we send it the last message so that it stays in sync 54 | webviewView.onDidChangeVisibility(() => { 55 | if (webviewView.visible && this._lastMessage) { 56 | webviewView.webview.postMessage(this._lastMessage); 57 | } 58 | }); 59 | 60 | // Send the last message to the inspector so it preserves state upon hiding and showing 61 | if (this._lastMessage) { 62 | webviewView.webview.postMessage(this._lastMessage); 63 | } 64 | } 65 | 66 | /** 67 | * @description This is where all the messages from the editor enter the view provider 68 | * @param message The message from the main editor window 69 | */ 70 | public handleEditorMessage(message: unknown): void { 71 | // We save the last message as the webview constantly gets disposed of, but the provider still receives messages 72 | this._lastMessage = message; 73 | this._view?.webview.postMessage(message); 74 | } 75 | 76 | /** 77 | * @description Function to reveal the view panel 78 | * @param forceFocus Whether or not to force focus of the panel 79 | */ 80 | public show(options?: { forceFocus?: boolean; autoReveal?: boolean }): void { 81 | // Don't reveal the panel if configured not to 82 | if ( 83 | options?.autoReveal && 84 | !vscode.workspace.getConfiguration("hexeditor.dataInspector").get("autoReveal", false) 85 | ) { 86 | return; 87 | } 88 | 89 | if (this._view && !options?.forceFocus) { 90 | this._view.show(); 91 | } else { 92 | vscode.commands.executeCommand(`${DataInspectorView.viewType}.focus`); 93 | } 94 | 95 | // We attempt to send the last message, this prevents the inspector from coming up blank 96 | if (this._lastMessage) { 97 | this._view?.webview.postMessage(this._lastMessage); 98 | } 99 | } 100 | 101 | private _getWebviewHTML(webview: vscode.Webview): string { 102 | const scriptURI = webview.asWebviewUri( 103 | vscode.Uri.joinPath(this._extensionURI, "dist", "inspector.js"), 104 | ); 105 | const styleURI = webview.asWebviewUri( 106 | vscode.Uri.joinPath(this._extensionURI, "dist", "inspector.css"), 107 | ); 108 | const endianness = vscode.workspace 109 | .getConfiguration() 110 | .get("hexeditor.defaultEndianness") as string; 111 | const nonce = randomString(); 112 | return ` 113 | 114 | 115 | 116 | 120 | 121 | 122 | 123 | 124 | Data Inspector 125 | 126 | 127 |
128 |
129 | 130 |
131 | 132 |
133 |
134 | 138 |
139 |
140 |
141 | 142 | 143 | `; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/dispose.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | import * as vscode from "vscode"; 5 | 6 | export function disposeAll(disposables: vscode.Disposable[]): void { 7 | while (disposables.length) { 8 | const item = disposables.pop(); 9 | if (item) { 10 | item.dispose(); 11 | } 12 | } 13 | } 14 | 15 | export abstract class Disposable { 16 | private _isDisposed = false; 17 | 18 | protected _disposables: vscode.Disposable[] = []; 19 | 20 | public dispose(): any { 21 | if (this._isDisposed) { 22 | return; 23 | } 24 | this._isDisposed = true; 25 | disposeAll(this._disposables); 26 | } 27 | 28 | protected _register(value: T): T { 29 | if (this._isDisposed) { 30 | value.dispose(); 31 | } else { 32 | this._disposables.push(value); 33 | } 34 | return value; 35 | } 36 | 37 | protected get isDisposed(): boolean { 38 | return this._isDisposed; 39 | } 40 | } 41 | 42 | export interface IDisposable { 43 | dispose(): void; 44 | } 45 | 46 | export class DisposableValue { 47 | private _value: T | undefined; 48 | 49 | constructor(value?: T) { 50 | this._value = value; 51 | } 52 | 53 | public get value(): T | undefined { 54 | return this._value; 55 | } 56 | 57 | public set value(value: T | undefined) { 58 | if (this._value === value) { 59 | return; 60 | } 61 | this._value?.dispose(); 62 | this._value = value; 63 | } 64 | 65 | public dispose(): void { 66 | this.value?.dispose(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | import TelemetryReporter from "@vscode/extension-telemetry"; 5 | import * as vscode from "vscode"; 6 | import { HexDocumentEditOp } from "../shared/hexDocumentModel"; 7 | import { openCompareSelected } from "./compareSelected"; 8 | import { copyAs } from "./copyAs"; 9 | import { DataInspectorView } from "./dataInspectorView"; 10 | import { showGoToOffset } from "./goToOffset"; 11 | import { HexDiffFSProvider } from "./hexDiffFS"; 12 | import { HexEditorProvider } from "./hexEditorProvider"; 13 | import { HexEditorRegistry } from "./hexEditorRegistry"; 14 | import { prepareLazyInitDiffWorker } from "./initWorker"; 15 | import { showSelectBetweenOffsets } from "./selectBetweenOffsets"; 16 | import StatusEditMode from "./statusEditMode"; 17 | import StatusFocus from "./statusFocus"; 18 | import StatusHoverAndSelection from "./statusHoverAndSelection"; 19 | 20 | function readConfigFromPackageJson(extension: vscode.Extension): { 21 | extId: string; 22 | version: string; 23 | aiKey: string; 24 | } { 25 | const packageJSON = extension.packageJSON; 26 | return { 27 | extId: `${packageJSON.publisher}.${packageJSON.name}`, 28 | version: packageJSON.version, 29 | aiKey: packageJSON.aiKey, 30 | }; 31 | } 32 | 33 | function reopenWithHexEditor() { 34 | const activeTabInput = vscode.window.tabGroups.activeTabGroup.activeTab?.input as { 35 | [key: string]: any; 36 | uri: vscode.Uri | undefined; 37 | }; 38 | if (activeTabInput.uri) { 39 | vscode.commands.executeCommand("vscode.openWith", activeTabInput.uri, "hexEditor.hexedit"); 40 | } 41 | } 42 | 43 | export async function activate(context: vscode.ExtensionContext) { 44 | // Prepares the worker to be lazily initialized 45 | const initWorker = prepareLazyInitDiffWorker(context.extensionUri, workerDispose => 46 | context.subscriptions.push(workerDispose), 47 | ); 48 | const registry = new HexEditorRegistry(initWorker); 49 | // Register the data inspector as a separate view on the side 50 | const dataInspectorProvider = new DataInspectorView(context.extensionUri, registry); 51 | const configValues = readConfigFromPackageJson(context.extension); 52 | context.subscriptions.push( 53 | registry, 54 | dataInspectorProvider, 55 | vscode.window.registerWebviewViewProvider(DataInspectorView.viewType, dataInspectorProvider), 56 | ); 57 | 58 | const telemetryReporter = new TelemetryReporter( 59 | configValues.extId, 60 | configValues.version, 61 | configValues.aiKey, 62 | ); 63 | context.subscriptions.push(telemetryReporter); 64 | const openWithCommand = vscode.commands.registerCommand( 65 | "hexEditor.openFile", 66 | reopenWithHexEditor, 67 | ); 68 | const goToOffsetCommand = vscode.commands.registerCommand("hexEditor.goToOffset", () => { 69 | const first = registry.activeMessaging[Symbol.iterator]().next(); 70 | if (first.value) { 71 | showGoToOffset(first.value); 72 | } 73 | }); 74 | const selectBetweenOffsetsCommand = vscode.commands.registerCommand( 75 | "hexEditor.selectBetweenOffsets", 76 | () => { 77 | const first = registry.activeMessaging[Symbol.iterator]().next(); 78 | if (first.value) { 79 | showSelectBetweenOffsets(first.value, registry); 80 | } 81 | }, 82 | ); 83 | 84 | const copyAsCommand = vscode.commands.registerCommand("hexEditor.copyAs", () => { 85 | const first = registry.activeMessaging[Symbol.iterator]().next(); 86 | if (first.value) { 87 | copyAs(first.value); 88 | } 89 | }); 90 | 91 | const switchEditModeCommand = vscode.commands.registerCommand("hexEditor.switchEditMode", () => { 92 | if (registry.activeDocument) { 93 | registry.activeDocument.editMode = 94 | registry.activeDocument.editMode === HexDocumentEditOp.Insert 95 | ? HexDocumentEditOp.Replace 96 | : HexDocumentEditOp.Insert; 97 | } 98 | }); 99 | 100 | const copyOffsetAsHex = vscode.commands.registerCommand("hexEditor.copyOffsetAsHex", () => { 101 | if (registry.activeDocument) { 102 | const focused = registry.activeDocument.selectionState.focused; 103 | if (focused !== undefined) { 104 | vscode.env.clipboard.writeText(focused.toString(16).toUpperCase()); 105 | } 106 | } 107 | }); 108 | 109 | const copyOffsetAsDec = vscode.commands.registerCommand("hexEditor.copyOffsetAsDec", () => { 110 | if (registry.activeDocument) { 111 | const focused = registry.activeDocument.selectionState.focused; 112 | if (focused !== undefined) { 113 | vscode.env.clipboard.writeText(focused.toString()); 114 | } 115 | } 116 | }); 117 | 118 | const compareSelectedCommand = vscode.commands.registerCommand( 119 | "hexEditor.compareSelected", 120 | async (...args) => { 121 | if (args.length !== 2 && !(args[1] instanceof Array)) { 122 | return; 123 | } 124 | const [leftFile, rightFile] = args[1]; 125 | if (!(leftFile instanceof vscode.Uri && rightFile instanceof vscode.Uri)) { 126 | return; 127 | } 128 | openCompareSelected(leftFile, rightFile); 129 | }, 130 | ); 131 | 132 | context.subscriptions.push(new StatusEditMode(registry)); 133 | context.subscriptions.push(new StatusFocus(registry)); 134 | context.subscriptions.push(new StatusHoverAndSelection(registry)); 135 | context.subscriptions.push(goToOffsetCommand); 136 | context.subscriptions.push(selectBetweenOffsetsCommand); 137 | context.subscriptions.push(copyAsCommand); 138 | context.subscriptions.push(switchEditModeCommand); 139 | context.subscriptions.push(openWithCommand); 140 | context.subscriptions.push(telemetryReporter); 141 | context.subscriptions.push(copyOffsetAsDec, copyOffsetAsHex); 142 | context.subscriptions.push(compareSelectedCommand); 143 | context.subscriptions.push( 144 | vscode.workspace.registerFileSystemProvider("hexdiff", new HexDiffFSProvider(), { 145 | isCaseSensitive: typeof process !== 'undefined' && process.platform !== 'win32' && process.platform !== 'darwin', 146 | }), 147 | ); 148 | context.subscriptions.push( 149 | HexEditorProvider.register(context, telemetryReporter, dataInspectorProvider, registry), 150 | ); 151 | } 152 | 153 | export function deactivate(): void { 154 | /* no-op */ 155 | } 156 | -------------------------------------------------------------------------------- /src/goToOffset.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { ExtensionHostMessageHandler, MessageType } from "../shared/protocol"; 3 | 4 | const addressRe = /^0x[a-f0-9]+$/i; 5 | const decimalRe = /^[0-9]+$/i; 6 | 7 | export const showGoToOffset = (messaging: ExtensionHostMessageHandler): void => { 8 | const input = vscode.window.createInputBox(); 9 | input.placeholder = "Enter offset to go to"; 10 | 11 | messaging.sendEvent({ type: MessageType.StashDisplayedOffset }); 12 | 13 | let lastValue: number | undefined; 14 | let accepted = false; 15 | 16 | input.onDidChangeValue(value => { 17 | if (!value) { 18 | lastValue = undefined; 19 | } else if (addressRe.test(value)) { 20 | lastValue = parseInt(value.slice(2), 16); 21 | } else if (decimalRe.test(value)) { 22 | lastValue = parseInt(value, 10); 23 | } else { 24 | input.validationMessage = vscode.l10n.t( 25 | "Offset must be provided as a decimal (12345) or hex (0x12345) address", 26 | ); 27 | return; 28 | } 29 | 30 | input.validationMessage = ""; 31 | if (lastValue !== undefined) { 32 | input.validationMessage = ""; 33 | messaging.sendEvent({ type: MessageType.GoToOffset, offset: lastValue }); 34 | } 35 | }); 36 | 37 | input.onDidAccept(() => { 38 | accepted = true; 39 | if (lastValue !== undefined) { 40 | messaging.sendEvent({ type: MessageType.SetFocusedByte, offset: lastValue }); 41 | } 42 | }); 43 | 44 | input.onDidHide(() => { 45 | if (!accepted) { 46 | messaging.sendEvent({ type: MessageType.PopDisplayedOffset }); 47 | } 48 | }); 49 | 50 | input.show(); 51 | }; 52 | -------------------------------------------------------------------------------- /src/hexDiffFS.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | 3 | import * as vscode from "vscode"; 4 | 5 | /** changes our scheme to file so we can use workspace.fs */ 6 | function toFileUri(uri: vscode.Uri) { 7 | return uri.with({ scheme: "file" }); 8 | } 9 | // Workaround to open our files in diff mode. Used by both web and node 10 | // to create a diff model, but the methods are only used in web whereas 11 | // in node we use node's fs. 12 | export class HexDiffFSProvider implements vscode.FileSystemProvider { 13 | readDirectory( 14 | uri: vscode.Uri, 15 | ): [string, vscode.FileType][] | Thenable<[string, vscode.FileType][]> { 16 | throw new Error("Method not implemented."); 17 | } 18 | createDirectory(uri: vscode.Uri): void | Thenable { 19 | throw new Error("Method not implemented."); 20 | } 21 | readFile(uri: vscode.Uri): Uint8Array | Thenable { 22 | return vscode.workspace.fs.readFile(toFileUri(uri)); 23 | } 24 | writeFile( 25 | uri: vscode.Uri, 26 | content: Uint8Array, 27 | options: { readonly create: boolean; readonly overwrite: boolean }, 28 | ): void | Thenable { 29 | return vscode.workspace.fs.writeFile(toFileUri(uri), content); 30 | } 31 | 32 | delete(uri: vscode.Uri, options: { readonly recursive: boolean }): void | Thenable { 33 | return vscode.workspace.fs.delete(toFileUri(uri), options); 34 | } 35 | rename( 36 | oldUri: vscode.Uri, 37 | newUri: vscode.Uri, 38 | options: { readonly overwrite: boolean }, 39 | ): void | Thenable { 40 | throw new Error("Method not implemented"); 41 | } 42 | copy?( 43 | source: vscode.Uri, 44 | destination: vscode.Uri, 45 | options: { readonly overwrite: boolean }, 46 | ): void | Thenable { 47 | throw new Error("Method not implemented."); 48 | } 49 | private _emitter = new vscode.EventEmitter(); 50 | onDidChangeFile: vscode.Event = this._emitter.event; 51 | public watch( 52 | uri: vscode.Uri, 53 | options: { readonly recursive: boolean; readonly excludes: readonly string[] }, 54 | ): vscode.Disposable { 55 | return new vscode.Disposable(() => {}); 56 | } 57 | stat(uri: vscode.Uri): vscode.FileStat | Thenable { 58 | return vscode.workspace.fs.stat(toFileUri(uri)); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/hexEditorRegistry.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | import * as vscode from "vscode"; 5 | import { DiffExtensionHostMessageHandler } from "../shared/diffWorkerProtocol"; 6 | import { HexDiffModel, HexDiffModelBuilder } from "../shared/hexDiffModel"; 7 | import { ExtensionHostMessageHandler } from "../shared/protocol"; 8 | import { parseQuery } from "../shared/util/uri"; 9 | import { Disposable } from "./dispose"; 10 | import { HexDocument } from "./hexDocument"; 11 | 12 | const EMPTY: never[] = []; 13 | 14 | export class HexEditorRegistry extends Disposable { 15 | private readonly docs = new Map>(); 16 | private readonly diffsBuilder = new Map< 17 | string, 18 | { refCount: number; value: HexDiffModelBuilder } 19 | >(); 20 | private onChangeEmitter = new vscode.EventEmitter(); 21 | private _activeDocument?: HexDocument; 22 | 23 | /** 24 | * Event emitter that fires when the focused hex editor changes. 25 | */ 26 | public readonly onDidChangeActiveDocument = this.onChangeEmitter.event; 27 | 28 | /** 29 | * The currently active hex editor. 30 | */ 31 | public get activeDocument() { 32 | return this._activeDocument; 33 | } 34 | 35 | /** 36 | * Messaging for the active hex editor. 37 | */ 38 | public get activeMessaging(): Iterable { 39 | return (this._activeDocument && this.docs.get(this._activeDocument)) || EMPTY; 40 | } 41 | 42 | constructor(private readonly initDiffWorker: () => DiffExtensionHostMessageHandler) { 43 | super(); 44 | this._register(vscode.window.tabGroups.onDidChangeTabs(this.onChangedTabs, this)); 45 | this._register(vscode.window.tabGroups.onDidChangeTabGroups(this.onChangedTabs, this)); 46 | this.onChangedTabs(); 47 | } 48 | 49 | /** Gets messaging info for a document */ 50 | public getMessaging(document: HexDocument): Iterable { 51 | return this.docs.get(document) || EMPTY; 52 | } 53 | 54 | /** Registers an opened hex document. */ 55 | public add(document: HexDocument, messaging: ExtensionHostMessageHandler) { 56 | let collection = this.docs.get(document); 57 | if (collection) { 58 | collection.add(messaging); 59 | } else { 60 | collection = new Set([messaging]); 61 | this.docs.set(document, collection); 62 | } 63 | 64 | // re-evaluate, since if a hex editor was just opened it won't have created 65 | // a HexDocument by the time the tab change event is delivered. 66 | this.onChangedTabs(); 67 | 68 | return { 69 | dispose: () => { 70 | collection!.delete(messaging); 71 | if (collection!.size === 0) { 72 | this.docs.delete(document); 73 | } 74 | }, 75 | }; 76 | } 77 | 78 | /** returns a diff model using the file uri */ 79 | public getDiff(uri: vscode.Uri): { 80 | builder: HexDiffModelBuilder | undefined; 81 | dispose: () => void; 82 | } { 83 | const { token } = parseQuery(uri.query); 84 | if (token === undefined) { 85 | return { builder: undefined, dispose: () => {} }; 86 | } 87 | // Lazily initializes the diff worker, if it isn't 88 | // iniitalized already 89 | const messageHandler = this.initDiffWorker(); 90 | 91 | // Creates a new diff model 92 | if (!this.diffsBuilder.has(token)) { 93 | this.diffsBuilder.set(token, { 94 | refCount: 0, 95 | value: new HexDiffModel.Builder(messageHandler), 96 | }); 97 | } 98 | const builder = this.diffsBuilder.get(token)!; 99 | builder.refCount++; 100 | 101 | return { 102 | builder: builder.value, 103 | dispose: () => { 104 | builder.refCount--; 105 | if (builder.refCount === 0) { 106 | this.diffsBuilder.delete(token); 107 | } 108 | }, 109 | }; 110 | } 111 | 112 | private onChangedTabs() { 113 | const input = vscode.window.tabGroups.activeTabGroup.activeTab?.input; 114 | const uri = input instanceof vscode.TabInputCustom ? input.uri : undefined; 115 | let next: HexDocument | undefined = undefined; 116 | if (uri) { 117 | for (const doc of this.docs.keys()) { 118 | if (doc.uri.toString() === uri.toString()) { 119 | next = doc; 120 | break; 121 | } 122 | } 123 | } 124 | 125 | if (next === this._activeDocument) { 126 | return; 127 | } 128 | 129 | this._activeDocument = next; 130 | vscode.commands.executeCommand("setContext", "hexEditor:isActive", !!next); 131 | this.onChangeEmitter.fire(next); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/initWorker.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { 3 | DiffExtensionHostMessageHandler, 4 | FromDiffWorkerMessage, 5 | ToDiffWorkerMessage, 6 | } from "../shared/diffWorkerProtocol"; 7 | import { MessageHandler } from "../shared/protocol"; 8 | 9 | /** Prepares diff worker to be lazily initialized and instantiated once*/ 10 | export function prepareLazyInitDiffWorker( 11 | extensionUri: vscode.Uri, 12 | addDispose: (dispose: vscode.Disposable) => void, 13 | ) { 14 | let messageHandler: DiffExtensionHostMessageHandler; 15 | return () => { 16 | if (!messageHandler) { 17 | const { msgHandler, dispose } = initDiffWorker(extensionUri); 18 | messageHandler = msgHandler; 19 | addDispose({ dispose: dispose }); 20 | } 21 | return messageHandler; 22 | }; 23 | } 24 | 25 | /** Initializes the diff worker */ 26 | function initDiffWorker(extensionUri: vscode.Uri): { 27 | msgHandler: DiffExtensionHostMessageHandler; 28 | dispose: () => void; 29 | } { 30 | let worker: Worker; 31 | const workerFilePath = vscode.Uri.joinPath(extensionUri, "dist", "diffWorker.js").toString(); 32 | 33 | try { 34 | worker = new Worker(workerFilePath); 35 | } catch { 36 | // eslint-disable-next-line @typescript-eslint/no-var-requires 37 | const { Worker } = require("worker_threads") as typeof import("worker_threads"); 38 | const nodeWorker = new Worker(new URL(workerFilePath)); 39 | // Web and node js have different worker interfaces, so we share a function 40 | // to initialize both workers the same way. 41 | const ref = nodeWorker.addListener; 42 | (nodeWorker as any).addEventListener = ref; 43 | worker = nodeWorker as any; 44 | } 45 | 46 | const workerMessageHandler = new MessageHandler( 47 | // Always return undefined as the diff worker 48 | // does not request anything from extension host 49 | async () => undefined, 50 | // worker.postMessage's transfer parameter type looks to be wrong because 51 | // it should be set as optional. 52 | (message, transfer) => worker.postMessage(message, transfer!), 53 | ); 54 | 55 | worker.addEventListener("message", e => 56 | // e.data is used in web worker and e is used in node js worker 57 | e.data 58 | ? workerMessageHandler.handleMessage(e.data) 59 | : workerMessageHandler.handleMessage(e as any), 60 | ); 61 | return { msgHandler: workerMessageHandler, dispose: () => worker.terminate() }; 62 | } 63 | -------------------------------------------------------------------------------- /src/literalSearch.ts: -------------------------------------------------------------------------------- 1 | export const Wildcard = Symbol("Wildcard"); 2 | 3 | export const identityEquivalency = new Uint8Array(0xff); 4 | for (let i = 0; i < 0xff; i++) { 5 | identityEquivalency[i] = i; 6 | } 7 | 8 | export const caseInsensitiveEquivalency = new Uint8Array(0xff); 9 | for (let i = 0; i < 0xff; i++) { 10 | caseInsensitiveEquivalency[i] = String.fromCharCode(i).toUpperCase().charCodeAt(0); 11 | } 12 | 13 | /** 14 | * A simple literal search implementation with support for placeholders. I've 15 | * attempted to use or adapt several string search algorithms for use here, 16 | * such as Boyer-Moore and a DFA approach, but was not able to find one that 17 | * worked with placeholders. 18 | * 19 | * A DFA can work with wildcards easily enough, and an implementation of that 20 | * can be found in commit 5bcc7b4e, but I don't think placeholders can be 21 | * efficiently encoded into the DFA in that approach. Eventually that just 22 | * becomes a regex engine... 23 | * 24 | * Note that streamsearch (connor4312/streamsearch) is 70% faster than this, 25 | * but doesn't have support for the equivalency table or placeholders. We could 26 | * add a happy path that uses that library if no placeholders are present, but 27 | * I've opted for simplicity for the moment. This operates at 28 | * around 50 MB/s on my macbook. 29 | */ 30 | export class LiteralSearch { 31 | /** Rolling window of the last bytes, used to emit matched text */ 32 | private readonly buffer: Uint8Array; 33 | /** Temporary buffer used to construct and return the match */ 34 | private matchTmpBuf?: Uint8Array; 35 | /** Length of the needle string */ 36 | private readonly needleLen = 0; 37 | /** Index in the source stream we're at. */ 38 | private index = 0; 39 | /** Amount of usable data in the buffer. Reset whenever a match happens */ 40 | private usableBuffer = 0; 41 | 42 | constructor( 43 | private readonly needle: readonly (typeof Wildcard | Uint8Array)[], 44 | private readonly onMatch: (index: number, match: Uint8Array) => void, 45 | private readonly equivalencyTable = identityEquivalency, 46 | ) { 47 | for (const chunk of needle) { 48 | if (chunk === Wildcard) { 49 | this.needleLen++; 50 | } else { 51 | this.needleLen += chunk.length; 52 | } 53 | } 54 | 55 | this.buffer = new Uint8Array(this.needleLen); 56 | } 57 | 58 | push(chunk: Uint8Array): void { 59 | const { needleLen, buffer } = this; 60 | for (let i = 0; i < chunk.length; i++) { 61 | buffer[this.index++ % needleLen] = chunk[i]; 62 | this.usableBuffer++; 63 | this.attemptMatch(); 64 | } 65 | } 66 | 67 | private attemptMatch() { 68 | const { needle, needleLen, buffer, index, equivalencyTable } = this; 69 | if (this.usableBuffer < needleLen) { 70 | return; 71 | } 72 | 73 | let k = 0; 74 | for (let i = 0; i < needle.length; i++) { 75 | const chunk = needle[i]; 76 | if (chunk === Wildcard) { 77 | k++; 78 | continue; 79 | } 80 | 81 | for (let j = 0; j < chunk.length; j++) { 82 | if (equivalencyTable[chunk[j]] !== equivalencyTable[buffer[(index + k) % needleLen]]) { 83 | return; 84 | } 85 | k++; 86 | } 87 | } 88 | 89 | if (!this.matchTmpBuf) { 90 | this.matchTmpBuf = new Uint8Array(needleLen); 91 | } 92 | 93 | const split = this.index % needleLen; 94 | this.matchTmpBuf.set(buffer.subarray(split), 0); 95 | this.matchTmpBuf.set(buffer.subarray(0, split), needleLen - split); 96 | this.onMatch(this.index - needleLen, this.matchTmpBuf); 97 | this.usableBuffer = 0; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/searchProvider.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | import { ExtensionHostMessageHandler, MessageType } from "../shared/protocol"; 5 | import { ISearchRequest } from "./searchRequest"; 6 | 7 | /*** 8 | * Simple helper class which holds the search request for a document 9 | */ 10 | export class SearchProvider { 11 | private _request: ISearchRequest | undefined; 12 | 13 | /*** 14 | * @description Creates a new search request and returns the request object 15 | * @ 16 | */ 17 | public start(messaging: ExtensionHostMessageHandler, request: ISearchRequest): void { 18 | this._request?.dispose(); 19 | this._request = request; 20 | 21 | (async () => { 22 | for await (const results of request.search()) { 23 | messaging.sendEvent({ type: MessageType.SearchProgress, data: results }); 24 | } 25 | })(); 26 | } 27 | 28 | /** 29 | * @description Cancels the search request and stops tracking in the provider 30 | */ 31 | public cancel(): void { 32 | this._request?.dispose(); 33 | this._request = undefined; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/searchRequest.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | import { Disposable } from "vscode"; 5 | import { 6 | LiteralSearchQuery, 7 | RegExpSearchQuery, 8 | SearchResult, 9 | SearchResultsWithProgress, 10 | } from "../shared/protocol"; 11 | import { Uint8ArrayMap } from "../shared/util/uint8ArrayMap"; 12 | import { HexDocument } from "./hexDocument"; 13 | import { LiteralSearch, Wildcard, caseInsensitiveEquivalency } from "./literalSearch"; 14 | 15 | /** Type that defines a search request created from the {@link SearchProvider} */ 16 | export interface ISearchRequest extends Disposable { 17 | search(): AsyncIterable; 18 | } 19 | 20 | class ResultsCollector { 21 | private static readonly targetUpdateInterval = 1000; 22 | private readonly buffers = new Uint8ArrayMap(); 23 | 24 | public get capped() { 25 | return this.cap === 0; 26 | } 27 | 28 | constructor( 29 | private readonly filesize: number | undefined, 30 | private cap: number | undefined, 31 | ) {} 32 | 33 | public fileOffset = 0; 34 | 35 | private lastYieldedTime = Date.now(); 36 | private results: SearchResult[] = []; 37 | 38 | /** Adds results to the collector */ 39 | public push(previousRef: Uint8Array, from: number, to: number) { 40 | // Copy the array, if new, since the search will return mutable references 41 | const previous = this.buffers.set(previousRef, () => new Uint8Array(previousRef)); 42 | 43 | if (this.cap === undefined) { 44 | this.results.push({ from, to, previous }); 45 | } else if (this.cap > 0) { 46 | this.results.push({ from, to, previous }); 47 | this.cap--; 48 | } 49 | } 50 | 51 | /** Returns the results to yield right now, if any */ 52 | public toYield(): SearchResultsWithProgress | undefined { 53 | const now = Date.now(); 54 | if (now - this.lastYieldedTime > ResultsCollector.targetUpdateInterval) { 55 | this.lastYieldedTime = now; 56 | const results = this.results; 57 | this.results = []; 58 | return { progress: this.filesize ? this.fileOffset / this.filesize : 0, results }; 59 | } 60 | 61 | return undefined; 62 | } 63 | 64 | /** Returns the final set of results */ 65 | public final(): SearchResultsWithProgress { 66 | return { progress: 1, capped: this.capped, results: this.results }; 67 | } 68 | } 69 | 70 | /** Request that handles searching for byte or text literals. */ 71 | export class LiteralSearchRequest implements ISearchRequest { 72 | private cancelled = false; 73 | 74 | constructor( 75 | private readonly document: HexDocument, 76 | private readonly query: LiteralSearchQuery, 77 | private readonly isCaseSensitive: boolean, 78 | private readonly cap: number | undefined, 79 | ) {} 80 | 81 | /** @inheritdoc */ 82 | public dispose(): void { 83 | this.cancelled = true; 84 | } 85 | 86 | /** @inheritdoc */ 87 | public async *search(): AsyncIterableIterator { 88 | const { isCaseSensitive, query, document, cap } = this; 89 | const collector = new ResultsCollector(await document.size(), cap); 90 | 91 | const streamSearch = new LiteralSearch( 92 | query.literal.map(c => (c === "*" ? Wildcard : c)), 93 | (index, data) => collector.push(data, index, index + data.length), 94 | isCaseSensitive ? undefined : caseInsensitiveEquivalency, 95 | ); 96 | 97 | for await (const chunk of document.readWithUnsavedEdits(0)) { 98 | if (this.cancelled || collector.capped) { 99 | yield collector.final(); 100 | return; 101 | } 102 | 103 | streamSearch.push(chunk); 104 | collector.fileOffset += chunk.length; 105 | 106 | const toYield = collector.toYield(); 107 | if (toYield) { 108 | yield toYield; 109 | } 110 | } 111 | 112 | yield collector.final(); 113 | } 114 | } 115 | 116 | const regexSearchWindow = 8 * 1024; 117 | 118 | /** 119 | * Request that handles searching for text regexes. This works on a window of 120 | * data and is not an ideal implementation. For instance, a regex could start 121 | * matching at byte 0 and end matching 10 GB later. For this, we need a 122 | * streaming regex matcher. There are a few of them, but none that I can find 123 | * in JavaScript/npm today. 124 | * 125 | * Neither Rust's regex engine or RE2 support streaming, but PCRE2 does, so we 126 | * may at some point evaluate wrapping that into webassembly and using it here. 127 | * 128 | * @see https://www.pcre.org/current/doc/html/pcre2partial.html 129 | */ 130 | export class RegexSearchRequest implements ISearchRequest { 131 | private cancelled = false; 132 | private re: RegExp; 133 | 134 | constructor( 135 | private readonly document: HexDocument, 136 | re: RegExpSearchQuery, 137 | caseSensitive: boolean, 138 | private readonly cap: number | undefined, 139 | ) { 140 | this.re = new RegExp(re.re, caseSensitive ? "g" : "ig"); 141 | } 142 | 143 | /** @inheritdoc */ 144 | public dispose(): void { 145 | this.cancelled = true; 146 | } 147 | 148 | /** @inheritdoc */ 149 | public async *search(): AsyncIterableIterator { 150 | let str = ""; 151 | let strStart = 0; 152 | 153 | const { re, document } = this; 154 | const decoder = new TextDecoder("ascii"); 155 | const encoder = new TextEncoder(); 156 | const collector = new ResultsCollector(await document.size(), this.cap); 157 | 158 | for await (const chunk of document.readWithUnsavedEdits(0)) { 159 | if (this.cancelled || collector.capped) { 160 | yield collector.final(); 161 | return; 162 | } 163 | 164 | str += decoder.decode(chunk); 165 | 166 | let lastReIndex = 0; 167 | for (const match of str.matchAll(re)) { 168 | const start = strStart + str.slice(0, match.index!).length; 169 | const length = match[0].length; 170 | collector.push(encoder.encode(match[0]), start, start + length); 171 | lastReIndex = match.index! + match[0].length; 172 | } 173 | 174 | collector.fileOffset += chunk.length; 175 | 176 | // Cut off the start of the string either to meet the window requirements, 177 | // or at the index of the last match -- whichever is greater. 178 | const overflow = Math.max(str.length - regexSearchWindow, lastReIndex); 179 | if (overflow > 0) { 180 | strStart += overflow; 181 | re.lastIndex = 0; 182 | str = str.slice(overflow); 183 | } 184 | 185 | const toYield = collector.toYield(); 186 | if (toYield) { 187 | yield toYield; 188 | } 189 | } 190 | 191 | yield collector.final(); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/selectBetweenOffsets.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { ExtensionHostMessageHandler, MessageType } from "../shared/protocol"; 3 | import { ISelectionState } from "./hexDocument"; 4 | import { HexEditorRegistry } from "./hexEditorRegistry"; 5 | 6 | const addressRe = /^0x[a-f0-9]+$/i; 7 | const decimalRe = /^[0-9]+$/i; 8 | 9 | export const showSelectBetweenOffsets = async ( 10 | messaging: ExtensionHostMessageHandler, 11 | registry: HexEditorRegistry, 12 | ): Promise => { 13 | messaging.sendEvent({ type: MessageType.StashDisplayedOffset }); 14 | 15 | let focusedOffset: string | undefined = undefined; 16 | 17 | // acquire selection state from active HexDocument 18 | const selectionState: ISelectionState | undefined = registry.activeDocument?.selectionState; 19 | 20 | // if there is a selection, use the focused offset as the starting offset 21 | if ( 22 | selectionState !== undefined && 23 | selectionState.selected > 0 && 24 | selectionState.focused !== undefined 25 | ) { 26 | // converting to hex to increase readability 27 | focusedOffset = `0x${selectionState.focused.toString(16)}`; 28 | } 29 | 30 | const offset1 = await getOffset(vscode.l10n.t("Enter offset to select from"), focusedOffset); 31 | if (offset1 !== undefined) { 32 | const offset2 = await getOffset(vscode.l10n.t("Enter offset to select until")); 33 | if (offset2 !== undefined) { 34 | messaging.sendEvent({ 35 | type: MessageType.SetFocusedByteRange, 36 | startingOffset: offset1, 37 | endingOffset: offset2, 38 | }); 39 | } 40 | } 41 | 42 | async function getOffset(inputBoxTitle: string, value?: string): Promise { 43 | const disposables: vscode.Disposable[] = []; 44 | try { 45 | return await new Promise((resolve, _reject) => { 46 | const input = vscode.window.createInputBox(); 47 | input.title = inputBoxTitle; 48 | input.value = value || ""; 49 | input.prompt = inputBoxTitle; 50 | input.ignoreFocusOut = true; 51 | input.placeholder = inputBoxTitle; 52 | disposables.push( 53 | input.onDidAccept(() => { 54 | const value = input.value; 55 | input.enabled = false; 56 | input.busy = true; 57 | const offset = validate(value); 58 | if (offset !== undefined) { 59 | resolve(offset); 60 | } 61 | input.enabled = true; 62 | input.busy = false; 63 | }), 64 | input.onDidChangeValue(text => { 65 | const offset = validate(text); 66 | 67 | if (offset === undefined) { 68 | input.validationMessage = 69 | "Offset must be provided as a decimal (12345) or hex (0x12345) address"; 70 | } else { 71 | input.validationMessage = ""; 72 | messaging.sendEvent({ type: MessageType.GoToOffset, offset: offset }); 73 | } 74 | }), 75 | input.onDidHide(() => { 76 | messaging.sendEvent({ type: MessageType.PopDisplayedOffset }); 77 | resolve(undefined); 78 | }), 79 | input, 80 | ); 81 | input.show(); 82 | }); 83 | } finally { 84 | disposables.forEach(d => d.dispose()); 85 | } 86 | 87 | function validate(text: string): number | undefined { 88 | let validatedOffset: number | undefined = undefined; 89 | if (!text) { 90 | validatedOffset = undefined; 91 | } else if (addressRe.test(text)) { 92 | validatedOffset = parseInt(text.slice(2), 16); 93 | } else if (decimalRe.test(text)) { 94 | validatedOffset = parseInt(text, 10); 95 | } 96 | 97 | return validatedOffset; 98 | } 99 | } 100 | }; 101 | -------------------------------------------------------------------------------- /src/statusEditMode.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | import * as vscode from "vscode"; 5 | import { HexDocumentEditOp } from "../shared/hexDocumentModel"; 6 | import { Disposable, DisposableValue } from "./dispose"; 7 | import { HexDocument } from "./hexDocument"; 8 | import { HexEditorRegistry } from "./hexEditorRegistry"; 9 | 10 | /** 11 | * this is a class to represent the status bar item that displays the edit mode 12 | * - Replace or Insert 13 | * 14 | * @class StatusEditMode 15 | */ 16 | export default class StatusEditMode extends Disposable { 17 | private readonly item: vscode.StatusBarItem; 18 | private readonly docChangeListener = this._register(new DisposableValue()); 19 | 20 | constructor(registry: HexEditorRegistry) { 21 | super(); 22 | 23 | this.item = this._register( 24 | vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 99), 25 | ); 26 | this.item.tooltip = vscode.l10n.t("Switch Edit Mode"); 27 | this.item.command = "hexEditor.switchEditMode"; 28 | 29 | const trackDocument = (doc: HexDocument | undefined) => { 30 | if (doc) { 31 | this.docChangeListener.value = doc.onDidChangeEditMode(e => this.update(e)); 32 | this.update(doc.editMode); 33 | this.show(); 34 | } else { 35 | this.hide(); 36 | } 37 | }; 38 | 39 | this._register(registry.onDidChangeActiveDocument(trackDocument)); 40 | trackDocument(registry.activeDocument); 41 | } 42 | 43 | update(mode: HexDocumentEditOp.Insert | HexDocumentEditOp.Replace): void { 44 | if (mode === HexDocumentEditOp.Insert) { 45 | this.item.text = vscode.l10n.t("Insert"); 46 | } else if (mode === HexDocumentEditOp.Replace) { 47 | this.item.text = vscode.l10n.t("Replace"); 48 | } else { 49 | this.item.hide(); 50 | return; 51 | } 52 | this.item.show(); 53 | } 54 | 55 | show() { 56 | this.item.show(); 57 | } 58 | 59 | hide() { 60 | this.item.hide(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/statusFocus.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | import * as vscode from "vscode"; 5 | import { Disposable, DisposableValue } from "./dispose"; 6 | import { HexDocument, ISelectionState } from "./hexDocument"; 7 | import { HexEditorRegistry } from "./hexEditorRegistry"; 8 | 9 | const numberFormat = new Intl.NumberFormat(); 10 | 11 | /** 12 | * Displays the focused byte in a StatusBarItem 13 | * 14 | * @class StatusFocus 15 | */ 16 | export default class StatusFocus extends Disposable { 17 | private readonly item: vscode.StatusBarItem; 18 | private readonly docChangeListener = this._register(new DisposableValue()); 19 | 20 | constructor(registry: HexEditorRegistry) { 21 | super(); 22 | 23 | this.item = this._register( 24 | vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100), 25 | ); 26 | 27 | const trackDocument = (doc: HexDocument | undefined) => { 28 | if (doc) { 29 | this.docChangeListener.value = doc.onDidChangeSelectionState(e => this.update(e)); 30 | this.update(doc.selectionState); 31 | this.show(); 32 | } else { 33 | this.hide(); 34 | } 35 | }; 36 | 37 | this._register(registry.onDidChangeActiveDocument(trackDocument)); 38 | trackDocument(registry.activeDocument); 39 | } 40 | 41 | update({ focused }: ISelectionState): void { 42 | const nFocus = focused !== undefined ? numberFormat.format(focused) : undefined; 43 | if (nFocus) { 44 | this.item.text = vscode.l10n.t("{0}/0x{1}", nFocus, focused!.toString(16).toUpperCase()); 45 | this.item.show(); 46 | } else { 47 | this.item.hide(); 48 | return; 49 | } 50 | } 51 | 52 | show() { 53 | this.item.show(); 54 | } 55 | 56 | hide() { 57 | this.item.hide(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/statusHoverAndSelection.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | import * as vscode from "vscode"; 5 | import { Disposable, DisposableValue } from "./dispose"; 6 | import { HexDocument } from "./hexDocument"; 7 | import { HexEditorRegistry } from "./hexEditorRegistry"; 8 | 9 | const numberFormat = new Intl.NumberFormat(); 10 | 11 | /** 12 | * Displays the hovered byte and the selection count in a StatusBarItem 13 | * 14 | * @class StatusHoverAndSelection 15 | */ 16 | export default class StatusHoverAndSelection extends Disposable { 17 | private readonly item: vscode.StatusBarItem; 18 | private readonly docChangeListener = this._register(new DisposableValue()); 19 | 20 | constructor(registry: HexEditorRegistry) { 21 | super(); 22 | 23 | this.item = this._register( 24 | // Primary Badge, so appears first 25 | vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 101), 26 | ); 27 | 28 | const trackDocument = (doc: HexDocument | undefined) => { 29 | if (doc) { 30 | this._register(doc.onDidChangeHoverState(() => this.update(doc))); 31 | this._register(doc.onDidChangeSelectionState(() => this.update(doc))); 32 | this.update(doc); 33 | this.show(); 34 | } else { 35 | this.hide(); 36 | } 37 | }; 38 | 39 | this._register(registry.onDidChangeActiveDocument(trackDocument)); 40 | trackDocument(registry.activeDocument); 41 | } 42 | 43 | update({ hoverState, selectionState }: HexDocument): void { 44 | const { selected } = selectionState; 45 | const nHovered = hoverState !== undefined ? numberFormat.format(hoverState) : undefined; 46 | const nSelected = selected > 1 ? numberFormat.format(selected) : undefined; 47 | if (nHovered && nSelected) { 48 | this.item.text = vscode.l10n.t( 49 | "{0}/0x{1} ({2}/0x{3} selected)", 50 | nHovered, 51 | hoverState!.toString(16).toUpperCase(), 52 | nSelected, 53 | selected!.toString(16).toUpperCase(), 54 | ); 55 | } else if (nHovered) { 56 | this.item.text = vscode.l10n.t("{0}/0x{1}", nHovered, hoverState!.toString(16).toUpperCase()); 57 | } else if (nSelected) { 58 | this.item.text = vscode.l10n.t( 59 | "{0}/0x{1} selected", 60 | nSelected, 61 | selected!.toString(16).toUpperCase(), 62 | ); 63 | } else { 64 | // Hiding the element creates a flashing effect so instead set it 65 | // to an empty string 66 | this.item.text = ""; 67 | } 68 | 69 | this.item.show(); 70 | } 71 | 72 | show() { 73 | this.item.show(); 74 | } 75 | 76 | hide() { 77 | this.item.hide(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/test/backup.test.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------- 2 | * Copyright (C) Microsoft Corporation. All rights reserved. 3 | *--------------------------------------------------------*/ 4 | 5 | import { expect } from "chai"; 6 | import { HexDocumentEdit, HexDocumentEditOp } from "../../shared/hexDocumentModel"; 7 | import { Backup } from "../backup"; 8 | import { getTempFile } from "./util"; 9 | 10 | describe("Backup", () => { 11 | const edits: HexDocumentEdit[] = [ 12 | { op: HexDocumentEditOp.Delete, offset: 3, previous: new Uint8Array([3, 4, 5]) }, 13 | { 14 | op: HexDocumentEditOp.Replace, 15 | offset: 1, 16 | value: new Uint8Array([10, 11, 12]), 17 | previous: new Uint8Array([1, 2, 6]), 18 | }, 19 | ]; 20 | 21 | it("round trips", async () => { 22 | const backup = new Backup(await getTempFile()); 23 | await backup.write(edits); 24 | expect(await backup.read()).to.deep.equal(edits); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/test/hexDocumentModel.test.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------- 2 | * Copyright (C) Microsoft Corporation. All rights reserved. 3 | *--------------------------------------------------------*/ 4 | 5 | import { expect } from "chai"; 6 | import { 7 | EditRangeOp, 8 | HexDocumentEdit, 9 | HexDocumentEditOp, 10 | HexDocumentModel, 11 | IEditTimeline, 12 | } from "../../shared/hexDocumentModel"; 13 | import { deserializeEdits, serializeEdits } from "../../shared/serialization"; 14 | import { getTestFileAccessor, pseudoRandom } from "./util"; 15 | 16 | describe("HexDocumentModel", () => { 17 | const original: ReadonlyArray = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; 18 | 19 | let model: HexDocumentModel; 20 | beforeEach(async () => { 21 | model = new HexDocumentModel({ 22 | accessor: await getTestFileAccessor(new Uint8Array(original)), 23 | supportsLengthChanges: false, 24 | isFiniteSize: true, 25 | }); 26 | }); 27 | 28 | describe("edit timeline", () => { 29 | it("keeps offsets in replacements", () => { 30 | model.makeEdits([ 31 | { 32 | op: HexDocumentEditOp.Replace, 33 | offset: 6, 34 | value: new Uint8Array([16]), 35 | previous: new Uint8Array([6, 7, 8]), 36 | }, 37 | { 38 | op: HexDocumentEditOp.Replace, 39 | offset: 1, 40 | value: new Uint8Array([11]), 41 | previous: new Uint8Array([1, 2, 3]), 42 | }, 43 | ]); 44 | 45 | const timeline: IEditTimeline = (model as any).getAllEditTimeline(); 46 | 47 | expect(timeline).to.deep.equal({ 48 | sizeDelta: -4, 49 | ranges: [ 50 | { op: EditRangeOp.Read, editIndex: 1, roffset: 0, offset: 0 }, 51 | { op: EditRangeOp.Insert, editIndex: 1, offset: 1, value: new Uint8Array([11]) }, 52 | { op: EditRangeOp.Read, editIndex: 1, roffset: 4, offset: 2 }, 53 | { op: EditRangeOp.Insert, editIndex: 0, offset: 4, value: new Uint8Array([16]) }, 54 | { op: EditRangeOp.Read, editIndex: 0, roffset: 9, offset: 5 }, 55 | ], 56 | }); 57 | }); 58 | }); 59 | 60 | describe("serialization", () => { 61 | it("round trips", () => { 62 | const edits: HexDocumentEdit[] = [ 63 | { op: HexDocumentEditOp.Insert, offset: 2, value: new Uint8Array([10, 11, 12]) }, 64 | { 65 | op: HexDocumentEditOp.Replace, 66 | offset: 2, 67 | value: new Uint8Array([10, 11, 12]), 68 | previous: new Uint8Array([1, 2, 3]), 69 | }, 70 | { op: HexDocumentEditOp.Delete, offset: 3, previous: new Uint8Array([3, 4, 5]) }, 71 | ]; 72 | 73 | const s = serializeEdits(edits); 74 | expect(deserializeEdits(s)).to.deep.equal(edits); 75 | expect(s.data.length).to.equal(9); // sum of unique values 76 | }); 77 | }); 78 | 79 | describe("edit reading", () => { 80 | const assertContents = async (value: Uint8Array) => { 81 | const state = model.isSynced ? "after saving" : "before saving"; 82 | for (let offset = 0; offset < value.length; offset++) { 83 | let built = new Uint8Array(); 84 | for await (const buf of model.readWithUnsavedEdits(offset)) { 85 | built = Buffer.concat([built, buf]); 86 | } 87 | 88 | expect(built).to.deep.equal( 89 | Buffer.from(value.subarray(offset)), 90 | `expected to be equal at offset ${offset} ${state}`, 91 | ); 92 | } 93 | 94 | expect(await model.sizeWithEdits()).to.equal( 95 | value.length, 96 | `model size does not match expected size ${state}`, 97 | ); 98 | }; 99 | 100 | it("reads file verbatim", async () => { 101 | await assertContents(new Uint8Array(original)); 102 | await model.save(); 103 | await assertContents(new Uint8Array(original)); 104 | }); 105 | 106 | it("reads with a simple insert", async () => { 107 | model.makeEdits([ 108 | { op: HexDocumentEditOp.Insert, offset: 2, value: new Uint8Array([10, 11, 12]) }, 109 | ]); 110 | await assertContents(new Uint8Array([0, 1, 10, 11, 12, 2, 3, 4, 5, 6, 7, 8, 9])); 111 | await model.save(); 112 | await assertContents(new Uint8Array([0, 1, 10, 11, 12, 2, 3, 4, 5, 6, 7, 8, 9])); 113 | }); 114 | 115 | it("reads with a simple delete", async () => { 116 | model.makeEdits([ 117 | { op: HexDocumentEditOp.Delete, offset: 2, previous: new Uint8Array([2, 3, 4]) }, 118 | ]); 119 | await assertContents(new Uint8Array([0, 1, 5, 6, 7, 8, 9])); 120 | await model.save(); 121 | await assertContents(new Uint8Array([0, 1, 5, 6, 7, 8, 9])); 122 | }); 123 | 124 | it("reads with a simple replace", async () => { 125 | model.makeEdits([ 126 | { 127 | op: HexDocumentEditOp.Replace, 128 | offset: 2, 129 | value: new Uint8Array([10, 11, 12]), 130 | previous: new Uint8Array([1, 2, 3]), 131 | }, 132 | ]); 133 | await assertContents(new Uint8Array([0, 1, 10, 11, 12, 5, 6, 7, 8, 9])); 134 | await model.save(); 135 | await assertContents(new Uint8Array([0, 1, 10, 11, 12, 5, 6, 7, 8, 9])); 136 | }); 137 | 138 | it("replaces at beginning", async () => { 139 | model.makeEdits([ 140 | { 141 | op: HexDocumentEditOp.Replace, 142 | offset: 0, 143 | value: new Uint8Array([10, 11, 12]), 144 | previous: new Uint8Array([0, 1, 2]), 145 | }, 146 | ]); 147 | await assertContents(new Uint8Array([10, 11, 12, 3, 4, 5, 6, 7, 8, 9])); 148 | await model.save(); 149 | await assertContents(new Uint8Array([10, 11, 12, 3, 4, 5, 6, 7, 8, 9])); 150 | }); 151 | 152 | it("makes random replacements", async function () { 153 | this.timeout(10_000); 154 | 155 | const expected = new Uint8Array(original); 156 | const rng = pseudoRandom("vs code!"); 157 | let str = `- [${expected.join(", ")}]\n`; 158 | 159 | let vc = 0; 160 | for (let i = 0; i < 1000; i++) { 161 | const start = Math.floor(rng() * expected.length); 162 | const len = Math.floor((expected.length - start) * rng()); 163 | const value = new Uint8Array(len); 164 | const previous = new Uint8Array(len); 165 | for (let k = 0; k < len; k++) { 166 | previous[k] = expected[start + k]; 167 | value[k] = expected[start + k] = vc++ & 0xff; 168 | } 169 | 170 | str += `- arr.set(${start}, [${value.join(", ")}]) -> [${expected.join(", ")}]\n`; 171 | model.makeEdits([{ op: HexDocumentEditOp.Replace, offset: start, value, previous }]); 172 | 173 | try { 174 | await assertContents(expected); 175 | await model.save(); 176 | await assertContents(expected); 177 | } catch (e) { 178 | console.log(str); 179 | // console.log(JSON.stringify((model as any).getEditTimeline(), (k, v) => v instanceof Uint8Array ? Array.from(v) : v, 2)); 180 | throw e; 181 | } 182 | } 183 | }); 184 | 185 | it("works with multiple replacements", async () => { 186 | model.makeEdits([ 187 | { 188 | op: HexDocumentEditOp.Replace, 189 | offset: 6, 190 | value: new Uint8Array([16]), 191 | previous: new Uint8Array([6, 7, 8]), 192 | }, 193 | { 194 | op: HexDocumentEditOp.Replace, 195 | offset: 1, 196 | value: new Uint8Array([11]), 197 | previous: new Uint8Array([1, 2, 3]), 198 | }, 199 | ]); 200 | await assertContents(new Uint8Array([0, 11, 4, 5, 16, 9])); 201 | await model.save(); 202 | await assertContents(new Uint8Array([0, 11, 4, 5, 16, 9])); 203 | }); 204 | 205 | it("overlaps replace on delete", async () => { 206 | model.makeEdits([ 207 | { op: HexDocumentEditOp.Delete, offset: 3, previous: new Uint8Array([3, 4, 5]) }, 208 | { 209 | op: HexDocumentEditOp.Replace, 210 | offset: 1, 211 | value: new Uint8Array([10, 11, 12]), 212 | previous: new Uint8Array([1, 2, 6]), 213 | }, 214 | ]); 215 | await assertContents(new Uint8Array([0, 10, 11, 12, 7, 8, 9])); 216 | await model.save(); 217 | await assertContents(new Uint8Array([0, 10, 11, 12, 7, 8, 9])); 218 | }); 219 | 220 | it("overlaps replace on insert", async () => { 221 | model.makeEdits([ 222 | { op: HexDocumentEditOp.Insert, offset: 2, value: new Uint8Array([10, 11, 12]) }, 223 | { 224 | op: HexDocumentEditOp.Replace, 225 | offset: 1, 226 | value: new Uint8Array([20, 21, 22]), 227 | previous: new Uint8Array([1, 10, 11]), 228 | }, 229 | ]); 230 | await assertContents(new Uint8Array([0, 20, 21, 22, 12, 2, 3, 4, 5, 6, 7, 8, 9])); 231 | await model.save(); 232 | await assertContents(new Uint8Array([0, 20, 21, 22, 12, 2, 3, 4, 5, 6, 7, 8, 9])); 233 | }); 234 | 235 | it("delete overlaps multiple", async () => { 236 | model.makeEdits([ 237 | { op: HexDocumentEditOp.Insert, offset: 2, value: new Uint8Array([10, 11, 12]) }, 238 | { 239 | op: HexDocumentEditOp.Delete, 240 | offset: 1, 241 | previous: new Uint8Array([1, 10, 11, 12, 2, 3]), 242 | }, 243 | ]); 244 | await assertContents(new Uint8Array([0, 4, 5, 6, 7, 8, 9])); 245 | await model.save(); 246 | await assertContents(new Uint8Array([0, 4, 5, 6, 7, 8, 9])); 247 | }); 248 | }); 249 | }); 250 | -------------------------------------------------------------------------------- /src/test/index.ts: -------------------------------------------------------------------------------- 1 | import Mocha from "mocha"; 2 | 3 | const fileImports = [ 4 | () => import("./backup.test"), 5 | () => import("./hexDocumentModel.test"), 6 | () => import("./searchRequest.test"), 7 | () => import("./range.test"), 8 | ]; 9 | 10 | export async function run(): Promise { 11 | // Create the mocha test 12 | const mocha = new Mocha({ 13 | ui: "bdd", 14 | color: true, 15 | }); 16 | 17 | for (const doImport of fileImports) { 18 | mocha.suite.emit(Mocha.Suite.constants.EVENT_FILE_PRE_REQUIRE, global, doImport, mocha); 19 | await doImport(); 20 | mocha.suite.emit(Mocha.Suite.constants.EVENT_FILE_REQUIRE, {}, doImport, mocha); 21 | mocha.suite.emit(Mocha.Suite.constants.EVENT_FILE_POST_REQUIRE, global, doImport, mocha); 22 | } 23 | 24 | return new Promise((c, e) => { 25 | mocha.run(failures => { 26 | if (failures > 0) { 27 | e(new Error(`${failures} tests failed.`)); 28 | } else { 29 | c(); 30 | } 31 | }); 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /src/test/literalSearch.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { LiteralSearch, Wildcard } from "../literalSearch"; 3 | 4 | describe("literal search", () => { 5 | const haystack = new TextEncoder().encode( 6 | "Excepteur aliquip sit commodo eiusmod nulla ullamco amet reprehenderit incididunt labore ipsum pariatur non. Laborum labore deserunt incididunt mollit do ex ullamco labore quis occaecat amet. Elit amet laborum et ullamco est labore ea. Ipsum ex laborum officia sit Lorem sint enim fugiat in eu. Exercitation laborum est enim cupidatat irure reprehenderit ea duis elit aliquip anim. Sunt reprehenderit consequat consectetur velit proident cupidatat amet.", 7 | ); 8 | 9 | const testNeedle = new TextEncoder().encode("laborum"); 10 | const testMatches = [ 11 | { index: 202, bytes: "laborum" }, 12 | { index: 245, bytes: "laborum" }, 13 | { index: 308, bytes: "laborum" }, 14 | ]; 15 | 16 | let matches: { bytes: string; index: number }[]; 17 | 18 | beforeEach(() => { 19 | matches = []; 20 | }); 21 | 22 | const addMatch = (index: number, bytes: Uint8Array) => 23 | matches.push({ bytes: new TextDecoder().decode(bytes), index }); 24 | 25 | it("is sane for simple strings", () => { 26 | const searcher = new LiteralSearch([testNeedle], addMatch); 27 | searcher.push(haystack); 28 | expect(matches).to.deep.equal(testMatches); 29 | }); 30 | 31 | it("is sane for wildcards", () => { 32 | const searcher = new LiteralSearch( 33 | [new TextEncoder().encode("lab"), Wildcard, new TextEncoder().encode("rum")], 34 | addMatch, 35 | ); 36 | searcher.push(haystack); 37 | expect(matches).to.deep.equal(testMatches); 38 | }); 39 | 40 | it("is sane for leading wildcards", () => { 41 | const searcher = new LiteralSearch([Wildcard, new TextEncoder().encode("aborum")], addMatch); 42 | searcher.push(haystack); 43 | expect(matches).to.deep.equal([{ index: 109, bytes: "Laborum" }, ...testMatches]); 44 | }); 45 | 46 | it("works with random wildcards", () => { 47 | for (let i = 0; i < testNeedle.length; i++) { 48 | for (let k = 1; k < testNeedle.length - i; k++) { 49 | const left = new Uint8Array(testNeedle.subarray(0, i)); 50 | const middle = new Array(k).fill(Wildcard); 51 | const right = new Uint8Array(testNeedle.subarray(i + k)); 52 | 53 | const searcher = new LiteralSearch([left, ...middle, right], addMatch); 54 | searcher.push(haystack); 55 | 56 | for (const match of testMatches) { 57 | expect(matches).to.deep.contain(match); 58 | } 59 | matches = []; 60 | } 61 | } 62 | }); 63 | 64 | it("works with random slicing", () => { 65 | for (let chunkSize = 1; chunkSize < 100; chunkSize++) { 66 | const searcher = new LiteralSearch([testNeedle], addMatch); 67 | for (let i = 0; i < haystack.length; i += chunkSize) { 68 | searcher.push(haystack.subarray(i, i + chunkSize)); 69 | } 70 | expect(matches).to.deep.equal(testMatches); 71 | matches = []; 72 | } 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/test/range.test.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------- 2 | * Copyright (C) Microsoft Corporation. All rights reserved. 3 | *--------------------------------------------------------*/ 4 | import { expect } from "chai"; 5 | import { getRangeSelectionsFromStack, Range } from "../../shared/util/range"; 6 | 7 | describe("Range", () => { 8 | describe("getRangeSelectionsFromStack", () => { 9 | it("works for a single", () => { 10 | expect(getRangeSelectionsFromStack([new Range(10, 20)])).to.deep.equal([new Range(10, 20)]); 11 | }); 12 | 13 | it("works for non-overlapping", () => { 14 | expect(getRangeSelectionsFromStack([new Range(10, 20), new Range(30, 40)])).to.deep.equal([ 15 | new Range(10, 20), 16 | new Range(30, 40), 17 | ]); 18 | }); 19 | 20 | it("excludes overlapping", () => { 21 | expect( 22 | getRangeSelectionsFromStack([new Range(10, 20), new Range(30, 40), new Range(15, 35)]), 23 | ).to.deep.equal([new Range(10, 15), new Range(20, 30), new Range(35, 40)]); 24 | }); 25 | 26 | it("excludes identity", () => { 27 | expect(getRangeSelectionsFromStack([new Range(10, 20), new Range(10, 20)])).to.deep.equal([]); 28 | }); 29 | 30 | it("includes identity", () => { 31 | expect( 32 | getRangeSelectionsFromStack([new Range(10, 20), new Range(10, 20), new Range(10, 20)]), 33 | ).to.deep.equal([new Range(10, 20)]); 34 | }); 35 | 36 | it("pyramid#1", () => { 37 | expect( 38 | getRangeSelectionsFromStack([ 39 | new Range(0, 100), 40 | new Range(10, 90), 41 | new Range(20, 80), 42 | new Range(30, 70), 43 | new Range(40, 60), 44 | ]), 45 | ).to.deep.equal([ 46 | new Range(0, 10), 47 | new Range(20, 30), 48 | new Range(40, 60), 49 | new Range(70, 80), 50 | new Range(90, 100), 51 | ]); 52 | }); 53 | 54 | it("pyramid#2", () => { 55 | expect( 56 | getRangeSelectionsFromStack([ 57 | new Range(0, 50), 58 | new Range(10, 60), 59 | new Range(20, 70), 60 | new Range(30, 80), 61 | ]), 62 | ).to.deep.equal([new Range(0, 10), new Range(20, 30), new Range(50, 60), new Range(70, 80)]); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/test/runTest.js: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------- 2 | * Copyright (C) Microsoft Corporation. All rights reserved. 3 | *--------------------------------------------------------*/ 4 | 5 | const path = require('path'); 6 | const { runTests } = require('@vscode/test-electron'); 7 | 8 | async function main() { 9 | try { 10 | // The folder containing the Extension Manifest package.json 11 | // Passed to `--extensionDevelopmentPath` 12 | const extensionDevelopmentPath = path.resolve(__dirname, '../..'); 13 | 14 | // The path to the extension test script 15 | // Passed to --extensionTestsPath 16 | const extensionTestsPath = path.resolve(extensionDevelopmentPath, 'dist/test.js'); 17 | 18 | const basedir = path.resolve(__dirname, '../..'); 19 | 20 | await runTests({ 21 | extensionDevelopmentPath, 22 | extensionTestsPath, 23 | launchArgs: [ 24 | basedir, 25 | '--disableExtensions', 26 | '--skip-getting-started', 27 | '--disable-user-env-probe', 28 | '--disable-workspace-trust', 29 | ], 30 | }); 31 | } catch (err) { 32 | console.error('Failed to run tests'); 33 | process.exit(1); 34 | } 35 | } 36 | 37 | main(); 38 | -------------------------------------------------------------------------------- /src/test/searchRequest.test.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------- 2 | * Copyright (C) Microsoft Corporation. All rights reserved. 3 | *--------------------------------------------------------*/ 4 | 5 | import { expect } from "chai"; 6 | import { HexDocumentEditOp, HexDocumentModel } from "../../shared/hexDocumentModel"; 7 | import { SearchResult } from "../../shared/protocol"; 8 | import { HexDocument } from "../hexDocument"; 9 | import { ISearchRequest, LiteralSearchRequest, RegexSearchRequest } from "../searchRequest"; 10 | import { getTestFileAccessor } from "./util"; 11 | 12 | describe("searchRequest", async () => { 13 | const testContent = `Esse in aliqua minim magna dolor tempor eiusmod exercitation ullamco veniam nisi ipsum cillum commodo. Velit ad aliquip dolor sint anim consequat. Excepteur culpa non adipisicing elit tempor laborum tempor qui. Do esse dolore incididunt consequat non excepteur fugiat fugiat veniam deserunt ut pariatur eiusmod. Deserunt irure qui cupidatat laboris. 14 | 15 | Irure nulla nulla consequat reprehenderit nisi nulla consequat dolor. Ad consectetur cillum consectetur ea. Reprehenderit esse in in anim mollit laboris eiusmod. Pariatur enim ipsum dolor eiusmod laboris mollit aliqua ullamco laborum fugiat non et. Officia adipisicing duis id sit aliqua et et occaecat. Commodo Lorem laborum aliquip officia ex quis elit elit do qui consectetur dolore. 16 | 17 | Lorem ad ullamco ad deserunt voluptate ullamco et in commodo et exercitation duis nisi. Reprehenderit deserunt deserunt eiusmod velit mollit cillum pariatur Lorem exercitation laboris non. Ad eiusmod mollit reprehenderit amet ex elit deserunt cupidatat ullamco ullamco consectetur elit. Laboris duis cupidatat ipsum id anim occaecat pariatur.`; 18 | 19 | const expectMatches = async (req: ISearchRequest, expected: SearchResult[]) => { 20 | let last: SearchResult[] | undefined; 21 | for await (const result of req.search()) { 22 | last = result.results.map(r => ({ from: r.from, to: r.to, previous: r.previous })); 23 | } 24 | 25 | if (!last) { 26 | throw new Error("no result"); 27 | } 28 | 29 | const process = (results: SearchResult[]) => 30 | results 31 | .sort((a, b) => a.from - b.from) 32 | .map(r => ({ ...r, previous: new TextDecoder().decode(r.previous) })); 33 | 34 | expect(process(last)).to.deep.equal(process(expected)); 35 | }; 36 | 37 | const testNeedle = "laboris"; 38 | const testNeedleBytes = new TextEncoder().encode(testNeedle); 39 | const expectedForTestNeedle: SearchResult[] = [ 40 | { from: 341, previous: testNeedleBytes, to: 348 }, 41 | { from: 496, previous: testNeedleBytes, to: 503 }, 42 | { from: 547, previous: testNeedleBytes, to: 554 }, 43 | { from: 915, previous: testNeedleBytes, to: 922 }, 44 | ]; 45 | 46 | const makeDocument = async (content = testContent) => 47 | new HexDocument( 48 | new HexDocumentModel({ 49 | accessor: await getTestFileAccessor(new TextEncoder().encode(content)), 50 | supportsLengthChanges: false, 51 | isFiniteSize: true, 52 | }), 53 | false, 54 | 0, 55 | ); 56 | 57 | it("searches for literal", async () => { 58 | const doc = await makeDocument(); 59 | await expectMatches( 60 | new LiteralSearchRequest(doc, { literal: [testNeedleBytes] }, true, undefined), 61 | expectedForTestNeedle, 62 | ); 63 | }); 64 | 65 | it("searches for literal in an edited document", async () => { 66 | const doc = await makeDocument(); 67 | const editedExpectedForTestNeedle = [ 68 | ...expectedForTestNeedle, 69 | { from: 1026, previous: new TextEncoder().encode("Laboris"), to: 1033 }, 70 | { from: 1081, previous: new TextEncoder().encode("Laboris"), to: 1088 }, 71 | { from: 1088, previous: new TextEncoder().encode("Laboris"), to: 1095 }, 72 | { from: 1095, previous: new TextEncoder().encode("Laboris"), to: 1102 }, 73 | ]; 74 | doc.makeEdits( 75 | new Array(3).fill({ 76 | op: HexDocumentEditOp.Insert, 77 | offset: testContent.length, 78 | value: new TextEncoder().encode("Laboris"), 79 | }), 80 | ); 81 | await expectMatches( 82 | new LiteralSearchRequest(doc, { literal: [testNeedleBytes] }, false, undefined), 83 | editedExpectedForTestNeedle, 84 | ); 85 | await doc.save(); 86 | await expectMatches( 87 | new LiteralSearchRequest(doc, { literal: [testNeedleBytes] }, false, undefined), 88 | editedExpectedForTestNeedle, 89 | ); 90 | }); 91 | 92 | it("searches for literal case insensitive", async () => { 93 | const doc = await makeDocument(); 94 | await expectMatches( 95 | new LiteralSearchRequest(doc, { literal: [testNeedleBytes] }, false, undefined), 96 | [ 97 | ...expectedForTestNeedle, 98 | { from: 1026, previous: new TextEncoder().encode("Laboris"), to: 1033 }, 99 | ], 100 | ); 101 | }); 102 | 103 | it("searches for regex", async () => { 104 | const doc = await makeDocument(); 105 | await expectMatches( 106 | new RegexSearchRequest(doc, { re: testNeedle }, true, undefined), 107 | expectedForTestNeedle, 108 | ); 109 | }); 110 | 111 | it("searches for regex case insensitive", async () => { 112 | const doc = await makeDocument(); 113 | await expectMatches(new RegexSearchRequest(doc, { re: testNeedle }, false, undefined), [ 114 | ...expectedForTestNeedle, 115 | { from: 1026, previous: new TextEncoder().encode("Laboris"), to: 1033 }, 116 | ]); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /src/test/util.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------- 2 | * Copyright (C) Microsoft Corporation. All rights reserved. 3 | *--------------------------------------------------------*/ 4 | 5 | import { createHash, randomBytes } from "crypto"; 6 | import { promises as fs } from "fs"; 7 | import { tmpdir } from "os"; 8 | import { join } from "path"; 9 | import * as vscode from "vscode"; 10 | import { FileAccessor } from "../../shared/fileAccessor"; 11 | import { accessFile } from "../fileSystemAdaptor"; 12 | 13 | let testFiles: string[] = []; 14 | 15 | export const getTempFile = async (initialContents?: Uint8Array): Promise => { 16 | const fname = join(tmpdir(), `vscode-hexeditor-test-${randomBytes(8).toString("hex")}.bin`); 17 | testFiles.push(fname); 18 | if (initialContents) { 19 | await fs.writeFile(fname, initialContents); 20 | } 21 | 22 | return vscode.Uri.file(fname); 23 | }; 24 | 25 | export const getTestFileAccessor = async (initialContents?: Uint8Array): Promise => 26 | accessFile(await getTempFile(initialContents)); 27 | 28 | afterEach(async () => { 29 | await Promise.all(testFiles.map(fs.unlink)); 30 | testFiles = []; 31 | }); 32 | 33 | /** Simple, slow, seedable pseudo-random number generator */ 34 | export const pseudoRandom = 35 | (seed: string | Buffer): (() => number) => 36 | () => { 37 | const digest = createHash("sha256").update(seed).digest(); 38 | seed = digest; 39 | return digest.readUInt32BE() / 0xff_ff_ff_ff; 40 | }; 41 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | import { l10n, window } from "vscode"; 5 | 6 | export function randomString(len = 32): string { 7 | let text = ""; 8 | const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 9 | for (let i = 0; i < len; i++) { 10 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 11 | } 12 | return text; 13 | } 14 | 15 | /** 16 | * @description Opens the input box so the user can input which offset they want to go to 17 | */ 18 | export async function openOffsetInput(): Promise { 19 | return window.showInputBox({ 20 | placeHolder: "Enter offset to go to", 21 | validateInput: text => { 22 | return text.length > 8 || new RegExp("^[a-fA-F0-9]+$").test(text) 23 | ? null 24 | : l10n.t("Invalid offset string"); 25 | }, 26 | }); 27 | } 28 | 29 | /** 30 | * Gets the ArrayBuffer for a uint8array sized correctly for the array. TypedArrays 31 | * are allowed to point at subsets of the underlying ArrayBuffers. 32 | */ 33 | export const getCorrectArrayBuffer = (u8: Uint8Array): ArrayBuffer => 34 | u8.byteLength === u8.buffer.byteLength ? u8.buffer : u8.buffer.slice(0, u8.byteLength); 35 | 36 | /** Returns the number of bytes in the str when interpreted as utf-8 */ 37 | export const utf8Length = (str: string): number => { 38 | if (typeof Buffer !== "undefined") { 39 | return Buffer.byteLength(str); 40 | } else { 41 | // todo: maybe doing some loops by hand here would be faster? does it matter? 42 | return new Blob([str]).size; 43 | } 44 | }; 45 | 46 | export const flattenBuffers = (buffers: readonly Uint8Array[]): Uint8Array => { 47 | let size = 0; 48 | for (const buffer of buffers) { 49 | size += buffer.byteLength; 50 | } 51 | 52 | const target = new Uint8Array(size); 53 | let offset = 0; 54 | for (const buffer of buffers) { 55 | target.set(buffer, offset); 56 | offset += buffer.byteLength; 57 | } 58 | 59 | return target; 60 | }; 61 | 62 | export const getBaseName = (path: string): string => { 63 | let filename = path.split("/").pop()!; 64 | filename = filename.substring(0, filename.lastIndexOf(".")) || filename; 65 | return filename.replace(/[^a-z0-9]/gi, ""); 66 | }; 67 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2020", 5 | "lib": [ 6 | "ES2020", 7 | "DOM", 8 | "DOM.Iterable" 9 | ], 10 | "outDir": "out", 11 | "esModuleInterop": true, 12 | "sourceMap": true, 13 | "jsx": "react", 14 | "forceConsistentCasingInFileNames": true, 15 | "importHelpers": true, 16 | "strict": true, /* enable all strict type-checking options */ 17 | /* Additional Checks */ 18 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 19 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 20 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 21 | }, 22 | "exclude": [ 23 | "node_modules", 24 | ".vscode-test" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------