diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 546add2..0762ad8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,4 +1,5 @@ # How to write code for this repository + - Code should document itself as much as possible. Only use comments where the code is not self-explanatory. - Write as minimal code as possible to achieve the desired functionality. - Embrace TypeScript's type system to ensure type safety and reduce runtime errors, create specific types and interfaces as needed. @@ -7,7 +8,7 @@ - Ensure proper error handling and input validation throughout the codebase. - RXJS should be used for state management and asynchronous operations where appropriate. - # Testing -- Unit tests can be written using vitest in "*.test.ts" files alongside the implementation files. -- Intergration tests can writen using Playwright in the "tests" folder. \ No newline at end of file + +- Unit tests can be written using vitest in "\*.test.ts" files alongside the implementation files. +- Intergration tests can writen using Playwright in the "tests" folder. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b70e2e6..55a708b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,6 +15,7 @@ jobs: - run: npm run build - run: npm run test - run: npm run lint + - run: npm run format - uses: actions/upload-artifact@v4 with: name: dist @@ -57,4 +58,4 @@ jobs: if: ${{ !cancelled() }} with: name: playwright-report-${{ matrix.os }}-${{ matrix.browser }} - path: playwright-report/ \ No newline at end of file + path: playwright-report/ diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9944f8c..c68c55f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,4 +27,4 @@ jobs: - run: npx wrangler deploy env: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} \ No newline at end of file + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..dff8992 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "experimentalSortImports": {} +} diff --git a/.oxlintrc.json b/.oxlintrc.json index 7d0389b..cf79429 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,9 +1,7 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": [ - "react" - ], + "plugins": ["react"], "rules": { - "eslint/no-unused-vars": "allow", + "eslint/no-unused-vars": "allow" } -} \ No newline at end of file +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 9f1e4b8..0d4efac 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,6 +1,3 @@ { - "recommendations": [ - "dbaeumer.vscode-eslint", - "oxc.oxc-vscode" - ] -} \ No newline at end of file + "recommendations": ["dbaeumer.vscode-eslint", "oxc.oxc-vscode"] +} diff --git a/.vscode/launch.json b/.vscode/launch.json index f437f0c..d8f247e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,4 +11,4 @@ "trace": false } ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 749f2f4..55bde68 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,12 +1,9 @@ { - "[typescript]": { - "editor.formatOnSave": true, - "editor.defaultFormatter": "vscode.typescript-language-features" - }, + "editor.defaultFormatter": "oxc.oxc-vscode", + "editor.formatOnSave": true, + "oxc.typeAware": true, "[typescriptreact]": { - "editor.formatOnSave": true, - "editor.defaultFormatter": "vscode.typescript-language-features" + "editor.defaultFormatter": "oxc.oxc-vscode" }, - "typescript.format.semicolons": "insert", - "oxc.typeAware": true -} \ No newline at end of file + "oxc.enable.oxfmt": true +} diff --git a/java/gradle/libs.versions.toml b/java/gradle/libs.versions.toml index 63de8b4..2a1c055 100644 --- a/java/gradle/libs.versions.toml +++ b/java/gradle/libs.versions.toml @@ -9,4 +9,4 @@ teavm-core = { group = "org.teavm", name = "teavm-core", version.ref = "teavm" } teavm-jso = { group = "org.teavm", name = "teavm-jso-apis", version.ref = "teavm" } [plugins] -teavm = { id = "org.teavm", version.ref = "teavm" } \ No newline at end of file +teavm = { id = "org.teavm", version.ref = "teavm" } diff --git a/package-lock.json b/package-lock.json index b09bc95..16cd31a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.4", "globals": "^17.3.0", + "oxfmt": "^0.32.0", "oxlint": "^1.47.0", "oxlint-tsgolint": "^0.13.0", "typescript": "~5.9.3", @@ -1593,6 +1594,329 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.32.0.tgz", + "integrity": "sha512-DpVyuVzgLH6/MvuB/YD3vXO9CN/o9EdRpA0zXwe/tagP6yfVSFkFWkPqTROdqp0mlzLH5Yl+/m+hOrcM601EbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.32.0.tgz", + "integrity": "sha512-w1cmNXf9zs0vKLuNgyUF3hZ9VUAS1hBmQGndYJv1OmcVqStBtRTRNxSWkWM0TMkrA9UbvIvM9gfN+ib4Wy6lkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.32.0.tgz", + "integrity": "sha512-m6wQojz/hn94XdZugFPtdFbOvXbOSYEqPsR2gyLyID3BvcrC2QsJyT1o3gb4BZEGtZrG1NiKVGwDRLM0dHd2mg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.32.0.tgz", + "integrity": "sha512-hN966Uh6r3Erkg2MvRcrJWaB6QpBzP15rxWK/QtkUyD47eItJLsAQ2Hrm88zMIpFZ3COXZLuN3hqgSlUtvB0Xw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.32.0.tgz", + "integrity": "sha512-g5UZPGt8tJj263OfSiDGdS54HPa0KgFfspLVAUivVSdoOgsk6DkwVS9nO16xQTDztzBPGxTvrby8WuufF0g86Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.32.0.tgz", + "integrity": "sha512-F4ZY83/PVQo9ZJhtzoMqbmjqEyTVEZjbaw4x1RhzdfUhddB41ZB2Vrt4eZi7b4a4TP85gjPRHgQBeO0c1jbtaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.32.0.tgz", + "integrity": "sha512-olR37eG16Lzdj9OBSvuoT5RxzgM5xfQEHm1OEjB3M7Wm4KWa5TDWIT13Aiy74GvAN77Hq1+kUKcGVJ/0ynf75g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.32.0.tgz", + "integrity": "sha512-eZhk6AIjRCDeLoXYBhMW7qq/R1YyVi+tGnGfc3kp7AZQrMsFaWtP/bgdCJCTNXMpbMwymtVz0qhSQvR5w2sKcg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.32.0.tgz", + "integrity": "sha512-UYiqO9MlipntFbdbUKOIo84vuyzrK4TVIs7Etat91WNMFSW54F6OnHq08xa5ZM+K9+cyYMgQPXvYCopuP+LyKw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.32.0.tgz", + "integrity": "sha512-IDH/fxMv+HmKsMtsjEbXqhScCKDIYp38sgGEcn0QKeXMxrda67PPZA7HMfoUwEtFUG+jsO1XJxTrQsL+kQ90xQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.32.0.tgz", + "integrity": "sha512-bQFGPDa0buYWJFeK2I7ah8wRZjrAgamaG2OAGv+Ua5UMYEnHxmHcv+r8lWUUrwP2oqQGvp1SB8JIVtBbYuAueQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.32.0.tgz", + "integrity": "sha512-3vFp9DW1ItEKWltADzCFqG5N7rYFToT4ztlhg8wALoo2E2VhveLD88uAF4FF9AxD9NhgHDGmPCV+WZl/Qlj8cQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.32.0.tgz", + "integrity": "sha512-Fub2y8S9ImuPzAzpbgkoz/EVTWFFBolxFZYCMRhRZc8cJZI2gl/NlZswqhvJd/U0Jopnwgm/OJ2x128vVzFFWA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.32.0.tgz", + "integrity": "sha512-XufwsnV3BF81zO2ofZvhT4FFaMmLTzZEZnC9HpFz/quPeg9C948+kbLlZnsfjmp+1dUxKMCpfmRMqOfF4AOLsA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.32.0.tgz", + "integrity": "sha512-u2f9tC2qYfikKmA2uGpnEJgManwmk0ZXWs5BB4ga4KDu2JNLdA3i634DGHeMLK9wY9+iRf3t7IYpgN3OVFrvDw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.32.0.tgz", + "integrity": "sha512-5ZXb1wrdbZ1YFXuNXNUCePLlmLDy4sUt4evvzD4Cgumbup5wJgS9PIe5BOaLywUg9f1wTH6lwltj3oT7dFpIGA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.32.0.tgz", + "integrity": "sha512-IGSMm/Agq+IA0++aeAV/AGPfjcBdjrsajB5YpM3j7cMcwoYgUTi/k2YwAmsHH3ueZUE98pSM/Ise2J7HtyRjOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.32.0.tgz", + "integrity": "sha512-H/9gsuqXmceWMsVoCPZhtJG2jLbnBeKr7xAXm2zuKpxLVF7/2n0eh7ocOLB6t+L1ARE76iORuUsRMnuGjj8FjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.32.0.tgz", + "integrity": "sha512-fF8VIOeligq+mA6KfKvWtFRXbf0EFy73TdR6ZnNejdJRM8VWN1e3QFhYgIwD7O8jBrQsd7EJbUpkAr/YlUOokg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@oxlint-tsgolint/darwin-arm64": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.13.0.tgz", @@ -4883,6 +5207,46 @@ ], "license": "MIT" }, + "node_modules/oxfmt": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.32.0.tgz", + "integrity": "sha512-KArQhGzt/Y8M1eSAX98Y8DLtGYYDQhkR55THUPY5VNcpFQ+9nRZkL3ULXhagHMD2hIvjy8JSeEQEP5/yYJSrLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.32.0", + "@oxfmt/binding-android-arm64": "0.32.0", + "@oxfmt/binding-darwin-arm64": "0.32.0", + "@oxfmt/binding-darwin-x64": "0.32.0", + "@oxfmt/binding-freebsd-x64": "0.32.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.32.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.32.0", + "@oxfmt/binding-linux-arm64-gnu": "0.32.0", + "@oxfmt/binding-linux-arm64-musl": "0.32.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.32.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.32.0", + "@oxfmt/binding-linux-riscv64-musl": "0.32.0", + "@oxfmt/binding-linux-s390x-gnu": "0.32.0", + "@oxfmt/binding-linux-x64-gnu": "0.32.0", + "@oxfmt/binding-linux-x64-musl": "0.32.0", + "@oxfmt/binding-openharmony-arm64": "0.32.0", + "@oxfmt/binding-win32-arm64-msvc": "0.32.0", + "@oxfmt/binding-win32-ia32-msvc": "0.32.0", + "@oxfmt/binding-win32-x64-msvc": "0.32.0" + } + }, "node_modules/oxlint": { "version": "1.47.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.47.0.tgz", @@ -5429,6 +5793,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/tinyrainbow": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", diff --git a/package.json b/package.json index 865c23d..acbffd5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mcsrc", - "private": true, "version": "0.0.0", + "private": true, "type": "module", "scripts": { "dev": "vite", @@ -14,7 +14,9 @@ "test:e2e:ui": "playwright test --ui", "test:e2e:debug": "playwright test --debug", "lint": "oxlint --type-aware --deny-warnings", - "lint:fix": "oxlint --fix --type-aware --deny-warnings" + "lint:fix": "oxlint --fix --type-aware --deny-warnings", + "format": "oxfmt --check", + "format:fix": "oxfmt" }, "dependencies": { "@katana-project/zip": "^0.7.1", @@ -38,6 +40,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.4", "globals": "^17.3.0", + "oxfmt": "^0.32.0", "oxlint": "^1.47.0", "oxlint-tsgolint": "^0.13.0", "typescript": "~5.9.3", diff --git a/playwright.config.ts b/playwright.config.ts index 7505bab..048b8f9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,37 +1,37 @@ -import { defineConfig, devices } from '@playwright/test'; +import { defineConfig, devices } from "@playwright/test"; // https://playwright.dev/docs/test-configuration export default defineConfig({ - testDir: './tests', - timeout: process.env.CI ? 300000 : 0, // 5 minutes - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - reporter: 'html', + testDir: "./tests", + timeout: process.env.CI ? 300000 : 0, // 5 minutes + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: "html", - use: { - baseURL: 'http://localhost:4173', - trace: 'on-first-retry', - }, - - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, - ], + use: { + baseURL: "http://localhost:4173", + trace: "on-first-retry", + }, - webServer: { - command: process.env.CI ? 'npm run preview' : 'npm run build && npm run preview', - url: 'http://localhost:4173', - reuseExistingServer: !process.env.CI, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + ], + + webServer: { + command: process.env.CI ? "npm run preview" : "npm run build && npm run preview", + url: "http://localhost:4173", + reuseExistingServer: !process.env.CI, + }, }); diff --git a/src/declarations.d.ts b/src/declarations.d.ts index 0dc067d..41eef4e 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -1,3 +1,3 @@ declare module "*/java.wasm-runtime.js" { - export async function load(src: string); + export async function load(src: string); } diff --git a/src/index.css b/src/index.css index 0490eef..c8dc566 100644 --- a/src/index.css +++ b/src/index.css @@ -3,11 +3,16 @@ body { } /* Prevent scrolling the body */ -html, body {margin: 0; height: 100%; overflow: hidden} +html, +body { + margin: 0; + height: 100%; + overflow: hidden; +} /* Webkit adds scrollbars as element selectors rather than allowing the element to control their style */ .webkit-scrollbar-hide::-webkit-scrollbar { - display: none + display: none; } .class-token-decoration { @@ -79,27 +84,27 @@ html, body {margin: 0; height: 100%; overflow: hidden} /* Stop icon & text in file tree from wrapping */ .ant-tree-node-content-wrapper { - display: flex; - flex-wrap: nowrap; - padding-inline: 0 !important; + display: flex; + flex-wrap: nowrap; + padding-inline: 0 !important; } /* Remove extraneous gap in file tree component */ .ant-tree-switcher { - margin-inline-end: 0 !important; + margin-inline-end: 0 !important; } /* Fix file tree icon centering */ .ant-tree-iconEle > .anticon { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); } /* Stop text in file tree from wrapping */ .ant-tree-title { - white-space: nowrap; + white-space: nowrap; } /* Don't have text overflow in the structure dialog */ diff --git a/src/javadoc/Javadoc.ts b/src/javadoc/Javadoc.ts index d173bec..3b41966 100644 --- a/src/javadoc/Javadoc.ts +++ b/src/javadoc/Javadoc.ts @@ -1,87 +1,96 @@ import { BehaviorSubject, map, Observable } from "rxjs"; + import type { Token } from "../logic/Tokens"; -import { javadocApi } from "./api/JavadocApi"; + import { selectedMinecraftVersion } from "../logic/State"; +import { javadocApi } from "./api/JavadocApi"; export type JavadocString = string; export interface JavadocData { - classes: Record; - fields: Record; - }>; + classes: Record< + string, + { + javadoc: JavadocString | null; + methods: Record; + fields: Record; + } + >; } export const javadocData = new BehaviorSubject({ - classes: {} + classes: {}, }); // Holds the currently active token for which Javadoc is being edited export const activeJavadocToken = new BehaviorSubject(null); export function setTokenJavadoc(token: Token, javadoc: JavadocString | undefined) { - const data = javadocData.getValue(); - const classEntry = data.classes[token.className] || { javadoc: null, methods: {}, fields: {} }; + const data = javadocData.getValue(); + const classEntry = data.classes[token.className] || { javadoc: null, methods: {}, fields: {} }; - if (token.type === 'class') { - classEntry.javadoc = javadoc ?? null; - } else if (token.type === 'method') { - if (javadoc === undefined) { - delete classEntry.methods[token.name + token.descriptor]; - } else { - classEntry.methods[token.name + token.descriptor] = javadoc; - } - } else if (token.type === 'field') { - if (javadoc === undefined) { - delete classEntry.fields[token.name + token.descriptor]; - } else { - classEntry.fields[token.name + token.descriptor] = javadoc; - } + if (token.type === "class") { + classEntry.javadoc = javadoc ?? null; + } else if (token.type === "method") { + if (javadoc === undefined) { + delete classEntry.methods[token.name + token.descriptor]; + } else { + classEntry.methods[token.name + token.descriptor] = javadoc; } + } else if (token.type === "field") { + if (javadoc === undefined) { + delete classEntry.fields[token.name + token.descriptor]; + } else { + classEntry.fields[token.name + token.descriptor] = javadoc; + } + } - data.classes[token.className] = classEntry; - javadocData.next(data); - console.log("Updated Javadoc data:", data); + data.classes[token.className] = classEntry; + javadocData.next(data); + console.log("Updated Javadoc data:", data); } // Refreshes the Javadoc data for a specific class from the server export async function refreshJavadocDataForClass(className: string) { - const minecraftVersion = selectedMinecraftVersion.value; - if (!minecraftVersion) { - throw new Error("No Minecraft version selected"); - } + const minecraftVersion = selectedMinecraftVersion.value; + if (!minecraftVersion) { + throw new Error("No Minecraft version selected"); + } - const data = await javadocApi.getJavadoc(minecraftVersion, className); + const data = await javadocApi.getJavadoc(minecraftVersion, className); - for (const [key, entry] of Object.entries(data.data)) { - const classEntry = javadocData.getValue().classes[key] || { javadoc: null, methods: {}, fields: {} }; - classEntry.javadoc = entry.value || null; - classEntry.methods = entry.methods || {}; - classEntry.fields = entry.fields || {}; - const nextData = { ...javadocData.getValue() }; - nextData.classes[key] = classEntry; - javadocData.next(nextData); - } + for (const [key, entry] of Object.entries(data.data)) { + const classEntry = javadocData.getValue().classes[key] || { + javadoc: null, + methods: {}, + fields: {}, + }; + classEntry.javadoc = entry.value || null; + classEntry.methods = entry.methods || {}; + classEntry.fields = entry.fields || {}; + const nextData = { ...javadocData.getValue() }; + nextData.classes[key] = classEntry; + javadocData.next(nextData); + } } export function observeJavadocForToken(token: Token): Observable { - return javadocData.pipe( - map(data => { - return getJavadocForToken(token, data); - }) - ); + return javadocData.pipe( + map((data) => { + return getJavadocForToken(token, data); + }), + ); } export function getJavadocForToken(token: Token, javadoc: JavadocData): JavadocString | null { - switch (token.type) { - case 'class': - return javadoc.classes[token.className]?.javadoc || null; - case 'method': - return javadoc.classes[token.className]?.methods[token.name + token.descriptor] || null; - case 'field': - return javadoc.classes[token.className]?.fields[token.name + token.descriptor] || null; - } + switch (token.type) { + case "class": + return javadoc.classes[token.className]?.javadoc || null; + case "method": + return javadoc.classes[token.className]?.methods[token.name + token.descriptor] || null; + case "field": + return javadoc.classes[token.className]?.fields[token.name + token.descriptor] || null; + } - return null; -} \ No newline at end of file + return null; +} diff --git a/src/javadoc/JavadocCmpletionProvider.ts b/src/javadoc/JavadocCmpletionProvider.ts index 92a77ed..96e6a11 100644 --- a/src/javadoc/JavadocCmpletionProvider.ts +++ b/src/javadoc/JavadocCmpletionProvider.ts @@ -1,108 +1,118 @@ -import { editor, languages, Position, Token, type CancellationToken } from 'monaco-editor'; -import type { MemberToken } from '../logic/Tokens'; -import type { DecompileResult } from '../workers/decompile/types'; +import { editor, languages, Position, Token, type CancellationToken } from "monaco-editor"; -export class JavdocCompletionProvider implements languages.CompletionItemProvider { - readonly decompileResult: DecompileResult; +import type { MemberToken } from "../logic/Tokens"; +import type { DecompileResult } from "../workers/decompile/types"; - constructor(decompileResult: DecompileResult) { - this.decompileResult = decompileResult; +export class JavdocCompletionProvider implements languages.CompletionItemProvider { + readonly decompileResult: DecompileResult; + + constructor(decompileResult: DecompileResult) { + this.decompileResult = decompileResult; + } + + triggerCharacters: string[] = ["[", "#"]; + + provideCompletionItems( + model: editor.ITextModel, + position: Position, + context: languages.CompletionContext, + token: CancellationToken, + ): languages.ProviderResult { + if (!this.isCreatingLink(model, position)) { + return { suggestions: [] }; } - triggerCharacters: string[] = ['[', '#']; - - provideCompletionItems(model: editor.ITextModel, position: Position, context: languages.CompletionContext, token: CancellationToken): languages.ProviderResult { - if (!this.isCreatingLink(model, position)) { - return { suggestions: [] }; - } - - const range = { - startLineNumber: position.lineNumber, - startColumn: position.column, - endLineNumber: position.lineNumber, - endColumn: position.column + const range = { + startLineNumber: position.lineNumber, + startColumn: position.column, + endLineNumber: position.lineNumber, + endColumn: position.column, + }; + + if (this.isCreatingMemberLink(model, position)) { + const suggestions: languages.CompletionItem[] = this.getMembers().map((token) => { + return { + label: token.name, + kind: + token.type === "method" + ? languages.CompletionItemKind.Method + : languages.CompletionItemKind.Field, + insertText: token.name, + range, }; + }); - if (this.isCreatingMemberLink(model, position)) { - const suggestions: languages.CompletionItem[] = this.getMembers().map(token => { - return { - label: token.name, - kind: token.type === 'method' ? languages.CompletionItemKind.Method : languages.CompletionItemKind.Field, - insertText: token.name, - range - }; - }); - - return { suggestions }; - } - - const imports = this.getImportedClasses(); - - const suggestions: languages.CompletionItem[] = imports.map(importPath => { - const className = importPath.split('.').pop() || importPath; - - return { - label: className, - kind: languages.CompletionItemKind.Reference, - insertText: className, - detail: importPath, - range - }; - }); - - return { suggestions }; + return { suggestions }; } - isCreatingLink(model: editor.ITextModel, position: Position): boolean { // Check if cursor is within [] characters - const lineContent = model.getLineContent(position.lineNumber); - const textBeforeCursor = lineContent.substring(0, position.column - 1); - const textAfterCursor = lineContent.substring(position.column - 1); - - // Find the last '[' before cursor and first ']' after cursor - const lastOpenBracket = textBeforeCursor.lastIndexOf('['); - const firstCloseBracket = textAfterCursor.indexOf(']'); - - // Only provide completions if we're inside brackets - return lastOpenBracket !== -1 && firstCloseBracket !== -1; + const imports = this.getImportedClasses(); + + const suggestions: languages.CompletionItem[] = imports.map((importPath) => { + const className = importPath.split(".").pop() || importPath; + + return { + label: className, + kind: languages.CompletionItemKind.Reference, + insertText: className, + detail: importPath, + range, + }; + }); + + return { suggestions }; + } + + isCreatingLink(model: editor.ITextModel, position: Position): boolean { + // Check if cursor is within [] characters + const lineContent = model.getLineContent(position.lineNumber); + const textBeforeCursor = lineContent.substring(0, position.column - 1); + const textAfterCursor = lineContent.substring(position.column - 1); + + // Find the last '[' before cursor and first ']' after cursor + const lastOpenBracket = textBeforeCursor.lastIndexOf("["); + const firstCloseBracket = textAfterCursor.indexOf("]"); + + // Only provide completions if we're inside brackets + return lastOpenBracket !== -1 && firstCloseBracket !== -1; + } + + isCreatingMemberLink(model: editor.ITextModel, position: Position): boolean { + const lineContent = model.getLineContent(position.lineNumber); + const textBeforeCursor = lineContent.substring(0, position.column - 1); + const lastOpenBracket = textBeforeCursor.lastIndexOf("["); + const textAfterBracket = textBeforeCursor.substring(lastOpenBracket + 1); + return textAfterBracket.startsWith("#"); + } + + getImportedClasses(): string[] { + const source = this.decompileResult.source; + const importedClasses: string[] = []; + + const importRegex = /^\s*import\s+(?!static\b)([^\s;]+)\s*;/gm; + + let match = null; + while ((match = importRegex.exec(source)) !== null) { + const importPath = match[1]; + if (importPath.endsWith("*")) { + continue; + } + + importedClasses.push(importPath); } - isCreatingMemberLink(model: editor.ITextModel, position: Position): boolean { - const lineContent = model.getLineContent(position.lineNumber); - const textBeforeCursor = lineContent.substring(0, position.column - 1); - const lastOpenBracket = textBeforeCursor.lastIndexOf('['); - const textAfterBracket = textBeforeCursor.substring(lastOpenBracket + 1); - return textAfterBracket.startsWith('#'); - } + return importedClasses; + } - getImportedClasses(): string[] { - const source = this.decompileResult.source; - const importedClasses: string[] = []; + getMembers(): MemberToken[] { + const tokens = this.decompileResult.tokens; + const members: MemberToken[] = []; - const importRegex = /^\s*import\s+(?!static\b)([^\s;]+)\s*;/gm; - - let match = null; - while ((match = importRegex.exec(source)) !== null) { - const importPath = match[1]; - if (importPath.endsWith('*')) { - continue; - } - - importedClasses.push(importPath); - } - - return importedClasses; + for (const token of tokens) { + if (token.declaration && (token.type == "method" || token.type == "field")) { + members.push(token); + } } - getMembers(): MemberToken[] { - const tokens = this.decompileResult.tokens; - const members: MemberToken[] = []; - - for (const token of tokens) { - if (token.declaration && (token.type == 'method' || token.type == 'field')) { - members.push(token); - } - } - - return members; - } + return members; + } } diff --git a/src/javadoc/JavadocCodeExtensions.ts b/src/javadoc/JavadocCodeExtensions.ts index 534b2a0..ab95830 100644 --- a/src/javadoc/JavadocCodeExtensions.ts +++ b/src/javadoc/JavadocCodeExtensions.ts @@ -1,126 +1,140 @@ -import { - editor, - languages, - type CancellationToken, - type IDisposable, -} from "monaco-editor"; -import { getTokenLocation, type Token, type TokenLocation } from "../logic/Tokens"; -import { activeJavadocToken, getJavadocForToken, javadocData, refreshJavadocDataForClass, type JavadocData, type JavadocString } from "./Javadoc"; +import { editor, languages, type CancellationToken, type IDisposable } from "monaco-editor"; + import type { DecompileResult } from "../workers/decompile/types"; +import { getTokenLocation, type Token, type TokenLocation } from "../logic/Tokens"; +import { + activeJavadocToken, + getJavadocForToken, + javadocData, + refreshJavadocDataForClass, + type JavadocData, + type JavadocString, +} from "./Javadoc"; + type monaco = typeof import("monaco-editor"); -const EDIT_JAVADOC_COMMAND_ID = 'editor.action.editJavadoc'; - -export function applyJavadocCodeExtensions(monaco: monaco, editor: editor.IStandaloneCodeEditor, decompile: DecompileResult): IDisposable { - const viewZoneIds: string[] = []; - const javadocDataSub = javadocData.subscribe((javadoc) => { - editor.changeViewZones((accessor) => { - // Remove any existing zones - viewZoneIds.forEach(id => accessor.removeZone(id)); - viewZoneIds.length = 0; - - decompile.tokens - .filter(token => token.declaration) - .forEach(token => { - const mdValue = getJavadocForToken(token, javadoc); - if (mdValue == null) { - return; - } - - const domNode = document.createElement('div'); - domNode.innerHTML = `${formatMarkdownAsHtml(mdValue, token)}`; - - const location = getTokenLocation(decompile, token); - const zoneId = accessor.addZone({ - afterLineNumber: location.line - 1, - heightInPx: cacluateHeightInPx(domNode), - domNode: domNode - }); - - viewZoneIds.push(zoneId); - }); +const EDIT_JAVADOC_COMMAND_ID = "editor.action.editJavadoc"; + +export function applyJavadocCodeExtensions( + monaco: monaco, + editor: editor.IStandaloneCodeEditor, + decompile: DecompileResult, +): IDisposable { + const viewZoneIds: string[] = []; + const javadocDataSub = javadocData.subscribe((javadoc) => { + editor.changeViewZones((accessor) => { + // Remove any existing zones + viewZoneIds.forEach((id) => accessor.removeZone(id)); + viewZoneIds.length = 0; + + decompile.tokens + .filter((token) => token.declaration) + .forEach((token) => { + const mdValue = getJavadocForToken(token, javadoc); + if (mdValue == null) { + return; + } + + const domNode = document.createElement("div"); + domNode.innerHTML = `${formatMarkdownAsHtml(mdValue, token)}`; + + const location = getTokenLocation(decompile, token); + const zoneId = accessor.addZone({ + afterLineNumber: location.line - 1, + heightInPx: cacluateHeightInPx(domNode), + domNode: domNode, + }); + + viewZoneIds.push(zoneId); }); }); - - const codeLense = monaco.languages.registerCodeLensProvider("java", { - provideCodeLenses: function(model: editor.ITextModel, token: CancellationToken): languages.ProviderResult { - const lenses: languages.CodeLens[] = []; - - for (const token of decompile.tokens) { - if (!token.declaration || token.type == 'parameter' || token.type == 'local') { - continue; - } - - const location = getTokenLocation(decompile, token); - lenses.push({ - range: { - startLineNumber: location.line, - startColumn: 0, - endLineNumber: location.line, - endColumn: 0, - }, - command: { - id: EDIT_JAVADOC_COMMAND_ID, - title: "Edit Javadoc", - arguments: [token] - } - }); - } - - return { - lenses, - dispose: () => { } - }; - } - }); - - - const editJavadocCommand = monaco.editor.addEditorAction({ - id: EDIT_JAVADOC_COMMAND_ID, - label: 'Edit Javadoc', - run: function(editor, ...args) { - const token = args[0] as Token; - activeJavadocToken.next(token); + }); + + const codeLense = monaco.languages.registerCodeLensProvider("java", { + provideCodeLenses: function ( + model: editor.ITextModel, + token: CancellationToken, + ): languages.ProviderResult { + const lenses: languages.CodeLens[] = []; + + for (const token of decompile.tokens) { + if (!token.declaration || token.type == "parameter" || token.type == "local") { + continue; } - }); - - refreshJavadocDataForClass(decompile.className.replace(".class", "")).catch(err => { - console.error("Failed to refresh Javadoc data for class:", err); - }); - - return { - dispose() { - editJavadocCommand.dispose(); - codeLense.dispose(); - javadocDataSub.unsubscribe(); - editor.changeViewZones((accessor) => { - viewZoneIds.forEach(id => accessor.removeZone(id)); - }); - } - }; + const location = getTokenLocation(decompile, token); + lenses.push({ + range: { + startLineNumber: location.line, + startColumn: 0, + endLineNumber: location.line, + endColumn: 0, + }, + command: { + id: EDIT_JAVADOC_COMMAND_ID, + title: "Edit Javadoc", + arguments: [token], + }, + }); + } + + return { + lenses, + dispose: () => {}, + }; + }, + }); + + const editJavadocCommand = monaco.editor.addEditorAction({ + id: EDIT_JAVADOC_COMMAND_ID, + label: "Edit Javadoc", + run: function (editor, ...args) { + const token = args[0] as Token; + activeJavadocToken.next(token); + }, + }); + + refreshJavadocDataForClass(decompile.className.replace(".class", "")).catch((err) => { + console.error("Failed to refresh Javadoc data for class:", err); + }); + + return { + dispose() { + editJavadocCommand.dispose(); + codeLense.dispose(); + + javadocDataSub.unsubscribe(); + editor.changeViewZones((accessor) => { + viewZoneIds.forEach((id) => accessor.removeZone(id)); + }); + }, + }; } function formatMarkdownAsHtml(md: string, token: Token): string { - // TODO maybe use a proper markdown parser/renderer here - - const nestingLevel = (token.className.match(/\$/g) || []).length + (token.type == 'method' || token.type == 'field' ? 1 : 0); - const depth = nestingLevel * 6; - - const indent = " ".repeat(depth) + "/// "; - return md.split("\n").map(line => indent + line).join("
"); + // TODO maybe use a proper markdown parser/renderer here + + const nestingLevel = + (token.className.match(/\$/g) || []).length + + (token.type == "method" || token.type == "field" ? 1 : 0); + const depth = nestingLevel * 6; + + const indent = " ".repeat(depth) + "/// "; + return md + .split("\n") + .map((line) => indent + line) + .join("
"); } - function cacluateHeightInPx(domNode: HTMLDivElement): number { - domNode.style.position = 'absolute'; - domNode.style.visibility = 'hidden'; - document.body.appendChild(domNode); - const heightInPx = domNode.offsetHeight * 1.2; // Magic number seems to fix it - document.body.removeChild(domNode); - domNode.style.position = ''; - domNode.style.visibility = ''; - - return heightInPx; + domNode.style.position = "absolute"; + domNode.style.visibility = "hidden"; + document.body.appendChild(domNode); + const heightInPx = domNode.offsetHeight * 1.2; // Magic number seems to fix it + document.body.removeChild(domNode); + domNode.style.position = ""; + domNode.style.visibility = ""; + + return heightInPx; } diff --git a/src/javadoc/JavadocMarkdownEditor.tsx b/src/javadoc/JavadocMarkdownEditor.tsx index d324642..a20d5ff 100644 --- a/src/javadoc/JavadocMarkdownEditor.tsx +++ b/src/javadoc/JavadocMarkdownEditor.tsx @@ -1,50 +1,55 @@ +import type { editor } from "monaco-editor"; + import { Editor, useMonaco } from "@monaco-editor/react"; -import { useObservable } from "../utils/UseObservable"; -import { currentResult } from "../logic/Decompiler"; import { useEffect, useRef } from "react"; -import type { editor } from "monaco-editor"; + +import { currentResult } from "../logic/Decompiler"; +import { useObservable } from "../utils/UseObservable"; import { JavdocCompletionProvider } from "./JavadocCmpletionProvider"; const JavadocMarkdownEditor = ({ - value, - onChange + value, + onChange, }: { - value: string; - onChange: (newValue: string | undefined) => void; + value: string; + onChange: (newValue: string | undefined) => void; }) => { - const monaco = useMonaco(); - const decompileResult = useObservable(currentResult); - const editorRef = useRef(null); - - useEffect(() => { - if (!monaco || !decompileResult) return; - - const completionItemProvider = monaco.languages.registerCompletionItemProvider('markdown', new JavdocCompletionProvider(decompileResult)); - - return () => { - completionItemProvider.dispose(); - }; - }, [monaco, decompileResult]); - - return ( - { - editorRef.current = codeEditor; - }} - /> + const monaco = useMonaco(); + const decompileResult = useObservable(currentResult); + const editorRef = useRef(null); + + useEffect(() => { + if (!monaco || !decompileResult) return; + + const completionItemProvider = monaco.languages.registerCompletionItemProvider( + "markdown", + new JavdocCompletionProvider(decompileResult), ); + + return () => { + completionItemProvider.dispose(); + }; + }, [monaco, decompileResult]); + + return ( + { + editorRef.current = codeEditor; + }} + /> + ); }; -export default JavadocMarkdownEditor; \ No newline at end of file +export default JavadocMarkdownEditor; diff --git a/src/javadoc/JavadocModal.tsx b/src/javadoc/JavadocModal.tsx index ac48ae3..f6ff5b9 100644 --- a/src/javadoc/JavadocModal.tsx +++ b/src/javadoc/JavadocModal.tsx @@ -1,116 +1,138 @@ import { Modal, Button, message } from "antd"; -import { activeJavadocToken, getJavadocForToken, javadocData, setTokenJavadoc } from "./Javadoc"; -import { useObservable } from "../utils/UseObservable"; -import { IS_JAVADOC_EDITOR } from "../site"; -import type { Token } from "../logic/Tokens"; -import JavadocMarkdownEditor from "./JavadocMarkdownEditor"; import { useMemo, useState } from "react"; -import { javadocApi, type UpdateTarget } from "./api/JavadocApi"; + +import type { Token } from "../logic/Tokens"; + import { selectedMinecraftVersion } from "../logic/State"; +import { IS_JAVADOC_EDITOR } from "../site"; +import { useObservable } from "../utils/UseObservable"; +import { javadocApi, type UpdateTarget } from "./api/JavadocApi"; +import { activeJavadocToken, getJavadocForToken, javadocData, setTokenJavadoc } from "./Javadoc"; +import JavadocMarkdownEditor from "./JavadocMarkdownEditor"; -const ModalBody = ({ token, onValueChange }: { token: Token; onValueChange: (value: string | undefined) => void; }) => { - const initialValue = useMemo(() => getJavadocForToken(token, javadocData.value) || "", [token]); - - return ( -
-
-
Type: {token.type}
-
Class: {token.className}
- {token.type === 'field' || token.type === 'method' ? ( - <> -
Name: {token.name}
-
Descriptor: {token.descriptor}
- - ) : null} +const ModalBody = ({ + token, + onValueChange, +}: { + token: Token; + onValueChange: (value: string | undefined) => void; +}) => { + const initialValue = useMemo(() => getJavadocForToken(token, javadocData.value) || "", [token]); + + return ( +
+
+
+ Type: {token.type} +
+
+ Class: {token.className} +
+ {token.type === "field" || token.type === "method" ? ( + <> +
+ Name: {token.name}
-
- +
+ Descriptor: {token.descriptor}
-
- ); + + ) : null} +
+
+ +
+
+ ); }; const JavadocModal = () => { - if (!IS_JAVADOC_EDITOR) { - return (<>); + if (!IS_JAVADOC_EDITOR) { + return <>; + } + + const token = useObservable(activeJavadocToken); + const minecraftVersion = useObservable(selectedMinecraftVersion); + const [currentValue, setCurrentValue] = useState(); + const [loading, setLoading] = useState(false); + + const [messageApi, contextHolder] = message.useMessage(); + + const handleSave = async () => { + if (!token) { + messageApi.error("No token selected."); + return; + } + + if (!minecraftVersion) { + messageApi.error("No Minecraft version selected."); + return; + } + + var target: UpdateTarget | null = null; + if (token.type == "method" || token.type == "field") { + target = { + type: token.type, + name: token.name, + descriptor: token.descriptor, + }; + } + + setLoading(true); + try { + await javadocApi.updateJavadoc(minecraftVersion, { + className: token.className, + target, + documentation: currentValue || "", + }); + + messageApi.success("Javadoc saved successfully."); + + // Update the local in-memory Javadoc data + setTokenJavadoc(token, currentValue); + + activeJavadocToken.next(null); + } catch (error) { + messageApi.error("Failed to save javadoc."); + console.error("Error saving javadoc:", error); + } finally { + setLoading(false); } + }; - const token = useObservable(activeJavadocToken); - const minecraftVersion = useObservable(selectedMinecraftVersion); - const [currentValue, setCurrentValue] = useState(); - const [loading, setLoading] = useState(false); - - const [messageApi, contextHolder] = message.useMessage(); - - const handleSave = async () => { - if (!token) { - messageApi.error("No token selected."); - return; - } - - if (!minecraftVersion) { - messageApi.error("No Minecraft version selected."); - return; - } - - var target: UpdateTarget | null = null; - if (token.type == 'method' || token.type == 'field') { - target = { - type: token.type, - name: token.name, - descriptor: token.descriptor - }; - } - - setLoading(true); - try { - await javadocApi.updateJavadoc(minecraftVersion, { - className: token.className, - target, - documentation: currentValue || "" - }); - - messageApi.success("Javadoc saved successfully."); - - // Update the local in-memory Javadoc data - setTokenJavadoc(token, currentValue); - - activeJavadocToken.next(null); - } catch (error) { - messageApi.error("Failed to save javadoc."); - console.error("Error saving javadoc:", error); - } finally { - setLoading(false); - } - }; - - const handleCancel = () => { - activeJavadocToken.next(null); - }; - - return ( - - - -
- } - width={750} - > - {token && } - - ); + const handleCancel = () => { + activeJavadocToken.next(null); + }; + + return ( + + + +
+ } + width={750} + > + {token && } + + ); }; -export default JavadocModal; \ No newline at end of file +export default JavadocModal; diff --git a/src/javadoc/api/JavadocApi.ts b/src/javadoc/api/JavadocApi.ts index c10894e..df6af1d 100644 --- a/src/javadoc/api/JavadocApi.ts +++ b/src/javadoc/api/JavadocApi.ts @@ -1,139 +1,138 @@ import { BehaviorSubject, map } from "rxjs"; + import { IS_JAVADOC_EDITOR } from "../../site"; class JavadocApi { - // The current access token, or null if not authenticated - accessToken = new BehaviorSubject(null); - - needsToLogin = this.accessToken.pipe( - map(token => token == null) - ); + // The current access token, or null if not authenticated + accessToken = new BehaviorSubject(null); - constructor() { - this.refreshAccessToken().catch((e) => { - // Ignore errors on initial load - }); - } + needsToLogin = this.accessToken.pipe(map((token) => token == null)); - async getGithubLoginUrl(): Promise { - const response = await fetch('/v1/auth/github'); + constructor() { + this.refreshAccessToken().catch((e) => { + // Ignore errors on initial load + }); + } - if (!response.ok) { - throw new Error('Failed to get GitHub login URL'); - } + async getGithubLoginUrl(): Promise { + const response = await fetch("/v1/auth/github"); - const data = await response.json(); - return data.url; + if (!response.ok) { + throw new Error("Failed to get GitHub login URL"); } - async checkStatus(): Promise { - const response = await this.fetchWithAuth('/v1/auth/check'); - return response.status == 200; - } + const data = await response.json(); + return data.url; + } - async getJavadoc(version: string, className: string): Promise { - const requestBody = { - className - }; + async checkStatus(): Promise { + const response = await this.fetchWithAuth("/v1/auth/check"); + return response.status == 200; + } - const response = await this.fetchWithAuth(`/v1/javadoc/${encodeURIComponent(version)}`, { - method: 'POST', - body: JSON.stringify(requestBody) - }); + async getJavadoc(version: string, className: string): Promise { + const requestBody = { + className, + }; - if (response.status === 404) { - // No Javadoc found for this class - return { data: {} }; - } + const response = await this.fetchWithAuth(`/v1/javadoc/${encodeURIComponent(version)}`, { + method: "POST", + body: JSON.stringify(requestBody), + }); - if (!response.ok) { - throw new Error('Failed to get Javadoc'); - } + if (response.status === 404) { + // No Javadoc found for this class + return { data: {} }; + } - return await response.json() as JavadocResponse; + if (!response.ok) { + throw new Error("Failed to get Javadoc"); } - async updateJavadoc(version: string, update: UpdateJavadocRequest): Promise { - const response = await this.fetchWithAuth(`/v1/javadoc/${encodeURIComponent(version)}`, { - method: 'PATCH', - body: JSON.stringify(update) - }); + return (await response.json()) as JavadocResponse; + } - if (!response.ok) { - throw new Error('Failed to update Javadoc'); - } + async updateJavadoc(version: string, update: UpdateJavadocRequest): Promise { + const response = await this.fetchWithAuth(`/v1/javadoc/${encodeURIComponent(version)}`, { + method: "PATCH", + body: JSON.stringify(update), + }); + + if (!response.ok) { + throw new Error("Failed to update Javadoc"); + } + } + + private async fetchWithAuth(path: string, options: RequestInit = {}): Promise { + if (!this.accessToken.value) { + throw new Error("Not authenticated"); } - private async fetchWithAuth(path: string, options: RequestInit = {}): Promise { - if (!this.accessToken.value) { - throw new Error('Not authenticated'); - } - - options.headers = { - ...(options.headers as Record), - 'Authorization': `Bearer ${this.accessToken.value}`, - 'Content-Type': 'application/json' - }; - - const response = await fetch(path, options); - - if (response.status == 401) { - // Token expired, try to refresh - await this.refreshAccessToken(); - - // Retry the request with the new token - options.headers = { - ...(options.headers as Record), - 'Authorization': `Bearer ${this.accessToken.value}`, - 'Content-Type': 'application/json' - }; - return fetch(path, options); - } - - return response; + options.headers = { + ...(options.headers as Record), + Authorization: `Bearer ${this.accessToken.value}`, + "Content-Type": "application/json", + }; + + const response = await fetch(path, options); + + if (response.status == 401) { + // Token expired, try to refresh + await this.refreshAccessToken(); + + // Retry the request with the new token + options.headers = { + ...(options.headers as Record), + Authorization: `Bearer ${this.accessToken.value}`, + "Content-Type": "application/json", + }; + return fetch(path, options); } - public async refreshAccessToken(): Promise { - const response = await fetch('/v1/auth/refresh', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }); - - if (response.status == 401) { - // Unauthorized, need to log in - this.accessToken.next(null); - throw new Error('Authentication required'); - } else if (!response.ok) { - throw new Error('Failed to refresh access token'); - } - - const data = await response.json(); - this.accessToken.next(data.accessToken); + return response; + } + + public async refreshAccessToken(): Promise { + const response = await fetch("/v1/auth/refresh", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + + if (response.status == 401) { + // Unauthorized, need to log in + this.accessToken.next(null); + throw new Error("Authentication required"); + } else if (!response.ok) { + throw new Error("Failed to refresh access token"); } + + const data = await response.json(); + this.accessToken.next(data.accessToken); + } } export const javadocApi = IS_JAVADOC_EDITOR ? new JavadocApi() : null!; export interface JavadocResponse { - data: Record; + data: Record; } export interface JavadocEntry { - value: string; - methods: Record | null; - fields: Record | null; + value: string; + methods: Record | null; + fields: Record | null; } export interface UpdateJavadocRequest { - className: string; - target: UpdateTarget | null; - documentation: string; + className: string; + target: UpdateTarget | null; + documentation: string; } export interface UpdateTarget { - type: "method" | "field"; - name: string; - descriptor: string; -} \ No newline at end of file + type: "method" | "field"; + name: string; + descriptor: string; +} diff --git a/src/javadoc/api/LoginModal.tsx b/src/javadoc/api/LoginModal.tsx index b448414..ccd79a1 100644 --- a/src/javadoc/api/LoginModal.tsx +++ b/src/javadoc/api/LoginModal.tsx @@ -1,59 +1,58 @@ -import { Modal, Button, Divider, message } from "antd"; import { GithubOutlined } from "@ant-design/icons"; +import { Modal, Button, Divider, message } from "antd"; +import { useState } from "react"; + +import { agreedEula } from "../../logic/Settings"; import { IS_JAVADOC_EDITOR } from "../../site"; import { useObservable } from "../../utils/UseObservable"; import { javadocApi } from "./JavadocApi"; -import { useState } from "react"; -import { agreedEula } from "../../logic/Settings"; const LoginModal = () => { - if (!IS_JAVADOC_EDITOR) { - return (<>); - } + if (!IS_JAVADOC_EDITOR) { + return <>; + } - const needsToLogin = useObservable(javadocApi.needsToLogin); - const accepted = useObservable(agreedEula.observable); - const [loading, setLoading] = useState(false); - const [messageApi, contextHolder] = message.useMessage(); + const needsToLogin = useObservable(javadocApi.needsToLogin); + const accepted = useObservable(agreedEula.observable); + const [loading, setLoading] = useState(false); + const [messageApi, contextHolder] = message.useMessage(); - const handleGithubLogin = async () => { - try { - setLoading(true); - const loginUrl = await javadocApi.getGithubLoginUrl(); - window.location.href = loginUrl; - } catch (error) { - console.error("Failed to get GitHub login URL:", error); - messageApi.error("Failed to initiate GitHub login. Please try again."); - setLoading(false); - } - }; + const handleGithubLogin = async () => { + try { + setLoading(true); + const loginUrl = await javadocApi.getGithubLoginUrl(); + window.location.href = loginUrl; + } catch (error) { + console.error("Failed to get GitHub login URL:", error); + messageApi.error("Failed to initiate GitHub login. Please try again."); + setLoading(false); + } + }; - return ( - -

- Please log in to access the Javadoc editor. -

+ return ( + +

Please log in to access the Javadoc editor.

-
- -
-
- ); +
+ +
+
+ ); }; -export default LoginModal; \ No newline at end of file +export default LoginModal; diff --git a/src/logic/Browser.ts b/src/logic/Browser.ts index 52c1d08..bbba563 100644 --- a/src/logic/Browser.ts +++ b/src/logic/Browser.ts @@ -1,8 +1,8 @@ import { distinctUntilChanged, fromEvent, map, startWith, throttleTime } from "rxjs"; -export const isThin = fromEvent(window, 'resize').pipe( - startWith(null), - map(() => window.innerWidth < 800), - throttleTime(50), - distinctUntilChanged() -); \ No newline at end of file +export const isThin = fromEvent(window, "resize").pipe( + startWith(null), + map(() => window.innerWidth < 800), + throttleTime(50), + distinctUntilChanged(), +); diff --git a/src/logic/Decompiler.ts b/src/logic/Decompiler.ts index c55bfdb..0c9543a 100644 --- a/src/logic/Decompiler.ts +++ b/src/logic/Decompiler.ts @@ -1,74 +1,80 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { - BehaviorSubject, - combineLatest, distinctUntilChanged, from, map, Observable, of, shareReplay, switchMap, tap, throttleTime + BehaviorSubject, + combineLatest, + distinctUntilChanged, + from, + map, + Observable, + of, + shareReplay, + switchMap, + tap, + throttleTime, } from "rxjs"; -import { minecraftJar, type MinecraftJar } from "./MinecraftApi"; -import { selectedFile } from "./State"; -import { bytecode, displayLambdas } from "./Settings"; -import type { Options } from "./vf"; + +import type { Jar } from "../utils/Jar"; import type { DecompileResult } from "../workers/decompile/types"; +import type { Options } from "./vf"; + import * as worker from "../workers/decompile/client"; -import type { Jar } from "../utils/Jar"; +import { minecraftJar, type MinecraftJar } from "./MinecraftApi"; +import { bytecode, displayLambdas } from "./Settings"; +import { selectedFile } from "./State"; const decompilerCounter = new BehaviorSubject(0); export const isDecompiling = decompilerCounter.pipe( - map(count => count > 0), - distinctUntilChanged() + map((count) => count > 0), + distinctUntilChanged(), ); -const decompilerOptions = combineLatest([ - displayLambdas.observable -]).pipe( - distinctUntilChanged(), - switchMap(([displayLambdas]) => { - const options: Options = {}; +const decompilerOptions = combineLatest([displayLambdas.observable]).pipe( + distinctUntilChanged(), + switchMap(([displayLambdas]) => { + const options: Options = {}; - if (displayLambdas) { - options["mark-corresponding-synthetics"] = "1"; - } + if (displayLambdas) { + options["mark-corresponding-synthetics"] = "1"; + } - return of(options); - }), + return of(options); + }), ); -decompilerOptions.subscribe(v => worker.setOptions(v)); +decompilerOptions.subscribe((v) => worker.setOptions(v)); export const currentResult = decompileResultPipeline(minecraftJar); -export function decompileResultPipeline(jar: Observable): Observable { - return combineLatest([ - selectedFile, - jar, - bytecode.observable, - decompilerOptions, - ]).pipe( - distinctUntilChanged(), - throttleTime(250), - switchMap(([className, jar, bytecode]) => { - if (bytecode) { - return from(getClassBytecode(className, jar.jar)); - } +export function decompileResultPipeline( + jar: Observable, +): Observable { + return combineLatest([selectedFile, jar, bytecode.observable, decompilerOptions]).pipe( + distinctUntilChanged(), + throttleTime(250), + switchMap(([className, jar, bytecode]) => { + if (bytecode) { + return from(getClassBytecode(className, jar.jar)); + } - return from(decompileClass(className, jar.jar)); - }), - shareReplay({ bufferSize: 1, refCount: false }) - ); + return from(decompileClass(className, jar.jar)); + }), + shareReplay({ bufferSize: 1, refCount: false }), + ); } export async function getClassBytecode(className: string, jar: Jar) { - try { - decompilerCounter.next(decompilerCounter.value + 1); - return await worker.getClassBytecode(className, jar); - } finally { - decompilerCounter.next(decompilerCounter.value - 1); - } + try { + decompilerCounter.next(decompilerCounter.value + 1); + return await worker.getClassBytecode(className, jar); + } finally { + decompilerCounter.next(decompilerCounter.value - 1); + } } export async function decompileClass(className: string, jar: Jar) { - try { - decompilerCounter.next(decompilerCounter.value + 1); - return await worker.decompileClass(className, jar); - } finally { - decompilerCounter.next(decompilerCounter.value - 1); - } + try { + decompilerCounter.next(decompilerCounter.value + 1); + return await worker.decompileClass(className, jar); + } finally { + decompilerCounter.next(decompilerCounter.value - 1); + } } diff --git a/src/logic/Diff.ts b/src/logic/Diff.ts index 19dcc4e..c5d11e3 100644 --- a/src/logic/Diff.ts +++ b/src/logic/Diff.ts @@ -1,192 +1,197 @@ -import { BehaviorSubject, combineLatest, from, map, Observable, switchMap, shareReplay } from "rxjs"; -import { minecraftJar, minecraftJarPipeline, type MinecraftJar } from "./MinecraftApi"; +import { + BehaviorSubject, + combineLatest, + from, + map, + Observable, + switchMap, + shareReplay, +} from "rxjs"; + +import type { DecompileResult } from "../workers/decompile/types"; + import { currentResult, decompileResultPipeline } from "./Decompiler"; import { calculatedLineChanges } from "./LineChanges"; +import { minecraftJar, minecraftJarPipeline, type MinecraftJar } from "./MinecraftApi"; import { diffLeftselectedMinecraftVersion, selectedMinecraftVersion } from "./State"; -import type { DecompileResult } from "../workers/decompile/types"; export const hideUnchangedSizes = new BehaviorSubject(false); export interface EntryInfo { - classCrcs: Map; - totalUncompressedSize: number; + classCrcs: Map; + totalUncompressedSize: number; } export interface DiffSide { - selectedVersion: BehaviorSubject; - jar: Observable; - entries: Observable>; - result: Observable; + selectedVersion: BehaviorSubject; + jar: Observable; + entries: Observable>; + result: Observable; } export const leftDownloadProgress = new BehaviorSubject(undefined); let leftDiff: DiffSide | null = null; export function getLeftDiff(): DiffSide { - if (!leftDiff) { - leftDiff = {} as DiffSide; - leftDiff.selectedVersion = diffLeftselectedMinecraftVersion; - leftDiff.jar = minecraftJarPipeline(leftDiff.selectedVersion); - leftDiff.entries = leftDiff.jar.pipe( - switchMap(jar => from(getEntriesWithCRC(jar))) - ); - leftDiff.result = decompileResultPipeline(leftDiff.jar); - } - return leftDiff; + if (!leftDiff) { + leftDiff = {} as DiffSide; + leftDiff.selectedVersion = diffLeftselectedMinecraftVersion; + leftDiff.jar = minecraftJarPipeline(leftDiff.selectedVersion); + leftDiff.entries = leftDiff.jar.pipe(switchMap((jar) => from(getEntriesWithCRC(jar)))); + leftDiff.result = decompileResultPipeline(leftDiff.jar); + } + return leftDiff; } let rightDiff: DiffSide | null = null; export function getRightDiff(): DiffSide { - if (!rightDiff) { - rightDiff = { - selectedVersion: selectedMinecraftVersion, - jar: minecraftJar, - entries: minecraftJar.pipe( - switchMap(jar => from(getEntriesWithCRC(jar))) - ), - result: currentResult - }; - } - return rightDiff; + if (!rightDiff) { + rightDiff = { + selectedVersion: selectedMinecraftVersion, + jar: minecraftJar, + entries: minecraftJar.pipe(switchMap((jar) => from(getEntriesWithCRC(jar)))), + result: currentResult, + }; + } + return rightDiff; } export interface DiffSummary { - added: number; - deleted: number; - modified: number; + added: number; + deleted: number; + modified: number; } export interface ChangeInfo { - state: ChangeState; - additions?: number; - deletions?: number; + state: ChangeState; + additions?: number; + deletions?: number; } // Clear calculated line changes when diff versions change to prevent stale data setTimeout(() => { - combineLatest([ - getLeftDiff().selectedVersion, - selectedMinecraftVersion - ]).subscribe(() => { - calculatedLineChanges.next(new Map()); - }); + combineLatest([getLeftDiff().selectedVersion, selectedMinecraftVersion]).subscribe(() => { + calculatedLineChanges.next(new Map()); + }); }, 0); let diffChanges: Observable> | null = null; export function getDiffChanges(): Observable> { - if (!diffChanges) { - diffChanges = combineLatest([ - getLeftDiff().entries, - getRightDiff().entries, - hideUnchangedSizes, - calculatedLineChanges - ]).pipe( - map(([leftEntries, rightEntries, skipUnchangedSize, lineChanges]) => { - const changes = getChangedEntries(leftEntries, rightEntries, skipUnchangedSize); - lineChanges.forEach((counts, file) => { - const info = changes.get(file); - if (info) { - info.additions = counts.additions; - info.deletions = counts.deletions; - } - }); - return changes; - }), - shareReplay(1) - ); - } - return diffChanges; + if (!diffChanges) { + diffChanges = combineLatest([ + getLeftDiff().entries, + getRightDiff().entries, + hideUnchangedSizes, + calculatedLineChanges, + ]).pipe( + map(([leftEntries, rightEntries, skipUnchangedSize, lineChanges]) => { + const changes = getChangedEntries(leftEntries, rightEntries, skipUnchangedSize); + lineChanges.forEach((counts, file) => { + const info = changes.get(file); + if (info) { + info.additions = counts.additions; + info.deletions = counts.deletions; + } + }); + return changes; + }), + shareReplay(1), + ); + } + return diffChanges; } let diffSummaryObs: Observable | null = null; export function getDiffSummary(): Observable { - if (!diffSummaryObs) { - diffSummaryObs = getDiffChanges().pipe( - map(changes => { - const summary: DiffSummary = { added: 0, deleted: 0, modified: 0 }; - changes.forEach(info => { - summary[info.state]++; - }); - return summary; - }), - shareReplay(1) - ); - } - return diffSummaryObs; + if (!diffSummaryObs) { + diffSummaryObs = getDiffChanges().pipe( + map((changes) => { + const summary: DiffSummary = { added: 0, deleted: 0, modified: 0 }; + changes.forEach((info) => { + summary[info.state]++; + }); + return summary; + }), + shareReplay(1), + ); + } + return diffSummaryObs; } export type ChangeState = "added" | "deleted" | "modified"; async function getEntriesWithCRC(jar: MinecraftJar): Promise> { - const entries = new Map(); - - for (const [path, file] of Object.entries(jar.jar.entries)) { - if (!path.endsWith('.class')) { - continue; - } - - const className = path.substring(0, path.length - 6); - const lastSlash = path.lastIndexOf('/'); - const folder = lastSlash !== -1 ? path.substring(0, lastSlash + 1) : ''; - const fileName = path.substring(folder.length); - const baseFileName = fileName.includes('$') ? fileName.split('$')[0] : fileName.replace('.class', ''); - const baseClassName = folder + baseFileName + '.class'; - - const existing = entries.get(baseClassName); - if (existing) { - existing.classCrcs.set(className, file.crc32); - existing.totalUncompressedSize += file.uncompressedSize; - } else { - entries.set(baseClassName, { - classCrcs: new Map([[className, file.crc32]]), - totalUncompressedSize: file.uncompressedSize - }); - } + const entries = new Map(); + + for (const [path, file] of Object.entries(jar.jar.entries)) { + if (!path.endsWith(".class")) { + continue; + } + + const className = path.substring(0, path.length - 6); + const lastSlash = path.lastIndexOf("/"); + const folder = lastSlash !== -1 ? path.substring(0, lastSlash + 1) : ""; + const fileName = path.substring(folder.length); + const baseFileName = fileName.includes("$") + ? fileName.split("$")[0] + : fileName.replace(".class", ""); + const baseClassName = folder + baseFileName + ".class"; + + const existing = entries.get(baseClassName); + if (existing) { + existing.classCrcs.set(className, file.crc32); + existing.totalUncompressedSize += file.uncompressedSize; + } else { + entries.set(baseClassName, { + classCrcs: new Map([[className, file.crc32]]), + totalUncompressedSize: file.uncompressedSize, + }); } + } - return entries; + return entries; } function getChangedEntries( - leftEntries: Map, - rightEntries: Map, - skipUnchangedSize: boolean = false + leftEntries: Map, + rightEntries: Map, + skipUnchangedSize: boolean = false, ): Map { - const changes = new Map(); - - const allKeys = new Set([ - ...leftEntries.keys(), - ...rightEntries.keys() - ]); - - for (const key of allKeys) { - const leftInfo = leftEntries.get(key); - const rightInfo = rightEntries.get(key); - - if (leftInfo === undefined) { - changes.set(key, { state: "added" }); - } else if (rightInfo === undefined) { - changes.set(key, { state: "deleted" }); - } else { - const leftClasses = leftInfo.classCrcs; - const rightClasses = rightInfo.classCrcs; - - // Check if any of the classes (including inner classes) have changed by comparing their CRCs. - // A Map is used to track the CRC of each individual class file that belongs to this base class. - const hasChanges = leftClasses.size !== rightClasses.size || - Array.from(leftClasses.entries()).some(([className, leftCrc]) => rightClasses.get(className) !== leftCrc); - - if (!hasChanges) { - continue; - } - - if (skipUnchangedSize && leftInfo.totalUncompressedSize === rightInfo.totalUncompressedSize) { - continue; - } - - changes.set(key, { state: "modified" }); - } + const changes = new Map(); + + const allKeys = new Set([...leftEntries.keys(), ...rightEntries.keys()]); + + for (const key of allKeys) { + const leftInfo = leftEntries.get(key); + const rightInfo = rightEntries.get(key); + + if (leftInfo === undefined) { + changes.set(key, { state: "added" }); + } else if (rightInfo === undefined) { + changes.set(key, { state: "deleted" }); + } else { + const leftClasses = leftInfo.classCrcs; + const rightClasses = rightInfo.classCrcs; + + // Check if any of the classes (including inner classes) have changed by comparing their CRCs. + // A Map is used to track the CRC of each individual class file that belongs to this base class. + const hasChanges = + leftClasses.size !== rightClasses.size || + Array.from(leftClasses.entries()).some( + ([className, leftCrc]) => rightClasses.get(className) !== leftCrc, + ); + + if (!hasChanges) { + continue; + } + + if (skipUnchangedSize && leftInfo.totalUncompressedSize === rightInfo.totalUncompressedSize) { + continue; + } + + changes.set(key, { state: "modified" }); } + } - return changes; + return changes; } diff --git a/src/logic/FindAllReferences.ts b/src/logic/FindAllReferences.ts index e2e288f..97bc5a4 100644 --- a/src/logic/FindAllReferences.ts +++ b/src/logic/FindAllReferences.ts @@ -1,188 +1,206 @@ -import { BehaviorSubject, combineLatest, distinctUntilChanged, from, map, Observable, switchMap, throttleTime } from "rxjs"; -import { jarIndex, type ReferenceKey, type ReferenceString } from "../workers/JarIndex"; -import { openTab } from "./Tabs"; -import { referencesQuery } from "./State"; -import type { Token } from "./Tokens"; +import { + BehaviorSubject, + combineLatest, + distinctUntilChanged, + from, + map, + Observable, + switchMap, + throttleTime, +} from "rxjs"; + import type { DecompileResult } from "../workers/decompile/types"; +import type { Token } from "./Tokens"; -export const referenceResults = referencesQuery - .pipe( - throttleTime(200), - distinctUntilChanged(), - switchMap((query) => { - if (!query) { - return from([[]]); - } - return jarIndex.pipe( - switchMap((index) => from(index.getReference(query))) - ); - }) - ); +import { jarIndex, type ReferenceKey, type ReferenceString } from "../workers/JarIndex"; +import { referencesQuery } from "./State"; +import { openTab } from "./Tabs"; -export const isViewingReferences = referencesQuery.pipe( - map((query) => query.length > 0) +export const referenceResults = referencesQuery.pipe( + throttleTime(200), + distinctUntilChanged(), + switchMap((query) => { + if (!query) { + return from([[]]); + } + return jarIndex.pipe(switchMap((index) => from(index.getReference(query)))); + }), ); +export const isViewingReferences = referencesQuery.pipe(map((query) => query.length > 0)); + // Format the reference string to be displayed by the user export function formatReference(reference: ReferenceString): string { - if (reference.startsWith("m:")) { - const parts = reference.slice(2).split(":"); - return `${parts[1]}${parts[2]}`; - } - if (reference.startsWith("f:")) { - const parts = reference.slice(2).split(":"); - return parts[1]; - } - if (reference.startsWith("c:")) { - return reference.slice(2); - } - return reference; + if (reference.startsWith("m:")) { + const parts = reference.slice(2).split(":"); + return `${parts[1]}${parts[2]}`; + } + if (reference.startsWith("f:")) { + const parts = reference.slice(2).split(":"); + return parts[1]; + } + if (reference.startsWith("c:")) { + return reference.slice(2); + } + return reference; } export function formatReferenceQuery(query: ReferenceKey): string { - const type = getQueryType(query); - - switch (type) { - case "class": - return query.split("/").pop() || query; - case "method": { - const parts = query.split(":"); - const className = parts[0].split("/").pop() || parts[0]; - return `${className}.${parts[1]}${parts[2]}`; - } - case "field": { - const parts = query.split(":"); - const className = parts[0].split("/").pop() || parts[0]; - return `${className}.${parts[1]}`; - } + const type = getQueryType(query); + + switch (type) { + case "class": + return query.split("/").pop() || query; + case "method": { + const parts = query.split(":"); + const className = parts[0].split("/").pop() || parts[0]; + return `${className}.${parts[1]}${parts[2]}`; + } + case "field": { + const parts = query.split(":"); + const className = parts[0].split("/").pop() || parts[0]; + return `${className}.${parts[1]}`; } + } } function getQueryType(query: ReferenceKey): "class" | "method" | "field" { - if (query.includes(":")) { - const parts = query.split(":"); - if (parts[2].includes("(")) { - return "method"; - } else { - return "field"; - } + if (query.includes(":")) { + const parts = query.split(":"); + if (parts[2].includes("(")) { + return "method"; + } else { + return "field"; } - return "class"; + } + return "class"; } interface ReferenceNavigation { - // The class to navigate to - className: string; - // The reference being navigated to - query: ReferenceKey; - // The location of where the reference is found - reference: ReferenceString; + // The class to navigate to + className: string; + // The reference being navigated to + query: ReferenceKey; + // The location of where the reference is found + reference: ReferenceString; } -export const nextReferenceNavigation = new BehaviorSubject(undefined); +export const nextReferenceNavigation = new BehaviorSubject( + undefined, +); export function goToReference(query: ReferenceKey, reference: ReferenceString) { - const className = reference.slice(2).split(":")[0].split('$')[0]; - openTab(className + ".class"); + const className = reference.slice(2).split(":")[0].split("$")[0]; + openTab(className + ".class"); - if (reference.startsWith("c:")) { - // Nothing to jump to - return; - } + if (reference.startsWith("c:")) { + // Nothing to jump to + return; + } - nextReferenceNavigation.next({ className, query, reference }); + nextReferenceNavigation.next({ className, query, reference }); } export function getNextJumpToken(decompileResult: DecompileResult): Token | undefined { - const referenceNavigation = nextReferenceNavigation.getValue(); + const referenceNavigation = nextReferenceNavigation.getValue(); - if (!referenceNavigation) { - return undefined; - } + if (!referenceNavigation) { + return undefined; + } - const { className, query, reference } = referenceNavigation; + const { className, query, reference } = referenceNavigation; - if (decompileResult.className != className) { - console.log("Decompile result class does not match reference navigation class", decompileResult.className, className); - return undefined; - } + if (decompileResult.className != className) { + console.log( + "Decompile result class does not match reference navigation class", + decompileResult.className, + className, + ); + return undefined; + } - nextReferenceNavigation.next(undefined); - - // This works by first finding the token that matches the reference we are looking for. - // We can then find the token that matches the declaration of the query we are looking for. - // This allows us to jump to the first reference of the query after the reference that was selected. - - let referenceTokenIndex: number | null = null; - - { // First find the reference token - const parts = reference.slice(2).split(":"); - const classname = parts[0]; - const name = parts[1]; - const descriptor = parts[2]; - const expectedType = reference.startsWith("m:") ? "method" : "field"; - - for (let i = 0; i < decompileResult.tokens.length; i++) { - const token = decompileResult.tokens[i]; - - if (!token.declaration) { - // We only want to jump to the declaration - continue; - } - - if (token.type != expectedType) { - continue; - } - - if (token.className == classname && token.name == name && token.descriptor == descriptor) { - if (token.type == "field") { - // For fields, just return the reference as there is only one declaration - return token; - } - - if (!query.includes(":")) { - // If the query is just a class, we can't find a method declaration for it - // Is this even possible? - return undefined; - } - - // For methods we can keep looking for a token that matches the query after this - referenceTokenIndex = i; - break; - } - } - } + nextReferenceNavigation.next(undefined); - if (!referenceTokenIndex) { - console.log("Could not find reference token for", reference); - return undefined; - } + // This works by first finding the token that matches the reference we are looking for. + // We can then find the token that matches the declaration of the query we are looking for. + // This allows us to jump to the first reference of the query after the reference that was selected. - const parts = query.split(":"); + let referenceTokenIndex: number | null = null; + + { + // First find the reference token + const parts = reference.slice(2).split(":"); + const classname = parts[0]; const name = parts[1]; const descriptor = parts[2]; - const queryType = getQueryType(query); + const expectedType = reference.startsWith("m:") ? "method" : "field"; - // Next continue searching from the reference token index to find the actual reference - for (let i = referenceTokenIndex + 1; i < decompileResult.tokens.length; i++) { - const token = decompileResult.tokens[i]; + for (let i = 0; i < decompileResult.tokens.length; i++) { + const token = decompileResult.tokens[i]; - // Special case for constructor reference - if (name == "" && token.type == "class" && token.className == parts[0]) { - return token; - } + if (!token.declaration) { + // We only want to jump to the declaration + continue; + } + + if (token.type != expectedType) { + continue; + } - if (queryType == "method" && token.type == "method" && token.name == name && token.descriptor == descriptor) { - return token; + if (token.className == classname && token.name == name && token.descriptor == descriptor) { + if (token.type == "field") { + // For fields, just return the reference as there is only one declaration + return token; } - if (queryType == "field" && token.type == "field" && token.name == name) { - return token; + if (!query.includes(":")) { + // If the query is just a class, we can't find a method declaration for it + // Is this even possible? + return undefined; } + + // For methods we can keep looking for a token that matches the query after this + referenceTokenIndex = i; + break; + } + } + } + + if (!referenceTokenIndex) { + console.log("Could not find reference token for", reference); + return undefined; + } + + const parts = query.split(":"); + const name = parts[1]; + const descriptor = parts[2]; + const queryType = getQueryType(query); + + // Next continue searching from the reference token index to find the actual reference + for (let i = referenceTokenIndex + 1; i < decompileResult.tokens.length; i++) { + const token = decompileResult.tokens[i]; + + // Special case for constructor reference + if (name == "" && token.type == "class" && token.className == parts[0]) { + return token; + } + + if ( + queryType == "method" && + token.type == "method" && + token.name == name && + token.descriptor == descriptor + ) { + return token; + } + + if (queryType == "field" && token.type == "field" && token.name == name) { + return token; } + } - // Give up if we reach another declaration, it means we didnt find it - // Just return the declaration that supposedly contains the reference - console.log("Could not find token for", query); - return decompileResult.tokens[referenceTokenIndex]; + // Give up if we reach another declaration, it means we didnt find it + // Just return the declaration that supposedly contains the reference + console.log("Could not find token for", query); + return decompileResult.tokens[referenceTokenIndex]; } diff --git a/src/logic/Inheritance.ts b/src/logic/Inheritance.ts index 0e09bf3..8202389 100644 --- a/src/logic/Inheritance.ts +++ b/src/logic/Inheritance.ts @@ -1,110 +1,119 @@ -import { BehaviorSubject, combineLatest, distinctUntilChanged, map, of, shareReplay, switchMap } from "rxjs"; +import { + BehaviorSubject, + combineLatest, + distinctUntilChanged, + map, + of, + shareReplay, + switchMap, +} from "rxjs"; + import { jarIndex } from "../workers/JarIndex"; import { minecraftJar } from "./MinecraftApi"; export class ClassNode { - readonly name: string; - parents: ClassNode[] = []; - children: ClassNode[] = []; - accessFlags: number = 0; - - constructor(name: string) { - this.name = name; - } + readonly name: string; + parents: ClassNode[] = []; + children: ClassNode[] = []; + accessFlags: number = 0; - getRoot(): ClassNode { - // oxlint-disable-next-line typescript/no-this-alias - let n: ClassNode = this; + constructor(name: string) { + this.name = name; + } - while (n.parents.length > 0) { - n = n.parents[0]; - } + getRoot(): ClassNode { + // oxlint-disable-next-line typescript/no-this-alias + let n: ClassNode = this; - return n; + while (n.parents.length > 0) { + n = n.parents[0]; } + + return n; + } } export class InheritanceIndex { - private readonly index = new Map(); + private readonly index = new Map(); - addClass(className: string): ClassNode { - let node = this.index.get(className); - if (!node) { - node = new ClassNode(className); - this.index.set(className, node); - } - return node; + addClass(className: string): ClassNode { + let node = this.index.get(className); + if (!node) { + node = new ClassNode(className); + this.index.set(className, node); } + return node; + } - addParentChildLink(parentName: string, childName: string): void { - const parent = this.addClass(parentName); - const child = this.addClass(childName); - - // Add parent if not already present - if (!child.parents.includes(parent)) { - child.parents.push(parent); - } + addParentChildLink(parentName: string, childName: string): void { + const parent = this.addClass(parentName); + const child = this.addClass(childName); - // Add to children list if not already present - if (!parent.children.includes(child)) { - parent.children.push(child); - } + // Add parent if not already present + if (!child.parents.includes(parent)) { + child.parents.push(parent); } - addChildParentLink(childName: string, parentName: string): void { - this.addParentChildLink(parentName, childName); + // Add to children list if not already present + if (!parent.children.includes(child)) { + parent.children.push(child); } -} - + } + addChildParentLink(childName: string, parentName: string): void { + this.addParentChildLink(parentName, childName); + } +} export const selectedInheritanceClassName = new BehaviorSubject(null); export const inheritanceIndex = combineLatest([jarIndex, minecraftJar]).pipe( - distinctUntilChanged(), - switchMap(async ([jarIndexInstance, jarInstance]) => { - const index = new InheritanceIndex(); - - const classDataArray = await jarIndexInstance.getClassData(); - - const classNames = new Set( - Object.keys(jarInstance.jar.entries) - .filter(name => name.endsWith(".class")) - .map(name => name.slice(0, -6)) - ); - - for (const classData of classDataArray) { - if (!classNames.has(classData.className)) { - continue; - } - - const node = index.addClass(classData.className); - node.accessFlags = classData.accessFlags; - - if (classData.superName && classData.superName.length > 0 && classNames.has(classData.superName)) { - index.addChildParentLink(classData.className, classData.superName); - } - - for (const interfaceName of classData.interfaces) { - if (classNames.has(interfaceName)) { - index.addChildParentLink(classData.className, interfaceName); - } - } + distinctUntilChanged(), + switchMap(async ([jarIndexInstance, jarInstance]) => { + const index = new InheritanceIndex(); + + const classDataArray = await jarIndexInstance.getClassData(); + + const classNames = new Set( + Object.keys(jarInstance.jar.entries) + .filter((name) => name.endsWith(".class")) + .map((name) => name.slice(0, -6)), + ); + + for (const classData of classDataArray) { + if (!classNames.has(classData.className)) { + continue; + } + + const node = index.addClass(classData.className); + node.accessFlags = classData.accessFlags; + + if ( + classData.superName && + classData.superName.length > 0 && + classNames.has(classData.superName) + ) { + index.addChildParentLink(classData.className, classData.superName); + } + + for (const interfaceName of classData.interfaces) { + if (classNames.has(interfaceName)) { + index.addChildParentLink(classData.className, interfaceName); } + } + } - return index; - }), - shareReplay({ bufferSize: 1, refCount: false }) + return index; + }), + shareReplay({ bufferSize: 1, refCount: false }), ); export const selectedInheritanceClassNode = selectedInheritanceClassName.pipe( - switchMap(className => { - if (className === null) { - return of(null); - } - return inheritanceIndex.pipe( - map(index => index.addClass(className)) - ); - }), - shareReplay({ bufferSize: 1, refCount: false }) -); \ No newline at end of file + switchMap((className) => { + if (className === null) { + return of(null); + } + return inheritanceIndex.pipe(map((index) => index.addClass(className))); + }), + shareReplay({ bufferSize: 1, refCount: false }), +); diff --git a/src/logic/JarFile.ts b/src/logic/JarFile.ts index 81deba7..d665542 100644 --- a/src/logic/JarFile.ts +++ b/src/logic/JarFile.ts @@ -1,29 +1,40 @@ -import { BehaviorSubject, combineLatest, distinct, distinctUntilChanged, map, Observable, switchMap, throttleTime } from 'rxjs'; -import { minecraftJar } from './MinecraftApi'; -import { performSearch } from './Search'; -import { searchQuery } from './State'; +import { + BehaviorSubject, + combineLatest, + distinct, + distinctUntilChanged, + map, + Observable, + switchMap, + throttleTime, +} from "rxjs"; + +import { minecraftJar } from "./MinecraftApi"; +import { performSearch } from "./Search"; +import { searchQuery } from "./State"; export const fileList = minecraftJar.pipe( - distinctUntilChanged(), - map(jar => Object.keys(jar.jar.entries)) + distinctUntilChanged(), + map((jar) => Object.keys(jar.jar.entries)), ); // File list that only contains outer class files export const classesList = fileList.pipe( - map(files => files.filter(file => file.endsWith('.class') && !file.includes('$'))) + map((files) => files.filter((file) => file.endsWith(".class") && !file.includes("$"))), ); const debouncedSearchQuery: Observable = searchQuery.pipe( - throttleTime(200), - distinctUntilChanged() + throttleTime(200), + distinctUntilChanged(), ); -export const searchResults: Observable = combineLatest([classesList, debouncedSearchQuery]).pipe( - switchMap(([classes, query]) => { - return [performSearch(query, classes)]; - }) +export const searchResults: Observable = combineLatest([ + classesList, + debouncedSearchQuery, +]).pipe( + switchMap(([classes, query]) => { + return [performSearch(query, classes)]; + }), ); -export const isSearching = searchQuery.pipe( - map((query) => query.length > 0) -); \ No newline at end of file +export const isSearching = searchQuery.pipe(map((query) => query.length > 0)); diff --git a/src/logic/Keybinds.ts b/src/logic/Keybinds.ts index 2488596..0a532a2 100644 --- a/src/logic/Keybinds.ts +++ b/src/logic/Keybinds.ts @@ -1,22 +1,21 @@ import { BehaviorSubject, filter, fromEvent, Observable, tap } from "rxjs"; + import { focusSearch, showStructure, type KeybindSetting } from "./Settings"; // Set to true when the user is currently capturing a keybind export const capturingKeybind = new BehaviorSubject(null); -export const rawKeydownEvent = fromEvent(document, 'keydown'); +export const rawKeydownEvent = fromEvent(document, "keydown"); // Keydown events that should be listened to for general operation -export const keyDownEvent = rawKeydownEvent.pipe( - filter(() => capturingKeybind.value === null) -); +export const keyDownEvent = rawKeydownEvent.pipe(filter(() => capturingKeybind.value === null)); function keyBindEvent(setting: KeybindSetting): Observable { - return keyDownEvent.pipe( - filter(event => setting.matches(event)), - tap(event => event.preventDefault()) - ); + return keyDownEvent.pipe( + filter((event) => setting.matches(event)), + tap((event) => event.preventDefault()), + ); } export const focusSearchEvent = keyBindEvent(focusSearch); -export const showStructureEvent = keyBindEvent(showStructure); \ No newline at end of file +export const showStructureEvent = keyBindEvent(showStructure); diff --git a/src/logic/LineChanges.test.ts b/src/logic/LineChanges.test.ts index e65aaaf..c124705 100644 --- a/src/logic/LineChanges.test.ts +++ b/src/logic/LineChanges.test.ts @@ -1,104 +1,105 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { countLineDiff, updateLineChanges, calculatedLineChanges } from './LineChanges'; - -describe('LineChanges', () => { - describe('countLineDiff', () => { - it('should return 0 additions and deletions for identical strings', () => { - const text = 'line 1\nline 2'; - expect(countLineDiff(text, text)).toEqual({ additions: 0, deletions: 0 }); - }); - - it('should count a single addition', () => { - const oldText = 'line 1'; - const newText = 'line 1\nline 2'; - expect(countLineDiff(oldText, newText)).toEqual({ additions: 1, deletions: 0 }); - }); - - it('should count a single deletion', () => { - const oldText = 'line 1\nline 2'; - const newText = 'line 1'; - expect(countLineDiff(oldText, newText)).toEqual({ additions: 0, deletions: 1 }); - }); - - it('should count modifications as one deletion and one addition', () => { - const oldText = 'line 1\nold line'; - const newText = 'line 1\nnew line'; - expect(countLineDiff(oldText, newText)).toEqual({ additions: 1, deletions: 1 }); - }); - - it('should handle completely different strings', () => { - const oldText = 'a\nb\nc'; - const newText = 'd\ne'; - expect(countLineDiff(oldText, newText)).toEqual({ additions: 2, deletions: 3 }); - }); - - it('should handle special error prefixes for old text', () => { - const oldText = '// Class not found'; - const newText = 'public class Test {}'; - expect(countLineDiff(oldText, newText)).toEqual({ additions: 1, deletions: 0 }); - }); - - it('should handle special error prefixes for new text', () => { - const oldText = 'public class Test {}'; - const newText = '// Error during decompilation'; - expect(countLineDiff(oldText, newText)).toEqual({ additions: 0, deletions: 1 }); - }); - - it('should handle empty strings', () => { - expect(countLineDiff('', 'line 1')).toEqual({ additions: 1, deletions: 0 }); - expect(countLineDiff('line 1', '')).toEqual({ additions: 0, deletions: 1 }); - expect(countLineDiff('', '')).toEqual({ additions: 0, deletions: 0 }); - }); - - it('should ignore common prefix and suffix', () => { - const oldText = 'start\nmiddle-old\nend'; - const newText = 'start\nmiddle-new\nend'; - expect(countLineDiff(oldText, newText)).toEqual({ additions: 1, deletions: 1 }); - }); +import { describe, it, expect, beforeEach } from "vitest"; + +import { countLineDiff, updateLineChanges, calculatedLineChanges } from "./LineChanges"; + +describe("LineChanges", () => { + describe("countLineDiff", () => { + it("should return 0 additions and deletions for identical strings", () => { + const text = "line 1\nline 2"; + expect(countLineDiff(text, text)).toEqual({ additions: 0, deletions: 0 }); + }); + + it("should count a single addition", () => { + const oldText = "line 1"; + const newText = "line 1\nline 2"; + expect(countLineDiff(oldText, newText)).toEqual({ additions: 1, deletions: 0 }); + }); + + it("should count a single deletion", () => { + const oldText = "line 1\nline 2"; + const newText = "line 1"; + expect(countLineDiff(oldText, newText)).toEqual({ additions: 0, deletions: 1 }); + }); + + it("should count modifications as one deletion and one addition", () => { + const oldText = "line 1\nold line"; + const newText = "line 1\nnew line"; + expect(countLineDiff(oldText, newText)).toEqual({ additions: 1, deletions: 1 }); + }); + + it("should handle completely different strings", () => { + const oldText = "a\nb\nc"; + const newText = "d\ne"; + expect(countLineDiff(oldText, newText)).toEqual({ additions: 2, deletions: 3 }); + }); + + it("should handle special error prefixes for old text", () => { + const oldText = "// Class not found"; + const newText = "public class Test {}"; + expect(countLineDiff(oldText, newText)).toEqual({ additions: 1, deletions: 0 }); + }); + + it("should handle special error prefixes for new text", () => { + const oldText = "public class Test {}"; + const newText = "// Error during decompilation"; + expect(countLineDiff(oldText, newText)).toEqual({ additions: 0, deletions: 1 }); + }); + + it("should handle empty strings", () => { + expect(countLineDiff("", "line 1")).toEqual({ additions: 1, deletions: 0 }); + expect(countLineDiff("line 1", "")).toEqual({ additions: 0, deletions: 1 }); + expect(countLineDiff("", "")).toEqual({ additions: 0, deletions: 0 }); }); - describe('updateLineChanges', () => { - beforeEach(() => { - calculatedLineChanges.next(new Map()); - }); - - it('should update the BehaviorSubject with new changes', () => { - updateLineChanges('file.java', 'line 1', 'line 1\nline 2'); - const result = calculatedLineChanges.value.get('file.java'); - expect(result).toEqual({ additions: 1, deletions: 0 }); - }); - - it('should not update the BehaviorSubject if changes are identical', () => { - const initialMap = new Map([['file.java', { additions: 1, deletions: 0 }]]); - calculatedLineChanges.next(initialMap); - - updateLineChanges('file.java', 'line 1', 'line 1\nline 2'); - - expect(calculatedLineChanges.value).toBe(initialMap); // Reference should stay same - }); - - it('should add multiple files to the map', () => { - updateLineChanges('file1.java', 'a', 'a\nb'); - updateLineChanges('file2.java', 'x', 'y'); - - expect(calculatedLineChanges.value.get('file1.java')).toEqual({ additions: 1, deletions: 0 }); - expect(calculatedLineChanges.value.get('file2.java')).toEqual({ additions: 1, deletions: 1 }); - }); - - it('should evict old entries when reaching MAX_CALCULATED_LINE_CHANGES', () => { - // Fill up to 500 - for (let i = 0; i < 500; i++) { - updateLineChanges(`file${i}.java`, '', 'line'); - } - expect(calculatedLineChanges.value.size).toBe(500); - expect(calculatedLineChanges.value.has('file0.java')).toBe(true); - - // Add 501st entry - updateLineChanges('file501.java', '', 'line'); - - expect(calculatedLineChanges.value.size).toBe(500); - expect(calculatedLineChanges.value.has('file0.java')).toBe(false); // First one should be evicted - expect(calculatedLineChanges.value.has('file501.java')).toBe(true); - }); + it("should ignore common prefix and suffix", () => { + const oldText = "start\nmiddle-old\nend"; + const newText = "start\nmiddle-new\nend"; + expect(countLineDiff(oldText, newText)).toEqual({ additions: 1, deletions: 1 }); + }); + }); + + describe("updateLineChanges", () => { + beforeEach(() => { + calculatedLineChanges.next(new Map()); + }); + + it("should update the BehaviorSubject with new changes", () => { + updateLineChanges("file.java", "line 1", "line 1\nline 2"); + const result = calculatedLineChanges.value.get("file.java"); + expect(result).toEqual({ additions: 1, deletions: 0 }); + }); + + it("should not update the BehaviorSubject if changes are identical", () => { + const initialMap = new Map([["file.java", { additions: 1, deletions: 0 }]]); + calculatedLineChanges.next(initialMap); + + updateLineChanges("file.java", "line 1", "line 1\nline 2"); + + expect(calculatedLineChanges.value).toBe(initialMap); // Reference should stay same + }); + + it("should add multiple files to the map", () => { + updateLineChanges("file1.java", "a", "a\nb"); + updateLineChanges("file2.java", "x", "y"); + + expect(calculatedLineChanges.value.get("file1.java")).toEqual({ additions: 1, deletions: 0 }); + expect(calculatedLineChanges.value.get("file2.java")).toEqual({ additions: 1, deletions: 1 }); + }); + + it("should evict old entries when reaching MAX_CALCULATED_LINE_CHANGES", () => { + // Fill up to 500 + for (let i = 0; i < 500; i++) { + updateLineChanges(`file${i}.java`, "", "line"); + } + expect(calculatedLineChanges.value.size).toBe(500); + expect(calculatedLineChanges.value.has("file0.java")).toBe(true); + + // Add 501st entry + updateLineChanges("file501.java", "", "line"); + + expect(calculatedLineChanges.value.size).toBe(500); + expect(calculatedLineChanges.value.has("file0.java")).toBe(false); // First one should be evicted + expect(calculatedLineChanges.value.has("file501.java")).toBe(true); }); + }); }); diff --git a/src/logic/LineChanges.ts b/src/logic/LineChanges.ts index ea6f52b..d85d911 100644 --- a/src/logic/LineChanges.ts +++ b/src/logic/LineChanges.ts @@ -2,86 +2,101 @@ import { BehaviorSubject } from "rxjs"; const MAX_CALCULATED_LINE_CHANGES = 500; -export const calculatedLineChanges = new BehaviorSubject>(new Map()); +export const calculatedLineChanges = new BehaviorSubject< + Map +>(new Map()); /** * Simple line-based diff to count additions and deletions without external libraries. * Uses an efficient O(N+M) hash-based counting approach. */ -export function countLineDiff(oldText: string, newText: string): { additions: number, deletions: number; } { - if (oldText === newText) return { additions: 0, deletions: 0 }; - - // Ignore error messages or "not found" messages from decompiler when counting - if (oldText.startsWith('// Class not found') || oldText.startsWith('// Error during decompilation')) { - const newLines = newText === "" ? 0 : newText.split(/\r?\n/).length; - return { additions: newLines, deletions: 0 }; - } - if (newText.startsWith('// Class not found') || newText.startsWith('// Error during decompilation')) { - const oldLines = oldText === "" ? 0 : oldText.split(/\r?\n/).length; - return { additions: 0, deletions: oldLines }; - } - - const oldLines = oldText === "" ? [] : oldText.split(/\r?\n/); - const newLines = newText === "" ? [] : newText.split(/\r?\n/); - - let start = 0; - while (start < oldLines.length && start < newLines.length && oldLines[start] === newLines[start]) { - start++; - } - - let oldEnd = oldLines.length - 1; - let newEnd = newLines.length - 1; - while (oldEnd >= start && newEnd >= start && oldLines[oldEnd] === newLines[newEnd]) { - oldEnd--; - newEnd--; - } - - const n = oldEnd - start + 1; - const m = newEnd - start + 1; - - if (n <= 0) return { additions: Math.max(0, m), deletions: 0 }; - if (m <= 0) return { additions: 0, deletions: Math.max(0, n) }; - - const oldDiff = oldLines.slice(start, oldEnd + 1); - const newDiff = newLines.slice(start, newEnd + 1); - - const lineCounts = new Map(); - for (const line of oldDiff) { - lineCounts.set(line, (lineCounts.get(line) || 0) + 1); +export function countLineDiff( + oldText: string, + newText: string, +): { additions: number; deletions: number } { + if (oldText === newText) return { additions: 0, deletions: 0 }; + + // Ignore error messages or "not found" messages from decompiler when counting + if ( + oldText.startsWith("// Class not found") || + oldText.startsWith("// Error during decompilation") + ) { + const newLines = newText === "" ? 0 : newText.split(/\r?\n/).length; + return { additions: newLines, deletions: 0 }; + } + if ( + newText.startsWith("// Class not found") || + newText.startsWith("// Error during decompilation") + ) { + const oldLines = oldText === "" ? 0 : oldText.split(/\r?\n/).length; + return { additions: 0, deletions: oldLines }; + } + + const oldLines = oldText === "" ? [] : oldText.split(/\r?\n/); + const newLines = newText === "" ? [] : newText.split(/\r?\n/); + + let start = 0; + while ( + start < oldLines.length && + start < newLines.length && + oldLines[start] === newLines[start] + ) { + start++; + } + + let oldEnd = oldLines.length - 1; + let newEnd = newLines.length - 1; + while (oldEnd >= start && newEnd >= start && oldLines[oldEnd] === newLines[newEnd]) { + oldEnd--; + newEnd--; + } + + const n = oldEnd - start + 1; + const m = newEnd - start + 1; + + if (n <= 0) return { additions: Math.max(0, m), deletions: 0 }; + if (m <= 0) return { additions: 0, deletions: Math.max(0, n) }; + + const oldDiff = oldLines.slice(start, oldEnd + 1); + const newDiff = newLines.slice(start, newEnd + 1); + + const lineCounts = new Map(); + for (const line of oldDiff) { + lineCounts.set(line, (lineCounts.get(line) || 0) + 1); + } + + let commonInDiff = 0; + for (const line of newDiff) { + const count = lineCounts.get(line); + if (count && count > 0) { + lineCounts.set(line, count - 1); + commonInDiff++; } + } - let commonInDiff = 0; - for (const line of newDiff) { - const count = lineCounts.get(line); - if (count && count > 0) { - lineCounts.set(line, count - 1); - commonInDiff++; - } - } + let deletionsInDiff = 0; + for (const count of lineCounts.values()) { + deletionsInDiff += count; + } - let deletionsInDiff = 0; - for (const count of lineCounts.values()) { - deletionsInDiff += count; - } - - return { - deletions: deletionsInDiff, - additions: newDiff.length - commonInDiff - }; + return { + deletions: deletionsInDiff, + additions: newDiff.length - commonInDiff, + }; } export function updateLineChanges(file: string, leftSource: string, rightSource: string) { - const { additions, deletions } = countLineDiff(leftSource, rightSource); - - const current = calculatedLineChanges.value; - const existing = current.get(file); - if (existing?.additions === additions && existing?.deletions === deletions) return; - - const next = new Map(current); - if (next.size >= MAX_CALCULATED_LINE_CHANGES) { - const firstKey = next.keys().next().value; - if (firstKey) next.delete(firstKey); - } - next.set(file, { additions, deletions }); - calculatedLineChanges.next(next); + const { additions, deletions } = countLineDiff(leftSource, rightSource); + + const current = calculatedLineChanges.value; + const existing = current.get(file); + if (existing?.additions === additions && existing?.deletions === deletions) return; + + const next = new Map(current); + if (next.size >= MAX_CALCULATED_LINE_CHANGES) { + const firstKey = next.keys().next().value; + if (firstKey) next.delete(firstKey); + } + next.set(file, { additions, deletions }); + calculatedLineChanges.next(next); } diff --git a/src/logic/MinecraftApi.ts b/src/logic/MinecraftApi.ts index 16273c2..a752b99 100644 --- a/src/logic/MinecraftApi.ts +++ b/src/logic/MinecraftApi.ts @@ -1,257 +1,273 @@ -import { BehaviorSubject, combineLatest, distinctUntilChanged, filter, from, map, shareReplay, switchMap, tap, Observable, withLatestFrom } from "rxjs"; -import { agreedEula } from "./Settings"; +import { + BehaviorSubject, + combineLatest, + distinctUntilChanged, + filter, + from, + map, + shareReplay, + switchMap, + tap, + Observable, + withLatestFrom, +} from "rxjs"; + import { openJar, type Jar } from "../utils/Jar"; +import { agreedEula } from "./Settings"; import { selectedMinecraftVersion } from "./State"; -const CACHE_NAME = 'mcsrc-v1'; +const CACHE_NAME = "mcsrc-v1"; const VERSIONS_URL = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; interface VersionsList { - versions: VersionListEntry[]; + versions: VersionListEntry[]; } interface VersionListEntry { - id: string; - type: string; - url: string; - time: string; - releaseTime: string; - sha1: string; + id: string; + type: string; + url: string; + time: string; + releaseTime: string; + sha1: string; } interface VersionManifest { - id: string; - downloads: { - [key: string]: { - url: string; - sha1: string; - }; + id: string; + downloads: { + [key: string]: { + url: string; + sha1: string; }; + }; } export interface MinecraftJar { - version: string; - jar: Jar; - blob: Blob; + version: string; + jar: Jar; + blob: Blob; } export const minecraftVersions = agreedEula.observable.pipe( - filter(agreed => agreed), - switchMap(() => from(fetchVersions())), - map(versionsList => versionsList.versions), - tap(versions => { - // On inital load, if we dont have a version selected or the selected version is not valid, default to the latest version. - const currentVersion = selectedMinecraftVersion.value; - const isValid = currentVersion !== null && versions.some(v => v.id === currentVersion); + filter((agreed) => agreed), + switchMap(() => from(fetchVersions())), + map((versionsList) => versionsList.versions), + tap((versions) => { + // On inital load, if we dont have a version selected or the selected version is not valid, default to the latest version. + const currentVersion = selectedMinecraftVersion.value; + const isValid = currentVersion !== null && versions.some((v) => v.id === currentVersion); - if (!isValid && versions.length > 0) { - selectedMinecraftVersion.next(versions[0].id); - } - }), - shareReplay({ bufferSize: 1, refCount: false }) + if (!isValid && versions.length > 0) { + selectedMinecraftVersion.next(versions[0].id); + } + }), + shareReplay({ bufferSize: 1, refCount: false }), ); export const minecraftVersionIds = minecraftVersions.pipe( - map(versions => versions.map(v => v.id)) + map((versions) => versions.map((v) => v.id)), ); export const downloadProgress = new BehaviorSubject(undefined); export const minecraftJar = minecraftJarPipeline(selectedMinecraftVersion); export function minecraftJarPipeline(source$: Observable): Observable { - return combineLatest([ - source$.pipe( - filter(id => id !== null), - distinctUntilChanged() - ), - minecraftVersions - ]).pipe( - map(([version, versions]) => versions.find(v => v.id === version)), - filter((version) => version !== undefined), - tap((version) => console.log(`Opening Minecraft jar ${version.id}`)), - switchMap(version => from(downloadMinecraftJar(version, downloadProgress))), - shareReplay({ bufferSize: 1, refCount: false }) - ); + return combineLatest([ + source$.pipe( + filter((id) => id !== null), + distinctUntilChanged(), + ), + minecraftVersions, + ]).pipe( + map(([version, versions]) => versions.find((v) => v.id === version)), + filter((version) => version !== undefined), + tap((version) => console.log(`Opening Minecraft jar ${version.id}`)), + switchMap((version) => from(downloadMinecraftJar(version, downloadProgress))), + shareReplay({ bufferSize: 1, refCount: false }), + ); } async function getJson(url: string): Promise { - console.log(`Fetching JSON from ${url}`); - const response = await fetch(url); + console.log(`Fetching JSON from ${url}`); + const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to fetch JSON from ${url}: ${response.statusText}`); - } + if (!response.ok) { + throw new Error(`Failed to fetch JSON from ${url}: ${response.statusText}`); + } - return response.json(); + return response.json(); } async function fetchVersions(): Promise { - const mojang = await getJson(VERSIONS_URL); - const filteredMojangVersions = mojang.versions.filter(v => { - const match = v.id.match(/^(\d+)\.(\d+)/); - if (!match) return false; - const major = parseInt(match[1], 10); - return major >= 26; - }); - const versions = filteredMojangVersions - .concat(EXPERIMENTAL_VERSIONS.versions) - .sort((a, b) => b.releaseTime.localeCompare(a.releaseTime)); - return { - versions: versions - }; + const mojang = await getJson(VERSIONS_URL); + const filteredMojangVersions = mojang.versions.filter((v) => { + const match = v.id.match(/^(\d+)\.(\d+)/); + if (!match) return false; + const major = parseInt(match[1], 10); + return major >= 26; + }); + const versions = filteredMojangVersions + .concat(EXPERIMENTAL_VERSIONS.versions) + .sort((a, b) => b.releaseTime.localeCompare(a.releaseTime)); + return { + versions: versions, + }; } async function fetchVersionManifest(version: VersionListEntry): Promise { - return getJson(version.url); + return getJson(version.url); } async function cachedFetch(url: string): Promise { - if (!('caches' in window)) { - return fetch(url); - } + if (!("caches" in window)) { + return fetch(url); + } - const cache = await caches.open(CACHE_NAME); - const cachedResponse = await cache.match(url); - if (cachedResponse) { - return cachedResponse; - }; + const cache = await caches.open(CACHE_NAME); + const cachedResponse = await cache.match(url); + if (cachedResponse) { + return cachedResponse; + } - const response = await fetch(url); - if (response.ok) { - await cache.put(url, response.clone()); - } - return response; + const response = await fetch(url); + if (response.ok) { + await cache.put(url, response.clone()); + } + return response; } -async function downloadMinecraftJar(version: VersionListEntry, progress: BehaviorSubject): Promise { - console.log(`Downloading Minecraft jar for version: ${version.id}`); - const versionManifest = await fetchVersionManifest(version); - const response = await cachedFetch(versionManifest.downloads.client.url); - if (!response.ok) { - throw new Error(`Failed to download Minecraft jar: ${response.statusText}`); - } +async function downloadMinecraftJar( + version: VersionListEntry, + progress: BehaviorSubject, +): Promise { + console.log(`Downloading Minecraft jar for version: ${version.id}`); + const versionManifest = await fetchVersionManifest(version); + const response = await cachedFetch(versionManifest.downloads.client.url); + if (!response.ok) { + throw new Error(`Failed to download Minecraft jar: ${response.statusText}`); + } - const contentLength = response.headers.get('content-length'); - const total = contentLength ? parseInt(contentLength, 10) : 0; + const contentLength = response.headers.get("content-length"); + const total = contentLength ? parseInt(contentLength, 10) : 0; - if (!response.body || total === 0) { - const blob = await response.blob(); - const jar = await openJar(version.id, blob); - progress.next(undefined); - return { version: version.id, jar, blob }; - } + if (!response.body || total === 0) { + const blob = await response.blob(); + const jar = await openJar(version.id, blob); + progress.next(undefined); + return { version: version.id, jar, blob }; + } - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let receivedLength = 0; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let receivedLength = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) break; + while (true) { + const { done, value } = await reader.read(); + if (done) break; - chunks.push(value); - receivedLength += value.length; + chunks.push(value); + receivedLength += value.length; - const percent = Math.round((receivedLength / total) * 100); - progress.next(percent); - } + const percent = Math.round((receivedLength / total) * 100); + progress.next(percent); + } - const blob = new Blob(chunks); - const jar = await openJar(version.id, blob); - progress.next(undefined); - return { version: version.id, jar, blob }; + const blob = new Blob(chunks); + const jar = await openJar(version.id, blob); + progress.next(undefined); + return { version: version.id, jar, blob }; } // Hardcode as these are never going to change. const EXPERIMENTAL_VERSIONS: VersionsList = { - versions: [ - { - id: "25w45a_unobfuscated", - type: "unobfuscated", - url: "https://maven.fabricmc.net/net/minecraft/25w45a_unobfuscated.json", - time: "2025-11-04T14:07:08+00:00", - releaseTime: "2025-11-04T14:07:08+00:00", - sha1: "7a3c149f148b6aa5ac3af48c4f701adea7e5b615", - }, - { - id: "25w46a_unobfuscated", - type: "unobfuscated", - url: "https://maven.fabricmc.net/net/minecraft/25w46a_unobfuscated.json", - time: "2025-11-11T13:20:54+00:00", - releaseTime: "2025-11-11T13:20:54+00:00", - sha1: "314ade2afeada364047798e163ef8e82427c69e1", - }, - { - id: "1.21.11-pre1_unobfuscated", - type: "unobfuscated", - url: "https://maven.fabricmc.net/net/minecraft/1_21_11-pre1_unobfuscated.json", - time: "2025-11-19T08:30:46+00:00", - releaseTime: "2025-11-19T08:30:46+00:00", - sha1: "9c267f8dda2728bae55201a753cdd07b584709f1", - }, - { - id: "1.21.11-pre2_unobfuscated", - type: "unobfuscated", - url: "https://maven.fabricmc.net/net/minecraft/1_21_11-pre2_unobfuscated.json", - time: "2025-11-21T12:07:21+00:00", - releaseTime: "2025-11-21T12:07:21+00:00", - sha1: "2955ce0af0512fdfe53ff0740b017344acf6f397", - }, - { - id: "1.21.11-pre3_unobfuscated", - type: "unobfuscated", - url: "https://maven.fabricmc.net/net/minecraft/1_21_11-pre3_unobfuscated.json", - time: "2025-11-25T14:14:30+00:00", - releaseTime: "2025-11-25T14:14:30+00:00", - sha1: "579bf3428f72b5ea04883d202e4831bfdcb2aa8d", - }, - { - id: "1.21.11-pre4_unobfuscated", - type: "unobfuscated", - url: "https://maven.fabricmc.net/net/minecraft/1_21_11-pre4_unobfuscated.json", - time: "2025-12-01T13:40:12+00:00", - releaseTime: "2025-12-01T13:40:12+00:00", - sha1: "410ce37a2506adcfd54ef7d89168cfbe89cac4cb", - }, - { - id: "1.21.11-pre5_unobfuscated", - type: "unobfuscated", - url: "https://maven.fabricmc.net/net/minecraft/1_21_11-pre5_unobfuscated.json", - time: "2025-12-03T13:34:06+00:00", - releaseTime: "2025-12-03T13:34:06+00:00", - sha1: "1028441ca6d288bbf2103e773196bf524f7260fd", - }, - { - id: "1.21.11-rc1_unobfuscated", - type: "unobfuscated", - url: "https://maven.fabricmc.net/net/minecraft/1_21_11-rc1_unobfuscated.json", - time: "2025-12-04T15:56:55+00:00", - releaseTime: "2025-12-04T15:56:55+00:00", - sha1: "5d3ee0ef1f0251cf7e073354ca9e085a884a643d", - }, - { - id: "1.21.11-rc2_unobfuscated", - type: "unobfuscated", - url: "https://maven.fabricmc.net/net/minecraft/1_21_11-rc2_unobfuscated.json", - time: "2025-12-05T11:57:45+00:00", - releaseTime: "2025-12-05T11:57:45+00:00", - sha1: "9282a3fb154d2a425086c62c11827281308bf93b", - }, - { - id: "1.21.11-rc3_unobfuscated", - type: "unobfuscated", - url: "https://maven.fabricmc.net/net/minecraft/1_21_11-rc3_unobfuscated.json", - time: "2025-12-08T13:59:34+00:00", - releaseTime: "2025-12-08T13:59:34+00:00", - sha1: "ce3f7ac6d0e9d23ea4e5f0354b91ff15039d9931", - }, - { - id: "1.21.11_unobfuscated", - type: "unobfuscated", - url: "https://maven.fabricmc.net/net/minecraft/1_21_11_unobfuscated.json", - time: "2025-12-09T12:43:15+00:00", - releaseTime: "2025-12-09T12:43:15+00:00", - sha1: "327be7759157b04495c591dbb721875e341877af", - } - ] + versions: [ + { + id: "25w45a_unobfuscated", + type: "unobfuscated", + url: "https://maven.fabricmc.net/net/minecraft/25w45a_unobfuscated.json", + time: "2025-11-04T14:07:08+00:00", + releaseTime: "2025-11-04T14:07:08+00:00", + sha1: "7a3c149f148b6aa5ac3af48c4f701adea7e5b615", + }, + { + id: "25w46a_unobfuscated", + type: "unobfuscated", + url: "https://maven.fabricmc.net/net/minecraft/25w46a_unobfuscated.json", + time: "2025-11-11T13:20:54+00:00", + releaseTime: "2025-11-11T13:20:54+00:00", + sha1: "314ade2afeada364047798e163ef8e82427c69e1", + }, + { + id: "1.21.11-pre1_unobfuscated", + type: "unobfuscated", + url: "https://maven.fabricmc.net/net/minecraft/1_21_11-pre1_unobfuscated.json", + time: "2025-11-19T08:30:46+00:00", + releaseTime: "2025-11-19T08:30:46+00:00", + sha1: "9c267f8dda2728bae55201a753cdd07b584709f1", + }, + { + id: "1.21.11-pre2_unobfuscated", + type: "unobfuscated", + url: "https://maven.fabricmc.net/net/minecraft/1_21_11-pre2_unobfuscated.json", + time: "2025-11-21T12:07:21+00:00", + releaseTime: "2025-11-21T12:07:21+00:00", + sha1: "2955ce0af0512fdfe53ff0740b017344acf6f397", + }, + { + id: "1.21.11-pre3_unobfuscated", + type: "unobfuscated", + url: "https://maven.fabricmc.net/net/minecraft/1_21_11-pre3_unobfuscated.json", + time: "2025-11-25T14:14:30+00:00", + releaseTime: "2025-11-25T14:14:30+00:00", + sha1: "579bf3428f72b5ea04883d202e4831bfdcb2aa8d", + }, + { + id: "1.21.11-pre4_unobfuscated", + type: "unobfuscated", + url: "https://maven.fabricmc.net/net/minecraft/1_21_11-pre4_unobfuscated.json", + time: "2025-12-01T13:40:12+00:00", + releaseTime: "2025-12-01T13:40:12+00:00", + sha1: "410ce37a2506adcfd54ef7d89168cfbe89cac4cb", + }, + { + id: "1.21.11-pre5_unobfuscated", + type: "unobfuscated", + url: "https://maven.fabricmc.net/net/minecraft/1_21_11-pre5_unobfuscated.json", + time: "2025-12-03T13:34:06+00:00", + releaseTime: "2025-12-03T13:34:06+00:00", + sha1: "1028441ca6d288bbf2103e773196bf524f7260fd", + }, + { + id: "1.21.11-rc1_unobfuscated", + type: "unobfuscated", + url: "https://maven.fabricmc.net/net/minecraft/1_21_11-rc1_unobfuscated.json", + time: "2025-12-04T15:56:55+00:00", + releaseTime: "2025-12-04T15:56:55+00:00", + sha1: "5d3ee0ef1f0251cf7e073354ca9e085a884a643d", + }, + { + id: "1.21.11-rc2_unobfuscated", + type: "unobfuscated", + url: "https://maven.fabricmc.net/net/minecraft/1_21_11-rc2_unobfuscated.json", + time: "2025-12-05T11:57:45+00:00", + releaseTime: "2025-12-05T11:57:45+00:00", + sha1: "9282a3fb154d2a425086c62c11827281308bf93b", + }, + { + id: "1.21.11-rc3_unobfuscated", + type: "unobfuscated", + url: "https://maven.fabricmc.net/net/minecraft/1_21_11-rc3_unobfuscated.json", + time: "2025-12-08T13:59:34+00:00", + releaseTime: "2025-12-08T13:59:34+00:00", + sha1: "ce3f7ac6d0e9d23ea4e5f0354b91ff15039d9931", + }, + { + id: "1.21.11_unobfuscated", + type: "unobfuscated", + url: "https://maven.fabricmc.net/net/minecraft/1_21_11_unobfuscated.json", + time: "2025-12-09T12:43:15+00:00", + releaseTime: "2025-12-09T12:43:15+00:00", + sha1: "327be7759157b04495c591dbb721875e341877af", + }, + ], }; diff --git a/src/logic/Permalink.test.ts b/src/logic/Permalink.test.ts index 7a9b61a..e0297cc 100644 --- a/src/logic/Permalink.test.ts +++ b/src/logic/Permalink.test.ts @@ -1,164 +1,164 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi } from "vitest"; // Mock the Settings module -vi.mock('./Settings', () => ({ - resetPermalinkAffectingSettings: vi.fn(), - supportsPermalinking: { pipe: vi.fn() } +vi.mock("./Settings", () => ({ + resetPermalinkAffectingSettings: vi.fn(), + supportsPermalinking: { pipe: vi.fn() }, })); // Mock the State module to prevent initialization issues -vi.mock('./State', () => ({ - selectedMinecraftVersion: { subscribe: vi.fn() }, - selectedFile: { subscribe: vi.fn() }, - selectedLines: { subscribe: vi.fn() }, - diffView: { subscribe: vi.fn() } +vi.mock("./State", () => ({ + selectedMinecraftVersion: { subscribe: vi.fn() }, + selectedFile: { subscribe: vi.fn() }, + selectedLines: { subscribe: vi.fn() }, + diffView: { subscribe: vi.fn() }, })); // Import the actual parsing function -import { parsePathToState } from './Permalink'; - -describe('Permalink', () => { - describe('parsePathToState', () => { - describe('Default State', () => { - it('should return default state when path is empty', () => { - expect(parsePathToState('')).toEqual(null); - }); - - it('should return default state when path has less than 3 segments', () => { - expect(parsePathToState('1')).toEqual(null); - expect(parsePathToState('1/1.21')).toEqual(null); - }); - - it('should return default state when path is malformed', () => { - expect(parsePathToState('//')).toEqual(null); - }); +import { parsePathToState } from "./Permalink"; + +describe("Permalink", () => { + describe("parsePathToState", () => { + describe("Default State", () => { + it("should return default state when path is empty", () => { + expect(parsePathToState("")).toEqual(null); + }); + + it("should return default state when path has less than 3 segments", () => { + expect(parsePathToState("1")).toEqual(null); + expect(parsePathToState("1/1.21")).toEqual(null); + }); + + it("should return default state when path is malformed", () => { + expect(parsePathToState("//")).toEqual(null); + }); + }); + + describe("Basic Path Parsing", () => { + it("should parse simple permalink with version, mc version, and file", () => { + const state = parsePathToState("1/1.21/net/minecraft/ChatFormatting")!; + + expect(state.version).toBe(1); + expect(state.minecraftVersion).toBe("1.21"); + expect(state.file).toBe("net/minecraft/ChatFormatting.class"); + expect(state.selectedLines).toBe(null); + }); + + it("should append .class if not present", () => { + const state = parsePathToState("1/1.21/net/minecraft/ChatFormatting")!; + expect(state.file).toBe("net/minecraft/ChatFormatting.class"); + }); + + it("should not duplicate .class extension", () => { + const state = parsePathToState("1/1.21/net/minecraft/ChatFormatting.class")!; + expect(state.file).toBe("net/minecraft/ChatFormatting.class"); + }); + + it("should handle nested package paths", () => { + const state = parsePathToState("1/1.21/net/minecraft/world/entity/player/Player")!; + expect(state.file).toBe("net/minecraft/world/entity/player/Player.class"); + }); + }); + + describe("Line Number Parsing", () => { + it("should parse single line number with #", () => { + const state = parsePathToState("1/1.21/net/minecraft/ChatFormatting#L123")!; + + expect(state.selectedLines).toEqual({ + line: 123, + lineEnd: undefined, }); + }); - describe('Basic Path Parsing', () => { - it('should parse simple permalink with version, mc version, and file', () => { - const state = parsePathToState('1/1.21/net/minecraft/ChatFormatting')!; - - expect(state.version).toBe(1); - expect(state.minecraftVersion).toBe('1.21'); - expect(state.file).toBe('net/minecraft/ChatFormatting.class'); - expect(state.selectedLines).toBe(null); - }); - - it('should append .class if not present', () => { - const state = parsePathToState('1/1.21/net/minecraft/ChatFormatting')!; - expect(state.file).toBe('net/minecraft/ChatFormatting.class'); - }); - - it('should not duplicate .class extension', () => { - const state = parsePathToState('1/1.21/net/minecraft/ChatFormatting.class')!; - expect(state.file).toBe('net/minecraft/ChatFormatting.class'); - }); - - it('should handle nested package paths', () => { - const state = parsePathToState('1/1.21/net/minecraft/world/entity/player/Player')!; - expect(state.file).toBe('net/minecraft/world/entity/player/Player.class'); - }); + it("should parse line range with #", () => { + const state = parsePathToState("1/1.21/net/minecraft/ChatFormatting#L10-20")!; + + expect(state.selectedLines).toEqual({ + line: 10, + lineEnd: 20, }); + }); + + it("should handle URL-encoded line marker (%23)", () => { + const state = parsePathToState("1/1.21/net/minecraft/ChatFormatting%23L50")!; + + expect(state.selectedLines).toEqual({ + line: 50, + lineEnd: undefined, + }); + }); + + it("should handle URL-encoded line range (%23)", () => { + const state = parsePathToState("1/1.21/net/minecraft/ChatFormatting%23L10-20")!; - describe('Line Number Parsing', () => { - it('should parse single line number with #', () => { - const state = parsePathToState('1/1.21/net/minecraft/ChatFormatting#L123')!; - - expect(state.selectedLines).toEqual({ - line: 123, - lineEnd: undefined - }); - }); - - it('should parse line range with #', () => { - const state = parsePathToState('1/1.21/net/minecraft/ChatFormatting#L10-20')!; - - expect(state.selectedLines).toEqual({ - line: 10, - lineEnd: 20 - }); - }); - - it('should handle URL-encoded line marker (%23)', () => { - const state = parsePathToState('1/1.21/net/minecraft/ChatFormatting%23L50')!; - - expect(state.selectedLines).toEqual({ - line: 50, - lineEnd: undefined - }); - }); - - it('should handle URL-encoded line range (%23)', () => { - const state = parsePathToState('1/1.21/net/minecraft/ChatFormatting%23L10-20')!; - - expect(state.selectedLines).toEqual({ - line: 10, - lineEnd: 20 - }); - }); - - it('should handle line number at end of complex path', () => { - const state = parsePathToState('1/1.21/net/minecraft/world/entity/player/Player#L456')!; - - expect(state.file).toBe('net/minecraft/world/entity/player/Player.class'); - expect(state.selectedLines).toEqual({ - line: 456, - lineEnd: undefined - }); - }); - - it('should return null selectedLines when no line number present', () => { - const state = parsePathToState('1/1.21/net/minecraft/ChatFormatting')!; - expect(state.selectedLines).toBe(null); - }); + expect(state.selectedLines).toEqual({ + line: 10, + lineEnd: 20, }); + }); - describe('URL Decoding', () => { - it('should decode URL-encoded minecraft version', () => { - const state = parsePathToState('1/1.21%2B/net/minecraft/ChatFormatting')!; - expect(state.minecraftVersion).toBe('1.21+'); - }); + it("should handle line number at end of complex path", () => { + const state = parsePathToState("1/1.21/net/minecraft/world/entity/player/Player#L456")!; - it('should handle spaces in version (unlikely but possible)', () => { - const state = parsePathToState('1/test%20version/net/minecraft/ChatFormatting')!; - expect(state.minecraftVersion).toBe('test version'); - }); + expect(state.file).toBe("net/minecraft/world/entity/player/Player.class"); + expect(state.selectedLines).toEqual({ + line: 456, + lineEnd: undefined, }); + }); - describe('Backwards Compatibility', () => { - it('should handle legacy version name 25w45a', () => { - const state = parsePathToState('1/25w45a/net/minecraft/ChatFormatting')!; - expect(state.minecraftVersion).toBe('25w45a_unobfuscated'); - }); - - it('should not modify other version names', () => { - const state = parsePathToState('1/25w46a/net/minecraft/ChatFormatting')!; - expect(state.minecraftVersion).toBe('25w46a'); - }); - - it('should handle the legacy version with line numbers', () => { - const state = parsePathToState('1/25w45a/net/minecraft/ChatFormatting#L100')!; - - expect(state.minecraftVersion).toBe('25w45a_unobfuscated'); - expect(state.selectedLines).toEqual({ - line: 100, - lineEnd: undefined - }); - }); + it("should return null selectedLines when no line number present", () => { + const state = parsePathToState("1/1.21/net/minecraft/ChatFormatting")!; + expect(state.selectedLines).toBe(null); + }); + }); + + describe("URL Decoding", () => { + it("should decode URL-encoded minecraft version", () => { + const state = parsePathToState("1/1.21%2B/net/minecraft/ChatFormatting")!; + expect(state.minecraftVersion).toBe("1.21+"); + }); + + it("should handle spaces in version (unlikely but possible)", () => { + const state = parsePathToState("1/test%20version/net/minecraft/ChatFormatting")!; + expect(state.minecraftVersion).toBe("test version"); + }); + }); + + describe("Backwards Compatibility", () => { + it("should handle legacy version name 25w45a", () => { + const state = parsePathToState("1/25w45a/net/minecraft/ChatFormatting")!; + expect(state.minecraftVersion).toBe("25w45a_unobfuscated"); + }); + + it("should not modify other version names", () => { + const state = parsePathToState("1/25w46a/net/minecraft/ChatFormatting")!; + expect(state.minecraftVersion).toBe("25w46a"); + }); + + it("should handle the legacy version with line numbers", () => { + const state = parsePathToState("1/25w45a/net/minecraft/ChatFormatting#L100")!; + + expect(state.minecraftVersion).toBe("25w45a_unobfuscated"); + expect(state.selectedLines).toEqual({ + line: 100, + lineEnd: undefined, }); + }); + }); + + describe("Real-world Examples", () => { + it("should parse multiline permalink", () => { + const state = parsePathToState("1/1.21.4/net/minecraft/server/MinecraftServer#L250-260")!; - describe('Real-world Examples', () => { - it('should parse multiline permalink', () => { - const state = parsePathToState('1/1.21.4/net/minecraft/server/MinecraftServer#L250-260')!; - - expect(state.version).toBe(1); - expect(state.minecraftVersion).toBe('1.21.4'); - expect(state.file).toBe('net/minecraft/server/MinecraftServer.class'); - expect(state.selectedLines).toEqual({ - line: 250, - lineEnd: 260 - }); - }); + expect(state.version).toBe(1); + expect(state.minecraftVersion).toBe("1.21.4"); + expect(state.file).toBe("net/minecraft/server/MinecraftServer.class"); + expect(state.selectedLines).toEqual({ + line: 250, + lineEnd: 260, }); + }); }); + }); }); diff --git a/src/logic/Permalink.ts b/src/logic/Permalink.ts index 181ad68..7ded4ec 100644 --- a/src/logic/Permalink.ts +++ b/src/logic/Permalink.ts @@ -1,126 +1,125 @@ import { combineLatest } from "rxjs"; + import { resetPermalinkAffectingSettings, supportsPermalinking } from "./Settings"; import { diffView, selectedFile, selectedLines, selectedMinecraftVersion } from "./State"; export interface State { - version: number; // Allows us to change the permalink structure in the future - minecraftVersion: string; - file: string; - selectedLines: { - line: number; - lineEnd?: number; - } | null; + version: number; // Allows us to change the permalink structure in the future + minecraftVersion: string; + file: string; + selectedLines: { + line: number; + lineEnd?: number; + } | null; } const DEFAULT_STATE: State = { - version: 0, - minecraftVersion: "", - file: "net/minecraft/ChatFormatting.class", - selectedLines: null + version: 0, + minecraftVersion: "", + file: "net/minecraft/ChatFormatting.class", + selectedLines: null, }; export const parsePathToState = (path: string): State | null => { - // Check for line number marker (e.g., #L123 or #L10-20) - let lineNumber: number | null = null; - let lineEnd: number | null = null; - const lineMatch = path.match(/(?:#|%23)L(\d+)(?:-(\d+))?$/); - if (lineMatch) { - lineNumber = parseInt(lineMatch[1], 10); - if (lineMatch[2]) { - lineEnd = parseInt(lineMatch[2], 10); - } - path = path.substring(0, lineMatch.index); - } - - const segments = path.split('/').filter(s => s.length > 0); - - if (segments.length < 3) { - return null; + // Check for line number marker (e.g., #L123 or #L10-20) + let lineNumber: number | null = null; + let lineEnd: number | null = null; + const lineMatch = path.match(/(?:#|%23)L(\d+)(?:-(\d+))?$/); + if (lineMatch) { + lineNumber = parseInt(lineMatch[1], 10); + if (lineMatch[2]) { + lineEnd = parseInt(lineMatch[2], 10); } - - const version = parseInt(segments[0], 10); - let minecraftVersion = decodeURIComponent(segments[1]); - const filePath = segments.slice(2).join('/'); - - // Backwards compatibility with the incorrect version name used previously - if (minecraftVersion == "25w45a") { - minecraftVersion = "25w45a_unobfuscated"; - } - - return { - version, - minecraftVersion, - file: filePath + (filePath.endsWith('.class') ? '' : '.class'), - selectedLines: lineNumber ? { line: lineNumber, lineEnd: lineEnd || undefined } : null - }; + path = path.substring(0, lineMatch.index); + } + + const segments = path.split("/").filter((s) => s.length > 0); + + if (segments.length < 3) { + return null; + } + + const version = parseInt(segments[0], 10); + let minecraftVersion = decodeURIComponent(segments[1]); + const filePath = segments.slice(2).join("/"); + + // Backwards compatibility with the incorrect version name used previously + if (minecraftVersion == "25w45a") { + minecraftVersion = "25w45a_unobfuscated"; + } + + return { + version, + minecraftVersion, + file: filePath + (filePath.endsWith(".class") ? "" : ".class"), + selectedLines: lineNumber ? { line: lineNumber, lineEnd: lineEnd || undefined } : null, + }; }; export const getInitialState = (): State => { - const pathname = window.location.pathname; - const hash = window.location.hash; - - const newStyle = pathname !== '/' && pathname !== ''; - - // Use pathname if it's not just "/" (new style), otherwise use hash (old style) - let path = newStyle - ? pathname.slice(1) // Remove leading / - : (hash.startsWith('#/') ? hash.slice(2) : (hash.startsWith('#') ? hash.slice(1) : '')); - - // For new style (pathname-based), append hash if it contains line number - if (newStyle && hash.startsWith('#L')) { - path += hash; + const pathname = window.location.pathname; + const hash = window.location.hash; + + const newStyle = pathname !== "/" && pathname !== ""; + + // Use pathname if it's not just "/" (new style), otherwise use hash (old style) + let path = newStyle + ? pathname.slice(1) // Remove leading / + : hash.startsWith("#/") + ? hash.slice(2) + : hash.startsWith("#") + ? hash.slice(1) + : ""; + + // For new style (pathname-based), append hash if it contains line number + if (newStyle && hash.startsWith("#L")) { + path += hash; + } + + try { + const state = parsePathToState(path); + if (state === null) { + return DEFAULT_STATE; } - try { - const state = parsePathToState(path); - if (state === null) { - return DEFAULT_STATE; - } - - resetPermalinkAffectingSettings(); - return state; - } catch (e) { - console.error("Error parsing permalink:", e); - return DEFAULT_STATE; - } + resetPermalinkAffectingSettings(); + return state; + } catch (e) { + console.error("Error parsing permalink:", e); + return DEFAULT_STATE; + } }; if (typeof window !== "undefined") { - window.addEventListener('load', () => { - combineLatest([ - selectedMinecraftVersion, - selectedFile, - selectedLines, - supportsPermalinking, - diffView - ]).subscribe(([ - minecraftVersion, - file, - selectedLines, - supported, - diffView - ]) => { - const className = file.split('/').pop()?.replace('.class', '') || file; - document.title = className; - - if (!supported || diffView) { - window.location.hash = ''; - window.history.replaceState({}, '', '/'); - return; - } - - let url = `/1/${minecraftVersion}/${file.replace(".class", "")}`; - - if (selectedLines) { - const { line, lineEnd } = selectedLines; - if (lineEnd && lineEnd !== line) { - url += `#L${Math.min(line, lineEnd)}-${Math.max(line, lineEnd)}`; - } else { - url += `#L${line}`; - } - } - - window.history.replaceState({}, '', url); - }); + window.addEventListener("load", () => { + combineLatest([ + selectedMinecraftVersion, + selectedFile, + selectedLines, + supportsPermalinking, + diffView, + ]).subscribe(([minecraftVersion, file, selectedLines, supported, diffView]) => { + const className = file.split("/").pop()?.replace(".class", "") || file; + document.title = className; + + if (!supported || diffView) { + window.location.hash = ""; + window.history.replaceState({}, "", "/"); + return; + } + + let url = `/1/${minecraftVersion}/${file.replace(".class", "")}`; + + if (selectedLines) { + const { line, lineEnd } = selectedLines; + if (lineEnd && lineEnd !== line) { + url += `#L${Math.min(line, lineEnd)}-${Math.max(line, lineEnd)}`; + } else { + url += `#L${line}`; + } + } + + window.history.replaceState({}, "", url); }); + }); } diff --git a/src/logic/Search.test.ts b/src/logic/Search.test.ts index 4ba91bb..3294843 100644 --- a/src/logic/Search.test.ts +++ b/src/logic/Search.test.ts @@ -1,195 +1,181 @@ -import { describe, it, expect } from 'vitest'; -import { performSearch, getCamelCaseAcronym, matchesCamelCase } from './Search'; - -describe('Search Algorithm', () => { - - describe('Exact Match', () => { - it('should prioritize exact matches', () => { - const classes = [ - 'com/example/Player', - 'com/example/PlayerEntity', - 'com/example/MultiPlayer', - ]; - const results = performSearch('player', classes); - expect(results[0]).toBe('com/example/Player'); - }); +import { describe, it, expect } from "vitest"; + +import { performSearch, getCamelCaseAcronym, matchesCamelCase } from "./Search"; + +describe("Search Algorithm", () => { + describe("Exact Match", () => { + it("should prioritize exact matches", () => { + const classes = ["com/example/Player", "com/example/PlayerEntity", "com/example/MultiPlayer"]; + const results = performSearch("player", classes); + expect(results[0]).toBe("com/example/Player"); + }); + }); + + describe("Starts With", () => { + it("should prioritize starts-with matches", () => { + const classes = [ + "com/example/EntityPlayer", + "com/example/Player", + "com/example/PlayerEntity", + ]; + const results = performSearch("player", classes); + expect(results[0]).toBe("com/example/Player"); + expect(results[1]).toBe("com/example/PlayerEntity"); }); + }); - describe('Starts With', () => { - it('should prioritize starts-with matches', () => { - const classes = [ - 'com/example/EntityPlayer', - 'com/example/Player', - 'com/example/PlayerEntity', - ]; - const results = performSearch('player', classes); - expect(results[0]).toBe('com/example/Player'); - expect(results[1]).toBe('com/example/PlayerEntity'); - }); + describe("CamelCase Matching", () => { + it("should match CamelCase acronyms", () => { + const classes = ["net/minecraft/server/MinecraftServer", "net/minecraft/util/MathHelper"]; + const results = performSearch("ms", classes); + expect(results).toContain("net/minecraft/server/MinecraftServer"); }); - describe('CamelCase Matching', () => { - it('should match CamelCase acronyms', () => { - const classes = [ - 'net/minecraft/server/MinecraftServer', - 'net/minecraft/util/MathHelper', - ]; - const results = performSearch('ms', classes); - expect(results).toContain('net/minecraft/server/MinecraftServer'); - }); - - it('should prioritize exact CamelCase acronym matches', () => { - const classes = [ - 'net/minecraft/client/renderer/RenderType', - 'net/minecraft/world/entity/player/Player', - ]; - const results = performSearch('rt', classes); - expect(results[0]).toBe('net/minecraft/client/renderer/RenderType'); - }); - - it('should match partial CamelCase acronyms', () => { - const classes = [ - 'net/minecraft/world/entity/player/Player', - 'net/minecraft/core/BlockPos', - 'net/minecraft/world/item/ItemStack', - ]; - const results = performSearch('bp', classes); - expect(results).toContain('net/minecraft/core/BlockPos'); - }); + it("should prioritize exact CamelCase acronym matches", () => { + const classes = [ + "net/minecraft/client/renderer/RenderType", + "net/minecraft/world/entity/player/Player", + ]; + const results = performSearch("rt", classes); + expect(results[0]).toBe("net/minecraft/client/renderer/RenderType"); }); - describe('Contains Match', () => { - it('should find classes containing the query', () => { - const classes = [ - 'com/example/EntityPlayer', - 'com/example/Player', - ]; - const results = performSearch('player', classes); - expect(results).toHaveLength(2); - expect(results).toContain('com/example/EntityPlayer'); - }); + it("should match partial CamelCase acronyms", () => { + const classes = [ + "net/minecraft/world/entity/player/Player", + "net/minecraft/core/BlockPos", + "net/minecraft/world/item/ItemStack", + ]; + const results = performSearch("bp", classes); + expect(results).toContain("net/minecraft/core/BlockPos"); + }); + }); + + describe("Contains Match", () => { + it("should find classes containing the query", () => { + const classes = ["com/example/EntityPlayer", "com/example/Player"]; + const results = performSearch("player", classes); + expect(results).toHaveLength(2); + expect(results).toContain("com/example/EntityPlayer"); + }); + }); + + describe("Scoring Priority", () => { + it("should order results by match quality", () => { + const classes = [ + "com/example/EntityBlock", + "com/example/Block", + "com/example/BlockEntity", + "com/example/BedrockLevel", + ]; + const results = performSearch("block", classes); + + // Exact match first + expect(results[0]).toBe("com/example/Block"); + // Starts with second + expect(results[1]).toBe("com/example/BlockEntity"); + // Contains last + expect(results[2]).toBe("com/example/EntityBlock"); }); + }); - describe('Scoring Priority', () => { - it('should order results by match quality', () => { - const classes = [ - 'com/example/EntityBlock', - 'com/example/Block', - 'com/example/BlockEntity', - 'com/example/BedrockLevel', - ]; - const results = performSearch('block', classes); - - // Exact match first - expect(results[0]).toBe('com/example/Block'); - // Starts with second - expect(results[1]).toBe('com/example/BlockEntity'); - // Contains last - expect(results[2]).toBe('com/example/EntityBlock'); - }); + describe("Case Insensitivity", () => { + it("should match regardless of case", () => { + const classes = ["com/example/Player", "com/example/PLAYER", "com/example/player"]; + + const results1 = performSearch("player", classes); + const results2 = performSearch("PLAYER", classes); + const results3 = performSearch("Player", classes); + + expect(results1).toHaveLength(3); + expect(results2).toHaveLength(3); + expect(results3).toHaveLength(3); }); + }); - describe('Case Insensitivity', () => { - it('should match regardless of case', () => { - const classes = [ - 'com/example/Player', - 'com/example/PLAYER', - 'com/example/player', - ]; - - const results1 = performSearch('player', classes); - const results2 = performSearch('PLAYER', classes); - const results3 = performSearch('Player', classes); - - expect(results1).toHaveLength(3); - expect(results2).toHaveLength(3); - expect(results3).toHaveLength(3); - }); + describe("Empty Query", () => { + it("should return empty array for empty query", () => { + const classes = ["com/example/Player"]; + const results = performSearch("", classes); + expect(results).toHaveLength(0); }); + }); - describe('Empty Query', () => { - it('should return empty array for empty query', () => { - const classes = ['com/example/Player']; - const results = performSearch('', classes); - expect(results).toHaveLength(0); - }); + describe("Result Limit", () => { + it("should limit results to 100 items", () => { + const classes = Array.from({ length: 200 }, (_, i) => `com/example/Class${i}`); + const results = performSearch("class", classes); + expect(results.length).toBeLessThanOrEqual(100); + }); + }); + + describe("Real-world Cases", () => { + it('should prioritize exact match "Items" over classes starting with "Item"', () => { + const classes = [ + "net/minecraft/client/renderer/item/ItemStackRenderState", + "net/minecraft/client/gui/ItemSlotMouseAction", + "net/minecraft/references/Items", + "net/minecraft/util/datafix/fixes/ItemShulkerBoxColorFix", + "net/minecraft/util/datafix/fixes/ItemSpawnEggFix", + ]; + const results = performSearch("Items", classes); + + // Exact match should be first + expect(results[0]).toBe("net/minecraft/references/Items"); }); - describe('Result Limit', () => { - it('should limit results to 100 items', () => { - const classes = Array.from({ length: 200 }, (_, i) => `com/example/Class${i}`); - const results = performSearch('class', classes); - expect(results.length).toBeLessThanOrEqual(100); - }); + it('should prioritize "Items" over "Item" when searching for "Items"', () => { + const classes = [ + "net/minecraft/world/item/Item", + "net/minecraft/references/Items", + "net/minecraft/world/item/Items", + ]; + const results = performSearch("Items", classes); + + // Both exact matches "Items" should come before "Item" + expect(results[0]).toBe("net/minecraft/references/Items"); + expect(results[1]).toBe("net/minecraft/world/item/Items"); + // "Item" should not match at all when searching for "Items" + expect(results).not.toContain("net/minecraft/world/item/Item"); }); - describe('Real-world Cases', () => { - it('should prioritize exact match "Items" over classes starting with "Item"', () => { - const classes = [ - 'net/minecraft/client/renderer/item/ItemStackRenderState', - 'net/minecraft/client/gui/ItemSlotMouseAction', - 'net/minecraft/references/Items', - 'net/minecraft/util/datafix/fixes/ItemShulkerBoxColorFix', - 'net/minecraft/util/datafix/fixes/ItemSpawnEggFix', - ]; - const results = performSearch('Items', classes); - - // Exact match should be first - expect(results[0]).toBe('net/minecraft/references/Items'); - }); - - it('should prioritize "Items" over "Item" when searching for "Items"', () => { - const classes = [ - 'net/minecraft/world/item/Item', - 'net/minecraft/references/Items', - 'net/minecraft/world/item/Items', - ]; - const results = performSearch('Items', classes); - - // Both exact matches "Items" should come before "Item" - expect(results[0]).toBe('net/minecraft/references/Items'); - expect(results[1]).toBe('net/minecraft/world/item/Items'); - // "Item" should not match at all when searching for "Items" - expect(results).not.toContain('net/minecraft/world/item/Item'); - }); - - it('should prioritize shorter class names when scores are equal', () => { - const classes = [ - 'com/example/PlayerController', - 'com/example/Player', - 'com/example/PlayerEntity', - ]; - const results = performSearch('play', classes); - - // All start with "play", but shorter name should come first - expect(results[0]).toBe('com/example/Player'); - }); + it("should prioritize shorter class names when scores are equal", () => { + const classes = [ + "com/example/PlayerController", + "com/example/Player", + "com/example/PlayerEntity", + ]; + const results = performSearch("play", classes); + + // All start with "play", but shorter name should come first + expect(results[0]).toBe("com/example/Player"); + }); + }); + + describe("Helper Functions", () => { + describe("getCamelCaseAcronym", () => { + it("should extract capital letters", () => { + expect(getCamelCaseAcronym("MinecraftServer")).toBe("MS"); + expect(getCamelCaseAcronym("RenderType")).toBe("RT"); + expect(getCamelCaseAcronym("BlockPos")).toBe("BP"); + }); + + it("should return empty string for no capitals", () => { + expect(getCamelCaseAcronym("lowercase")).toBe(""); + }); }); - describe('Helper Functions', () => { - describe('getCamelCaseAcronym', () => { - it('should extract capital letters', () => { - expect(getCamelCaseAcronym('MinecraftServer')).toBe('MS'); - expect(getCamelCaseAcronym('RenderType')).toBe('RT'); - expect(getCamelCaseAcronym('BlockPos')).toBe('BP'); - }); - - it('should return empty string for no capitals', () => { - expect(getCamelCaseAcronym('lowercase')).toBe(''); - }); - }); - - describe('matchesCamelCase', () => { - it('should match CamelCase acronyms case-insensitively', () => { - expect(matchesCamelCase('MinecraftServer', 'ms')).toBe(true); - expect(matchesCamelCase('MinecraftServer', 'MS')).toBe(true); - expect(matchesCamelCase('MinecraftServer', 'M')).toBe(true); - }); - - it('should not match if acronym does not start with query', () => { - expect(matchesCamelCase('MinecraftServer', 'sr')).toBe(false); - expect(matchesCamelCase('MinecraftServer', 's')).toBe(false); - }); - }); + describe("matchesCamelCase", () => { + it("should match CamelCase acronyms case-insensitively", () => { + expect(matchesCamelCase("MinecraftServer", "ms")).toBe(true); + expect(matchesCamelCase("MinecraftServer", "MS")).toBe(true); + expect(matchesCamelCase("MinecraftServer", "M")).toBe(true); + }); + + it("should not match if acronym does not start with query", () => { + expect(matchesCamelCase("MinecraftServer", "sr")).toBe(false); + expect(matchesCamelCase("MinecraftServer", "s")).toBe(false); + }); }); + }); }); diff --git a/src/logic/Search.ts b/src/logic/Search.ts index 89652be..be094b8 100644 --- a/src/logic/Search.ts +++ b/src/logic/Search.ts @@ -1,68 +1,62 @@ export function getCamelCaseAcronym(str: string): string { - return str.replace(/[^A-Z]/g, ''); + return str.replace(/[^A-Z]/g, ""); } export function matchesCamelCase(className: string, query: string): boolean { - const acronym = getCamelCaseAcronym(className); - return acronym.toLowerCase().startsWith(query.toLowerCase()); + const acronym = getCamelCaseAcronym(className); + return acronym.toLowerCase().startsWith(query.toLowerCase()); } // Vibe coded mess that no one other than copilot or should read or touch :D export function performSearch(query: string, classes: string[]): string[] { - if (query.length === 0) { - return []; - } - - const lowerQuery = query.toLowerCase(); - - const results = classes - .filter(className => { - const simpleClassName = className.split('/').pop() || className; - const lowerSimpleName = simpleClassName.toLowerCase(); - - return lowerSimpleName.includes(lowerQuery) || matchesCamelCase(simpleClassName, query); - }) - .map(className => { - const simpleClassName = className.split('/').pop() || className; - const lowerSimpleName = simpleClassName.toLowerCase(); - - let score = 0; - - if (lowerSimpleName === lowerQuery) { - score = 0; - } - else if (lowerSimpleName.startsWith(lowerQuery)) { - score = 1; - } - else if (getCamelCaseAcronym(simpleClassName).toLowerCase() === lowerQuery) { - score = 2; - } - else if (matchesCamelCase(simpleClassName, query)) { - score = 3; - } - else { - score = 4 + lowerSimpleName.indexOf(lowerQuery); - } - - return { className, score }; - }) - .sort((a, b) => { - if (a.score !== b.score) { - return a.score - b.score; - } - - const aSimple = a.className.split('/').pop() || a.className; - const bSimple = b.className.split('/').pop() || b.className; - if (aSimple.length !== bSimple.length) { - return aSimple.length - bSimple.length; - } - - return aSimple.localeCompare(bSimple); - }) - .slice(0, 100) - .map(result => result.className); - - return results; + if (query.length === 0) { + return []; + } + + const lowerQuery = query.toLowerCase(); + + const results = classes + .filter((className) => { + const simpleClassName = className.split("/").pop() || className; + const lowerSimpleName = simpleClassName.toLowerCase(); + + return lowerSimpleName.includes(lowerQuery) || matchesCamelCase(simpleClassName, query); + }) + .map((className) => { + const simpleClassName = className.split("/").pop() || className; + const lowerSimpleName = simpleClassName.toLowerCase(); + + let score = 0; + + if (lowerSimpleName === lowerQuery) { + score = 0; + } else if (lowerSimpleName.startsWith(lowerQuery)) { + score = 1; + } else if (getCamelCaseAcronym(simpleClassName).toLowerCase() === lowerQuery) { + score = 2; + } else if (matchesCamelCase(simpleClassName, query)) { + score = 3; + } else { + score = 4 + lowerSimpleName.indexOf(lowerQuery); + } + + return { className, score }; + }) + .sort((a, b) => { + if (a.score !== b.score) { + return a.score - b.score; + } + + const aSimple = a.className.split("/").pop() || a.className; + const bSimple = b.className.split("/").pop() || b.className; + if (aSimple.length !== bSimple.length) { + return aSimple.length - bSimple.length; + } + + return aSimple.localeCompare(bSimple); + }) + .slice(0, 100) + .map((result) => result.className); + + return results; } - - diff --git a/src/logic/Settings.ts b/src/logic/Settings.ts index 8561eb5..c33b289 100644 --- a/src/logic/Settings.ts +++ b/src/logic/Settings.ts @@ -1,154 +1,187 @@ // oxlint-disable typescript/no-redundant-type-constituents -import { BehaviorSubject, combineLatest, distinctUntilChanged, map, Observable, switchMap } from "rxjs"; -import * as decompiler from "../workers/decompile/client"; +import { + BehaviorSubject, + combineLatest, + distinctUntilChanged, + map, + Observable, + switchMap, +} from "rxjs"; +import * as decompiler from "../workers/decompile/client"; -export type ModifierKey = 'Ctrl' | 'Alt' | 'Shift'; +export type ModifierKey = "Ctrl" | "Alt" | "Shift"; export type Key = string; export type KeybindValue = - | Key - | `${ModifierKey}+${Key}` - | `${ModifierKey}+${ModifierKey}+${Key}` - | `${ModifierKey}+${ModifierKey}+${ModifierKey}+${Key}`; + | Key + | `${ModifierKey}+${Key}` + | `${ModifierKey}+${ModifierKey}+${Key}` + | `${ModifierKey}+${ModifierKey}+${ModifierKey}+${Key}`; abstract class Setting { - protected key: string; - protected subject: BehaviorSubject; - readonly defaultValue: T; - private toString: (t: T) => string; - - constructor(key: string, defaultValue: T, fromString: (s: string) => T, toString: (t: T) => string) { - const stored = localStorage.getItem(`setting_${key}`); - const initialValue = stored ? fromString(stored) : defaultValue; - - this.key = key; - this.subject = new BehaviorSubject(initialValue); - this.defaultValue = defaultValue; - this.toString = toString; - - window.addEventListener('storage', (event) => { - if (event.key === `setting_${this.key}` && event.newValue !== null) { - const newValue = fromString(event.newValue); - if (this.subject.value !== newValue) { - this.subject.next(newValue); - } - } - }); - } - - get observable(): Observable { - return this.subject; - } - - get value(): T { - return this.subject.value; - } - - set value(newValue: T) { - this.subject.next(newValue); - localStorage.setItem(`setting_${this.key}`, this.toString(newValue)); - } + protected key: string; + protected subject: BehaviorSubject; + readonly defaultValue: T; + private toString: (t: T) => string; + + constructor( + key: string, + defaultValue: T, + fromString: (s: string) => T, + toString: (t: T) => string, + ) { + const stored = localStorage.getItem(`setting_${key}`); + const initialValue = stored ? fromString(stored) : defaultValue; + + this.key = key; + this.subject = new BehaviorSubject(initialValue); + this.defaultValue = defaultValue; + this.toString = toString; + + window.addEventListener("storage", (event) => { + if (event.key === `setting_${this.key}` && event.newValue !== null) { + const newValue = fromString(event.newValue); + if (this.subject.value !== newValue) { + this.subject.next(newValue); + } + } + }); + } + + get observable(): Observable { + return this.subject; + } + + get value(): T { + return this.subject.value; + } + + set value(newValue: T) { + this.subject.next(newValue); + localStorage.setItem(`setting_${this.key}`, this.toString(newValue)); + } } export class BooleanSetting extends Setting { - constructor(key: string, defaultValue: boolean) { - super(key, defaultValue, s => s === "true", b => b ? "true" : "false"); - } + constructor(key: string, defaultValue: boolean) { + super( + key, + defaultValue, + (s) => s === "true", + (b) => (b ? "true" : "false"), + ); + } } export class NumberSetting extends Setting { - constructor(key: string, defaultValue: number) { - super(key, defaultValue, (s) => { - const n = Number.parseInt(s); - return Number.isNaN(n) ? defaultValue : n; - }, n => n.toString()); - } + constructor(key: string, defaultValue: number) { + super( + key, + defaultValue, + (s) => { + const n = Number.parseInt(s); + return Number.isNaN(n) ? defaultValue : n; + }, + (n) => n.toString(), + ); + } } export class KeybindSetting extends Setting { - constructor(key: string, defaultValue: KeybindValue) { - super(key, defaultValue, s => s, v => v); - } - - reset(): void { - this.value = this.defaultValue; + constructor(key: string, defaultValue: KeybindValue) { + super( + key, + defaultValue, + (s) => s, + (v) => v, + ); + } + + reset(): void { + this.value = this.defaultValue; + } + + setFromEvent(event: KeyboardEvent): void { + const parts: string[] = []; + + if (event.ctrlKey) parts.push("Ctrl"); + if (event.altKey) parts.push("Alt"); + if (event.shiftKey) parts.push("Shift"); + if (event.metaKey) parts.push("Cmd"); + + const modifierKeys = ["Control", "Alt", "Shift", "Meta"]; + if (!modifierKeys.includes(event.key)) { + parts.push(event.key); } - setFromEvent(event: KeyboardEvent): void { - const parts: string[] = []; - - if (event.ctrlKey) parts.push('Ctrl'); - if (event.altKey) parts.push('Alt'); - if (event.shiftKey) parts.push('Shift'); - if (event.metaKey) parts.push('Cmd'); - - const modifierKeys = ['Control', 'Alt', 'Shift', 'Meta']; - if (!modifierKeys.includes(event.key)) { - parts.push(event.key); - } - - if (parts.length > 0) { - this.value = parts.join('+'); - } - } - - parse(): { ctrl: boolean; alt: boolean; shift: boolean; cmd: boolean; key: string | null; } { - const keys = this.value.split('+').map(k => k.toLowerCase()); - const modifierKeys = ['ctrl', 'alt', 'shift', 'cmd']; - const mainKey = keys.find(k => !modifierKeys.includes(k)) ?? null; - - return { - ctrl: keys.includes('ctrl'), - alt: keys.includes('alt'), - shift: keys.includes('shift'), - cmd: keys.includes('cmd'), - key: mainKey - }; - } - - matches(event: KeyboardEvent): boolean { - const parsed = this.parse(); - if (event.ctrlKey !== parsed.ctrl) return false; - if (event.altKey !== parsed.alt) return false; - if (event.shiftKey !== parsed.shift) return false; - if (event.metaKey !== parsed.cmd) return false; - - if (!parsed.key) return false; - - return event.key.toLowerCase() === parsed.key.toLowerCase(); + if (parts.length > 0) { + this.value = parts.join("+"); } + } + + parse(): { ctrl: boolean; alt: boolean; shift: boolean; cmd: boolean; key: string | null } { + const keys = this.value.split("+").map((k) => k.toLowerCase()); + const modifierKeys = ["ctrl", "alt", "shift", "cmd"]; + const mainKey = keys.find((k) => !modifierKeys.includes(k)) ?? null; + + return { + ctrl: keys.includes("ctrl"), + alt: keys.includes("alt"), + shift: keys.includes("shift"), + cmd: keys.includes("cmd"), + key: mainKey, + }; + } + + matches(event: KeyboardEvent): boolean { + const parsed = this.parse(); + if (event.ctrlKey !== parsed.ctrl) return false; + if (event.altKey !== parsed.alt) return false; + if (event.shiftKey !== parsed.shift) return false; + if (event.metaKey !== parsed.cmd) return false; + + if (!parsed.key) return false; + + return event.key.toLowerCase() === parsed.key.toLowerCase(); + } } -export const agreedEula = new BooleanSetting('eula', false); -export const enableTabs = new BooleanSetting('enable_tabs', true); -export const compactPackages = new BooleanSetting('compact_packages', true); -export const displayLambdas = new BooleanSetting('display_lambdas', false); -export const bytecode = new BooleanSetting('bytecode', false); -export const unifiedDiff = new BooleanSetting('unified_diff', false); -export const focusSearch = new KeybindSetting('focus_search', 'Ctrl+ '); -export const showStructure = new KeybindSetting('show_structure', 'Ctrl+F12'); +export const agreedEula = new BooleanSetting("eula", false); +export const enableTabs = new BooleanSetting("enable_tabs", true); +export const compactPackages = new BooleanSetting("compact_packages", true); +export const displayLambdas = new BooleanSetting("display_lambdas", false); +export const bytecode = new BooleanSetting("bytecode", false); +export const unifiedDiff = new BooleanSetting("unified_diff", false); +export const focusSearch = new KeybindSetting("focus_search", "Ctrl+ "); +export const showStructure = new KeybindSetting("show_structure", "Ctrl+F12"); -export const preferWasmDecompiler = new BooleanSetting('prefer_wasm_decompiler', true); +export const preferWasmDecompiler = new BooleanSetting("prefer_wasm_decompiler", true); preferWasmDecompiler.observable - .pipe(distinctUntilChanged()) - .subscribe((v) => decompiler.setRuntime(v)); + .pipe(distinctUntilChanged()) + .subscribe((v) => decompiler.setRuntime(v)); export const MAX_THREADS = navigator.hardwareConcurrency || 4; -export const decompilerThreads = new NumberSetting("decompiler_threads", Math.max(MAX_THREADS / 2, 1)); +export const decompilerThreads = new NumberSetting( + "decompiler_threads", + Math.max(MAX_THREADS / 2, 1), +); export const decompilerSplits = new NumberSetting("decompiler_splits", 100); -export const supportsPermalinking = combineLatest([displayLambdas.observable, bytecode.observable]).pipe( - map(([lambdaDisplay, bytecode]) => { - if (lambdaDisplay || bytecode) { - // Alters the decompilation output, so permalinks are not stable - return false; - } +export const supportsPermalinking = combineLatest([ + displayLambdas.observable, + bytecode.observable, +]).pipe( + map(([lambdaDisplay, bytecode]) => { + if (lambdaDisplay || bytecode) { + // Alters the decompilation output, so permalinks are not stable + return false; + } - return true; - }) + return true; + }), ); export function resetPermalinkAffectingSettings(): void { - displayLambdas.value = false; - bytecode.value = false; + displayLambdas.value = false; + bytecode.value = false; } diff --git a/src/logic/State.ts b/src/logic/State.ts index 809de41..92ae8e1 100644 --- a/src/logic/State.ts +++ b/src/logic/State.ts @@ -1,13 +1,16 @@ import { BehaviorSubject } from "rxjs"; import { pairwise } from "rxjs/operators"; -import { Tab } from "./Tabs"; + import { getInitialState } from "./Permalink"; +import { Tab } from "./Tabs"; const initialState = getInitialState(); /// All of the user controled global state should be defined here: -export const selectedMinecraftVersion = new BehaviorSubject(initialState.minecraftVersion); +export const selectedMinecraftVersion = new BehaviorSubject( + initialState.minecraftVersion, +); export const mobileDrawerOpen = new BehaviorSubject(false); export const selectedFile = new BehaviorSubject(initialState.file); diff --git a/src/logic/Tabs.ts b/src/logic/Tabs.ts index 8fd5460..5451d29 100644 --- a/src/logic/Tabs.ts +++ b/src/logic/Tabs.ts @@ -1,151 +1,149 @@ -import { enableTabs } from "./Settings"; import { editor } from "monaco-editor"; + +import { enableTabs } from "./Settings"; import { selectedFile, openTabs, tabHistory } from "./State"; export class Tab { - public key: string; - public scroll: number = 0; - - public viewState: editor.ICodeEditorViewState | null = null; - public model: editor.ITextModel | null = null; - - public constructor(key: string) { - this.key = key; + public key: string; + public scroll: number = 0; + + public viewState: editor.ICodeEditorViewState | null = null; + public model: editor.ITextModel | null = null; + + public constructor(key: string) { + this.key = key; + } + + isCachedModelEqualTo(model: editor.ITextModel): boolean { + if (this.model === null || this.model.isDisposed()) return false; + if (model === null || model.isDisposed()) return false; + if (this.model.getLanguageId() !== model.getLanguageId()) return false; + if (this.model.getLineCount() !== model.getLineCount()) return false; + + for (let i = 1; i <= this.model.getLineCount(); i++) { + if (this.model.getLineContent(i) !== model.getLineContent(i)) { + return false; + } } - isCachedModelEqualTo(model: editor.ITextModel): boolean { - if (this.model === null || this.model.isDisposed()) return false; - if (model === null || model.isDisposed()) return false; - if (this.model.getLanguageId() !== model.getLanguageId()) return false; - if (this.model.getLineCount() !== model.getLineCount()) return false; + return true; + } - for (let i = 1; i <= this.model.getLineCount(); i++) { - if (this.model.getLineContent(i) !== model.getLineContent(i)) { - return false; - } - } + cacheView(viewState: editor.ICodeEditorViewState | null, model: editor.ITextModel | null) { + this.viewState = viewState; + this.model = model; + } - return true; - } - - cacheView( - viewState: editor.ICodeEditorViewState | null, - model: editor.ITextModel | null - ) { - this.viewState = viewState; - this.model = model; - } + invalidateCachedView() { + this.viewState = null; - invalidateCachedView() { - this.viewState = null; - - if (!this.model) return; - this.model.dispose(); - this.model = null; - } + if (!this.model) return; + this.model.dispose(); + this.model = null; + } - applyViewToEditor(editor: editor.IStandaloneCodeEditor) { - if (!this.model) return; - editor.setModel(this.model); - if (this.viewState) editor.restoreViewState(this.viewState); - } + applyViewToEditor(editor: editor.IStandaloneCodeEditor) { + if (!this.model) return; + editor.setModel(this.model); + if (this.viewState) editor.restoreViewState(this.viewState); + } } -export const getOpenTab = (): (Tab | null) => { - return openTabs.value.find(o => o.key === selectedFile.value) || null; +export const getOpenTab = (): Tab | null => { + return openTabs.value.find((o) => o.key === selectedFile.value) || null; }; export const openTab = (key: string) => { - if (!enableTabs.value) { - selectedFile.next(key); - - const currentTab = openTabs.value[0]; - if (currentTab && currentTab.key !== key) { - currentTab.invalidateCachedView(); - openTabs.next([new Tab(key)]); - } + if (!enableTabs.value) { + selectedFile.next(key); - return; + const currentTab = openTabs.value[0]; + if (currentTab && currentTab.key !== key) { + currentTab.invalidateCachedView(); + openTabs.next([new Tab(key)]); } - const tabs = [...openTabs.value]; - const activeIndex = tabs.findIndex(tab => tab.key === selectedFile.value); + return; + } - // If class is not already open, open it - if (!tabs.some(tab => tab.key === key)) { - const insertIndex = activeIndex >= 0 ? activeIndex + 1 : tabs.length; - tabs.splice(insertIndex, 0, new Tab(key)); - openTabs.next(tabs); - } + const tabs = [...openTabs.value]; + const activeIndex = tabs.findIndex((tab) => tab.key === selectedFile.value); + + // If class is not already open, open it + if (!tabs.some((tab) => tab.key === key)) { + const insertIndex = activeIndex >= 0 ? activeIndex + 1 : tabs.length; + tabs.splice(insertIndex, 0, new Tab(key)); + openTabs.next(tabs); + } - // Switch to the newly opened tab, if not already open to the right class - if (selectedFile.value !== key) { - selectedFile.next(key); + // Switch to the newly opened tab, if not already open to the right class + if (selectedFile.value !== key) { + selectedFile.next(key); - if (tabHistory.value.length < 50) { - // Limit history to 50 - tabHistory.next([...tabHistory.value, key]); - } + if (tabHistory.value.length < 50) { + // Limit history to 50 + tabHistory.next([...tabHistory.value, key]); } + } }; export const closeTab = (key: string) => { - if (openTabs.value.length <= 1) return; + if (openTabs.value.length <= 1) return; - const tab = openTabs.value.find(o => o.key === key); + const tab = openTabs.value.find((o) => o.key === key); - tab?.invalidateCachedView(); - tabHistory.next(tabHistory.value.filter(v => v != key)); - const modifiedOpenTabs = openTabs.value.filter(v => v.key != key); + tab?.invalidateCachedView(); + tabHistory.next(tabHistory.value.filter((v) => v != key)); + const modifiedOpenTabs = openTabs.value.filter((v) => v.key != key); - if (key === selectedFile.value) { - const history = [...tabHistory.value]; - let newKey = history.pop(); - tabHistory.next(history); + if (key === selectedFile.value) { + const history = [...tabHistory.value]; + let newKey = history.pop(); + tabHistory.next(history); - if (!newKey) { - // If undefined, open tab left of it - let i = openTabs.value.findIndex(tab => tab.key === key) - 1; - i = Math.max(i, 0); - i = Math.min(i, modifiedOpenTabs.length - 1); - newKey = modifiedOpenTabs[i].key; - } - - openTab(newKey); + if (!newKey) { + // If undefined, open tab left of it + let i = openTabs.value.findIndex((tab) => tab.key === key) - 1; + i = Math.max(i, 0); + i = Math.min(i, modifiedOpenTabs.length - 1); + newKey = modifiedOpenTabs[i].key; } - openTabs.next(modifiedOpenTabs); + openTab(newKey); + } + + openTabs.next(modifiedOpenTabs); }; export const setTabPosition = (key: string, placeIndex: number) => { - const tabs = [...openTabs.value]; - const currentIndex = tabs.findIndex(tab => tab.key === key); - if (currentIndex === -1) return; - const currentTab = tabs[currentIndex]; + const tabs = [...openTabs.value]; + const currentIndex = tabs.findIndex((tab) => tab.key === key); + if (currentIndex === -1) return; + const currentTab = tabs[currentIndex]; - tabs.splice(currentIndex, 1); + tabs.splice(currentIndex, 1); - // Adjust index if moving right - let index = placeIndex; - if (placeIndex > currentIndex) index -= 1; + // Adjust index if moving right + let index = placeIndex; + if (placeIndex > currentIndex) index -= 1; - tabs.splice(index, 0, currentTab); - openTabs.next(tabs); + tabs.splice(index, 0, currentTab); + openTabs.next(tabs); }; export const closeOtherTabs = (key: string) => { - const tab = openTabs.value.find(tab => tab.key === key); - if (!tab) return; + const tab = openTabs.value.find((tab) => tab.key === key); + if (!tab) return; - // Invalidate all tabs except the one being kept - openTabs.value.forEach(t => { - if (t.key !== key) t.invalidateCachedView(); - }); + // Invalidate all tabs except the one being kept + openTabs.value.forEach((t) => { + if (t.key !== key) t.invalidateCachedView(); + }); - openTabs.next([tab]); - tabHistory.next([key]); + openTabs.next([tab]); + tabHistory.next([key]); - if (selectedFile.value !== key) { - selectedFile.next(key); - } + if (selectedFile.value !== key) { + selectedFile.next(key); + } }; diff --git a/src/logic/Tokens.ts b/src/logic/Tokens.ts index 1908fb1..0ec0d0e 100644 --- a/src/logic/Tokens.ts +++ b/src/logic/Tokens.ts @@ -1,41 +1,41 @@ import type { DecompileResult } from "../workers/decompile/types.ts"; -export type TokenType = 'class' | 'field' | 'method' | 'parameter' | 'local'; +export type TokenType = "class" | "field" | "method" | "parameter" | "local"; interface BaseToken { - // The number of characters from the start of the source - start: number; - // The length of the token in characters - length: number; - // The name of the class this token represents - className: string; - // Whether this token is a declaration or a reference - declaration: boolean; + // The number of characters from the start of the source + start: number; + // The length of the token in characters + length: number; + // The name of the class this token represents + className: string; + // Whether this token is a declaration or a reference + declaration: boolean; } export interface MemberToken extends BaseToken { - type: 'field' | 'method'; - // The member name - name: string; - // The member descriptor - descriptor: string; + type: "field" | "method"; + // The member name + name: string; + // The member descriptor + descriptor: string; } interface NonMethodToken extends BaseToken { - type: 'class' | 'parameter' | 'local'; + type: "class" | "parameter" | "local"; } export type Token = MemberToken | NonMethodToken; export interface TokenLocation { - line: number, - column: number; - length: number; + line: number; + column: number; + length: number; } export function getTokenLocation(result: DecompileResult, token: Token): TokenLocation { - const sourceUpTo = result.source.slice(0, token.start); - const line = sourceUpTo.match(/\n/g)!.length + 1; - const column = sourceUpTo.length - sourceUpTo.lastIndexOf("\n"); - return { line, column, length: token.length }; + const sourceUpTo = result.source.slice(0, token.start); + const line = sourceUpTo.match(/\n/g)!.length + 1; + const column = sourceUpTo.length - sourceUpTo.lastIndexOf("\n"); + return { line, column, length: token.length }; } diff --git a/src/logic/vf.ts b/src/logic/vf.ts index adc596e..5ec4572 100644 --- a/src/logic/vf.ts +++ b/src/logic/vf.ts @@ -1,6 +1,7 @@ +import type * as vf from "@run-slicer/vf"; + import wasmPath from "@run-slicer/vf/vf.wasm?url"; import { load } from "@run-slicer/vf/vf.wasm-runtime.js"; -import type * as vf from "@run-slicer/vf"; export type * from "@run-slicer/vf"; @@ -8,29 +9,32 @@ let runtime: typeof vf | null = null; let runtimePreferWasm = true; export async function loadRuntime(preferWasm: boolean) { - if (!runtime || runtimePreferWasm !== preferWasm) { - runtimePreferWasm = preferWasm; - console.log(`Loading VineFlower ${preferWasm ? "WASM" : "JavaScript"} runtime`); + if (!runtime || runtimePreferWasm !== preferWasm) { + runtimePreferWasm = preferWasm; + console.log(`Loading VineFlower ${preferWasm ? "WASM" : "JavaScript"} runtime`); - let loadJs = !preferWasm; - if (preferWasm) { - try { - const { exports } = await load(wasmPath, { noAutoImports: true }); - runtime = exports; - loadJs = false; - } catch (e) { - console.warn("Failed to load WASM module (non-compliant browser?), falling back to JS implementation", e); - loadJs = true; - } - } + let loadJs = !preferWasm; + if (preferWasm) { + try { + const { exports } = await load(wasmPath, { noAutoImports: true }); + runtime = exports; + loadJs = false; + } catch (e) { + console.warn( + "Failed to load WASM module (non-compliant browser?), falling back to JS implementation", + e, + ); + loadJs = true; + } + } - if (loadJs) { - runtime = await import("@run-slicer/vf/vf.runtime.js"); - } + if (loadJs) { + runtime = await import("@run-slicer/vf/vf.runtime.js"); } + } } export const decompile: typeof vf.decompile = async (name, options) => { - if (!runtime) throw "No runtime loaded"; - return await runtime.decompile(name, options); + if (!runtime) throw "No runtime loaded"; + return await runtime.decompile(name, options); }; diff --git a/src/main.tsx b/src/main.tsx index a50f324..95753ec 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,23 +1,23 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import * as monaco from 'monaco-editor'; -import { loader } from '@monaco-editor/react'; -import App from './ui/App.tsx'; +import { loader } from "@monaco-editor/react"; +import * as monaco from "monaco-editor"; +import MonacoWorker from "monaco-editor/esm/vs/editor/editor.worker.js?worker"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; import "./index.css"; -import MonacoWorker from "monaco-editor/esm/vs/editor/editor.worker.js?worker"; +import App from "./ui/App.tsx"; // Dont load monaco from 3rd party CDN. loader.config({ monaco }); globalThis.MonacoEnvironment = { - getWorker() { - return new MonacoWorker(); - } + getWorker() { + return new MonacoWorker(); + }, }; -createRoot(document.getElementById('root')!).render( - - - , +createRoot(document.getElementById("root")!).render( + + + , ); diff --git a/src/site.ts b/src/site.ts index 666b49a..c168b9a 100644 --- a/src/site.ts +++ b/src/site.ts @@ -1 +1 @@ -export const IS_JAVADOC_EDITOR = import.meta.env.VITE_JAVADOC_EDITOR === 'true'; \ No newline at end of file +export const IS_JAVADOC_EDITOR = import.meta.env.VITE_JAVADOC_EDITOR === "true"; diff --git a/src/types/java-indexer.d.ts b/src/types/java-indexer.d.ts index 58fe9c3..0159cd2 100644 --- a/src/types/java-indexer.d.ts +++ b/src/types/java-indexer.d.ts @@ -1,9 +1,9 @@ // Type declarations for TeaVM-generated Java indexer modules declare module "*/java.js" { - export function index(data: ArrayBufferLike): void; - export function getReference(key: string): string[]; - export function getReferenceSize(): number; - export function getBytecode(classData: ArrayBufferLike[]): string; - export function getClassData(): string[]; + export function index(data: ArrayBufferLike): void; + export function getReference(key: string): string[]; + export function getReferenceSize(): number; + export function getBytecode(classData: ArrayBufferLike[]): string; + export function getClassData(): string[]; } diff --git a/src/types/vf-runtime.d.ts b/src/types/vf-runtime.d.ts index 00f472f..2659995 100644 --- a/src/types/vf-runtime.d.ts +++ b/src/types/vf-runtime.d.ts @@ -1,10 +1,10 @@ declare module "@run-slicer/vf/vf.wasm-runtime.js" { - export function load( - wasmPath: string, - options?: { noAutoImports?: boolean; } - ): Promise<{ exports: typeof import("@run-slicer/vf"); }>; + export function load( + wasmPath: string, + options?: { noAutoImports?: boolean }, + ): Promise<{ exports: typeof import("@run-slicer/vf") }>; } declare module "@run-slicer/vf/vf.runtime.js" { - export * from "@run-slicer/vf"; + export * from "@run-slicer/vf"; } diff --git a/src/ui/AboutModal.tsx b/src/ui/AboutModal.tsx index bdcaacc..70b7957 100644 --- a/src/ui/AboutModal.tsx +++ b/src/ui/AboutModal.tsx @@ -1,74 +1,92 @@ +import { InfoCircleOutlined } from "@ant-design/icons"; import { Button, Checkbox, Modal } from "antd"; import { useState } from "react"; +import { BehaviorSubject } from "rxjs"; + import { agreedEula } from "../logic/Settings"; -import { InfoCircleOutlined } from '@ant-design/icons'; import { useObservable } from "../utils/UseObservable"; -import { BehaviorSubject } from "rxjs"; export const aboutModalOpen = new BehaviorSubject(false); export const AboutModalButton = () => { - return ( - - ); + return ( + + ); }; const AboutModal = () => { - const accepted = useObservable(agreedEula.observable); - const isModalOpen = useObservable(aboutModalOpen); + const accepted = useObservable(agreedEula.observable); + const isModalOpen = useObservable(aboutModalOpen); - // Open modal automatically if EULA not accepted - useState(() => { - if (!agreedEula.value) { - aboutModalOpen.next(true); - } - }); + // Open modal automatically if EULA not accepted + useState(() => { + if (!agreedEula.value) { + aboutModalOpen.next(true); + } + }); - const handleCancel = () => { - if (!accepted) { - return; - } + const handleCancel = () => { + if (!accepted) { + return; + } - aboutModalOpen.next(false); - }; + aboutModalOpen.next(false); + }; - return ( - -

NOTE! This website is not redistributing any Minecraft code or compiled bytecode. The minecraft jar is downloaded directly from Mojang's servers to your device when you use this tool. Check your browser's network requests!

-

The Vineflower decompiler is used after being compiled to wasm as part of the @run-slicer/vf project.

+ return ( + +

+ NOTE! This website is not redistributing any Minecraft code or compiled bytecode. The + minecraft jar is downloaded directly from Mojang's servers to your device when you use this + tool. Check your browser's network requests! +

+

+ The Vineflower decompiler is used + after being compiled to wasm as part of the{" "} + @run-slicer/vf project. +

-

GitHub

+

+ GitHub +

- aboutModalOpen.next(false)} /> -
- ); + aboutModalOpen.next(false)} /> +
+ ); }; -const Eula = ({ onAccept }: { onAccept: () => void; }) => { - const accepted = useObservable(agreedEula.observable); +const Eula = ({ onAccept }: { onAccept: () => void }) => { + const accepted = useObservable(agreedEula.observable); - if (accepted) { - return <>; - } + if (accepted) { + return <>; + } - return ( - { - agreedEula.value = e.target.checked; - if (e.target.checked) { - onAccept(); - } - }}> - I agree to the Minecraft EULA before using this website. - ); + return ( + { + agreedEula.value = e.target.checked; + if (e.target.checked) { + onAccept(); + } + }} + > + I agree to the Minecraft{" "} + + EULA + {" "} + before using this website. + + ); }; - export default AboutModal; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index fac082e..95fbafa 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,104 +1,117 @@ -import { Button, ConfigProvider, Drawer, Flex, Splitter, theme } from 'antd'; +import { MenuFoldOutlined } from "@ant-design/icons"; +import { Button, ConfigProvider, Drawer, Flex, Splitter, theme } from "antd"; +import { useState } from "react"; + +import { isThin } from "../logic/Browser.ts"; +import { enableTabs } from "../logic/Settings.ts"; +import { diffView, mobileDrawerOpen } from "../logic/State"; +import { useObservable } from "../utils/UseObservable.ts"; import Code from "./Code.tsx"; -import SideBar from './SideBar.tsx'; -import { useState } from 'react'; -import { useObservable } from '../utils/UseObservable.ts'; -import { isThin } from '../logic/Browser.ts'; -import { diffView, mobileDrawerOpen } from '../logic/State'; -import DiffView from './diff/DiffView.tsx'; -import { FilepathHeader } from './FilepathHeader.tsx'; -import { enableTabs } from '../logic/Settings.ts'; -import { MenuFoldOutlined } from '@ant-design/icons'; -import { TabsComponent } from './TabsComponent.tsx'; -import Modals from './Modals.tsx'; +import DiffView from "./diff/DiffView.tsx"; +import { FilepathHeader } from "./FilepathHeader.tsx"; +import Modals from "./Modals.tsx"; +import SideBar from "./SideBar.tsx"; +import { TabsComponent } from "./TabsComponent.tsx"; const App = () => { - const isSmall = useObservable(isThin); - const enableDiff = useObservable(diffView); + const isSmall = useObservable(isThin); + const enableDiff = useObservable(diffView); - return ( - - - {enableDiff ? : isSmall ? : } - - ); + return ( + + + {enableDiff ? : isSmall ? : } + + ); }; const LargeApp = () => { - const [sizes, setSizes] = useState<(number | string)[]>(['25%', '75%']); - const tabsEnabled = useObservable(enableTabs.observable); + const [sizes, setSizes] = useState<(number | string)[]>(["25%", "75%"]); + const tabsEnabled = useObservable(enableTabs.observable); - return ( - - - - - - - {tabsEnabled && } - -
-
-
-
- ); + return ( + + + + + + + {tabsEnabled && } + +
+ +
+
+
+
+ ); }; const MobileApp = () => { - const open = useObservable(mobileDrawerOpen); - const tabsEnabled = useObservable(enableTabs.observable); + const open = useObservable(mobileDrawerOpen); + const tabsEnabled = useObservable(enableTabs.observable); - const showDrawer = () => { - mobileDrawerOpen.next(true); - }; + const showDrawer = () => { + mobileDrawerOpen.next(true); + }; - const onClose = () => { - mobileDrawerOpen.next(false); - }; + const onClose = () => { + mobileDrawerOpen.next(false); + }; - return ( - - - - - - + ); export const JarDecompilerModal = () => { - const jar = useObservable(minecraftJar); - const isModalOpen = useObservable(modalOpen); + const jar = useObservable(minecraftJar); + const isModalOpen = useObservable(modalOpen); - const [messageApi, messageCtx] = message.useMessage(); - const [modalApi, modalCtx] = Modal.useModal(); + const [messageApi, messageCtx] = message.useMessage(); + const [modalApi, modalCtx] = Modal.useModal(); - const onOk = () => { - modalOpen.next(false); - if (!jar) return; + const onOk = () => { + modalOpen.next(false); + if (!jar) return; - const task = decompileEntireJar(jar.jar, { - threads: decompilerThreads.value, - splits: decompilerSplits.value, - logger(progress, current, total) { - progressSubject.next([progress, current, total]); - }, - }); + const task = decompileEntireJar(jar.jar, { + threads: decompilerThreads.value, + splits: decompilerSplits.value, + logger(progress, current, total) { + progressSubject.next([progress, current, total]); + }, + }); - const start = performance.now(); - taskSubject.next(task); - void task.start().then((total) => { - const elapsed = (performance.now() - start) / 1000; - modalApi.info({ - bodyProps: { "data-testid": "jar-decompiler-result" }, - content: `Decompiled ${total} new classes in ${elapsed.toFixed(3)} s.`, - closable: true, - keyboard: true, - mask: { closable: true }, - }); - }).finally(() => { - taskSubject.next(undefined); - progressSubject.next(undefined); + const start = performance.now(); + taskSubject.next(task); + void task + .start() + .then((total) => { + const elapsed = (performance.now() - start) / 1000; + modalApi.info({ + bodyProps: { "data-testid": "jar-decompiler-result" }, + content: `Decompiled ${total} new classes in ${elapsed.toFixed(3)} s.`, + closable: true, + keyboard: true, + mask: { closable: true }, }); - }; - - const clearCache = () => { - if (!jar) return; - void deleteCache().then(c => messageApi.open({ type: "success", content: `Deleted ${c} clasess from cache.` })); - }; + }) + .finally(() => { + taskSubject.next(undefined); + progressSubject.next(undefined); + }); + }; - return ( - modalOpen.next(false)} - onOk={onOk} - okButtonProps={{ "data-testid": "jar-decompiler-ok" }} - > - {messageCtx} - {modalCtx} - -
-
- - - - - - - - - - -
+ const clearCache = () => { + if (!jar) return; + void deleteCache().then((c) => + messageApi.open({ type: "success", content: `Deleted ${c} clasess from cache.` }), ); + }; + + return ( + modalOpen.next(false)} + onOk={onOk} + okButtonProps={{ "data-testid": "jar-decompiler-ok" }} + > + {messageCtx} + {modalCtx} + +
+
+ + + + + + + + + +
+ ); }; const progressSubject = new BehaviorSubject<[string, number, number] | undefined>(undefined); const taskSubject = new BehaviorSubject(undefined); export const JarDecompilerProgressModal = () => { - const [text, current, total] = useObservable(progressSubject) ?? []; - const task = useObservable(taskSubject); + const [text, current, total] = useObservable(progressSubject) ?? []; + const task = useObservable(taskSubject); - const percent = (current ?? 0) / (total ?? 1) * 100; + const percent = ((current ?? 0) / (total ?? 1)) * 100; - return ( - { - if (task) task.stop(); - taskSubject.next(undefined); - }} - okText={task ? "Stop" : "Stopping..."} - footer={(_, { OkBtn }) => ( - - )} + return ( + { + if (task) task.stop(); + taskSubject.next(undefined); + }} + okText={task ? "Stop" : "Stopping..."} + footer={(_, { OkBtn }) => } + > + +
- -
- {text} -
- `${current}/${total}`} /> -
- - ); + {text} +
+ `${current}/${total}`} /> +
+
+ ); }; diff --git a/src/ui/Modals.tsx b/src/ui/Modals.tsx index 296246e..73a52d7 100644 --- a/src/ui/Modals.tsx +++ b/src/ui/Modals.tsx @@ -1,28 +1,28 @@ import LoginModal from "../javadoc/api/LoginModal"; import JavadocModal from "../javadoc/JavadocModal"; +import AboutModal from "./AboutModal"; +import IndexProgressNotification from "./IndexProgressNotification"; import InheritanceModal from "./inheritance/InheritanceModal"; +import { JarDecompilerModal, JarDecompilerProgressModal } from "./JarDecompilerModal"; import ProgressModal from "./ProgressModal"; -import AboutModal from "./AboutModal"; import SettingsModal from "./SettingsModal"; import StructureModal from "./StructureModal"; -import { JarDecompilerModal, JarDecompilerProgressModal } from "./JarDecompilerModal"; -import IndexProgressNotification from "./IndexProgressNotification"; const Modals = () => { - return ( - <> - - - - - - - - - - - - ); + return ( + <> + + + + + + + + + + + + ); }; export default Modals; diff --git a/src/ui/ProgressModal.tsx b/src/ui/ProgressModal.tsx index b07557e..60becc3 100644 --- a/src/ui/ProgressModal.tsx +++ b/src/ui/ProgressModal.tsx @@ -1,20 +1,21 @@ import { Modal, Progress } from "antd"; + import { downloadProgress } from "../logic/MinecraftApi"; import { useObservable } from "../utils/UseObservable"; const ProcesModal = () => { - const progress = useObservable(downloadProgress); + const progress = useObservable(downloadProgress); - return ( - - - - ); + return ( + + + + ); }; -export default ProcesModal; \ No newline at end of file +export default ProcesModal; diff --git a/src/ui/ReferenceResults.tsx b/src/ui/ReferenceResults.tsx index 2e3f0d7..3d996a6 100644 --- a/src/ui/ReferenceResults.tsx +++ b/src/ui/ReferenceResults.tsx @@ -1,99 +1,103 @@ -import { useObservable } from "../utils/UseObservable"; -import { formatReference, goToReference, referenceResults } from "../logic/FindAllReferences"; -import type { ReferenceString } from "../workers/JarIndex"; import { map, Observable } from "rxjs"; -import { openTab } from "../logic/Tabs"; + +import type { ReferenceString } from "../workers/JarIndex"; + +import { formatReference, goToReference, referenceResults } from "../logic/FindAllReferences"; import { referencesQuery } from "../logic/State"; +import { openTab } from "../logic/Tabs"; +import { useObservable } from "../utils/UseObservable"; function getUsageClass(usage: ReferenceString): string { - if (usage.startsWith("m:") || usage.startsWith("f:")) { - const parts = usage.slice(2).split(":"); - return parts[0]; - } + if (usage.startsWith("m:") || usage.startsWith("f:")) { + const parts = usage.slice(2).split(":"); + return parts[0]; + } - // class usage - return usage; + // class usage + return usage; } interface ReferenceGroup { - className: string; - references: ReferenceString[]; + className: string; + references: ReferenceString[]; } const groupedResults: Observable = referenceResults.pipe( - map(results => { - const groups: Record = {}; + map((results) => { + const groups: Record = {}; - for (const usage of results) { - const className = getUsageClass(usage); - if (!groups[className]) { - groups[className] = []; - } - groups[className].push(usage); - } + for (const usage of results) { + const className = getUsageClass(usage); + if (!groups[className]) { + groups[className] = []; + } + groups[className].push(usage); + } - return Object.entries(groups).map(([className, references]) => ({ - className, - references - })); - }) + return Object.entries(groups).map(([className, references]) => ({ + className, + references, + })); + }), ); interface UsageGroupItemProps { - group: ReferenceGroup; + group: ReferenceGroup; } const UsageGroupItem = ({ group }: UsageGroupItemProps) => { - const query = useObservable(referencesQuery)!; + const query = useObservable(referencesQuery)!; - return ( -
-
openTab(group.className + ".class")} - style={{ - cursor: "pointer", - fontSize: "13px", - fontWeight: "bold", - transition: "background-color 0.2s", - borderRadius: "4px" - }} - onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'rgba(255, 255, 255, 0.1)'} - onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} - > - {group.className} -
-
- {group.references.map((reference, index) => ( -
goToReference(query, reference)} - style={{ - cursor: "pointer", - fontSize: "12px", - transition: "background-color 0.2s", - color: "rgba(255, 255, 255, 0.7)" - }} - onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'rgba(255, 255, 255, 0.05)'} - onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} - > - {formatReference(reference)} -
- ))} -
-
- ); + return ( +
+
openTab(group.className + ".class")} + style={{ + cursor: "pointer", + fontSize: "13px", + fontWeight: "bold", + transition: "background-color 0.2s", + borderRadius: "4px", + }} + onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.1)")} + onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "transparent")} + > + {group.className} +
+
+ {group.references.map((reference, index) => ( +
goToReference(query, reference)} + style={{ + cursor: "pointer", + fontSize: "12px", + transition: "background-color 0.2s", + color: "rgba(255, 255, 255, 0.7)", + }} + onMouseEnter={(e) => + (e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.05)") + } + onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "transparent")} + > + {formatReference(reference)} +
+ ))} +
+
+ ); }; const UsageResults = () => { - const results = useObservable(groupedResults) || []; + const results = useObservable(groupedResults) || []; - return ( -
- {results.map((group, index) => ( - - ))} -
- ); + return ( +
+ {results.map((group, index) => ( + + ))} +
+ ); }; -export default UsageResults; \ No newline at end of file +export default UsageResults; diff --git a/src/ui/SearchResults.tsx b/src/ui/SearchResults.tsx index a9c8fc9..458edbe 100644 --- a/src/ui/SearchResults.tsx +++ b/src/ui/SearchResults.tsx @@ -1,32 +1,33 @@ import { List } from "antd"; + import { searchResults } from "../logic/JarFile"; -import { useObservable } from "../utils/UseObservable"; import { openTab } from "../logic/Tabs"; +import { useObservable } from "../utils/UseObservable"; const SearchResults = () => { - const results = useObservable(searchResults); + const results = useObservable(searchResults); - return ( - ( - openTab(item)} - style={{ - cursor: "pointer", - padding: "2px 8px", - fontSize: "12px", - transition: "background-color 0.2s" - }} - onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'rgba(255, 255, 255, 0.1)'} - onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} - > - {item.replace(/\.class$/, '')} - - )} - /> - ); + return ( + ( + openTab(item)} + style={{ + cursor: "pointer", + padding: "2px 8px", + fontSize: "12px", + transition: "background-color 0.2s", + }} + onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.1)")} + onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "transparent")} + > + {item.replace(/\.class$/, "")} + + )} + /> + ); }; -export default SearchResults; \ No newline at end of file +export default SearchResults; diff --git a/src/ui/SettingsModal.tsx b/src/ui/SettingsModal.tsx index 4c778ae..1d24ef4 100644 --- a/src/ui/SettingsModal.tsx +++ b/src/ui/SettingsModal.tsx @@ -1,144 +1,190 @@ -import { Button, Modal, type CheckboxProps, Form, Tooltip, InputNumber, type InputNumberProps } from "antd"; -import { SettingOutlined } from '@ant-design/icons'; -import { Checkbox } from 'antd'; -import { useObservable } from "../utils/UseObservable"; -import { BooleanSetting, enableTabs, displayLambdas, focusSearch, KeybindSetting, type KeybindValue, bytecode, showStructure, NumberSetting, preferWasmDecompiler, compactPackages } from "../logic/Settings"; -import { capturingKeybind, rawKeydownEvent } from "../logic/Keybinds"; -import { BehaviorSubject } from "rxjs"; import type React from "react"; +import { SettingOutlined } from "@ant-design/icons"; +import { + Button, + Modal, + type CheckboxProps, + Form, + Tooltip, + InputNumber, + type InputNumberProps, +} from "antd"; +import { Checkbox } from "antd"; +import { BehaviorSubject } from "rxjs"; + +import { capturingKeybind, rawKeydownEvent } from "../logic/Keybinds"; +import { + BooleanSetting, + enableTabs, + displayLambdas, + focusSearch, + KeybindSetting, + type KeybindValue, + bytecode, + showStructure, + NumberSetting, + preferWasmDecompiler, + compactPackages, +} from "../logic/Settings"; +import { useObservable } from "../utils/UseObservable"; + export const settingsModalOpen = new BehaviorSubject(false); export const SettingsModalButton = () => { - return ( - - ); + return ( + + ); }; const SettingsModal = () => { - const isModalOpen = useObservable(settingsModalOpen); - const displayLambdasValue = useObservable(displayLambdas.observable); - const bytecodeValue = useObservable(bytecode.observable); - - return ( - settingsModalOpen.next(false)} - footer={null} - > -
- - - - - - - - -
- ); + const isModalOpen = useObservable(settingsModalOpen); + const displayLambdasValue = useObservable(displayLambdas.observable); + const bytecodeValue = useObservable(bytecode.observable); + + return ( + settingsModalOpen.next(false)} + footer={null} + > +
+ + + + + + + + +
+ ); }; export interface BooleanOptionProps { - setting: BooleanSetting; - title: string; - tooltip?: string; - disabled?: boolean; + setting: BooleanSetting; + title: string; + tooltip?: string; + disabled?: boolean; } -export const BooleanOption: React.FC = ({ setting, title, tooltip, disabled }) => { - const value = useObservable(setting.observable); - const onChange: CheckboxProps['onChange'] = (e) => { - setting.value = e.target.checked; - }; - - const checkbox = ; - - return ( - - {tooltip ? {checkbox} : checkbox} - - ); +export const BooleanOption: React.FC = ({ + setting, + title, + tooltip, + disabled, +}) => { + const value = useObservable(setting.observable); + const onChange: CheckboxProps["onChange"] = (e) => { + setting.value = e.target.checked; + }; + + const checkbox = ; + + return ( + + {tooltip ? {checkbox} : checkbox} + + ); }; export interface NumberOptionProps { - setting: NumberSetting; - title: string; - min?: number; - max?: number; - testid?: string; + setting: NumberSetting; + title: string; + min?: number; + max?: number; + testid?: string; } -export const NumberOption: React.FC = ({ setting, title, min, max, testid}) => { - const value = useObservable(setting.observable); - const onChange: InputNumberProps["onChange"] = (e) => { - setting.value = e ?? setting.defaultValue; - } - - return ( - - - - ); -} +export const NumberOption: React.FC = ({ setting, title, min, max, testid }) => { + const value = useObservable(setting.observable); + const onChange: InputNumberProps["onChange"] = (e) => { + setting.value = e ?? setting.defaultValue; + }; + + return ( + + + + ); +}; interface KeybindOptionProps { - setting: KeybindSetting; - title: string; - captureId: string; + setting: KeybindSetting; + title: string; + captureId: string; } const KeybindOption: React.FC = ({ setting, title, captureId }) => { - const value = useObservable(setting.observable); - const capturing = useObservable(capturingKeybind); - const isCapturing = capturing === captureId; - - const startCapture = () => { - if (capturingKeybind.value !== null) { - return; - } - capturingKeybind.next(captureId); - const subscription = rawKeydownEvent.subscribe((event) => { - event.preventDefault(); - - // Only capture if a non-modifier key is pressed - const modifierKeys = ['Control', 'Alt', 'Shift', 'Meta']; - if (!modifierKeys.includes(event.key)) { - setting.setFromEvent(event); - capturingKeybind.next(null); - subscription.unsubscribe(); - } - }); - }; - - const formatKeybind = (keybind: KeybindValue | undefined): string => { - if (!keybind) return 'Not set'; - return keybind.split('+').map(k => { - if (k == ' ') return ''; - const key = k.trim(); - return key.charAt(0).toUpperCase() + key.slice(1); - }).join('+'); - }; - - return ( - - - - - ); + const value = useObservable(setting.observable); + const capturing = useObservable(capturingKeybind); + const isCapturing = capturing === captureId; + + const startCapture = () => { + if (capturingKeybind.value !== null) { + return; + } + capturingKeybind.next(captureId); + const subscription = rawKeydownEvent.subscribe((event) => { + event.preventDefault(); + + // Only capture if a non-modifier key is pressed + const modifierKeys = ["Control", "Alt", "Shift", "Meta"]; + if (!modifierKeys.includes(event.key)) { + setting.setFromEvent(event); + capturingKeybind.next(null); + subscription.unsubscribe(); + } + }); + }; + + const formatKeybind = (keybind: KeybindValue | undefined): string => { + if (!keybind) return "Not set"; + return keybind + .split("+") + .map((k) => { + if (k == " ") return ""; + const key = k.trim(); + return key.charAt(0).toUpperCase() + key.slice(1); + }) + .join("+"); + }; + + return ( + + + + + ); }; export default SettingsModal; diff --git a/src/ui/SideBar.tsx b/src/ui/SideBar.tsx index a91538a..17902f6 100644 --- a/src/ui/SideBar.tsx +++ b/src/ui/SideBar.tsx @@ -1,80 +1,87 @@ -import { Button, Divider, Flex, Input } from "antd"; -import Header from "./Header"; -import FileList from "./FileList"; import type { InputRef, SearchProps } from "antd/es/input"; -import { useObservable } from "../utils/UseObservable"; -import { isSearching } from "../logic/JarFile"; -import SearchResults from "./SearchResults"; -import ReferenceResults from "./ReferenceResults"; -import { formatReferenceQuery, isViewingReferences } from "../logic/FindAllReferences"; + import { ArrowLeftOutlined } from "@ant-design/icons"; -import { focusSearchEvent } from "../logic/Keybinds"; +import { Button, Divider, Flex, Input } from "antd"; import { useEffect, useRef } from "react"; + +import { formatReferenceQuery, isViewingReferences } from "../logic/FindAllReferences"; +import { isSearching } from "../logic/JarFile"; +import { focusSearchEvent } from "../logic/Keybinds"; import { searchQuery, referencesQuery } from "../logic/State"; +import { useObservable } from "../utils/UseObservable"; +import FileList from "./FileList"; +import Header from "./Header"; +import ReferenceResults from "./ReferenceResults"; +import SearchResults from "./SearchResults"; const { Search } = Input; const SideBar = () => { - const showReference = useObservable(isViewingReferences); - const currentReferenceQuery = useObservable(referencesQuery); - const focusSearch = useObservable(focusSearchEvent); - const searchRef = useRef(null); + const showReference = useObservable(isViewingReferences); + const currentReferenceQuery = useObservable(referencesQuery); + const focusSearch = useObservable(focusSearchEvent); + const searchRef = useRef(null); - useEffect(() => { - if (focusSearch) { - referencesQuery.next(""); - searchRef?.current?.focus(); - } - }, [focusSearch]); + useEffect(() => { + if (focusSearch) { + referencesQuery.next(""); + searchRef?.current?.focus(); + } + }, [focusSearch]); - useEffect(() => { - if (focusSearch && !showReference) { - searchRef?.current?.focus(); - } - }, [focusSearch, showReference]); + useEffect(() => { + if (focusSearch && !showReference) { + searchRef?.current?.focus(); + } + }, [focusSearch, showReference]); - const onChange: SearchProps['onChange'] = (e) => { - searchQuery.next(e.target.value); - }; + const onChange: SearchProps["onChange"] = (e) => { + searchQuery.next(e.target.value); + }; - const onBackClick = () => { - referencesQuery.next(""); - }; + const onBackClick = () => { + referencesQuery.next(""); + }; - return ( - -
- {showReference ? ( - <> - -
- References of: {formatReferenceQuery(currentReferenceQuery || "")} -
- - ) : ( - - )} - -
- -
- - ); + return ( + +
+ {showReference ? ( + <> + +
+ References of: {formatReferenceQuery(currentReferenceQuery || "")} +
+ + ) : ( + + )} + +
+ +
+ + ); }; const FileListOrSearchResults = () => { - const showSearchResults = useObservable(isSearching); - const showReference = useObservable(isViewingReferences); + const showSearchResults = useObservable(isSearching); + const showReference = useObservable(isViewingReferences); - if (showReference) { - return ; - } else if (showSearchResults) { - return ; - } else { - return ; - } + if (showReference) { + return ; + } else if (showSearchResults) { + return ; + } else { + return ; + } }; export default SideBar; diff --git a/src/ui/StructureModal.tsx b/src/ui/StructureModal.tsx index e4e8f14..0eebf2e 100644 --- a/src/ui/StructureModal.tsx +++ b/src/ui/StructureModal.tsx @@ -1,31 +1,32 @@ import { Modal } from "antd"; import { useEffect, useState } from "react"; -import { useObservable } from "../utils/UseObservable"; + import { showStructureEvent } from "../logic/Keybinds"; +import { useObservable } from "../utils/UseObservable"; import StructureView from "./StructureView"; const StructureModal = () => { - const showEvent = useObservable(showStructureEvent); - const [open, setOpen] = useState(false); + const showEvent = useObservable(showStructureEvent); + const [open, setOpen] = useState(false); - useEffect(() => { - if (showEvent) { - setOpen(true); - } - }, [showEvent]); + useEffect(() => { + if (showEvent) { + setOpen(true); + } + }, [showEvent]); - return ( - setOpen(false)} - footer={null} - width={"fit-content"} - styles={{ body: { maxHeight: "70vh", overflow: "auto" } }} - > - setOpen(false)} /> - - ); + return ( + setOpen(false)} + footer={null} + width={"fit-content"} + styles={{ body: { maxHeight: "70vh", overflow: "auto" } }} + > + setOpen(false)} /> + + ); }; -export default StructureModal; \ No newline at end of file +export default StructureModal; diff --git a/src/ui/StructureView.tsx b/src/ui/StructureView.tsx index afbe881..e7e29d1 100644 --- a/src/ui/StructureView.tsx +++ b/src/ui/StructureView.tsx @@ -1,119 +1,129 @@ -import { Empty, Tree } from "antd"; import type { TreeDataNode, TreeProps } from "antd"; + +import { Empty, Tree } from "antd"; import { useMemo } from "react"; -import { useObservable } from "../utils/UseObservable"; + import { currentResult } from "../logic/Decompiler"; -import { parseDescriptor } from "./CodeHoverProvider"; -import { getTokenLocation, type MemberToken, type Token } from "../logic/Tokens"; import { selectedLines } from "../logic/State"; +import { getTokenLocation, type MemberToken, type Token } from "../logic/Tokens"; +import { useObservable } from "../utils/UseObservable"; +import { parseDescriptor } from "./CodeHoverProvider"; -type StructureNode = TreeDataNode & { token?: Token; }; +type StructureNode = TreeDataNode & { token?: Token }; const formatClassDisplayName = (className: string) => { - const simpleName = className.split("/").pop(); - return simpleName || className; + const simpleName = className.split("/").pop(); + return simpleName || className; }; const formatMethodDisplayName = (token: MemberToken, classDisplayName: string) => { - const methodName = token.name === "" ? classDisplayName : token.name; - const signature = parseDescriptor(token.descriptor); - return `${methodName}${signature}`; + const methodName = token.name === "" ? classDisplayName : token.name; + const signature = parseDescriptor(token.descriptor); + return `${methodName}${signature}`; }; type StructureViewProps = { - onNavigate?: () => void; + onNavigate?: () => void; }; const StructureView = ({ onNavigate }: StructureViewProps) => { - const decompileResult = useObservable(currentResult); - - const { treeData, tokenByKey } = useMemo(() => { - const tokenMap = new Map(); - - if (!decompileResult || decompileResult.language !== "java") { - return { treeData: [] as StructureNode[], tokenByKey: tokenMap }; - } - - const classTokens = new Map(); - const methodsByClass = new Map(); - - for (const token of decompileResult.tokens) { - if (token.type === "class" && token.declaration) { - classTokens.set(token.className, token); - } - - if (token.type === "method" && token.declaration) { - const bucket = methodsByClass.get(token.className) || []; - bucket.push(token); - methodsByClass.set(token.className, bucket); - } - } - - const classes = Array.from(methodsByClass.keys()).sort(); - const nodes: StructureNode[] = classes.map((className) => { - const classDisplayName = formatClassDisplayName(className); - const classKey = `class:${className}`; - const classNode: StructureNode = { - key: classKey, - title: {classDisplayName}, - children: [] - }; - - const classToken = classTokens.get(className); - if (classToken) { - classNode.token = classToken; - tokenMap.set(classKey, classToken); - } - - const methods = methodsByClass.get(className) || []; - methods.sort((a, b) => a.start - b.start); - - classNode.children = methods.map((method) => { - const methodKey = `method:${className}:${method.start}`; - tokenMap.set(methodKey, method); - return { - key: methodKey, - title: {formatMethodDisplayName(method, classDisplayName)}, - isLeaf: true, - token: method - } satisfies StructureNode; - }); - - return classNode; - }); - - return { treeData: nodes, tokenByKey: tokenMap }; - }, [decompileResult]); - - const onSelect: TreeProps["onSelect"] = (_, info) => { - if (!decompileResult) return; - const node = info.node as StructureNode; - const token = node.token || (node.key ? tokenByKey.get(String(node.key)) : undefined); - if (!token) return; - - const location = getTokenLocation(decompileResult, token); - selectedLines.next({ line: location.line }); - onNavigate?.(); - }; + const decompileResult = useObservable(currentResult); + + const { treeData, tokenByKey } = useMemo(() => { + const tokenMap = new Map(); if (!decompileResult || decompileResult.language !== "java") { - return ; + return { treeData: [] as StructureNode[], tokenByKey: tokenMap }; } - if (treeData.length === 0) { - return ; + const classTokens = new Map(); + const methodsByClass = new Map(); + + for (const token of decompileResult.tokens) { + if (token.type === "class" && token.declaration) { + classTokens.set(token.className, token); + } + + if (token.type === "method" && token.declaration) { + const bucket = methodsByClass.get(token.className) || []; + bucket.push(token); + methodsByClass.set(token.className, bucket); + } } - return ( - - ); + const classes = Array.from(methodsByClass.keys()).sort(); + const nodes: StructureNode[] = classes.map((className) => { + const classDisplayName = formatClassDisplayName(className); + const classKey = `class:${className}`; + const classNode: StructureNode = { + key: classKey, + title: ( + + {classDisplayName} + + ), + children: [], + }; + + const classToken = classTokens.get(className); + if (classToken) { + classNode.token = classToken; + tokenMap.set(classKey, classToken); + } + + const methods = methodsByClass.get(className) || []; + methods.sort((a, b) => a.start - b.start); + + classNode.children = methods.map((method) => { + const methodKey = `method:${className}:${method.start}`; + tokenMap.set(methodKey, method); + return { + key: methodKey, + title: ( + + {formatMethodDisplayName(method, classDisplayName)} + + ), + isLeaf: true, + token: method, + } satisfies StructureNode; + }); + + return classNode; + }); + + return { treeData: nodes, tokenByKey: tokenMap }; + }, [decompileResult]); + + const onSelect: TreeProps["onSelect"] = (_, info) => { + if (!decompileResult) return; + const node = info.node as StructureNode; + const token = node.token || (node.key ? tokenByKey.get(String(node.key)) : undefined); + if (!token) return; + + const location = getTokenLocation(decompileResult, token); + selectedLines.next({ line: location.line }); + onNavigate?.(); + }; + + if (!decompileResult || decompileResult.language !== "java") { + return ; + } + + if (treeData.length === 0) { + return ; + } + + return ( + + ); }; -export default StructureView; \ No newline at end of file +export default StructureView; diff --git a/src/ui/TabsComponent.tsx b/src/ui/TabsComponent.tsx index 77a4249..6d8ce37 100644 --- a/src/ui/TabsComponent.tsx +++ b/src/ui/TabsComponent.tsx @@ -1,274 +1,279 @@ import { Tabs } from "antd"; -import { useObservable } from "../utils/UseObservable"; -import { closeTab, openTab, setTabPosition, closeOtherTabs } from "../logic/Tabs"; import React, { useEffect, useRef, useState } from "react"; + import { selectedFile, openTabs } from "../logic/State"; +import { closeTab, openTab, setTabPosition, closeOtherTabs } from "../logic/Tabs"; +import { useObservable } from "../utils/UseObservable"; export const TabsComponent = () => { - // variables - tabs - const activeKey = useObservable(selectedFile); - const tabs = useObservable(openTabs); - const tabRefs = useRef>({}); - - // variables - context menu - const [contextMenu, setContextMenu] = useState<{ x: number; y: number; key: string; } | null>(null); - - // variables - dragging - const draggingKey = useRef(""); - const [placeIndex, _setPlaceIndex] = useState(-1); // state to render border - const placeIndexRef = useRef(-1); // additional ref because mouseup and mousemove happen in same tick and would not be in sync - - // variables - dragging (mouse positioning) - const mouseMovementDelta = useRef(0); // tracks how far the mouse has moved - const lastMousePos = useRef({ x: -1, y: -1 }); - const threshold = 50; // mouse movement threshold after which tab dragging will activate - - // variables - tab ghost image - const ghostImage = useRef(null); - - // helpers - const getRects = () => { - return Object.entries(tabRefs.current).map(([k, el]) => { - const rect = el?.getBoundingClientRect(); - return ({ - key: k, - x: rect?.x ?? -1000, - width: rect?.width ?? 0 - }); - }); - }; - - const borderStyle = (key: string) => { - if (!tabs) return; - - const style = { - borderLeft: "2px solid transparent", - borderRight: "2px solid transparent" - }; - - const tabIndex = tabs.findIndex(tab => tab.key === key); - - if (tabIndex === placeIndex && mouseMovementDelta.current >= threshold) { - style.borderLeft = "2px solid white"; - return style; - }; - - if (tabIndex === tabs.length - 1 && placeIndex > tabIndex && mouseMovementDelta.current >= threshold) { - style.borderRight = "2px solid white"; - return style; - } - - return style; - }; - - const setGhostImage = (element: HTMLDivElement) => { - if (!tabRefs || !tabRefs.current) return; - - // Not the best, but it works :> - const ghost = document.createElement("div"); - ghost.textContent = element.textContent || ""; - ghost.style.position = "absolute"; - ghost.style.background = "black"; - ghost.style.color = "white"; - ghost.style.padding = ".5rem 1rem"; - ghost.style.whiteSpace = "nowrap"; - ghost.style.pointerEvents = "none"; - ghost.style.border = "1px solid #303030"; - ghost.style.borderRadius = "8px"; - ghost.style.visibility = "hidden"; - document.body.appendChild(ghost); - ghostImage.current = ghost; - }; - - const setPlaceIndexSync = (v: number) => { - placeIndexRef.current = v; - _setPlaceIndex(v); - }; - - // listeners - type TargetKey = React.MouseEvent | React.KeyboardEvent | string; - const onEdit = (targetKey: TargetKey, action: "add" | "remove") => { - if (action === "add") return; - if (!(typeof targetKey === "string")) return; - closeTab(targetKey); - }; - - const handleMouseDown = (e: React.MouseEvent, key: string) => { - // Middle click (button 1) closes the tab - if (e.button === 1) { - e.preventDefault(); - closeTab(key); - return; - } - - // Only proceed with drag for left click (button 0) - if (e.button !== 0) return; - - mouseMovementDelta.current = 0; - lastMousePos.current = { x: e.clientX, y: e.clientY }; - - draggingKey.current = key; - - setGhostImage(e.currentTarget); - - // Add listeners to global document - document.addEventListener("mouseup", handleMouseUp); - document.addEventListener("mousemove", handleMouseMove); + // variables - tabs + const activeKey = useObservable(selectedFile); + const tabs = useObservable(openTabs); + const tabRefs = useRef>({}); + + // variables - context menu + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; key: string } | null>( + null, + ); + + // variables - dragging + const draggingKey = useRef(""); + const [placeIndex, _setPlaceIndex] = useState(-1); // state to render border + const placeIndexRef = useRef(-1); // additional ref because mouseup and mousemove happen in same tick and would not be in sync + + // variables - dragging (mouse positioning) + const mouseMovementDelta = useRef(0); // tracks how far the mouse has moved + const lastMousePos = useRef({ x: -1, y: -1 }); + const threshold = 50; // mouse movement threshold after which tab dragging will activate + + // variables - tab ghost image + const ghostImage = useRef(null); + + // helpers + const getRects = () => { + return Object.entries(tabRefs.current).map(([k, el]) => { + const rect = el?.getBoundingClientRect(); + return { + key: k, + x: rect?.x ?? -1000, + width: rect?.width ?? 0, + }; + }); + }; + + const borderStyle = (key: string) => { + if (!tabs) return; + + const style = { + borderLeft: "2px solid transparent", + borderRight: "2px solid transparent", }; - const handleMouseMove = (e: MouseEvent) => { - if (draggingKey.current === "") return; - - const rects = getRects(); - if (!rects[0]) return; - - - const dx = e.clientX - lastMousePos.current.x; - const dy = e.clientY - lastMousePos.current.y; - mouseMovementDelta.current += Math.sqrt(dx * dx + dy * dy); - - lastMousePos.current = { x: e.clientX, y: e.clientY }; - - if (mouseMovementDelta.current < threshold) return; - - if (ghostImage.current) { - ghostImage.current.style.visibility = "visible"; - ghostImage.current.style.left = e.clientX + "px"; - ghostImage.current.style.top = e.clientY + "px"; + const tabIndex = tabs.findIndex((tab) => tab.key === key); + + if (tabIndex === placeIndex && mouseMovementDelta.current >= threshold) { + style.borderLeft = "2px solid white"; + return style; + } + + if ( + tabIndex === tabs.length - 1 && + placeIndex > tabIndex && + mouseMovementDelta.current >= threshold + ) { + style.borderRight = "2px solid white"; + return style; + } + + return style; + }; + + const setGhostImage = (element: HTMLDivElement) => { + if (!tabRefs || !tabRefs.current) return; + + // Not the best, but it works :> + const ghost = document.createElement("div"); + ghost.textContent = element.textContent || ""; + ghost.style.position = "absolute"; + ghost.style.background = "black"; + ghost.style.color = "white"; + ghost.style.padding = ".5rem 1rem"; + ghost.style.whiteSpace = "nowrap"; + ghost.style.pointerEvents = "none"; + ghost.style.border = "1px solid #303030"; + ghost.style.borderRadius = "8px"; + ghost.style.visibility = "hidden"; + document.body.appendChild(ghost); + ghostImage.current = ghost; + }; + + const setPlaceIndexSync = (v: number) => { + placeIndexRef.current = v; + _setPlaceIndex(v); + }; + + // listeners + type TargetKey = React.MouseEvent | React.KeyboardEvent | string; + const onEdit = (targetKey: TargetKey, action: "add" | "remove") => { + if (action === "add") return; + if (!(typeof targetKey === "string")) return; + closeTab(targetKey); + }; + + const handleMouseDown = (e: React.MouseEvent, key: string) => { + // Middle click (button 1) closes the tab + if (e.button === 1) { + e.preventDefault(); + closeTab(key); + return; + } + + // Only proceed with drag for left click (button 0) + if (e.button !== 0) return; + + mouseMovementDelta.current = 0; + lastMousePos.current = { x: e.clientX, y: e.clientY }; + + draggingKey.current = key; + + setGhostImage(e.currentTarget); + + // Add listeners to global document + document.addEventListener("mouseup", handleMouseUp); + document.addEventListener("mousemove", handleMouseMove); + }; + + const handleMouseMove = (e: MouseEvent) => { + if (draggingKey.current === "") return; + + const rects = getRects(); + if (!rects[0]) return; + + const dx = e.clientX - lastMousePos.current.x; + const dy = e.clientY - lastMousePos.current.y; + mouseMovementDelta.current += Math.sqrt(dx * dx + dy * dy); + + lastMousePos.current = { x: e.clientX, y: e.clientY }; + + if (mouseMovementDelta.current < threshold) return; + + if (ghostImage.current) { + ghostImage.current.style.visibility = "visible"; + ghostImage.current.style.left = e.clientX + "px"; + ghostImage.current.style.top = e.clientY + "px"; + } + + const startX = rects[0].x; + + const { clientX } = e; + const mouseX = clientX - startX; + + const closest = rects + .map((r) => { + const mid = r.x + r.width / 2 - startX; + return { + key: r.key, + dist: Math.abs(mid - mouseX), + before: mouseX < mid, // before midpoint? }; - - - const startX = rects[0].x; - - const { clientX } = e; - const mouseX = clientX - startX; - - const closest = rects - .map(r => { - const mid = r.x + r.width / 2 - startX; - return { - key: r.key, - dist: Math.abs(mid - mouseX), - before: mouseX < mid // before midpoint? - }; - }) - .sort((a, b) => a.dist - b.dist)[0]; - - const index = (tabs?.findIndex(tab => tab.key === closest.key) ?? -Infinity) + (closest.before ? 0 : 1); - if (index < 0) return; - - setPlaceIndexSync(index); - }; - - const handleMouseUp = () => { - document.removeEventListener("mouseup", handleMouseUp); - document.removeEventListener("mouseover", handleMouseMove); - - const currentIndex = tabs ? tabs.findIndex(tab => tab.key === draggingKey.current) : -1; - if ( - placeIndexRef.current >= 0 && - placeIndexRef.current !== currentIndex - ) { - setTabPosition(draggingKey.current, placeIndexRef.current); - openTab(draggingKey.current); - } - - draggingKey.current = ""; - setPlaceIndexSync(-1); - - if (ghostImage.current) document.body.removeChild(ghostImage.current); - }; - - const handleContextMenu = (e: React.MouseEvent, key: string) => { - e.preventDefault(); - setContextMenu({ x: e.clientX, y: e.clientY, key }); - }; - - const handleCloseContextMenu = () => { - setContextMenu(null); - }; - - const handleCloseOtherTabs = () => { - if (contextMenu) { - closeOtherTabs(contextMenu.key); - } - setContextMenu(null); - }; - - useEffect(() => { - if (contextMenu) { - document.addEventListener("click", handleCloseContextMenu); - return () => document.removeEventListener("click", handleCloseContextMenu); - } - }, [contextMenu]); - - return ( - <> - openTab(key)} - items={tabs?.map(({ key }) => ({ - key, - label: ( -
{ handleMouseDown(e, key); }} - onContextMenu={(e) => { handleContextMenu(e, key); }} - ref={(el) => { tabRefs.current[key] = el; }} - style={{ userSelect: "none", }} - > - {key.replace(".class", "").split("/").pop()} -
- ) - }))} - renderTabBar={(tabBarProps, DefaultTabBar) => ( - - {(node) => ( -
- {node} -
- )} -
- )} - /> - - {contextMenu && ( -
e.stopPropagation()} - > -
{ - e.currentTarget.style.background = "#2a2a2a"; - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = "transparent"; - }} - > - Close Other Tabs -
-
- )} - - ); + }) + .sort((a, b) => a.dist - b.dist)[0]; + + const index = + (tabs?.findIndex((tab) => tab.key === closest.key) ?? -Infinity) + (closest.before ? 0 : 1); + if (index < 0) return; + + setPlaceIndexSync(index); + }; + + const handleMouseUp = () => { + document.removeEventListener("mouseup", handleMouseUp); + document.removeEventListener("mouseover", handleMouseMove); + + const currentIndex = tabs ? tabs.findIndex((tab) => tab.key === draggingKey.current) : -1; + if (placeIndexRef.current >= 0 && placeIndexRef.current !== currentIndex) { + setTabPosition(draggingKey.current, placeIndexRef.current); + openTab(draggingKey.current); + } + + draggingKey.current = ""; + setPlaceIndexSync(-1); + + if (ghostImage.current) document.body.removeChild(ghostImage.current); + }; + + const handleContextMenu = (e: React.MouseEvent, key: string) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, key }); + }; + + const handleCloseContextMenu = () => { + setContextMenu(null); + }; + + const handleCloseOtherTabs = () => { + if (contextMenu) { + closeOtherTabs(contextMenu.key); + } + setContextMenu(null); + }; + + useEffect(() => { + if (contextMenu) { + document.addEventListener("click", handleCloseContextMenu); + return () => document.removeEventListener("click", handleCloseContextMenu); + } + }, [contextMenu]); + + return ( + <> + openTab(key)} + items={tabs?.map(({ key }) => ({ + key, + label: ( +
{ + handleMouseDown(e, key); + }} + onContextMenu={(e) => { + handleContextMenu(e, key); + }} + ref={(el) => { + tabRefs.current[key] = el; + }} + style={{ userSelect: "none" }} + > + {key.replace(".class", "").split("/").pop()} +
+ ), + }))} + renderTabBar={(tabBarProps, DefaultTabBar) => ( + + {(node) =>
{node}
} +
+ )} + /> + + {contextMenu && ( +
e.stopPropagation()} + > +
{ + e.currentTarget.style.background = "#2a2a2a"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = "transparent"; + }} + > + Close Other Tabs +
+
+ )} + + ); }; diff --git a/src/ui/diff/DiffCode.tsx b/src/ui/diff/DiffCode.tsx index a99e135..cfa349d 100644 --- a/src/ui/diff/DiffCode.tsx +++ b/src/ui/diff/DiffCode.tsx @@ -1,37 +1,39 @@ -import { DiffEditor } from '@monaco-editor/react'; -import { useObservable } from '../../utils/UseObservable'; -import { getLeftDiff, getRightDiff } from '../../logic/Diff'; -import { updateLineChanges } from '../../logic/LineChanges'; -import { useEffect, useRef } from 'react'; -import type { editor } from 'monaco-editor'; +import type { editor } from "monaco-editor"; + +import { LoadingOutlined } from "@ant-design/icons"; +import { DiffEditor } from "@monaco-editor/react"; import { Spin } from "antd"; -import { LoadingOutlined } from '@ant-design/icons'; +import { useEffect, useRef } from "react"; + import { isDecompiling } from "../../logic/Decompiler.ts"; -import { unifiedDiff } from '../../logic/Settings'; -import { selectedFile } from '../../logic/State.ts'; +import { getLeftDiff, getRightDiff } from "../../logic/Diff"; +import { updateLineChanges } from "../../logic/LineChanges"; +import { unifiedDiff } from "../../logic/Settings"; +import { selectedFile } from "../../logic/State.ts"; +import { useObservable } from "../../utils/UseObservable"; const DiffCode = () => { - const leftResult = useObservable(getLeftDiff().result); - const rightResult = useObservable(getRightDiff().result); - const editorRef = useRef(null); - const loading = useObservable(isDecompiling); - const currentPath = useObservable(selectedFile); - const isUnified = useObservable(unifiedDiff.observable); + const leftResult = useObservable(getLeftDiff().result); + const rightResult = useObservable(getRightDiff().result); + const editorRef = useRef(null); + const loading = useObservable(isDecompiling); + const currentPath = useObservable(selectedFile); + const isUnified = useObservable(unifiedDiff.observable); - useEffect(() => { - if (loading) return; - if (!currentPath) return; - if (!leftResult) return; - if (!rightResult) return; + useEffect(() => { + if (loading) return; + if (!currentPath) return; + if (!leftResult) return; + if (!rightResult) return; - const currentClass = currentPath.replace(".class", ""); - if (leftResult.className !== currentClass) return; - if (rightResult.className !== currentClass) return; + const currentClass = currentPath.replace(".class", ""); + if (leftResult.className !== currentClass) return; + if (rightResult.className !== currentClass) return; - updateLineChanges(currentPath, leftResult.source, rightResult.source); - }, [leftResult, rightResult, loading, currentPath]); + updateLineChanges(currentPath, leftResult.source, rightResult.source); + }, [leftResult, rightResult, loading, currentPath]); - /* Disabled as it jumps to the line of the previous change when switching files + /* Disabled as it jumps to the line of the previous change when switching files useEffect(() => { if (!editorRef.current) { return; @@ -46,41 +48,42 @@ const DiffCode = () => { }, [leftResult, rightResult]); */ - return ( - } - size={"large"} - spinning={!!loading} - description="Decompiling..." - styles={{ - root: { - height: '100%', - color: 'white' - }, - container: { - height: '100%', - } - }} - > - { - editorRef.current = editor; - }} - options={{ - readOnly: true, - domReadOnly: true, - renderSideBySide: !isUnified, - scrollBeyondLastLine: false, - //tabSize: 3, - }} /> - - ); + return ( + } + size={"large"} + spinning={!!loading} + description="Decompiling..." + styles={{ + root: { + height: "100%", + color: "white", + }, + container: { + height: "100%", + }, + }} + > + { + editorRef.current = editor; + }} + options={{ + readOnly: true, + domReadOnly: true, + renderSideBySide: !isUnified, + scrollBeyondLastLine: false, + //tabSize: 3, + }} + /> + + ); }; export default DiffCode; diff --git a/src/ui/diff/DiffFileList.tsx b/src/ui/diff/DiffFileList.tsx index e578322..221ef39 100644 --- a/src/ui/diff/DiffFileList.tsx +++ b/src/ui/diff/DiffFileList.tsx @@ -1,219 +1,255 @@ -import { Table, Tag, Input, Button, Flex, theme, Checkbox, Tooltip, Layout, Space } from 'antd'; -import { SplitCellsOutlined, AlignLeftOutlined, EyeOutlined, EyeInvisibleOutlined, CodeOutlined, FileTextOutlined } from '@ant-design/icons'; -import DiffVersionSelection from './DiffVersionSelection'; +import type { SearchProps } from "antd/es/input"; + import { - getDiffChanges, - type ChangeState, - type ChangeInfo, - hideUnchangedSizes, - getDiffSummary, - type DiffSummary, -} from '../../logic/Diff'; -import { BehaviorSubject, map, combineLatest } from 'rxjs'; -import { useObservable } from '../../utils/UseObservable'; -import type { SearchProps } from 'antd/es/input'; + SplitCellsOutlined, + AlignLeftOutlined, + EyeOutlined, + EyeInvisibleOutlined, + CodeOutlined, + FileTextOutlined, +} from "@ant-design/icons"; +import { Table, Tag, Input, Button, Flex, theme, Checkbox, Tooltip, Layout, Space } from "antd"; +import { useEffect, useMemo } from "react"; +import { BehaviorSubject, map, combineLatest } from "rxjs"; + import { isDecompiling } from "../../logic/Decompiler.ts"; -import { useEffect, useMemo } from 'react'; +import { + getDiffChanges, + type ChangeState, + type ChangeInfo, + hideUnchangedSizes, + getDiffSummary, + type DiffSummary, +} from "../../logic/Diff"; import { bytecode, unifiedDiff } from "../../logic/Settings.ts"; -import { selectedFile, diffView } from '../../logic/State.ts'; +import { selectedFile, diffView } from "../../logic/State.ts"; +import { useObservable } from "../../utils/UseObservable"; +import DiffVersionSelection from "./DiffVersionSelection"; const statusColors: Record = { - modified: 'gold', - added: 'green', - deleted: 'red', + modified: "gold", + added: "green", + deleted: "red", }; const searchQuery = new BehaviorSubject(""); interface DiffEntry { - key: string; - file: string; - statusInfo: ChangeInfo; + key: string; + file: string; + statusInfo: ChangeInfo; } const entries = combineLatest([getDiffChanges(), searchQuery]).pipe( - map(([changesMap, query]) => { - const entriesArray: DiffEntry[] = []; - const lowerQuery = query.toLowerCase(); - changesMap.forEach((info, file) => { - if (!query || file.toLowerCase().includes(lowerQuery)) { - entriesArray.push({ - key: file, - file, - statusInfo: info, - }); - } + map(([changesMap, query]) => { + const entriesArray: DiffEntry[] = []; + const lowerQuery = query.toLowerCase(); + changesMap.forEach((info, file) => { + if (!query || file.toLowerCase().includes(lowerQuery)) { + entriesArray.push({ + key: file, + file, + statusInfo: info, }); - return entriesArray; - }) + } + }); + return entriesArray; + }), ); const DiffFileList = () => { - const dataSource = useObservable(entries) || []; - const currentFile = useObservable(selectedFile); - const loading = useObservable(isDecompiling); - const hideUnchanged = useObservable(hideUnchangedSizes) || false; - const summary = useObservable(useMemo(() => getDiffSummary(), [])); - const isUnifiedDiff = useObservable(unifiedDiff.observable); - const isBytecode = useObservable(bytecode.observable); - const { token } = theme.useToken(); - - const columns = useMemo(() => [ - { - title: 'File', - dataIndex: 'file', - key: 'file', - render: (file: string) => {file.replace('.class', '')}, - }, - { - title: 'Status', - dataIndex: 'statusInfo', - key: 'status', - render: (info: ChangeInfo) => ( - - - {info.state.toUpperCase()} - - {info.deletions !== undefined && info.deletions > 0 && ( - -{info.deletions} - )} - {info.additions !== undefined && info.additions > 0 && ( - +{info.additions} - )} - {info.state === 'modified' && info.additions === 0 && info.deletions === 0 && ( - None - )} - - ), - }, - ], [token]); - - const onChange: SearchProps['onChange'] = (e) => { - searchQuery.next(e.target.value); - }; - - const handleExitDiff = () => { - diffView.next(false); - }; - - useEffect(() => { - if (dataSource.length > 500 && !hideUnchanged) { - hideUnchangedSizes.next(true); - } - }, [dataSource.length, hideUnchanged]); - - return ( - - (useMemo(() => getDiffSummary(), [])); + const isUnifiedDiff = useObservable(unifiedDiff.observable); + const isBytecode = useObservable(bytecode.observable); + const { token } = theme.useToken(); + + const columns = useMemo( + () => [ + { + title: "File", + dataIndex: "file", + key: "file", + render: (file: string) => ( + {file.replace(".class", "")} + ), + }, + { + title: "Status", + dataIndex: "statusInfo", + key: "status", + render: (info: ChangeInfo) => ( + + + {info.state.toUpperCase()} + + {info.deletions !== undefined && info.deletions > 0 && ( + + -{info.deletions} + + )} + {info.additions !== undefined && info.additions > 0 && ( + + +{info.additions} + + )} + {info.state === "modified" && info.additions === 0 && info.deletions === 0 && ( + + None + + )} + + ), + }, + ], + [token], + ); + + const onChange: SearchProps["onChange"] = (e) => { + searchQuery.next(e.target.value); + }; + + const handleExitDiff = () => { + diffView.next(false); + }; + + useEffect(() => { + if (dataSource.length > 500 && !hideUnchanged) { + hideUnchangedSizes.next(true); + } + }, [dataSource.length, hideUnchanged]); + + return ( + + + + + + {summary && ( + + {summary.added === 0 && summary.deleted === 0 && summary.modified === 0 ? ( + None + ) : ( + <> + +{summary.added} new files + -{summary.deleted} deleted + {summary.modified} modified + + )} + + )} + + + + + + + + - - - - {summary && ( - - {summary.added === 0 && summary.deleted === 0 && summary.modified === 0 ? ( - None - ) : ( - <> - - +{summary.added} new files - - - -{summary.deleted} deleted - - - {summary.modified} modified - - - )} - - )} - - - - - - - - - - - - - - : } + onClick={() => (unifiedDiff.value = !unifiedDiff.value)} + /> + + +