From af282af955309ffd9f4cb9234874c8064b83f2c8 Mon Sep 17 00:00:00 2001 From: Timon Home Date: Tue, 31 Mar 2026 23:20:58 +0200 Subject: [PATCH] renderer rework --- .github/workflows/format-check.yml | 7 +- eslint.config.js | 51 ++ package-lock.json | 858 +++++++++++++++++- package.json | 6 +- src/main/core/AppIcon.ts | 26 + src/main/core/DevToolsGuard.ts | 77 ++ src/main/core/TrayManager.ts | 47 + src/main/core/WindowManager.ts | 39 + src/main/main.ts | 195 +--- src/main/shared/config/Dev.config.ts | 52 ++ src/main/shared/config/Language.config.ts | 7 - .../config/languages/Language.config.ts | 7 + src/main/shared/config/languages/de.lang.ts | 20 +- src/main/shared/config/languages/en.lang.ts | 20 +- .../config/{ => themes}/Theme.config.ts | 8 +- src/main/shared/types/Dev.types.ts | 18 + .../shared/utils/AnsiSgrParser.ts} | 0 src/renderer/App.tsx | 2 +- src/renderer/components/MainLayout.tsx | 10 +- .../components/common/display/Badge.tsx | 31 + .../common/{ => display}/EmptyState.tsx | 0 .../components/common/display/StatusDot.tsx | 25 + .../components/common/display/index.ts | 3 + src/renderer/components/common/index.ts | 4 + .../components/common/{ => inputs}/Button.tsx | 0 .../components/common/inputs/Checkbox.tsx | 38 + .../components/common/{ => inputs}/Input.tsx | 0 .../components/common/{ => inputs}/Toggle.tsx | 0 .../components/common/inputs/index.ts | 4 + .../components/common/{ => lists}/ArgList.tsx | 43 +- .../common/{ => lists}/EnvVarList.tsx | 4 +- .../common/{ => lists}/PropList.tsx | 42 +- src/renderer/components/common/lists/index.ts | 5 + .../common/{ => overlays}/ContextMenu.tsx | 0 .../common/{ => overlays}/Dialog.tsx | 2 +- .../common/{ => overlays}/Modal.tsx | 0 .../common/{ => overlays}/Tooltip.tsx | 0 .../components/common/overlays/index.ts | 5 + .../components/console/ConsoleInput.tsx | 85 +- .../components/console/ConsoleLineRow.tsx | 115 +++ .../components/console/ConsoleOutput.tsx | 240 ----- .../components/console/ConsoleSearch.tsx | 76 -- .../components/console/ConsoleSearchBar.tsx | 85 ++ .../components/console/ConsoleTab.tsx | 431 ++------- .../components/console/ConsoleToolbar.tsx | 63 +- .../components/developer/DevApiExplorer.tsx | 27 +- .../components/developer/DevAssets.tsx | 517 +++++++++-- .../components/developer/DevDashboard.tsx | 163 ++-- .../components/developer/DevDiagnostics.tsx | 120 +-- .../components/developer/DevModeGate.tsx | 2 +- .../components/developer/DevStorage.tsx | 63 +- .../components/developer/DeveloperTab.tsx | 26 +- src/renderer/components/faq/FaqPanel.tsx | 2 +- .../components/layout/containers/Card.tsx | 24 + .../components/layout/containers/DataRow.tsx | 35 + .../layout/containers/ScrollContent.tsx | 18 + .../components/layout/containers/Section.tsx | 53 ++ .../components/layout/containers/index.ts | 4 + src/renderer/components/layout/index.ts | 3 + .../layout/{ => navigation}/PanelHeader.tsx | 2 +- .../layout/{ => navigation}/SidebarLayout.tsx | 0 .../layout/{ => navigation}/TabBar.tsx | 27 +- .../components/layout/navigation/index.ts | 5 + .../layout/{ => shell}/TitleBar.tsx | 5 +- .../components/layout/shell/Toolbar.tsx | 21 + src/renderer/components/layout/shell/index.ts | 2 + .../components/profiles/ConfigTab.tsx | 94 +- .../components/profiles/FilesSection.tsx | 2 +- .../components/profiles/GeneralSection.tsx | 40 +- src/renderer/components/profiles/LogsTab.tsx | 14 +- .../components/profiles/ProfileSidebar.tsx | 3 +- .../components/profiles/ProfileTab.tsx | 31 +- .../components/profiles/TemplateModal.tsx | 4 +- .../profiles/jar/DynamicJarConfig.tsx | 2 +- .../profiles/jar/StaticJarPicker.tsx | 2 +- .../components/settings/SettingsRow.tsx | 10 +- .../components/settings/SettingsTab.tsx | 4 +- .../settings/sections/AdvancedSection.tsx | 6 +- .../settings/sections/AppearanceSection.tsx | 4 +- .../settings/sections/ConsoleSection.tsx | 4 +- .../settings/sections/GeneralSection.tsx | 4 +- .../settings/sections/about/AboutSection.tsx | 2 +- .../settings/sections/about/ReleaseModal.tsx | 4 +- .../sections/about/VersionChecker.tsx | 2 +- .../components/utils/ActivityLogPanel.tsx | 18 +- .../components/utils/ScannerPanel.tsx | 84 +- .../components/utils/UtilitiesTab.tsx | 19 +- src/renderer/hooks/ThemeProvider.tsx | 3 +- src/renderer/hooks/useAutoScroll.ts | 27 - src/renderer/hooks/useInputContextMenu.tsx | 2 +- src/renderer/i18n/I18nProvider.tsx | 2 +- src/renderer/i18n/TranslationKeys.ts | 2 +- tailwind.config.js | 2 +- 93 files changed, 2679 insertions(+), 1582 deletions(-) create mode 100644 eslint.config.js create mode 100644 src/main/core/AppIcon.ts create mode 100644 src/main/core/DevToolsGuard.ts create mode 100644 src/main/core/TrayManager.ts create mode 100644 src/main/core/WindowManager.ts create mode 100644 src/main/shared/config/Dev.config.ts delete mode 100644 src/main/shared/config/Language.config.ts create mode 100644 src/main/shared/config/languages/Language.config.ts rename src/main/shared/config/{ => themes}/Theme.config.ts (51%) create mode 100644 src/main/shared/types/Dev.types.ts rename src/{renderer/utils/ansi.ts => main/shared/utils/AnsiSgrParser.ts} (100%) create mode 100644 src/renderer/components/common/display/Badge.tsx rename src/renderer/components/common/{ => display}/EmptyState.tsx (100%) create mode 100644 src/renderer/components/common/display/StatusDot.tsx create mode 100644 src/renderer/components/common/display/index.ts create mode 100644 src/renderer/components/common/index.ts rename src/renderer/components/common/{ => inputs}/Button.tsx (100%) create mode 100644 src/renderer/components/common/inputs/Checkbox.tsx rename src/renderer/components/common/{ => inputs}/Input.tsx (100%) rename src/renderer/components/common/{ => inputs}/Toggle.tsx (100%) create mode 100644 src/renderer/components/common/inputs/index.ts rename src/renderer/components/common/{ => lists}/ArgList.tsx (67%) rename src/renderer/components/common/{ => lists}/EnvVarList.tsx (97%) rename src/renderer/components/common/{ => lists}/PropList.tsx (78%) create mode 100644 src/renderer/components/common/lists/index.ts rename src/renderer/components/common/{ => overlays}/ContextMenu.tsx (100%) rename src/renderer/components/common/{ => overlays}/Dialog.tsx (96%) rename src/renderer/components/common/{ => overlays}/Modal.tsx (100%) rename src/renderer/components/common/{ => overlays}/Tooltip.tsx (100%) create mode 100644 src/renderer/components/common/overlays/index.ts create mode 100644 src/renderer/components/console/ConsoleLineRow.tsx delete mode 100644 src/renderer/components/console/ConsoleOutput.tsx delete mode 100644 src/renderer/components/console/ConsoleSearch.tsx create mode 100644 src/renderer/components/console/ConsoleSearchBar.tsx create mode 100644 src/renderer/components/layout/containers/Card.tsx create mode 100644 src/renderer/components/layout/containers/DataRow.tsx create mode 100644 src/renderer/components/layout/containers/ScrollContent.tsx create mode 100644 src/renderer/components/layout/containers/Section.tsx create mode 100644 src/renderer/components/layout/containers/index.ts create mode 100644 src/renderer/components/layout/index.ts rename src/renderer/components/layout/{ => navigation}/PanelHeader.tsx (94%) rename src/renderer/components/layout/{ => navigation}/SidebarLayout.tsx (100%) rename src/renderer/components/layout/{ => navigation}/TabBar.tsx (66%) create mode 100644 src/renderer/components/layout/navigation/index.ts rename src/renderer/components/layout/{ => shell}/TitleBar.tsx (94%) create mode 100644 src/renderer/components/layout/shell/Toolbar.tsx create mode 100644 src/renderer/components/layout/shell/index.ts delete mode 100644 src/renderer/hooks/useAutoScroll.ts diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml index 365c891..23a98ef 100644 --- a/.github/workflows/format-check.yml +++ b/.github/workflows/format-check.yml @@ -1,4 +1,4 @@ -name: Code Formatting +name: Code Quality on: [pull_request] @@ -15,4 +15,7 @@ jobs: - run: npm ci - name: Prettier check - run: npm run format:check \ No newline at end of file + run: npm run format:check + + - name: ESLint check + run: npm run lint \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..879ed54 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,51 @@ +import tsParser from '@typescript-eslint/parser'; + +/** @type {import("eslint").Linter.Config[]} */ +export default [ + { + files: ['src/renderer/**/*.{ts,tsx}'], + languageOptions: { + parser: tsParser, + parserOptions: { project: false }, + }, + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '**/common/inputs/*', + '**/common/overlays/*', + '**/common/display/*', + '**/common/lists/*', + ], + message: + 'Import from the barrel index instead (e.g. "../common/inputs" not "../common/inputs/Button").', + }, + { + group: ['**/layout/containers/*', '**/layout/navigation/*', '**/layout/shell/*'], + message: + 'Import from the barrel index instead (e.g. "../layout/containers" not "../layout/containers/Card").', + }, + ], + }, + ], + }, + }, + // Allow internal imports within the barrel subfolders themselves + { + files: [ + 'src/renderer/components/common/inputs/*.{ts,tsx}', + 'src/renderer/components/common/overlays/*.{ts,tsx}', + 'src/renderer/components/common/display/*.{ts,tsx}', + 'src/renderer/components/common/lists/*.{ts,tsx}', + 'src/renderer/components/layout/containers/*.{ts,tsx}', + 'src/renderer/components/layout/navigation/*.{ts,tsx}', + 'src/renderer/components/layout/shell/*.{ts,tsx}', + ], + rules: { + 'no-restricted-imports': 'off', + }, + }, +]; diff --git a/package-lock.json b/package-lock.json index cf5f5c6..b7dc922 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,11 +21,13 @@ "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@types/uuid": "^9.0.0", + "@typescript-eslint/parser": "^8.58.0", "@vitejs/plugin-react": "^4.2.0", "autoprefixer": "^10.4.0", "concurrently": "^8.2.0", "electron": "^28.0.0", "electron-builder": "^24.9.0", + "eslint": "^10.1.0", "postcss": "^8.4.0", "prettier": "^3.8.1", "prettier-plugin-organize-imports": "^4.3.0", @@ -940,6 +942,152 @@ "node": ">=12" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.3", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "dev": true, @@ -953,6 +1101,58 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "dev": true, @@ -1666,6 +1866,13 @@ "@types/ms": "*" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "dev": true, @@ -1684,6 +1891,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/keyv": { "version": "3.1.4", "dev": true, @@ -1769,6 +1983,200 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "dev": true, @@ -1814,6 +2222,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "dev": true, @@ -2955,6 +3373,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/defer-to-connect": { "version": "2.0.1", "dev": true, @@ -3500,7 +3925,6 @@ "version": "4.0.0", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=10" }, @@ -3508,6 +3932,272 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", + "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.3", + "@eslint/config-helpers": "^0.5.3", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "dev": true, @@ -3573,6 +4263,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.0", "funding": [ @@ -3603,6 +4300,19 @@ "pend": "~1.2.0" } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/filelist": { "version": "1.0.6", "dev": true, @@ -3632,6 +4342,27 @@ "node": ">=6" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/follow-redirects": { "version": "1.15.11", "dev": true, @@ -4167,6 +4898,26 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inflight": { "version": "1.0.6", "dev": true, @@ -4371,6 +5122,13 @@ "version": "7.0.3", "license": "BSD-2-Clause" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "dev": true, @@ -4451,6 +5209,20 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "dev": true, @@ -4737,6 +5509,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", @@ -4822,6 +5601,24 @@ "node": ">=6" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-cancelable": { "version": "2.1.1", "dev": true, @@ -5122,6 +5919,16 @@ "dev": true, "license": "MIT" }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", @@ -6171,6 +6978,19 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "dev": true, @@ -6180,6 +7000,19 @@ "version": "2.8.1", "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "2.19.0", "license": "(MIT OR CC0-1.0)", @@ -6381,6 +7214,16 @@ "node": ">= 8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "dev": true, @@ -6474,6 +7317,19 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zip-stream": { "version": "4.1.1", "dev": true, diff --git a/package.json b/package.json index e7ef34e..7a81eb2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "java-runner-client", - "version": "2.2.0", + "version": "2.2.1", "description": "Run and manage Java processes with profiles, console I/O, and system tray support", "main": "dist/main/main.js", "scripts": { @@ -11,6 +11,8 @@ "build:renderer": "vite build", "build:main": "tsc -p tsconfig.main.json", "dist": "rimraf dist && npm run build && electron-builder", + "lint": "eslint src/renderer", + "check": "npm run format:check && npm run lint", "format": "prettier --write .", "format:check": "prettier --check ." }, @@ -28,11 +30,13 @@ "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@types/uuid": "^9.0.0", + "@typescript-eslint/parser": "^8.58.0", "@vitejs/plugin-react": "^4.2.0", "autoprefixer": "^10.4.0", "concurrently": "^8.2.0", "electron": "^28.0.0", "electron-builder": "^24.9.0", + "eslint": "^10.1.0", "postcss": "^8.4.0", "prettier": "^3.8.1", "prettier-plugin-organize-imports": "^4.3.0", diff --git a/src/main/core/AppIcon.ts b/src/main/core/AppIcon.ts new file mode 100644 index 0000000..72f46a6 --- /dev/null +++ b/src/main/core/AppIcon.ts @@ -0,0 +1,26 @@ +import { app, nativeImage } from 'electron'; +import fs from 'fs'; +import path from 'path'; +import { getEnvironment } from './JRCEnvironment'; + +function getResourcesPath(): string { + return getEnvironment().type === 'dev' + ? path.join(__dirname, '../../../resources') + : path.join(app.getAppPath(), 'resources'); +} + +export function getIconImage(): Electron.NativeImage { + const resources = getResourcesPath(); + const candidates = + process.platform === 'win32' ? ['icon.ico', 'icon.png'] : ['icon.png', 'icon.ico']; + for (const name of candidates) { + const p = path.join(resources, name); + if (fs.existsSync(p)) { + const img = nativeImage.createFromPath(p); + if (!img.isEmpty()) return img; + } + } + return nativeImage.createFromDataURL( + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' + ); +} diff --git a/src/main/core/DevToolsGuard.ts b/src/main/core/DevToolsGuard.ts new file mode 100644 index 0000000..50a8bf0 --- /dev/null +++ b/src/main/core/DevToolsGuard.ts @@ -0,0 +1,77 @@ +import { BrowserWindow, Input } from 'electron'; +import { getEnvironment } from './JRCEnvironment'; + +const REQUIRED_PRESSES = 7; +const RESET_DELAY_MS = 1000; + +let pressCount = 0; +let timer: NodeJS.Timeout | null = null; + +function isDevToolsShortcut(input: Input): boolean { + return ( + input.key === 'F12' || + (input.control && input.shift && input.key.toUpperCase() === 'I') || + (input.meta && input.alt && input.key.toUpperCase() === 'I') + ); +} + +function isInspectElementShortcut(input: Input): boolean { + return ( + (input.control && input.shift && input.key.toUpperCase() === 'C') || + (input.meta && input.alt && input.key.toUpperCase() === 'C') + ); +} + +function toggleDevTools(window: BrowserWindow): void { + if (window.webContents.isDevToolsOpened()) { + window.webContents.closeDevTools(); + } else { + window.webContents.openDevTools(); + } +} + +export function handleBeforeInput( + event: Electron.Event, + input: Input, + window: BrowserWindow +): void { + // Ctrl+Shift+C — toggle devtools with element-select mode (devMode only) + if (isInspectElementShortcut(input)) { + if (!getEnvironment().devMode) return; + event.preventDefault(); + + if (window.webContents.isDevToolsOpened()) { + window.webContents.devToolsWebContents?.executeJavaScript( + 'DevToolsAPI.enterInspectElementMode()' + ); + } else { + window.webContents.once('devtools-opened', () => { + window.webContents.devToolsWebContents?.executeJavaScript( + 'DevToolsAPI.enterInspectElementMode()' + ); + }); + window.webContents.openDevTools(); + } + + return; + } + + if (!isDevToolsShortcut(input)) return; + + event.preventDefault(); + + pressCount++; + if (timer) clearTimeout(timer); + timer = setTimeout(() => (pressCount = 0), RESET_DELAY_MS); + + // 7x press — force open detached (even without devMode) + if (pressCount >= REQUIRED_PRESSES) { + pressCount = 0; + window.webContents.openDevTools({ mode: 'detach' }); + return; + } + + if (getEnvironment().devMode) { + toggleDevTools(window); + } +} diff --git a/src/main/core/TrayManager.ts b/src/main/core/TrayManager.ts new file mode 100644 index 0000000..e02442f --- /dev/null +++ b/src/main/core/TrayManager.ts @@ -0,0 +1,47 @@ +import { BrowserWindow, Menu, Tray } from 'electron'; +import { getIconImage } from './AppIcon'; +import { processManager } from './process/ProcessManager'; +import { getAllProfiles } from './Store'; + +let tray: Tray | null = null; + +export function createTray(getWindow: () => BrowserWindow | null, onQuit: () => void): void { + tray = new Tray(getIconImage().resize({ width: 16, height: 16 })); + tray.setToolTip('Java Runner Client'); + updateTrayMenu(getWindow, onQuit); + tray.on('double-click', () => { + const win = getWindow(); + win?.show(); + win?.focus(); + }); +} + +export function updateTrayMenu(getWindow: () => BrowserWindow | null, onQuit: () => void): void { + if (!tray) return; + const states = processManager.getStates(); + const profiles = getAllProfiles(); + const items = states.map((s) => ({ + label: ` ${profiles.find((p) => p.id === s.profileId)?.name ?? s.profileId} (PID ${s.pid ?? '?'})`, + enabled: false, + })); + tray.setContextMenu( + Menu.buildFromTemplate([ + { + label: 'Open Java Runner Client', + click: () => { + const win = getWindow(); + win?.show(); + win?.focus(); + }, + }, + { type: 'separator' }, + ...(items.length > 0 + ? [...items, { type: 'separator' as const }] + : [{ label: 'No processes running', enabled: false }, { type: 'separator' as const }]), + { + label: 'Quit', + click: onQuit, + }, + ]) + ); +} diff --git a/src/main/core/WindowManager.ts b/src/main/core/WindowManager.ts new file mode 100644 index 0000000..57e7e0c --- /dev/null +++ b/src/main/core/WindowManager.ts @@ -0,0 +1,39 @@ +import { BrowserWindow } from 'electron'; +import path from 'path'; +import { ALL_THEMES, BUILTIN_THEME } from '../shared/config/themes/Theme.config'; +import { getIconImage } from './AppIcon'; +import { getEnvironment, shouldStartMinimized } from './JRCEnvironment'; +import { getSettings } from './Store'; + +export function createWindow(onClose: (e: Electron.Event) => void): BrowserWindow { + const win = new BrowserWindow({ + width: 1200, + height: 760, + minWidth: 900, + minHeight: 600, + frame: false, + backgroundColor: (ALL_THEMES.find((t) => t.id === getSettings().themeId) ?? BUILTIN_THEME) + .colors['base-950'], + icon: getIconImage(), + show: getEnvironment().startUpSource !== 'withSystem', + webPreferences: { + preload: path.join(__dirname, '../preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + devTools: true, + }, + }); + + if (getEnvironment().type === 'dev') win.loadURL('http://localhost:5173'); + else win.loadFile(path.join(__dirname, '../../renderer/index.html')); + + win.once('ready-to-show', () => { + if (shouldStartMinimized()) win.hide(); + else win.show(); + }); + + win.on('close', onClose); + + return win; +} diff --git a/src/main/main.ts b/src/main/main.ts index 2696dbc..3955c4d 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,14 +1,14 @@ -import { app, BrowserWindow, Input, Menu, nativeImage, Tray } from 'electron'; -import fs from 'fs'; -import path from 'path'; +import { app, BrowserWindow } from 'electron'; +import { handleBeforeInput } from './core/DevToolsGuard'; import { registerIPC } from './core/IPCController'; -import { getEnvironment, loadEnvironment, shouldStartMinimized } from './core/JRCEnvironment'; +import { loadEnvironment } from './core/JRCEnvironment'; import { processManager } from './core/process/ProcessManager'; import { restApiServer } from './core/RestAPI'; import { getAllProfiles, getSettings, syncLoginItem } from './core/Store'; +import { createTray, updateTrayMenu } from './core/TrayManager'; +import { createWindow } from './core/WindowManager'; import { allRoutes, initDevIPC, initSystemIPC, initWindowIPC } from './ipc/_index'; import { EnvironmentIPC } from './ipc/Environment.ipc'; -import { ALL_THEMES, BUILTIN_THEME } from './shared/config/Theme.config'; import { hasJarConfigured } from './shared/types/Profile.types'; loadEnvironment(); @@ -17,114 +17,14 @@ if (process.platform === 'win32') { app.setAppUserModelId('Java Runner Client'); } -const RESOURCES = - getEnvironment().type === 'dev' - ? path.join(__dirname, '../../resources') - : path.join(app.getAppPath(), 'resources'); - -function getIconImage(): Electron.NativeImage { - const candidates = - process.platform === 'win32' ? ['icon.ico', 'icon.png'] : ['icon.png', 'icon.ico']; - for (const name of candidates) { - const p = path.join(RESOURCES, name); - if (fs.existsSync(p)) { - const img = nativeImage.createFromPath(p); - if (!img.isEmpty()) return img; - } - } - return nativeImage.createFromDataURL( - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' - ); -} - let mainWindow: BrowserWindow | null = null; -let tray: Tray | null = null; let forceQuit = false; -function createWindow(): void { - mainWindow = new BrowserWindow({ - width: 1200, - height: 760, - minWidth: 900, - minHeight: 600, - frame: false, - backgroundColor: (ALL_THEMES.find((t) => t.id === getSettings().themeId) ?? BUILTIN_THEME) - .colors['base-950'], - icon: getIconImage(), - show: getEnvironment().startUpSource !== 'withSystem', - webPreferences: { - preload: path.join(__dirname, 'preload.js'), - contextIsolation: true, - nodeIntegration: false, - sandbox: false, - devTools: true, - }, - }); - - if (getEnvironment().type === 'dev') mainWindow.loadURL('http://localhost:5173'); - else mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')); - - mainWindow.once('ready-to-show', () => { - const shouldStartHidden = shouldStartMinimized(); - if (shouldStartHidden) mainWindow?.hide(); - else mainWindow?.show(); - }); - - mainWindow.on('close', (e) => { - if (forceQuit) return; - if (getSettings().minimizeToTray) { - e.preventDefault(); - mainWindow?.hide(); - } - }); - - processManager.setWindow(mainWindow); -} - -function createTray(): void { - tray = new Tray(getIconImage().resize({ width: 16, height: 16 })); - tray.setToolTip('Java Runner Client'); - updateTrayMenu(); - tray.on('double-click', () => { - mainWindow?.show(); - mainWindow?.focus(); - }); -} - -function updateTrayMenu(): void { - if (!tray) return; - const states = processManager.getStates(); - const profiles = getAllProfiles(); - const items = states.map((s) => ({ - label: ` ${profiles.find((p) => p.id === s.profileId)?.name ?? s.profileId} (PID ${s.pid ?? '?'})`, - enabled: false, - })); - tray.setContextMenu( - Menu.buildFromTemplate([ - { - label: 'Open Java Runner Client', - click: () => { - mainWindow?.show(); - mainWindow?.focus(); - }, - }, - { type: 'separator' }, - ...(items.length > 0 - ? [...items, { type: 'separator' as const }] - : [{ label: 'No processes running', enabled: false }, { type: 'separator' as const }]), - { - label: 'Quit', - click: () => { - forceQuit = true; - app.quit(); - }, - }, - ]) - ); -} - -let devToolsPressCount = 0; -let devToolsTimer: NodeJS.Timeout | null = null; +const getWindow = () => mainWindow; +const doForceQuit = () => { + forceQuit = true; + app.quit(); +}; const gotLock = app.requestSingleInstanceLock(); @@ -140,38 +40,45 @@ if (!gotLock) { }); app.whenReady().then(() => { + const settings = getSettings(); + // If launched by the OS at login but the user has since disabled autostart, bail out. - // This handles the edge case where the registry entry outlives the setting. - if (getEnvironment().startUpSource === 'withSystem' && !getSettings().launchOnStartup) return; + if (settings.launchOnStartup === false && process.argv.includes('--autostart')) return; - // Ensure the OS login item always reflects the stored setting - syncLoginItem(getSettings().launchOnStartup, getSettings().startMinimized); + syncLoginItem(settings.launchOnStartup, settings.startMinimized); - createWindow(); - createTray(); + mainWindow = createWindow((e) => { + if (forceQuit) return; + if (getSettings().minimizeToTray) { + e.preventDefault(); + mainWindow?.hide(); + } + }); - mainWindow?.webContents.on('before-input-event', handleBeforeInputEvent); + processManager.setWindow(mainWindow); + createTray(getWindow, doForceQuit); - // ── IPC ──────────────────────────────────────────────────────────────────── - initSystemIPC(() => mainWindow); - initWindowIPC( - () => mainWindow, - () => { - forceQuit = true; - } - ); - initDevIPC(() => mainWindow); + mainWindow.webContents.on('before-input-event', (event, input) => { + handleBeforeInput(event, input, mainWindow!); + }); + + // IPC registration + initSystemIPC(getWindow); + initWindowIPC(getWindow, () => { + forceQuit = true; + }); + initDevIPC(getWindow); registerIPC([...allRoutes]); registerIPC([EnvironmentIPC]); - // ────────────────────────────────────────────────────────────────────────── - const settings = getSettings(); + // Auto-start REST API and processes if (settings.restApiEnabled) restApiServer.start(settings.restApiPort); - for (const p of getAllProfiles()) + for (const p of getAllProfiles()) { if (p.autoStart && hasJarConfigured(p)) processManager.start(p); + } - mainWindow?.webContents.on('did-finish-load', updateTrayMenu); - processManager.setTrayUpdater(updateTrayMenu); + mainWindow.webContents.on('did-finish-load', () => updateTrayMenu(getWindow, doForceQuit)); + processManager.setTrayUpdater(() => updateTrayMenu(getWindow, doForceQuit)); }); } @@ -184,29 +91,3 @@ app.on('before-quit', () => { app.on('activate', () => { mainWindow?.show(); }); - -const handleBeforeInputEvent = (event: Electron.Event, input: Input) => { - const isDevToolsShortcut = - input.key === 'F12' || - (input.control && input.shift && input.key.toUpperCase() === 'I') || - (input.meta && input.alt && input.key.toUpperCase() === 'I'); - - if (!isDevToolsShortcut) return; - - event.preventDefault(); - - devToolsPressCount++; - - if (devToolsTimer) clearTimeout(devToolsTimer); - devToolsTimer = setTimeout(() => (devToolsPressCount = 0), 1000); - - if (devToolsPressCount >= 7) { - devToolsPressCount = 0; - mainWindow?.webContents.openDevTools({ mode: 'detach' }); - return; - } - - if (getEnvironment().devMode) { - mainWindow?.webContents.openDevTools(); - } -}; diff --git a/src/main/shared/config/Dev.config.ts b/src/main/shared/config/Dev.config.ts new file mode 100644 index 0000000..c0c1e09 --- /dev/null +++ b/src/main/shared/config/Dev.config.ts @@ -0,0 +1,52 @@ +import type { JsonToken, ThemeColorCategory } from '../types/Dev.types'; + +export const JSON_TOKEN_COLORS: Record = { + key: 'text-blue-300', + string: 'text-emerald-400', + number: 'text-amber-400', + boolean: 'text-purple-400', + null: 'text-red-400/80', + punct: 'text-text-muted', + plain: 'text-text-secondary', +}; + +/** Colour categories shown in the theme editor. Order here = order in UI. */ +export const THEME_COLOR_CATEGORIES: ThemeColorCategory[] = [ + { + labelKey: 'dev.colorCategories.accent', + keys: ['accent'], + }, + { + labelKey: 'dev.colorCategories.base', + keys: ['base-950', 'base-900', 'base-800'], + }, + { + labelKey: 'dev.colorCategories.surface', + keys: ['surface-raised', 'surface-border'], + }, + { + labelKey: 'dev.colorCategories.text', + keys: ['text-primary', 'text-secondary', 'text-muted'], + }, + { + labelKey: 'dev.colorCategories.console', + keys: ['console-error', 'console-warn', 'console-input', 'console-system'], + }, +]; + +/** Human-readable labels for each ThemeColors key (used in the editor). */ +export const THEME_COLOR_LABELS: Record = { + accent: 'Accent', + 'base-950': 'Base 950', + 'base-900': 'Base 900', + 'base-800': 'Base 800', + 'surface-raised': 'Surface Raised', + 'surface-border': 'Surface Border', + 'text-primary': 'Text Primary', + 'text-secondary': 'Text Secondary', + 'text-muted': 'Text Muted', + 'console-error': 'Console Error', + 'console-warn': 'Console Warn', + 'console-input': 'Console Input', + 'console-system': 'Console System', +}; diff --git a/src/main/shared/config/Language.config.ts b/src/main/shared/config/Language.config.ts deleted file mode 100644 index baf1313..0000000 --- a/src/main/shared/config/Language.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { LanguageDefinition } from '../types/Language.types'; -import { GERMAN } from './languages/de.lang'; -import { ENGLISH, ENGLISH_STRINGS } from './languages/en.lang'; - -export const ALL_LANGUAGES: LanguageDefinition[] = [ENGLISH, GERMAN]; - -export { ENGLISH, ENGLISH_STRINGS }; diff --git a/src/main/shared/config/languages/Language.config.ts b/src/main/shared/config/languages/Language.config.ts new file mode 100644 index 0000000..01b0fa5 --- /dev/null +++ b/src/main/shared/config/languages/Language.config.ts @@ -0,0 +1,7 @@ +import type { LanguageDefinition } from '../../types/Language.types'; +import { GERMAN } from './de.lang'; +import { ENGLISH, ENGLISH_STRINGS } from './en.lang'; + +export const ALL_LANGUAGES: LanguageDefinition[] = [ENGLISH, GERMAN]; + +export { ENGLISH, ENGLISH_STRINGS }; diff --git a/src/main/shared/config/languages/de.lang.ts b/src/main/shared/config/languages/de.lang.ts index bf91ae8..1c1cca5 100644 --- a/src/main/shared/config/languages/de.lang.ts +++ b/src/main/shared/config/languages/de.lang.ts @@ -310,7 +310,25 @@ export const GERMAN: LanguageDefinition = { 'dev.apply': 'Anwenden', 'dev.activeTheme': 'Aktives Design', 'dev.activeLang': 'Aktive Sprache', - 'dev.bundled': 'Eingebaut', + 'dev.themeEditor': 'Design-Editor', + 'dev.themeEditorHint': + 'Farben live bearbeiten — Änderungen werden sofort angezeigt, aber nicht gespeichert.', + 'dev.resetColor': 'Zurücksetzen', + 'dev.resetAll': 'Alles zurücksetzen', + 'dev.colorCategories.base': 'Basis', + 'dev.colorCategories.surface': 'Oberfläche', + 'dev.colorCategories.text': 'Text', + 'dev.colorCategories.console': 'Konsole', + 'dev.colorCategories.accent': 'Akzent', + 'dev.livePreview': 'Live-Vorschau', + 'dev.exportTheme': 'Exportieren', + 'dev.importTheme': 'Importieren', + 'dev.copiedToClipboard': 'In Zwischenablage kopiert', + 'dev.invalidJson': 'Ungültiges JSON in Zwischenablage', + 'dev.allKeysTranslated': 'Alle {count} Schlüssel übersetzt', + 'dev.missingKeyCount': '{count} fehlende(r) Schlüssel', + 'dev.refreshLanguage': 'Aktualisieren', + 'dev.lastRefreshed': 'Zuletzt aktualisiert: {time}', 'template.title': 'Profil-Vorlagen', 'template.searchPlaceholder': 'Vorlagen suchen...', 'template.noTemplates': 'Keine Vorlagen gefunden.', diff --git a/src/main/shared/config/languages/en.lang.ts b/src/main/shared/config/languages/en.lang.ts index 5acabd2..41afed9 100644 --- a/src/main/shared/config/languages/en.lang.ts +++ b/src/main/shared/config/languages/en.lang.ts @@ -325,7 +325,25 @@ const ENGLISH_STRINGS = { 'dev.apply': 'Apply', 'dev.activeTheme': 'Active theme', 'dev.activeLang': 'Active language', - 'dev.bundled': 'Bundled', + 'dev.themeEditor': 'Theme Editor', + 'dev.themeEditorHint': + 'Edit colors live — changes are previewed instantly but not saved to disk.', + 'dev.resetColor': 'Reset', + 'dev.resetAll': 'Reset All', + 'dev.colorCategories.base': 'Base', + 'dev.colorCategories.surface': 'Surface', + 'dev.colorCategories.text': 'Text', + 'dev.colorCategories.console': 'Console', + 'dev.colorCategories.accent': 'Accent', + 'dev.livePreview': 'Live Preview', + 'dev.exportTheme': 'Export', + 'dev.importTheme': 'Import', + 'dev.copiedToClipboard': 'Copied to clipboard', + 'dev.invalidJson': 'Invalid JSON in clipboard', + 'dev.allKeysTranslated': 'All {count} keys translated', + 'dev.missingKeyCount': '{count} missing key(s)', + 'dev.refreshLanguage': 'Refresh', + 'dev.lastRefreshed': 'Last refreshed: {time}', // Template modal 'template.title': 'Profile Templates', diff --git a/src/main/shared/config/Theme.config.ts b/src/main/shared/config/themes/Theme.config.ts similarity index 51% rename from src/main/shared/config/Theme.config.ts rename to src/main/shared/config/themes/Theme.config.ts index ec3eef0..0525497 100644 --- a/src/main/shared/config/Theme.config.ts +++ b/src/main/shared/config/themes/Theme.config.ts @@ -1,7 +1,7 @@ -import type { ThemeDefinition } from '../types/Theme.types'; -import { DARK_DEFAULT_THEME } from './themes/dark-default.theme'; -import { LIGHT_THEME } from './themes/light.theme'; -import { MIDNIGHT_BLUE_THEME } from './themes/midnight-blue.theme'; +import type { ThemeDefinition } from '../../types/Theme.types'; +import { DARK_DEFAULT_THEME } from './dark-default.theme'; +import { LIGHT_THEME } from './light.theme'; +import { MIDNIGHT_BLUE_THEME } from './midnight-blue.theme'; export const BUILTIN_THEME: ThemeDefinition = DARK_DEFAULT_THEME; diff --git a/src/main/shared/types/Dev.types.ts b/src/main/shared/types/Dev.types.ts new file mode 100644 index 0000000..defd337 --- /dev/null +++ b/src/main/shared/types/Dev.types.ts @@ -0,0 +1,18 @@ +export type JsonToken = { + type: 'key' | 'string' | 'number' | 'boolean' | 'null' | 'punct' | 'plain'; + value: string; +}; + +import type { ThemeColors } from './Theme.types'; + +/** Groups theme color keys into logical categories for the editor UI. */ +export type ThemeColorCategory = { + labelKey: string; + keys: (keyof ThemeColors)[]; +}; + +/** Maps a theme color key to a human-readable label for the editor. */ +export type ThemeColorLabel = { + key: keyof ThemeColors; + label: string; +}; diff --git a/src/renderer/utils/ansi.ts b/src/main/shared/utils/AnsiSgrParser.ts similarity index 100% rename from src/renderer/utils/ansi.ts rename to src/main/shared/utils/AnsiSgrParser.ts diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 45120a0..6c3e435 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,7 +1,7 @@ import { HashRouter, Navigate, Route, Routes } from 'react-router-dom'; import { AppProvider } from './AppProvider'; import { DevModeGate } from './components/developer/DevModeGate'; -import { TitleBar } from './components/layout/TitleBar'; +import { TitleBar } from './components/layout/shell'; import { MainLayout } from './components/MainLayout'; import { ThemeProvider } from './hooks/ThemeProvider'; import { I18nProvider } from './i18n/I18nProvider'; diff --git a/src/renderer/components/MainLayout.tsx b/src/renderer/components/MainLayout.tsx index ddcc592..b54200c 100644 --- a/src/renderer/components/MainLayout.tsx +++ b/src/renderer/components/MainLayout.tsx @@ -6,10 +6,11 @@ import { useApp } from '../AppProvider'; import { useDevMode } from '../hooks/useDevMode'; import { useTranslation } from '../i18n/I18nProvider'; import type { TranslationKey } from '../i18n/TranslationKeys'; +import { StatusDot } from './common/display'; import { ConsoleTab } from './console/ConsoleTab'; import { DeveloperTab } from './developer/DeveloperTab'; import { FaqPanel } from './faq/FaqPanel'; -import { PanelHeader } from './layout/PanelHeader'; +import { PanelHeader } from './layout/navigation'; import { ConfigTab } from './profiles/ConfigTab'; import { LogsTab } from './profiles/LogsTab'; import { ProfileSidebar } from './profiles/ProfileSidebar'; @@ -104,12 +105,7 @@ export function MainLayout() { > {t(tab.labelKey)} - {tab.path === 'console' && running && ( - - )} + {tab.path === 'console' && running && } ); })} diff --git a/src/renderer/components/common/display/Badge.tsx b/src/renderer/components/common/display/Badge.tsx new file mode 100644 index 0000000..32a5f0a --- /dev/null +++ b/src/renderer/components/common/display/Badge.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +type BadgeVariant = 'default' | 'accent' | 'blue' | 'success' | 'danger'; + +const VARIANT_CLASSES: Record = { + default: 'bg-surface-raised text-text-muted border-surface-border', + accent: 'bg-accent/15 text-accent border-accent/30', + blue: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + success: 'bg-accent/10 border-accent/20 text-accent', + danger: 'bg-red-500/10 text-red-400 border-red-500/20', +}; + +interface BadgeProps { + label: string; + variant?: BadgeVariant; + icon?: React.ReactNode; +} + +export function Badge({ label, variant = 'default', icon }: BadgeProps) { + return ( + + {icon} + {label} + + ); +} diff --git a/src/renderer/components/common/EmptyState.tsx b/src/renderer/components/common/display/EmptyState.tsx similarity index 100% rename from src/renderer/components/common/EmptyState.tsx rename to src/renderer/components/common/display/EmptyState.tsx diff --git a/src/renderer/components/common/display/StatusDot.tsx b/src/renderer/components/common/display/StatusDot.tsx new file mode 100644 index 0000000..a77077d --- /dev/null +++ b/src/renderer/components/common/display/StatusDot.tsx @@ -0,0 +1,25 @@ +interface Props { + color?: string; + pulse?: boolean; + size?: 'sm' | 'md'; + className?: string; +} + +const SIZES = { sm: 'w-1.5 h-1.5', md: 'w-2 h-2' }; + +export function StatusDot({ color, pulse, size = 'sm', className }: Props) { + return ( + + ); +} diff --git a/src/renderer/components/common/display/index.ts b/src/renderer/components/common/display/index.ts new file mode 100644 index 0000000..5bbd4bf --- /dev/null +++ b/src/renderer/components/common/display/index.ts @@ -0,0 +1,3 @@ +export { Badge } from './Badge'; +export { EmptyState } from './EmptyState'; +export { StatusDot } from './StatusDot'; diff --git a/src/renderer/components/common/index.ts b/src/renderer/components/common/index.ts new file mode 100644 index 0000000..24bf0dc --- /dev/null +++ b/src/renderer/components/common/index.ts @@ -0,0 +1,4 @@ +export * from './display'; +export * from './inputs'; +export * from './lists'; +export * from './overlays'; diff --git a/src/renderer/components/common/Button.tsx b/src/renderer/components/common/inputs/Button.tsx similarity index 100% rename from src/renderer/components/common/Button.tsx rename to src/renderer/components/common/inputs/Button.tsx diff --git a/src/renderer/components/common/inputs/Checkbox.tsx b/src/renderer/components/common/inputs/Checkbox.tsx new file mode 100644 index 0000000..c76131e --- /dev/null +++ b/src/renderer/components/common/inputs/Checkbox.tsx @@ -0,0 +1,38 @@ +interface Props { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; +} + +export function Checkbox({ checked, onChange, disabled }: Props) { + return ( + + ); +} diff --git a/src/renderer/components/common/Input.tsx b/src/renderer/components/common/inputs/Input.tsx similarity index 100% rename from src/renderer/components/common/Input.tsx rename to src/renderer/components/common/inputs/Input.tsx diff --git a/src/renderer/components/common/Toggle.tsx b/src/renderer/components/common/inputs/Toggle.tsx similarity index 100% rename from src/renderer/components/common/Toggle.tsx rename to src/renderer/components/common/inputs/Toggle.tsx diff --git a/src/renderer/components/common/inputs/index.ts b/src/renderer/components/common/inputs/index.ts new file mode 100644 index 0000000..96234ee --- /dev/null +++ b/src/renderer/components/common/inputs/index.ts @@ -0,0 +1,4 @@ +export { Button } from './Button'; +export { Checkbox } from './Checkbox'; +export { Input } from './Input'; +export { Toggle } from './Toggle'; diff --git a/src/renderer/components/common/ArgList.tsx b/src/renderer/components/common/lists/ArgList.tsx similarity index 67% rename from src/renderer/components/common/ArgList.tsx rename to src/renderer/components/common/lists/ArgList.tsx index 47b81c5..44f27fe 100644 --- a/src/renderer/components/common/ArgList.tsx +++ b/src/renderer/components/common/lists/ArgList.tsx @@ -1,6 +1,8 @@ import { useState } from 'react'; -import { useInputContextMenu } from '../../hooks/useInputContextMenu'; -import { Button } from './Button'; +import { VscClose } from 'react-icons/vsc'; +import { useInputContextMenu } from '../../../hooks/useInputContextMenu'; +import { Button } from '../inputs/Button'; +import { Checkbox } from '../inputs/Checkbox'; export interface ArgItem { value: string; @@ -42,29 +44,7 @@ export function ArgList({ items, onChange, onPendingChange, placeholder = '--arg
{items.map((item, i) => (
- + toggle(i)} /> (
- + toggle(i)} /> remove(i)} className="opacity-0 group-hover:opacity-100 transition-opacity text-text-muted hover:text-red-400 shrink-0" > - - - - +
))} diff --git a/src/renderer/components/common/lists/index.ts b/src/renderer/components/common/lists/index.ts new file mode 100644 index 0000000..3dd24ab --- /dev/null +++ b/src/renderer/components/common/lists/index.ts @@ -0,0 +1,5 @@ +export { ArgList } from './ArgList'; +export type { ArgItem } from './ArgList'; +export { EnvVarList } from './EnvVarList'; +export { PropList } from './PropList'; +export type { PropItem } from './PropList'; diff --git a/src/renderer/components/common/ContextMenu.tsx b/src/renderer/components/common/overlays/ContextMenu.tsx similarity index 100% rename from src/renderer/components/common/ContextMenu.tsx rename to src/renderer/components/common/overlays/ContextMenu.tsx diff --git a/src/renderer/components/common/Dialog.tsx b/src/renderer/components/common/overlays/Dialog.tsx similarity index 96% rename from src/renderer/components/common/Dialog.tsx rename to src/renderer/components/common/overlays/Dialog.tsx index 381eb4f..6f2645c 100644 --- a/src/renderer/components/common/Dialog.tsx +++ b/src/renderer/components/common/overlays/Dialog.tsx @@ -1,4 +1,4 @@ -import { Button } from './Button'; +import { Button } from '../inputs/Button'; interface Props { open: boolean; diff --git a/src/renderer/components/common/Modal.tsx b/src/renderer/components/common/overlays/Modal.tsx similarity index 100% rename from src/renderer/components/common/Modal.tsx rename to src/renderer/components/common/overlays/Modal.tsx diff --git a/src/renderer/components/common/Tooltip.tsx b/src/renderer/components/common/overlays/Tooltip.tsx similarity index 100% rename from src/renderer/components/common/Tooltip.tsx rename to src/renderer/components/common/overlays/Tooltip.tsx diff --git a/src/renderer/components/common/overlays/index.ts b/src/renderer/components/common/overlays/index.ts new file mode 100644 index 0000000..f664189 --- /dev/null +++ b/src/renderer/components/common/overlays/index.ts @@ -0,0 +1,5 @@ +export { ContextMenu } from './ContextMenu'; +export type { ContextMenuItem } from './ContextMenu'; +export { Dialog } from './Dialog'; +export { Modal } from './Modal'; +export { Tooltip } from './Tooltip'; diff --git a/src/renderer/components/console/ConsoleInput.tsx b/src/renderer/components/console/ConsoleInput.tsx index 69ab8e2..4095a00 100644 --- a/src/renderer/components/console/ConsoleInput.tsx +++ b/src/renderer/components/console/ConsoleInput.tsx @@ -1,74 +1,91 @@ -import React, { KeyboardEvent, useCallback, useRef } from 'react'; +import { KeyboardEvent, useCallback, useRef, useState } from 'react'; +import { useInputContextMenu } from '../../hooks/useInputContextMenu'; +import { useTranslation } from '../../i18n/I18nProvider'; interface Props { running: boolean; fontSize: number; - history: string[]; onSend: (cmd: string) => void; onClear: () => void; onOpenSearch: () => void; + historySize: number; } -export function ConsoleInput({ running, fontSize, history, onSend, onClear, onOpenSearch }: Props) { +export function ConsoleInput({ + running, + fontSize, + onSend, + onClear, + onOpenSearch, + historySize, +}: Props) { + const { t } = useTranslation(); const inputRef = useRef(null); - const [value, setValue] = React.useState(''); - const [historyIdx, setHistoryIdx] = React.useState(-1); + const [value, setValue] = useState(''); + const [historyIdx, setHistoryIdx] = useState(-1); + const [cmdHistory, setCmdHistory] = useState([]); + const { onContextMenu, contextMenu } = useInputContextMenu(); + + const handleSend = useCallback(() => { + const cmd = value.trim(); + if (!cmd || !running) return; + onSend(cmd); + setCmdHistory((prev) => [cmd, ...prev.filter((c) => c !== cmd)].slice(0, historySize)); + setValue(''); + setHistoryIdx(-1); + }, [value, running, onSend, historySize]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); - const cmd = value.trim(); - if (!cmd || !running) return; - onSend(cmd); - setValue(''); - setHistoryIdx(-1); + handleSend(); return; } if (e.key === 'ArrowUp') { e.preventDefault(); - const n = Math.min(historyIdx + 1, history.length - 1); + const n = Math.min(historyIdx + 1, cmdHistory.length - 1); setHistoryIdx(n); - setValue(history[n] ?? ''); + setValue(cmdHistory[n] ?? ''); return; } if (e.key === 'ArrowDown') { e.preventDefault(); const n = Math.max(historyIdx - 1, -1); setHistoryIdx(n); - setValue(n === -1 ? '' : (history[n] ?? '')); + setValue(n === -1 ? '' : (cmdHistory[n] ?? '')); return; } - if (e.ctrlKey && e.key === 'l') { + if (e.key === 'l' && e.ctrlKey) { e.preventDefault(); onClear(); } - if (e.ctrlKey && e.key === 'f') { + if (e.key === 'f' && e.ctrlKey) { e.preventDefault(); onOpenSearch(); } }, - [value, running, history, historyIdx, onSend, onClear, onOpenSearch] + [handleSend, historyIdx, cmdHistory, onClear, onOpenSearch] ); return ( -
- - setValue(e.target.value)} - onKeyDown={handleKeyDown} - disabled={!running} - placeholder={ - running - ? 'Send command... (↑/↓ history, Ctrl+L clear, Ctrl+F search)' - : 'Start the process to send commands' - } - className="flex-1 bg-transparent text-xs font-mono text-text-primary placeholder:text-text-muted focus:outline-none disabled:opacity-40" - style={{ fontSize }} - /> -
+ <> +
+ + setValue(e.target.value)} + onKeyDown={handleKeyDown} + onContextMenu={onContextMenu} + disabled={!running} + placeholder={running ? t('console.inputPlaceholder') : t('console.inputDisabled')} + className="flex-1 bg-transparent text-xs font-mono text-text-primary placeholder:text-text-muted focus:outline-none disabled:opacity-40" + style={{ fontSize }} + /> +
+ {contextMenu} + ); } diff --git a/src/renderer/components/console/ConsoleLineRow.tsx b/src/renderer/components/console/ConsoleLineRow.tsx new file mode 100644 index 0000000..432b5ce --- /dev/null +++ b/src/renderer/components/console/ConsoleLineRow.tsx @@ -0,0 +1,115 @@ +import type { ConsoleLine } from '@shared/types/Process.types'; +import React from 'react'; + +const LINE_COLORS: Record = { + stdout: 'text-text-primary', + stderr: 'text-console-error', + input: 'text-console-input', + system: 'text-text-muted', +}; + +interface Props { + line: ConsoleLine; + lineNum: number; + showLineNum: boolean; + showTimestamp: boolean; + wordWrap: boolean; + searchTerm: string; + isCurrentMatch: boolean; + isAnyMatch: boolean; + onContextMenu: (e: React.MouseEvent, text: string) => void; +} + +export function formatTimestamp(ts: number): string { + const d = new Date(ts); + return ( + d.toLocaleTimeString('en-GB', { hour12: false }) + + '.' + + String(d.getMilliseconds()).padStart(3, '0') + ); +} + +export const ConsoleLineRow = React.forwardRef( + ( + { + line, + lineNum, + showLineNum, + showTimestamp, + wordWrap, + searchTerm, + isCurrentMatch, + isAnyMatch, + onContextMenu, + }, + ref + ) => { + const text = line.text || ' '; + const content = + searchTerm && isAnyMatch ? renderHighlighted(text, searchTerm, isCurrentMatch) : text; + + return ( +
onContextMenu(e, line.text)} + className={[ + 'flex gap-0 px-2', + LINE_COLORS[line.type], + isCurrentMatch + ? 'bg-yellow-400/10' + : isAnyMatch + ? 'bg-yellow-400/5' + : 'hover:bg-white/[0.02]', + ].join(' ')} + > + {showLineNum && ( + + {lineNum} + + )} + {showTimestamp && ( + + {formatTimestamp(line.timestamp)} + + )} + + {content} + +
+ ); + } +); +ConsoleLineRow.displayName = 'ConsoleLineRow'; + +function renderHighlighted(text: string, term: string, isCurrent: boolean): React.ReactNode { + const parts: React.ReactNode[] = []; + const lower = text.toLowerCase(); + let last = 0; + let idx = lower.indexOf(term); + let key = 0; + + while (idx !== -1) { + if (idx > last) parts.push(text.slice(last, idx)); + parts.push( + + {text.slice(idx, idx + term.length)} + + ); + last = idx + term.length; + idx = lower.indexOf(term, last); + } + if (last < text.length) parts.push(text.slice(last)); + return parts; +} diff --git a/src/renderer/components/console/ConsoleOutput.tsx b/src/renderer/components/console/ConsoleOutput.tsx deleted file mode 100644 index 0bed7ab..0000000 --- a/src/renderer/components/console/ConsoleOutput.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import type { ConsoleLine } from '@shared/types/Process.types'; -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; -import { AnsiSpan, hasAnsi, parseAnsi } from '../../utils/ansi'; - -interface Props { - lines: ConsoleLine[]; - fontSize: number; - wordWrap: boolean; - lineNumbers: boolean; - searchOpen: boolean; - searchQuery: string; - searchIdx: number; - onSearchIdxChange: (idx: number) => void; - onAutoScrollChange: (v: boolean) => void; - autoScroll: boolean; -} - -export function ConsoleOutput({ - lines, - fontSize, - wordWrap, - lineNumbers, - searchOpen, - searchQuery, - searchIdx, - onSearchIdxChange, - onAutoScrollChange, - autoScroll, -}: Props) { - const scrollRef = useRef(null); - const bottomRef = useRef(null); - const matchRefs = useRef<(HTMLDivElement | null)[]>([]); - - const searchTerm = searchQuery.trim().toLowerCase(); - - const matchIndices = useMemo(() => { - if (!searchTerm) return []; - return lines.reduce((acc, line, i) => { - if (line.text.toLowerCase().includes(searchTerm)) acc.push(i); - return acc; - }, []); - }, [lines, searchTerm]); - - const clampedIdx = - matchIndices.length > 0 - ? ((searchIdx % matchIndices.length) + matchIndices.length) % matchIndices.length - : 0; - - useEffect(() => { - if (autoScroll && !searchOpen) { - bottomRef.current?.scrollIntoView({ behavior: 'instant' }); - } - }, [lines.length, autoScroll, searchOpen]); - - useEffect(() => { - if (matchIndices.length > 0) { - matchRefs.current[clampedIdx]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - }, [clampedIdx, matchIndices.length]); - - const handleScroll = useCallback(() => { - const el = scrollRef.current; - if (!el) return; - onAutoScrollChange(el.scrollHeight - el.scrollTop - el.clientHeight < 40); - }, [onAutoScrollChange]); - - matchRefs.current = new Array(matchIndices.length).fill(null); - - return ( -
-
- {lines.length === 0 && ( -
- Process not running. Press Run to start. -
- )} - {lines.map((line, i) => { - const matchPos = matchIndices.indexOf(i); - const isCurrentMatch = matchPos === clampedIdx && matchPos !== -1; - const isAnyMatch = matchPos !== -1; - return ( - { - matchRefs.current[matchPos] = el; - } - : undefined - } - /> - ); - })} -
-
-
- ); -} - -// ─── Line row ───────────────────────────────────────────────────────────────── - -const LINE_COLORS: Record = { - stdout: 'text-text-primary', - stderr: 'text-console-error', - input: 'text-console-input', - system: 'text-text-muted', -}; - -const ConsoleLineRow = React.forwardRef< - HTMLDivElement, - { - line: ConsoleLine; - lineNum: number; - showLineNum: boolean; - wordWrap: boolean; - searchTerm: string; - isCurrentMatch: boolean; - isAnyMatch: boolean; - } ->(({ line, lineNum, showLineNum, wordWrap, searchTerm, isCurrentMatch, isAnyMatch }, ref) => { - const text = line.text || ' '; - - return ( -
- {showLineNum && ( - - {lineNum} - - )} - - {renderContent(text, searchTerm, isCurrentMatch, isAnyMatch)} - -
- ); -}); -ConsoleLineRow.displayName = 'ConsoleLineRow'; - -function renderContent( - text: string, - searchTerm: string, - isCurrentMatch: boolean, - isAnyMatch: boolean -): React.ReactNode { - if (hasAnsi(text)) { - const spans = parseAnsi(text); - if (!searchTerm || !isAnyMatch) { - return spans.map((span, i) => ); - } - return spans.map((span, i) => ( - - )); - } - - if (searchTerm && isAnyMatch) { - return renderHighlighted(text, searchTerm, isCurrentMatch); - } - - return text; -} - -function AnsiSpanNode({ - span, - searchTerm, - isCurrent, -}: { - span: AnsiSpan; - searchTerm?: string; - isCurrent?: boolean; -}) { - const style: React.CSSProperties = {}; - if (span.color) style.color = span.color; - if (span.bgColor) style.backgroundColor = span.bgColor; - if (span.bold) style.fontWeight = 'bold'; - if (span.dim) style.opacity = 0.6; - if (span.italic) style.fontStyle = 'italic'; - if (span.underline) style.textDecoration = 'underline'; - - const content = - searchTerm && span.text.toLowerCase().includes(searchTerm.toLowerCase()) - ? renderHighlighted(span.text, searchTerm, isCurrent ?? false) - : span.text; - - return {content}; -} - -function renderHighlighted(text: string, term: string, isCurrent: boolean): React.ReactNode { - const parts: React.ReactNode[] = []; - const lower = text.toLowerCase(); - let last = 0; - let idx = lower.indexOf(term); - let key = 0; - - while (idx !== -1) { - if (idx > last) parts.push(text.slice(last, idx)); - parts.push( - - {text.slice(idx, idx + term.length)} - - ); - last = idx + term.length; - idx = lower.indexOf(term, last); - } - if (last < text.length) parts.push(text.slice(last)); - return parts; -} diff --git a/src/renderer/components/console/ConsoleSearch.tsx b/src/renderer/components/console/ConsoleSearch.tsx deleted file mode 100644 index e304434..0000000 --- a/src/renderer/components/console/ConsoleSearch.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { VscChevronDown, VscChevronUp, VscClose } from 'react-icons/vsc'; - -interface Props { - query: string; - matchCount: number; - currentIdx: number; - onQueryChange: (q: string) => void; - onNext: () => void; - onPrev: () => void; - onClose: () => void; -} - -export function ConsoleSearch({ - query, - matchCount, - currentIdx, - onQueryChange, - onNext, - onPrev, - onClose, -}: Props) { - const inputRef = useRef(null); - - useEffect(() => { - setTimeout(() => inputRef.current?.focus(), 50); - }, []); - - return ( -
- onQueryChange(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - e.shiftKey ? onPrev() : onNext(); - } - if (e.key === 'Escape') onClose(); - }} - placeholder="Search console... (Enter next, Shift+Enter prev)" - className="flex-1 bg-base-950 border border-surface-border rounded px-2.5 py-1 text-xs font-mono text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent/40" - /> - {query.trim() && ( - - {matchCount === 0 ? 'No matches' : `${currentIdx + 1} / ${matchCount}`} - - )} - - - -
- ); -} diff --git a/src/renderer/components/console/ConsoleSearchBar.tsx b/src/renderer/components/console/ConsoleSearchBar.tsx new file mode 100644 index 0000000..82be128 --- /dev/null +++ b/src/renderer/components/console/ConsoleSearchBar.tsx @@ -0,0 +1,85 @@ +import { useEffect, useRef } from 'react'; +import { VscChevronDown, VscChevronUp, VscClose } from 'react-icons/vsc'; +import { useInputContextMenu } from '../../hooks/useInputContextMenu'; +import { useTranslation } from '../../i18n/I18nProvider'; + +interface Props { + query: string; + matchCount: number; + currentIdx: number; + fontSize: number; + onQueryChange: (q: string) => void; + onNext: () => void; + onPrev: () => void; + onClose: () => void; +} + +export function ConsoleSearchBar({ + query, + matchCount, + currentIdx, + fontSize, + onQueryChange, + onNext, + onPrev, + onClose, +}: Props) { + const { t } = useTranslation(); + const searchRef = useRef(null); + const { onContextMenu, contextMenu } = useInputContextMenu(); + + useEffect(() => { + setTimeout(() => searchRef.current?.focus(), 50); + }, []); + + return ( + <> +
+ { + onQueryChange(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.shiftKey ? onPrev() : onNext(); + } + if (e.key === 'Escape') onClose(); + }} + onContextMenu={onContextMenu} + placeholder={t('console.searchPlaceholder')} + className="flex-1 bg-base-950 border border-surface-border rounded-md px-2.5 py-1 text-xs font-mono text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent/40 transition-colors" + style={{ fontSize: Math.max(fontSize - 1, 11) }} + /> + + {matchCount > 0 + ? `${currentIdx + 1}/${matchCount}` + : query.trim() + ? t('console.noMatches') + : ''} + + + + +
+ {contextMenu} + + ); +} diff --git a/src/renderer/components/console/ConsoleTab.tsx b/src/renderer/components/console/ConsoleTab.tsx index b8ba429..5c3c531 100644 --- a/src/renderer/components/console/ConsoleTab.tsx +++ b/src/renderer/components/console/ConsoleTab.tsx @@ -1,29 +1,13 @@ -import { ConsoleLine } from '@shared/types/Process.types'; import { hasJarConfigured } from '@shared/types/Profile.types'; -import React, { KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - VscChevronDown, - VscChevronUp, - VscClearAll, - VscClose, - VscCopy, - VscFolderOpened, - VscSearch, -} from 'react-icons/vsc'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { VscCopy } from 'react-icons/vsc'; import { useApp } from '../../AppProvider'; -import { useInputContextMenu } from '../../hooks/useInputContextMenu'; import { useTranslation } from '../../i18n/I18nProvider'; -import { Button } from '../common/Button'; -import { ContextMenu, ContextMenuItem } from '../common/ContextMenu'; - -function formatTimestamp(ts: number): string { - const d = new Date(ts); - return ( - d.toLocaleTimeString('en-GB', { hour12: false }) + - '.' + - String(d.getMilliseconds()).padStart(3, '0') - ); -} +import { ContextMenu, ContextMenuItem } from '../common/overlays'; +import { ConsoleInput } from './ConsoleInput'; +import { ConsoleLineRow } from './ConsoleLineRow'; +import { ConsoleSearchBar } from './ConsoleSearchBar'; +import { ConsoleToolbar } from './ConsoleToolbar'; export function ConsoleTab() { const { @@ -42,45 +26,36 @@ export function ConsoleTab() { const running = isRunning(profileId); const lines = state.consoleLogs[profileId] ?? []; const settings = state.settings; - const processState = state.processStates.find((s) => s.profileId === profileId); - const pid = processState?.pid; + const pid = state.processStates.find((s) => s.profileId === profileId)?.pid; + const color = activeProfile?.color ?? '#4ade80'; - const [color, setColor] = useState(activeProfile?.color ?? '#4ade80'); - const [inputValue, setInputValue] = useState(''); - const [historyIdx, setHistoryIdx] = useState(-1); - const [cmdHistory, setCmdHistory] = useState([]); - const [autoScroll, setAutoScroll] = useState(true); const [starting, setStarting] = useState(false); const [errorMsg, setErrorMsg] = useState(null); + const [autoScroll, setAutoScroll] = useState(true); + + // Search state const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [searchIdx, setSearchIdx] = useState(0); + + // Context menu state const [lineCtxMenu, setLineCtxMenu] = useState<{ x: number; y: number; text: string } | null>( null ); - const { onContextMenu: inputContextMenu, contextMenu: inputCtxMenuEl } = useInputContextMenu(); const scrollRef = useRef(null); - const inputRef = useRef(null); - const searchRef = useRef(null); const bottomRef = useRef(null); const matchRefs = useRef<(HTMLDivElement | null)[]>([]); + // Reset state on profile change useEffect(() => { - setInputValue(''); - setHistoryIdx(-1); setErrorMsg(null); setSearchOpen(false); setSearchQuery(''); setSearchIdx(0); - setColor(activeProfile?.color ?? '#4ade80'); }, [profileId]); - useEffect(() => { - setColor(activeProfile?.color ?? '#4ade80'); - console.log(activeProfile?.color); - }, [activeProfile]); - + // Auto-scroll useEffect(() => { if (autoScroll && !searchOpen) bottomRef.current?.scrollIntoView({ behavior: 'instant' }); }, [lines.length, autoScroll, searchOpen]); @@ -91,6 +66,7 @@ export function ConsoleTab() { setAutoScroll(el.scrollHeight - el.scrollTop - el.clientHeight < 40); }, []); + // Search logic const searchTerm = searchQuery.trim().toLowerCase(); const matchIndices = useMemo(() => { @@ -106,19 +82,15 @@ export function ConsoleTab() { ? ((searchIdx % matchIndices.length) + matchIndices.length) % matchIndices.length : 0; - const scrollToMatch = useCallback((idx: number) => { - const el = matchRefs.current[idx]; - if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }, []); - useEffect(() => { - if (matchIndices.length > 0) scrollToMatch(clampedIdx); - }, [clampedIdx, matchIndices, scrollToMatch]); + if (matchIndices.length > 0) { + matchRefs.current[clampedIdx]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, [clampedIdx, matchIndices]); const openSearch = useCallback(() => { setSearchOpen(true); setAutoScroll(false); - setTimeout(() => searchRef.current?.focus(), 50); }, []); const closeSearch = useCallback(() => { @@ -128,9 +100,20 @@ export function ConsoleTab() { setAutoScroll(true); }, []); - const goNext = useCallback(() => setSearchIdx((i) => i + 1), []); - const goPrev = useCallback(() => setSearchIdx((i) => i - 1), []); + // Global keyboard shortcuts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.ctrlKey && e.key === 'f') { + e.preventDefault(); + openSearch(); + } + if (e.key === 'Escape' && searchOpen) closeSearch(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [openSearch, closeSearch, searchOpen]); + // Process actions const handleToggle = useCallback(async () => { if (!activeProfile) return; setErrorMsg(null); @@ -149,71 +132,20 @@ export function ConsoleTab() { }, [activeProfile, running, profileId, stopProcess, startProcess, t]); const handleForceStop = useCallback(async () => { - if (!profileId) return; - await forceStopProcess(profileId); + if (profileId) await forceStopProcess(profileId); }, [profileId, forceStopProcess]); const handleOpenWorkDir = useCallback(async () => { - if (!profileId) return; - await window.api.openWorkingDir(profileId); + if (profileId) await window.api.openWorkingDir(profileId); }, [profileId]); - const handleSend = useCallback(async () => { - const cmd = inputValue.trim(); - if (!cmd || !running) return; - await sendInput(profileId, cmd); - setCmdHistory((prev) => - [cmd, ...prev.filter((c) => c !== cmd)].slice(0, settings?.consoleHistorySize ?? 200) - ); - setInputValue(''); - setHistoryIdx(-1); - }, [inputValue, running, profileId, sendInput, settings]); - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSend(); - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - const n = Math.min(historyIdx + 1, cmdHistory.length - 1); - setHistoryIdx(n); - setInputValue(cmdHistory[n] ?? ''); - return; - } - if (e.key === 'ArrowDown') { - e.preventDefault(); - const n = Math.max(historyIdx - 1, -1); - setHistoryIdx(n); - setInputValue(n === -1 ? '' : (cmdHistory[n] ?? '')); - return; - } - if (e.key === 'l' && e.ctrlKey) { - e.preventDefault(); - clearConsole(profileId); - } - if (e.key === 'f' && e.ctrlKey) { - e.preventDefault(); - openSearch(); - } + const handleSendInput = useCallback( + async (cmd: string) => { + await sendInput(profileId, cmd); }, - [handleSend, historyIdx, cmdHistory, clearConsole, profileId, openSearch] + [profileId, sendInput] ); - useEffect(() => { - const handler = (e: globalThis.KeyboardEvent) => { - if (e.ctrlKey && e.key === 'f') { - e.preventDefault(); - openSearch(); - } - if (e.key === 'Escape' && searchOpen) closeSearch(); - }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }, [openSearch, closeSearch, searchOpen]); - const handleLineContextMenu = useCallback((e: React.MouseEvent, text: string) => { e.preventDefault(); setLineCtxMenu({ x: e.clientX, y: e.clientY, text }); @@ -251,129 +183,39 @@ export function ConsoleTab() { return (
-
- - - {running && ( - - )} - - {running && pid && ( - - - PID {pid} - - )} - -
- - - - {!autoScroll && !searchOpen && ( - - )} - - - - - - - {lines.length.toLocaleString()} {t('console.lines')} - -
+ { + setAutoScroll(true); + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }} + onOpenSearch={openSearch} + onClear={() => clearConsole(profileId)} + /> {searchOpen && ( -
- { - setSearchQuery(e.target.value); - setSearchIdx(0); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - e.shiftKey ? goPrev() : goNext(); - } - if (e.key === 'Escape') closeSearch(); - }} - onContextMenu={inputContextMenu} - placeholder={t('console.searchPlaceholder')} - className="flex-1 bg-base-950 border border-surface-border rounded-md px-2.5 py-1 text-xs font-mono text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent/40 transition-colors" - style={{ fontSize: Math.max(fontSize - 1, 11) }} - /> - - {matchIndices.length > 0 - ? `${clampedIdx + 1}/${matchIndices.length}` - : searchTerm - ? t('console.noMatches') - : ''} - - - - -
+ { + setSearchQuery(q); + setSearchIdx(0); + }} + onNext={() => setSearchIdx((i) => i + 1)} + onPrev={() => setSearchIdx((i) => i - 1)} + onClose={closeSearch} + /> )} {errorMsg && ( @@ -425,21 +267,14 @@ export function ConsoleTab() {
-
- - setInputValue(e.target.value)} - onKeyDown={handleKeyDown} - onContextMenu={inputContextMenu} - disabled={!running} - placeholder={running ? t('console.inputPlaceholder') : t('console.inputDisabled')} - className="flex-1 bg-transparent text-xs font-mono text-text-primary placeholder:text-text-muted focus:outline-none disabled:opacity-40" - style={{ fontSize }} - /> -
+ clearConsole(profileId)} + onOpenSearch={openSearch} + historySize={settings?.consoleHistorySize ?? 200} + /> {lineCtxMenu && lineCtxItems.length > 0 && ( setLineCtxMenu(null)} /> )} - {inputCtxMenuEl}
); } - -// ─── Line row ───────────────────────────────────────────────────────────────── - -const LINE_COLORS: Record = { - stdout: 'text-text-primary', - stderr: 'text-console-error', - input: 'text-console-input', - system: 'text-text-muted', -}; - -const ConsoleLineRow = React.forwardRef< - HTMLDivElement, - { - line: ConsoleLine; - lineNum: number; - showLineNum: boolean; - showTimestamp: boolean; - wordWrap: boolean; - searchTerm: string; - isCurrentMatch: boolean; - isAnyMatch: boolean; - onContextMenu: (e: React.MouseEvent, text: string) => void; - } ->( - ( - { - line, - lineNum, - showLineNum, - showTimestamp, - wordWrap, - searchTerm, - isCurrentMatch, - isAnyMatch, - onContextMenu, - }, - ref - ) => { - const text = line.text || ' '; - const content = - searchTerm && isAnyMatch ? renderHighlighted(text, searchTerm, isCurrentMatch) : text; - - return ( -
onContextMenu(e, line.text)} - className={[ - 'flex gap-0 px-2', - LINE_COLORS[line.type], - isCurrentMatch - ? 'bg-yellow-400/10' - : isAnyMatch - ? 'bg-yellow-400/5' - : 'hover:bg-white/[0.02]', - ].join(' ')} - > - {showLineNum && ( - - {lineNum} - - )} - {showTimestamp && ( - - {formatTimestamp(line.timestamp)} - - )} - - {content} - -
- ); - } -); -ConsoleLineRow.displayName = 'ConsoleLineRow'; - -function renderHighlighted(text: string, term: string, isCurrent: boolean): React.ReactNode { - const parts: React.ReactNode[] = []; - const lower = text.toLowerCase(); - let last = 0; - let idx = lower.indexOf(term); - let key = 0; - - while (idx !== -1) { - if (idx > last) parts.push(text.slice(last, idx)); - parts.push( - - {text.slice(idx, idx + term.length)} - - ); - last = idx + term.length; - idx = lower.indexOf(term, last); - } - if (last < text.length) parts.push(text.slice(last)); - return parts; -} diff --git a/src/renderer/components/console/ConsoleToolbar.tsx b/src/renderer/components/console/ConsoleToolbar.tsx index a8037a0..f21e6ce 100644 --- a/src/renderer/components/console/ConsoleToolbar.tsx +++ b/src/renderer/components/console/ConsoleToolbar.tsx @@ -1,17 +1,22 @@ -import { VscSearch } from 'react-icons/vsc'; -import { Button } from '../common/Button'; +import { VscClearAll, VscFolderOpened, VscSearch } from 'react-icons/vsc'; +import { useTranslation } from '../../i18n/I18nProvider'; +import { StatusDot } from '../common/display'; +import { Button } from '../common/inputs'; interface Props { running: boolean; starting: boolean; - pid?: number; + pid: number | undefined; color: string; lineCount: number; autoScroll: boolean; + searchOpen: boolean; onToggle: () => void; - onClear: () => void; - onOpenSearch: () => void; + onForceStop: () => void; + onOpenWorkDir: () => void; onScrollToBottom: () => void; + onOpenSearch: () => void; + onClear: () => void; } export function ConsoleToolbar({ @@ -21,63 +26,79 @@ export function ConsoleToolbar({ color, lineCount, autoScroll, + searchOpen, onToggle, - onClear, - onOpenSearch, + onForceStop, + onOpenWorkDir, onScrollToBottom, + onOpenSearch, + onClear, }: Props) { + const { t } = useTranslation(); + return (
+ {running && ( + + )} + {running && pid && ( - + PID {pid} )}
- {!autoScroll && ( + + + {!autoScroll && !searchOpen && ( )} - - {lineCount.toLocaleString()} lines + + {lineCount.toLocaleString()} {t('console.lines')}
); diff --git a/src/renderer/components/developer/DevApiExplorer.tsx b/src/renderer/components/developer/DevApiExplorer.tsx index 76e041a..34d8dd4 100644 --- a/src/renderer/components/developer/DevApiExplorer.tsx +++ b/src/renderer/components/developer/DevApiExplorer.tsx @@ -1,12 +1,14 @@ import { REST_API_CONFIG, routeConfig } from '@shared/config/API.config'; +import { JSON_TOKEN_COLORS } from '@shared/config/Dev.config'; +import { JsonToken } from '@shared/types/Dev.types'; import { RouteDefinition } from '@shared/types/RestAPI.types'; import React, { useCallback, useState } from 'react'; import { VscCheck, VscCode, VscCopy, VscEdit, VscPlay } from 'react-icons/vsc'; import { useApp } from '../../AppProvider'; import { useInputContextMenu } from '../../hooks/useInputContextMenu'; import { useTranslation } from '../../i18n/I18nProvider'; -import { Button } from '../common/Button'; -import { ContextMenu, ContextMenuItem } from '../common/ContextMenu'; +import { Button } from '../common/inputs'; +import { ContextMenu, ContextMenuItem } from '../common/overlays'; const METHOD_COLORS: Record = { GET: 'text-accent border-accent/30 bg-accent/10', @@ -17,13 +19,8 @@ const METHOD_COLORS: Record = { // ─── JSON syntax highlighter ───────────────────────────────────────────────── -type Token = { - type: 'key' | 'string' | 'number' | 'boolean' | 'null' | 'punct' | 'plain'; - value: string; -}; - -function tokenizeJson(text: string): Token[] { - const tokens: Token[] = []; +function tokenizeJson(text: string): JsonToken[] { + const tokens: JsonToken[] = []; let i = 0; while (i < text.length) { const ws = text.slice(i).match(/^[\s,:\[\]{}]+/); @@ -71,16 +68,6 @@ function tokenizeJson(text: string): Token[] { return tokens; } -const TOKEN_CLASS: Record = { - key: 'text-blue-300', - string: 'text-emerald-400', - number: 'text-amber-400', - boolean: 'text-purple-400', - null: 'text-red-400/80', - punct: 'text-text-muted', - plain: 'text-text-secondary', -}; - function JsonHighlight({ text }: { text: string }) { let isJson = false; try { @@ -93,7 +80,7 @@ function JsonHighlight({ text }: { text: string }) { return ( <> {tokenizeJson(text).map((tok, i) => ( - + {tok.value} ))} diff --git a/src/renderer/components/developer/DevAssets.tsx b/src/renderer/components/developer/DevAssets.tsx index d86089a..56f42ca 100644 --- a/src/renderer/components/developer/DevAssets.tsx +++ b/src/renderer/components/developer/DevAssets.tsx @@ -1,10 +1,13 @@ -import { ALL_LANGUAGES, ENGLISH_STRINGS } from '@shared/config/Language.config'; -import { ALL_THEMES } from '@shared/config/Theme.config'; +import { THEME_COLOR_CATEGORIES, THEME_COLOR_LABELS } from '@shared/config/Dev.config'; +import { ALL_LANGUAGES, ENGLISH_STRINGS } from '@shared/config/languages/Language.config'; +import { ALL_THEMES } from '@shared/config/themes/Theme.config'; import type { LanguageDefinition } from '@shared/types/Language.types'; -import React from 'react'; -import { VscCheck, VscGlobe } from 'react-icons/vsc'; +import type { ThemeColors, ThemeDefinition } from '@shared/types/Theme.types'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { VscCheck, VscCopy, VscGlobe, VscRefresh, VscSymbolColor } from 'react-icons/vsc'; import { useTheme } from '../../hooks/ThemeProvider'; import { useTranslation } from '../../i18n/I18nProvider'; +import { Card, ScrollContent, Section } from '../layout/containers'; export function DevAssets() { const { theme, setTheme } = useTheme(); @@ -13,51 +16,19 @@ export function DevAssets() { const totalKeys = Object.keys(ENGLISH_STRINGS).length; return ( -
-
- {/* ── Themes ──────────────────────────────────────────────────── */} -
-
- {ALL_THEMES.map((item) => ( -
-
- {( - ['accent', 'base-900', 'base-800', 'surface-raised', 'text-primary'] as const - ).map((key) => ( - - ))} -
-
-

{item.name}

-

{item.author}

-
- - {theme.id === item.id ? ( - - {t('dev.activeTheme')} - - ) : ( - - )} -
- ))} -
-
+ + {/* ── Theme Editor ────────────────────────────────────────── */} +
+ +
- {/* ── Languages ───────────────────────────────────────────────── */} -
+ {/* ── Languages ───────────────────────────────────────────── */} +
+
{ALL_LANGUAGES.map((item) => { const translated = Object.keys(item.strings).filter( @@ -65,10 +36,7 @@ export function DevAssets() { ).length; const coverage = totalKeys > 0 ? Math.round((translated / totalKeys) * 100) : 100; return ( -
+

{item.name}

@@ -76,7 +44,6 @@ export function DevAssets() { {coverage}% ({translated}/{totalKeys})

- {language.id === item.id ? ( {t('dev.activeLang')} @@ -89,42 +56,349 @@ export function DevAssets() { {t('dev.apply')} )} -
+ ); })}
-
- - {/* ── Missing Keys ────────────────────────────────────────────── */} - {language.id !== 'en' && ( -
- -
- )} + {language.id !== 'en' && } +
+ + + ); +} + +// ─── Theme Editor ─────────────────────────────────────────────────────────── + +function ThemeEditorPanel({ + theme, + setTheme, +}: { + theme: ThemeDefinition; + setTheme: (t: ThemeDefinition) => void; +}) { + const { t } = useTranslation(); + + // The base theme the user selected (used for resetting) + const [baseThemeId, setBaseThemeId] = useState(theme.id); + const baseTheme = useMemo( + () => ALL_THEMES.find((th) => th.id === baseThemeId) ?? ALL_THEMES[0], + [baseThemeId] + ); + + // Live-edited colors (starts from current theme) + const [editedColors, setEditedColors] = useState({ + ...theme.colors, + }); + + // Track which colors were modified from base + const dirtyKeys = useMemo(() => { + const dirty = new Set(); + for (const key of Object.keys(baseTheme.colors) as (keyof ThemeColors)[]) { + if (editedColors[key] !== baseTheme.colors[key]) dirty.add(key); + } + return dirty; + }, [editedColors, baseTheme]); + + // Apply live preview whenever editedColors change. + // Use a synthetic id so applyThemeToDOM never short-circuits for the builtin theme. + useEffect(() => { + const liveTheme: ThemeDefinition = { + ...theme, + id: `__live-preview__`, + colors: editedColors, + }; + setTheme(liveTheme); + }, [editedColors]); + + // Switch base theme + const handleBaseThemeChange = useCallback((th: ThemeDefinition) => { + setBaseThemeId(th.id); + setEditedColors({ ...th.colors }); + }, []); + + // Update a single color + const handleColorChange = useCallback((key: keyof ThemeColors, value: string) => { + setEditedColors((prev) => ({ ...prev, [key]: value })); + }, []); + + // Reset a single color to base + const handleResetColor = useCallback( + (key: keyof ThemeColors) => { + setEditedColors((prev) => ({ ...prev, [key]: baseTheme.colors[key] })); + }, + [baseTheme] + ); + + // Reset all colors to base + const handleResetAll = useCallback(() => { + setEditedColors({ ...baseTheme.colors }); + }, [baseTheme]); + + // Export current colors to clipboard as JSON + const [copied, setCopied] = useState(false); + const handleExport = useCallback(() => { + const exportObj: ThemeDefinition = { + id: 'custom-theme', + name: 'Custom Theme', + author: 'you', + colors: editedColors, + }; + navigator.clipboard.writeText(JSON.stringify(exportObj, null, 2)); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [editedColors]); + + // Import from clipboard + const handleImport = useCallback(async () => { + try { + const text = await navigator.clipboard.readText(); + const parsed = JSON.parse(text); + if (parsed.colors && typeof parsed.colors === 'object') { + const merged: ThemeColors = { ...editedColors }; + for (const key of Object.keys(editedColors) as (keyof ThemeColors)[]) { + if (typeof parsed.colors[key] === 'string') { + merged[key] = parsed.colors[key]; + } + } + setEditedColors(merged); + } + } catch { + // silently ignore invalid clipboard + } + }, [editedColors]); + + return ( +
+ {/* Hint */} +

{t('dev.themeEditorHint')}

+ + {/* Base theme selector */} +
+ {ALL_THEMES.map((th) => ( + + ))} +
+ + {/* Color editor grid */} +
+ {THEME_COLOR_CATEGORIES.map((cat) => ( +
+

+ {t(cat.labelKey as any)} +

+
+ {cat.keys.map((colorKey) => ( + handleColorChange(colorKey, v)} + onReset={() => handleResetColor(colorKey)} + resetLabel={t('dev.resetColor')} + /> + ))} +
+
+ ))} +
+ + {/* Live preview strip */} +
+

+ {t('dev.livePreview')} +

+ +
+ + {/* Actions */} +
+ + +
); } -// ─── Reusable pieces ───────────────────────────────────────────────────────── +// ─── Color Row ────────────────────────────────────────────────────────────── + +function ColorRow({ + colorKey, + label, + value, + isDirty, + onChange, + onReset, + resetLabel, +}: { + colorKey: string; + label: string; + value: string; + isDirty: boolean; + onChange: (v: string) => void; + onReset: () => void; + resetLabel: string; +}) { + const inputRef = useRef(null); -function Section({ title, children }: { title: string; children: React.ReactNode }) { return ( -
-

{title}

- {children} +
+ {/* Color swatch (click to open picker) */} + + )}
); } -function SourceBadge({ label }: { label: string }) { +// ─── Theme Preview Strip ──────────────────────────────────────────────────── + +function ThemePreviewStrip({ colors }: { colors: ThemeColors }) { return ( - - {label} - +
+ {/* Fake title bar */} +
+ + + preview.jar + +
+ {/* Fake console area */} +
+

+ [System] Server starting... +

+

+ Loading world "overworld" +

+

+ [WARN] Deprecated config key found +

+

+ [ERROR] Failed to bind port 25565 +

+

+ {'>'} reload confirm +

+

+ Done (2.34s)! For help, type "help" +

+
+ {/* Fake toolbar */} +
+ + Running + + + 6 lines + +
+
); } +// ─── Missing Keys Panel ───────────────────────────────────────────────────── + function MissingKeysPanel({ language, totalKeys, @@ -132,37 +406,94 @@ function MissingKeysPanel({ language: LanguageDefinition; totalKeys: number; }) { - const allKeys = Object.keys(ENGLISH_STRINGS); - const missing = allKeys.filter((k) => !(k in language.strings)); + const { t } = useTranslation(); + + // "Hot reload" — re-evaluate missing keys on demand + const [refreshCount, setRefreshCount] = useState(0); + const [lastRefresh, setLastRefresh] = useState(null); + + const allKeys = useMemo(() => Object.keys(ENGLISH_STRINGS), [refreshCount]); + const missing = useMemo( + () => allKeys.filter((k) => !(k in language.strings)), + [allKeys, language, refreshCount] + ); + + const handleRefresh = useCallback(() => { + setRefreshCount((c) => c + 1); + setLastRefresh( + new Date().toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + ); + }, []); if (missing.length === 0) { return ( -
- -

All {totalKeys} keys translated

+
+
+ +

+ {t('dev.allKeysTranslated', { count: String(totalKeys) })} +

+
+
); } return ( -
-
-

- {missing.length} missing key{missing.length !== 1 ? 's' : ''} -

-
-
- {missing.map((key) => ( -
- - {key} - -
- ))} +
+
+
+

+ {t('dev.missingKeyCount', { count: String(missing.length) })} +

+
+
+ {missing.map((key) => ( +
+ + {key} + +
+ ))} +
+ +
+ ); +} + +// ─── Refresh bar for language hot-reload ──────────────────────────────────── + +function RefreshBar({ + lastRefresh, + onRefresh, +}: { + lastRefresh: string | null; + onRefresh: () => void; +}) { + const { t } = useTranslation(); + + return ( +
+ + {lastRefresh && ( + + {t('dev.lastRefreshed', { time: lastRefresh })} + + )}
); } diff --git a/src/renderer/components/developer/DevDashboard.tsx b/src/renderer/components/developer/DevDashboard.tsx index 9a12886..161991b 100644 --- a/src/renderer/components/developer/DevDashboard.tsx +++ b/src/renderer/components/developer/DevDashboard.tsx @@ -1,7 +1,9 @@ import { JRCEnvironment } from '@shared/types/App.types'; -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { VscCircle, VscCircleFilled } from 'react-icons/vsc'; import { useApp } from '../../AppProvider'; +import { Badge, StatusDot } from '../common/display'; +import { Card, ScrollContent, Section } from '../layout/containers'; declare const __APP_VERSION__: string; @@ -37,110 +39,93 @@ export function DevDashboard() { const restEnabled = state.settings?.restApiEnabled ?? false; return ( -
-
- -
- - -
-
+ +
+
+ + +
+
+ + {sysInfo && ( + +

+ Process Argv +

+

+ {sysInfo.argv.join(' ')} +

+
+ )} + +
+
+ + + + + +
+
- {sysInfo && ( -
-

- Process Argv -

-

- {sysInfo.argv.join(' ')} -

+
+ {runningProfiles.length === 0 ? ( +

No processes running

+ ) : ( +
+ {runningProfiles.map((s) => { + const profile = state.profiles.find((p) => p.id === s.profileId); + const uptimeSec = s.startedAt ? Math.floor((Date.now() - s.startedAt) / 1000) : 0; + return ( +
+ + + {profile?.name ?? s.profileId} + + PID {s.pid} + {uptimeSec}s +
+ ); + })}
)} +
- -
- - - - - +
+ {sysInfo ? ( +
+ + + +
- - - - {runningProfiles.length === 0 ? ( -

No processes running

- ) : ( -
- {runningProfiles.map((s) => { - const profile = state.profiles.find((p) => p.id === s.profileId); - const uptimeSec = s.startedAt ? Math.floor((Date.now() - s.startedAt) / 1000) : 0; - return ( -
- - - {profile?.name ?? s.profileId} - - PID {s.pid} - {uptimeSec}s -
- ); - })} -
- )} -
- - - {sysInfo ? ( -
- - - - -
- ) : ( -

Loading...

- )} -
-
-
- ); -} - -function DevSection({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

{title}

- {children} -
+ ) : ( +

Loading...

+ )} + +
); } function StatCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) { return ( -
+

{label}

{value}

{sub &&

{sub}

} -
+ ); } -function Badge({ ok, label }: { ok: boolean; label: string }) { +function FeatureBadge({ ok, label }: { ok: boolean; label: string }) { return ( - - {ok ? : } - {label} - + : } + /> ); } diff --git a/src/renderer/components/developer/DevDiagnostics.tsx b/src/renderer/components/developer/DevDiagnostics.tsx index 222527d..41574a5 100644 --- a/src/renderer/components/developer/DevDiagnostics.tsx +++ b/src/renderer/components/developer/DevDiagnostics.tsx @@ -1,7 +1,8 @@ -import React, { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { VscCheck, VscCopy } from 'react-icons/vsc'; import { useApp } from '../../AppProvider'; -import { Button } from '../common/Button'; +import { Button } from '../common/inputs'; +import { Card, DataRow, ScrollContent, Section } from '../layout/containers'; declare const __APP_VERSION__: string; @@ -62,8 +63,8 @@ export function DevDiagnostics() { const totalEnvVars = state.profiles.reduce((sum, p) => sum + (p.envVars ?? []).length, 0); return ( -
- + +
{perfSamples.map((s, i) => ( @@ -85,50 +86,80 @@ export function DevDiagnostics() { max {maxMem} MB
- +
- -
- - - - + + + + + - a + b.length, 0))} + labelWidth="w-44" /> - -
-
+ + + - -
- + + - - - + - - -
-
+ + + + - +
{state.profiles.length === 0 ? (

No profiles

) : ( -
+ {state.profiles.map((p) => { const lines = state.consoleLogs[p.id]?.length ?? 0; const running = state.processStates.some((s) => s.profileId === p.id && s.running); @@ -151,11 +182,11 @@ export function DevDiagnostics() {
); })} -
+ )} - + - +

Copy a JSON snapshot of the current app state to clipboard for bug reports.

@@ -163,32 +194,7 @@ export function DevDiagnostics() { {copied ? : } {copied ? 'Copied!' : 'Copy Report to Clipboard'} - -
- ); -} - -function DiagSection({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

{title}

- {children} -
- ); -} - -function DiagRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { - return ( -
- {label} - - {value} - -
+ + ); } diff --git a/src/renderer/components/developer/DevModeGate.tsx b/src/renderer/components/developer/DevModeGate.tsx index 4b8d17c..1dd7360 100644 --- a/src/renderer/components/developer/DevModeGate.tsx +++ b/src/renderer/components/developer/DevModeGate.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { VscCode } from 'react-icons/vsc'; import { useDevMode } from '../../hooks/useDevMode'; -import { Button } from '../common/Button'; +import { Button } from '../common/inputs'; export function DevModeGate() { const devEnabled = useDevMode(); diff --git a/src/renderer/components/developer/DevStorage.tsx b/src/renderer/components/developer/DevStorage.tsx index 3104a26..d3b620a 100644 --- a/src/renderer/components/developer/DevStorage.tsx +++ b/src/renderer/components/developer/DevStorage.tsx @@ -1,8 +1,9 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { VscRefresh, VscTrash } from 'react-icons/vsc'; import { useApp } from '../../AppProvider'; -import { Button } from '../common/Button'; -import { Dialog } from '../common/Dialog'; +import { Button } from '../common/inputs'; +import { Dialog } from '../common/overlays'; +import { Card, DataRow, ScrollContent, Section } from '../layout/containers'; interface SessionEntry { key: string; @@ -43,12 +44,12 @@ export function DevStorage() { const profilesWithLogging = state.profiles.filter((p) => p.fileLogging).length; return ( -
- -
- - - +
+ + + + - - - -
+ -
+ - +

{sessionEntries.length} keys

- +
setConfirmReset(null)} /> -
- ); -} - -function StorageSection({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

{title}

- {children} -
- ); -} - -function StoreRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { - return ( -
- {label} - - {value} - -
+ ); } diff --git a/src/renderer/components/developer/DeveloperTab.tsx b/src/renderer/components/developer/DeveloperTab.tsx index 6aaa16e..47469c9 100644 --- a/src/renderer/components/developer/DeveloperTab.tsx +++ b/src/renderer/components/developer/DeveloperTab.tsx @@ -1,6 +1,8 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import { VscBeaker, VscDashboard, VscDatabase, VscPlug, VscSymbolColor } from 'react-icons/vsc'; import { useTranslation } from '../../i18n/I18nProvider'; +import { StatusDot } from '../common/display'; +import { TabBar } from '../layout/navigation'; import { DevApiExplorer } from './DevApiExplorer'; import { DevAssets } from './DevAssets'; import { DevDashboard } from './DevDashboard'; @@ -13,7 +15,7 @@ export function DeveloperTab() { const { t } = useTranslation(); const [panel, setPanel] = useState('dashboard'); - const PANELS: { id: Panel; label: string; Icon: React.ElementType }[] = [ + const PANELS = [ { id: 'dashboard', label: t('dev.dashboard'), Icon: VscDashboard }, { id: 'api', label: t('dev.apiExplorer'), Icon: VscPlug }, { id: 'storage', label: t('dev.storage'), Icon: VscDatabase }, @@ -24,29 +26,13 @@ export function DeveloperTab() { return (
- + {t('dev.mode')}
-
- {PANELS.map((p) => ( - - ))} -
+ setPanel(id as Panel)} />
{panel === 'dashboard' && } diff --git a/src/renderer/components/faq/FaqPanel.tsx b/src/renderer/components/faq/FaqPanel.tsx index 61c6ab7..bdfe1d7 100644 --- a/src/renderer/components/faq/FaqPanel.tsx +++ b/src/renderer/components/faq/FaqPanel.tsx @@ -2,7 +2,7 @@ import type { FaqItem } from '@shared/config/faq/_index'; import { getFAQ } from '@shared/config/faq/_index'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from '../../i18n/I18nProvider'; -import { SidebarLayout } from '../layout/SidebarLayout'; +import { SidebarLayout } from '../layout/navigation'; export function FaqPanel() { const { language, t } = useTranslation(); diff --git a/src/renderer/components/layout/containers/Card.tsx b/src/renderer/components/layout/containers/Card.tsx new file mode 100644 index 0000000..740d5b5 --- /dev/null +++ b/src/renderer/components/layout/containers/Card.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +interface CardProps { + children: React.ReactNode; + /** Show dividers between direct children */ + divided?: boolean; + className?: string; +} + +export function Card({ children, divided, className }: CardProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/renderer/components/layout/containers/DataRow.tsx b/src/renderer/components/layout/containers/DataRow.tsx new file mode 100644 index 0000000..52cb110 --- /dev/null +++ b/src/renderer/components/layout/containers/DataRow.tsx @@ -0,0 +1,35 @@ +interface DataRowProps { + label: string; + value: string; + /** Use monospace font for the value */ + mono?: boolean; + /** Allow value to wrap instead of truncate */ + wrap?: boolean; + /** Width of the label column (tailwind class, e.g. "w-28") */ + labelWidth?: string; +} + +export function DataRow({ label, value, mono, wrap, labelWidth }: DataRowProps) { + return ( +
+ + {label} + + + {value} + +
+ ); +} diff --git a/src/renderer/components/layout/containers/ScrollContent.tsx b/src/renderer/components/layout/containers/ScrollContent.tsx new file mode 100644 index 0000000..6a0996d --- /dev/null +++ b/src/renderer/components/layout/containers/ScrollContent.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +interface ScrollContentProps { + children: React.ReactNode; + className?: string; +} + +export function ScrollContent({ children, className }: ScrollContentProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/renderer/components/layout/containers/Section.tsx b/src/renderer/components/layout/containers/Section.tsx new file mode 100644 index 0000000..48f7353 --- /dev/null +++ b/src/renderer/components/layout/containers/Section.tsx @@ -0,0 +1,53 @@ +import React, { useState } from 'react'; +import { VscChevronDown, VscChevronRight } from 'react-icons/vsc'; + +interface SectionProps { + title: string; + hint?: string; + children: React.ReactNode; + /** Render dividers between children (used for settings rows) */ + divided?: boolean; + /** Allow collapsing the section */ + collapsible?: boolean; + /** Start collapsed (requires collapsible) */ + defaultCollapsed?: boolean; +} + +export function Section({ + title, + hint, + children, + divided, + collapsible = false, + defaultCollapsed = false, +}: SectionProps) { + const [collapsed, setCollapsed] = useState(collapsible && defaultCollapsed); + + return ( +
+ + {hint &&

{hint}

} + {!collapsed && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/src/renderer/components/layout/containers/index.ts b/src/renderer/components/layout/containers/index.ts new file mode 100644 index 0000000..bf978b9 --- /dev/null +++ b/src/renderer/components/layout/containers/index.ts @@ -0,0 +1,4 @@ +export { Card } from './Card'; +export { DataRow } from './DataRow'; +export { ScrollContent } from './ScrollContent'; +export { Section } from './Section'; diff --git a/src/renderer/components/layout/index.ts b/src/renderer/components/layout/index.ts new file mode 100644 index 0000000..04209fd --- /dev/null +++ b/src/renderer/components/layout/index.ts @@ -0,0 +1,3 @@ +export * from './containers'; +export * from './navigation'; +export * from './shell'; diff --git a/src/renderer/components/layout/PanelHeader.tsx b/src/renderer/components/layout/navigation/PanelHeader.tsx similarity index 94% rename from src/renderer/components/layout/PanelHeader.tsx rename to src/renderer/components/layout/navigation/PanelHeader.tsx index 8d12cbf..a31e796 100644 --- a/src/renderer/components/layout/PanelHeader.tsx +++ b/src/renderer/components/layout/navigation/PanelHeader.tsx @@ -1,5 +1,5 @@ import { useNavigate } from 'react-router-dom'; -import { useTranslation } from '../../i18n/I18nProvider'; +import { useTranslation } from '../../../i18n/I18nProvider'; interface Props { title: string; diff --git a/src/renderer/components/layout/SidebarLayout.tsx b/src/renderer/components/layout/navigation/SidebarLayout.tsx similarity index 100% rename from src/renderer/components/layout/SidebarLayout.tsx rename to src/renderer/components/layout/navigation/SidebarLayout.tsx diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/navigation/TabBar.tsx similarity index 66% rename from src/renderer/components/layout/TabBar.tsx rename to src/renderer/components/layout/navigation/TabBar.tsx index 5418a75..515220b 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/navigation/TabBar.tsx @@ -1,27 +1,30 @@ import React from 'react'; -export interface TabDef { +export interface Tab { id: string; label: string; icon?: React.ReactNode; badge?: React.ReactNode; + Icon?: React.ElementType; } -interface Props { - tabs: TabDef[]; - activeId: string; - onSelect: (id: string) => void; +interface TabBarProps { + tabs: Tab[]; + active: string; + onChange: (id: string) => void; accentColor?: string; className?: string; + dotTab?: string; } export function TabBar({ tabs, - activeId, - onSelect, + active, + onChange, accentColor = '#4ade80', className = '', -}: Props) { + dotTab, +}: TabBarProps) { return (
{tabs.map((tab) => { - const isActive = tab.id === activeId; + const isActive = tab.id === active; return ( ); })} diff --git a/src/renderer/components/layout/navigation/index.ts b/src/renderer/components/layout/navigation/index.ts new file mode 100644 index 0000000..d7489b6 --- /dev/null +++ b/src/renderer/components/layout/navigation/index.ts @@ -0,0 +1,5 @@ +export { PanelHeader } from './PanelHeader'; +export { SidebarLayout } from './SidebarLayout'; +export type { SidebarTopic } from './SidebarLayout'; +export { TabBar } from './TabBar'; +export type { Tab } from './TabBar'; diff --git a/src/renderer/components/layout/TitleBar.tsx b/src/renderer/components/layout/shell/TitleBar.tsx similarity index 94% rename from src/renderer/components/layout/TitleBar.tsx rename to src/renderer/components/layout/shell/TitleBar.tsx index fb6518a..bc2d1f0 100644 --- a/src/renderer/components/layout/TitleBar.tsx +++ b/src/renderer/components/layout/shell/TitleBar.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { VscChromeClose, VscChromeMinimize } from 'react-icons/vsc'; -import { useApp } from '../../AppProvider'; +import { useApp } from '../../../AppProvider'; +import { StatusDot } from '../../common/display/StatusDot'; export function TitleBar() { const { state } = useApp(); @@ -45,7 +46,7 @@ export function TitleBar() { {runningCount > 0 && ( - + {runningCount} diff --git a/src/renderer/components/layout/shell/Toolbar.tsx b/src/renderer/components/layout/shell/Toolbar.tsx new file mode 100644 index 0000000..0ea2c7f --- /dev/null +++ b/src/renderer/components/layout/shell/Toolbar.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +interface ToolbarProps { + children: React.ReactNode; + className?: string; +} + +export function Toolbar({ children, className }: ToolbarProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/renderer/components/layout/shell/index.ts b/src/renderer/components/layout/shell/index.ts new file mode 100644 index 0000000..0ec0e1e --- /dev/null +++ b/src/renderer/components/layout/shell/index.ts @@ -0,0 +1,2 @@ +export { TitleBar } from './TitleBar'; +export { Toolbar } from './Toolbar'; diff --git a/src/renderer/components/profiles/ConfigTab.tsx b/src/renderer/components/profiles/ConfigTab.tsx index 45c3ccc..8bf3cab 100644 --- a/src/renderer/components/profiles/ConfigTab.tsx +++ b/src/renderer/components/profiles/ConfigTab.tsx @@ -1,22 +1,23 @@ import { Profile } from '@shared/types/Profile.types'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useApp } from '../../AppProvider'; import { useTranslation } from '../../i18n/I18nProvider'; -import { ArgList } from '../common/ArgList'; -import { Button } from '../common/Button'; -import { Dialog } from '../common/Dialog'; -import { EnvVarList } from '../common/EnvVarList'; -import { PropList } from '../common/PropList'; +import { Button } from '../common/inputs'; +import { ArgList, EnvVarList, PropList } from '../common/lists'; +import { Dialog } from '../common/overlays'; +import { Card, Section } from '../layout/containers'; +import { TabBar } from '../layout/navigation'; +import { Toolbar } from '../layout/shell'; import { FilesSection } from './FilesSection'; import { GeneralSection } from './GeneralSection'; -type Section = 'general' | 'files' | 'jvm' | 'props' | 'args' | 'env'; +type SectionId = 'general' | 'files' | 'jvm' | 'props' | 'args' | 'env'; export function ConfigTab() { const { activeProfile, saveProfile, isRunning, startProcess, stopProcess } = useApp(); const { t } = useTranslation(); - const SECTIONS: { id: Section; label: string }[] = [ + const TABS = [ { id: 'general', label: t('config.general') }, { id: 'files', label: t('config.files') }, { id: 'jvm', label: t('config.jvm') }, @@ -28,9 +29,9 @@ export function ConfigTab() { const [draft, setDraft] = useState(null); const [savedSnapshot, setSavedSnapshot] = useState(null); const [saved, setSaved] = useState(false); - const [section, setSection] = useState
('general'); + const [section, setSection] = useState('general'); const [pendingArg, setPendingArg] = useState(false); - const [pendingChange, setPendingChange] = useState
(null); + const [pendingChange, setPendingChange] = useState(null); useEffect(() => { if (activeProfile) { @@ -55,7 +56,7 @@ export function ConfigTab() { }, [draft, saveProfile]); const requestSectionChange = useCallback( - (next: Section) => { + (next: SectionId) => { if (pendingArg && next !== section) { setPendingChange(next); return; @@ -90,7 +91,7 @@ export function ConfigTab() { return ( <>
-
+

{draft.name}

{isDirty && !saved && ( @@ -108,79 +109,66 @@ export function ConfigTab() { > {saved ? t('general.saved') : t('general.save')} -
+ -
- {SECTIONS.map((s) => ( - - ))} -
+ requestSectionChange(id as SectionId)} + accentColor={color} + dotTab={pendingArg ? section : undefined} + />
{section === 'general' && } {section === 'files' && } {section === 'jvm' && ( - +
update({ jvmArgs })} onPendingChange={setPendingArg} placeholder="-Xmx2g" /> - +
)} {section === 'props' && ( - +
update({ systemProperties })} onPendingChange={setPendingArg} /> - +
)} {section === 'args' && ( - +
update({ programArgs })} onPendingChange={setPendingArg} placeholder="--nogui" /> - +
)} {section === 'env' && ( - +
update({ envVars })} onPendingChange={setPendingArg} /> - +
)} -
+

{t('config.commandPreview')}

{buildCmdPreview(draft)}

-
+
@@ -203,26 +191,6 @@ export function ConfigTab() { ); } -function ArgSection({ - title, - hint, - children, -}: { - title: string; - hint: string; - children: React.ReactNode; -}) { - return ( -
-
-

{title}

-

{hint}

-
- {children} -
- ); -} - function buildCmdPreview(p: Profile): string { const isDynamic = p.jarResolution?.enabled; const jarDisplay = isDynamic diff --git a/src/renderer/components/profiles/FilesSection.tsx b/src/renderer/components/profiles/FilesSection.tsx index 1441b99..7e8c06b 100644 --- a/src/renderer/components/profiles/FilesSection.tsx +++ b/src/renderer/components/profiles/FilesSection.tsx @@ -1,7 +1,7 @@ import { Profile } from '@shared/types/Profile.types'; import { useInputContextMenu } from '../../hooks/useInputContextMenu'; import { useTranslation } from '../../i18n/I18nProvider'; -import { Input } from '../common/Input'; +import { Input } from '../common/inputs'; import { JarSelector } from './jar/JarSelector'; interface Props { diff --git a/src/renderer/components/profiles/GeneralSection.tsx b/src/renderer/components/profiles/GeneralSection.tsx index 6f4cfbd..e3fdd00 100644 --- a/src/renderer/components/profiles/GeneralSection.tsx +++ b/src/renderer/components/profiles/GeneralSection.tsx @@ -1,6 +1,7 @@ import { Profile } from '@shared/types/Profile.types'; import { useTranslation } from '../../i18n/I18nProvider'; -import { Toggle } from '../common/Toggle'; +import { Toggle } from '../common/inputs'; +import { Card, Section } from '../layout/containers'; export function GeneralSection({ draft, @@ -13,24 +14,18 @@ export function GeneralSection({ return (
-
-

- {t('config.autoStartTitle')} -

-
+
+

{t('config.autoStart')}

{t('config.autoStartHint')}

update({ autoStart: v })} /> -
-
+ +
-
-

- {t('config.autoRestartTitle')} -

-
+
+

{t('config.autoRestart')}

{t('config.autoRestartHint')}

@@ -39,10 +34,10 @@ export function GeneralSection({ checked={draft.autoRestart ?? false} onChange={(v) => update({ autoRestart: v })} /> -
+
{draft.autoRestart && ( -
+

{t('config.autoRestartInterval')} @@ -64,15 +59,12 @@ export function GeneralSection({ /> {t('config.sec')}

-
+ )} -
+
-
-

- {t('config.logging')} -

-
+
+

{t('config.fileLogging')}

{t('config.fileLoggingHint')}

@@ -81,8 +73,8 @@ export function GeneralSection({ checked={draft.fileLogging ?? false} onChange={(v) => update({ fileLogging: v })} /> -
-
+ +
); } diff --git a/src/renderer/components/profiles/LogsTab.tsx b/src/renderer/components/profiles/LogsTab.tsx index 489622d..3319b0d 100644 --- a/src/renderer/components/profiles/LogsTab.tsx +++ b/src/renderer/components/profiles/LogsTab.tsx @@ -2,9 +2,9 @@ import { useCallback, useEffect, useState } from 'react'; import { VscCopy, VscFolderOpened, VscRefresh, VscTrash } from 'react-icons/vsc'; import { useApp } from '../../AppProvider'; import { useTranslation } from '../../i18n/I18nProvider'; -import { Button } from '../common/Button'; -import { ContextMenu } from '../common/ContextMenu'; -import { Dialog } from '../common/Dialog'; +import { Button } from '../common/inputs'; +import { ContextMenu, Dialog } from '../common/overlays'; +import { Toolbar } from '../layout/shell'; interface LogFileInfo { filename: string; @@ -88,7 +88,7 @@ export function LogsTab() { return (
-
+

{t('logs.title')} @@ -109,7 +109,7 @@ export function LogsTab() { > -

+
@@ -160,7 +160,7 @@ export function LogsTab() { )} {selectedFile && !loading && logContent !== null && ( <> -
+ {selectedFile.filename} @@ -187,7 +187,7 @@ export function LogsTab() { > -
+
{ diff --git a/src/renderer/components/profiles/ProfileSidebar.tsx b/src/renderer/components/profiles/ProfileSidebar.tsx index c6aba8d..784de73 100644 --- a/src/renderer/components/profiles/ProfileSidebar.tsx +++ b/src/renderer/components/profiles/ProfileSidebar.tsx @@ -18,8 +18,7 @@ import { useNavigate } from 'react-router-dom'; import { PROFILE_COLORS, useApp } from '../../AppProvider'; import { useDevMode } from '../../hooks/useDevMode'; import { useTranslation } from '../../i18n/I18nProvider'; -import { ContextMenu, ContextMenuItem } from '../common/ContextMenu'; -import { Dialog } from '../common/Dialog'; +import { ContextMenu, ContextMenuItem, Dialog } from '../common/overlays'; import { TemplateModal } from './TemplateModal'; interface Props { diff --git a/src/renderer/components/profiles/ProfileTab.tsx b/src/renderer/components/profiles/ProfileTab.tsx index 7d671ac..02152a2 100644 --- a/src/renderer/components/profiles/ProfileTab.tsx +++ b/src/renderer/components/profiles/ProfileTab.tsx @@ -2,9 +2,10 @@ import { Profile } from '@shared/types/Profile.types'; import React, { useEffect, useState } from 'react'; import { PROFILE_COLORS, useApp } from '../../AppProvider'; import { useTranslation } from '../../i18n/I18nProvider'; -import { Button } from '../common/Button'; -import { Dialog } from '../common/Dialog'; -import { Input } from '../common/Input'; +import { Button, Input } from '../common/inputs'; +import { Dialog } from '../common/overlays'; +import { Section } from '../layout/containers'; +import { Toolbar } from '../layout/shell'; export function ProfileTab() { const { activeProfile, saveProfile, deleteProfile } = useApp(); @@ -39,7 +40,7 @@ export function ProfileTab() { return ( <>
-
+

{t('profile.identity')}

-
+
@@ -140,23 +141,3 @@ export function ProfileTab() { ); } - -function Section({ - title, - hint, - children, -}: { - title: string; - hint?: string; - children: React.ReactNode; -}) { - return ( -
-
-

{title}

- {hint &&

{hint}

} -
- {children} -
- ); -} diff --git a/src/renderer/components/profiles/TemplateModal.tsx b/src/renderer/components/profiles/TemplateModal.tsx index 9d305b7..1b9adbf 100644 --- a/src/renderer/components/profiles/TemplateModal.tsx +++ b/src/renderer/components/profiles/TemplateModal.tsx @@ -4,8 +4,8 @@ import { LuShield } from 'react-icons/lu'; import { VscAdd, VscPackage, VscRefresh, VscTag } from 'react-icons/vsc'; import { useApp } from '../../AppProvider'; import { useTranslation } from '../../i18n/I18nProvider'; -import { Button } from '../common/Button'; -import { Modal } from '../common/Modal'; +import { Button } from '../common/inputs'; +import { Modal } from '../common/overlays'; const APP_TEMPLATE_VERSION = 1; diff --git a/src/renderer/components/profiles/jar/DynamicJarConfig.tsx b/src/renderer/components/profiles/jar/DynamicJarConfig.tsx index b7a3cba..12e8a72 100644 --- a/src/renderer/components/profiles/jar/DynamicJarConfig.tsx +++ b/src/renderer/components/profiles/jar/DynamicJarConfig.tsx @@ -4,7 +4,7 @@ import { useInputContextMenu } from '../../../hooks/useInputContextMenu'; import { useJarResolutionPreview } from '../../../hooks/useJarResolutionPreview'; import { useTranslation } from '../../../i18n/I18nProvider'; import type { TranslationKey } from '../../../i18n/TranslationKeys'; -import { Input } from '../../common/Input'; +import { Input } from '../../common/inputs'; import { FolderBtn } from './FolderBtn'; import { ResolutionPreview } from './ResolutionPreview'; diff --git a/src/renderer/components/profiles/jar/StaticJarPicker.tsx b/src/renderer/components/profiles/jar/StaticJarPicker.tsx index 070112e..c404538 100644 --- a/src/renderer/components/profiles/jar/StaticJarPicker.tsx +++ b/src/renderer/components/profiles/jar/StaticJarPicker.tsx @@ -1,6 +1,6 @@ import { useInputContextMenu } from '../../../hooks/useInputContextMenu'; import { useTranslation } from '../../../i18n/I18nProvider'; -import { Input } from '../../common/Input'; +import { Input } from '../../common/inputs'; import { FolderBtn } from './FolderBtn'; interface Props { diff --git a/src/renderer/components/settings/SettingsRow.tsx b/src/renderer/components/settings/SettingsRow.tsx index ebc475b..e9dfe93 100644 --- a/src/renderer/components/settings/SettingsRow.tsx +++ b/src/renderer/components/settings/SettingsRow.tsx @@ -1,13 +1,7 @@ import React from 'react'; +import { Section } from '../layout/containers'; -export function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

{title}

-
{children}
-
- ); -} +export { Section }; export function Row({ label, diff --git a/src/renderer/components/settings/SettingsTab.tsx b/src/renderer/components/settings/SettingsTab.tsx index c0d4170..04126e8 100644 --- a/src/renderer/components/settings/SettingsTab.tsx +++ b/src/renderer/components/settings/SettingsTab.tsx @@ -4,8 +4,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useApp } from '../../AppProvider'; import { useTranslation } from '../../i18n/I18nProvider'; import type { TranslationKey } from '../../i18n/TranslationKeys'; -import { Button } from '../common/Button'; -import { SidebarLayout } from '../layout/SidebarLayout'; +import { Button } from '../common/inputs'; +import { SidebarLayout } from '../layout/navigation'; import { AdvancedSection } from './sections/AdvancedSection'; import { AppearanceSection } from './sections/AppearanceSection'; import { ConsoleSection } from './sections/ConsoleSection'; diff --git a/src/renderer/components/settings/sections/AdvancedSection.tsx b/src/renderer/components/settings/sections/AdvancedSection.tsx index d061222..ee66146 100644 --- a/src/renderer/components/settings/sections/AdvancedSection.tsx +++ b/src/renderer/components/settings/sections/AdvancedSection.tsx @@ -1,7 +1,7 @@ import { REST_API_CONFIG } from '@shared/config/API.config'; import { AppSettings } from '@shared/types/App.types'; import { useTranslation } from '../../../i18n/I18nProvider'; -import { Toggle } from '../../common/Toggle'; +import { Toggle } from '../../common/inputs'; import { NumInput, Row, Section } from '../SettingsRow'; interface Props { @@ -13,13 +13,13 @@ export function AdvancedSection({ draft, set }: Props) { const { t } = useTranslation(); return ( <> -
+
set({ devModeEnabled: v })} />
-
+
+
-
+
set({ launchOnStartup: v })} /> diff --git a/src/renderer/components/settings/sections/about/AboutSection.tsx b/src/renderer/components/settings/sections/about/AboutSection.tsx index 003760b..7942ff9 100644 --- a/src/renderer/components/settings/sections/about/AboutSection.tsx +++ b/src/renderer/components/settings/sections/about/AboutSection.tsx @@ -1,7 +1,7 @@ import { VscFolderOpened } from 'react-icons/vsc'; import { version } from '../../../../../../package.json'; import { useTranslation } from '../../../../i18n/I18nProvider'; -import { Tooltip } from '../../../common/Tooltip'; +import { Tooltip } from '../../../common/overlays'; import { Row, Section } from '../../SettingsRow'; import { VersionChecker } from './VersionChecker'; diff --git a/src/renderer/components/settings/sections/about/ReleaseModal.tsx b/src/renderer/components/settings/sections/about/ReleaseModal.tsx index 32e8c23..4a9a53a 100644 --- a/src/renderer/components/settings/sections/about/ReleaseModal.tsx +++ b/src/renderer/components/settings/sections/about/ReleaseModal.tsx @@ -13,8 +13,8 @@ import { VscVerified, } from 'react-icons/vsc'; import { useTranslation } from '../../../../i18n/I18nProvider'; -import { Button } from '../../../common/Button'; -import { Modal } from '../../../common/Modal'; +import { Button } from '../../../common/inputs'; +import { Modal } from '../../../common/overlays'; interface Props { release: GitHubRelease; diff --git a/src/renderer/components/settings/sections/about/VersionChecker.tsx b/src/renderer/components/settings/sections/about/VersionChecker.tsx index ac00fa7..391e971 100644 --- a/src/renderer/components/settings/sections/about/VersionChecker.tsx +++ b/src/renderer/components/settings/sections/about/VersionChecker.tsx @@ -2,7 +2,7 @@ import { GitHubRelease } from '@shared/types/GitHub.types'; import { useCallback, useState } from 'react'; import { VscCheck, VscCircleSlash, VscSync, VscWarning } from 'react-icons/vsc'; import { useTranslation } from '../../../../i18n/I18nProvider'; -import { Tooltip } from '../../../common/Tooltip'; +import { Tooltip } from '../../../common/overlays'; import { ReleaseModal } from './ReleaseModal'; interface Props { diff --git a/src/renderer/components/utils/ActivityLogPanel.tsx b/src/renderer/components/utils/ActivityLogPanel.tsx index 4cf2c83..0b0bf15 100644 --- a/src/renderer/components/utils/ActivityLogPanel.tsx +++ b/src/renderer/components/utils/ActivityLogPanel.tsx @@ -2,9 +2,11 @@ import { ProcessLogEntry } from '@shared/types/Process.types'; import { useCallback, useEffect, useState } from 'react'; import { VscListUnordered } from 'react-icons/vsc'; import { useTranslation } from '../../i18n/I18nProvider'; -import { Button } from '../common/Button'; -import { Dialog } from '../common/Dialog'; -import { EmptyState } from '../common/EmptyState'; +import { EmptyState, StatusDot } from '../common/display'; +import { Button } from '../common/inputs'; +import { Dialog } from '../common/overlays'; +import { Card } from '../layout/containers'; +import { Toolbar } from '../layout/shell'; export function ActivityLogPanel() { const { t } = useTranslation(); @@ -25,7 +27,7 @@ export function ActivityLogPanel() { return ( <>
-
+

{t('activity.description')}

-
+
{loading && !entries && ( @@ -81,7 +83,7 @@ function LogEntryRow({ entry }: { entry: ProcessLogEntry }) { const duration = entry.stoppedAt ? formatDuration(entry.stoppedAt - entry.startedAt) : null; const jarName = entry.jarPath.split(/[/\\]/).pop() ?? entry.jarPath; return ( -
+
@@ -93,7 +95,7 @@ function LogEntryRow({ entry }: { entry: ProcessLogEntry }) { {t('activity.stopped')} ) : ( - + {t('activity.running')} )} @@ -117,7 +119,7 @@ function LogEntryRow({ entry }: { entry: ProcessLogEntry }) { )}
-
+
); } diff --git a/src/renderer/components/utils/ScannerPanel.tsx b/src/renderer/components/utils/ScannerPanel.tsx index c0f7acc..7964d15 100644 --- a/src/renderer/components/utils/ScannerPanel.tsx +++ b/src/renderer/components/utils/ScannerPanel.tsx @@ -3,9 +3,11 @@ import { useCallback, useState } from 'react'; import { LuScanLine } from 'react-icons/lu'; import { VscCheck } from 'react-icons/vsc'; import { useTranslation } from '../../i18n/I18nProvider'; -import { Button } from '../common/Button'; -import { Dialog } from '../common/Dialog'; -import { EmptyState } from '../common/EmptyState'; +import { Badge, EmptyState } from '../common/display'; +import { Button } from '../common/inputs'; +import { Dialog } from '../common/overlays'; +import { DataRow } from '../layout/containers'; +import { Toolbar } from '../layout/shell'; type Filter = 'java' | 'all'; interface KillIntent { @@ -91,7 +93,7 @@ export function ScannerPanel() { return (
-
+
{(['java', 'all'] as const).map((f) => ( -
+
{statusMsg && (
{proc.protected && } - {proc.managed && } + {proc.managed && } {proc.isJava ? ( - + ) : ( )} @@ -290,69 +292,35 @@ function ProcessRow({
{expanded && (
- - {proc.jarName && } + + {proc.jarName && } {proc.memoryMB !== undefined && ( - + )} {proc.threads !== undefined && ( - + + )} + {proc.startTime && ( + )} - {proc.startTime && } - -
)}
); } - -function Badge({ label, accent, blue }: { label: string; accent?: boolean; blue?: boolean }) { - return ( - - {label} - - ); -} - -function DetailRow({ - label, - value, - mono, - wrap, -}: { - label: string; - value: string; - mono?: boolean; - wrap?: boolean; -}) { - return ( -
- {label} - - {value} - -
- ); -} diff --git a/src/renderer/components/utils/UtilitiesTab.tsx b/src/renderer/components/utils/UtilitiesTab.tsx index 7868c74..ec616e1 100644 --- a/src/renderer/components/utils/UtilitiesTab.tsx +++ b/src/renderer/components/utils/UtilitiesTab.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { LuScanLine } from 'react-icons/lu'; import { VscListUnordered } from 'react-icons/vsc'; import { useTranslation } from '../../i18n/I18nProvider'; +import { TabBar } from '../layout/navigation'; import { ActivityLogPanel } from './ActivityLogPanel'; import { ScannerPanel } from './ScannerPanel'; @@ -18,23 +19,7 @@ export function UtilitiesTab() { return (
-
- {PANELS.map((p) => ( - - ))} -
+ setPanel(id as Panel)} />
{panel === 'log' && } diff --git a/src/renderer/hooks/ThemeProvider.tsx b/src/renderer/hooks/ThemeProvider.tsx index ad3be0f..41e99ed 100644 --- a/src/renderer/hooks/ThemeProvider.tsx +++ b/src/renderer/hooks/ThemeProvider.tsx @@ -1,4 +1,4 @@ -import { ALL_THEMES, BUILTIN_THEME } from '@shared/config/Theme.config'; +import { ALL_THEMES, BUILTIN_THEME } from '@shared/config/themes/Theme.config'; import type { ThemeColors, ThemeDefinition } from '@shared/types/Theme.types'; import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; @@ -49,6 +49,7 @@ function buildThemeCSS(colors: ThemeColors): string { lines.push(`.bg-${CSS.escape(twName)} { background-color: ${v} !important; }`); lines.push(`.text-${CSS.escape(twName)} { color: ${v} !important; }`); lines.push(`.border-${CSS.escape(twName)} { border-color: ${v} !important; }`); + lines.push(`.accent-${CSS.escape(twName)} { accent-color: ${v} !important; }`); lines.push( `.divide-${CSS.escape(twName)} > :not([hidden]) ~ :not([hidden]) { border-color: ${v} !important; }` ); diff --git a/src/renderer/hooks/useAutoScroll.ts b/src/renderer/hooks/useAutoScroll.ts deleted file mode 100644 index 6bc912b..0000000 --- a/src/renderer/hooks/useAutoScroll.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; - -export function useAutoScroll(deps: unknown[]) { - const scrollRef = useRef(null); - const bottomRef = useRef(null); - const [autoScroll, setAutoScroll] = useState(true); - - const handleScroll = useCallback(() => { - const el = scrollRef.current; - if (!el) return; - setAutoScroll(el.scrollHeight - el.scrollTop - el.clientHeight < 40); - }, []); - - useEffect(() => { - if (autoScroll) { - bottomRef.current?.scrollIntoView({ behavior: 'instant' }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [...deps, autoScroll]); - - const scrollToBottom = useCallback(() => { - setAutoScroll(true); - bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, []); - - return { scrollRef, bottomRef, autoScroll, handleScroll, scrollToBottom }; -} diff --git a/src/renderer/hooks/useInputContextMenu.tsx b/src/renderer/hooks/useInputContextMenu.tsx index 662426d..b9be727 100644 --- a/src/renderer/hooks/useInputContextMenu.tsx +++ b/src/renderer/hooks/useInputContextMenu.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useState } from 'react'; import { MdContentCopy, MdContentCut, MdContentPaste, MdSelectAll } from 'react-icons/md'; -import { ContextMenu } from '../components/common/ContextMenu'; +import { ContextMenu } from '../components/common/overlays'; import { useTranslation } from '../i18n/I18nProvider'; type InputEl = HTMLInputElement | HTMLTextAreaElement; diff --git a/src/renderer/i18n/I18nProvider.tsx b/src/renderer/i18n/I18nProvider.tsx index cfc8d20..f5146de 100644 --- a/src/renderer/i18n/I18nProvider.tsx +++ b/src/renderer/i18n/I18nProvider.tsx @@ -1,4 +1,4 @@ -import { ALL_LANGUAGES, ENGLISH } from '@shared/config/Language.config'; +import { ALL_LANGUAGES, ENGLISH } from '@shared/config/languages/Language.config'; import type { LanguageDefinition } from '@shared/types/Language.types'; import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; import type { TranslationKey } from './TranslationKeys'; diff --git a/src/renderer/i18n/TranslationKeys.ts b/src/renderer/i18n/TranslationKeys.ts index d5d99e9..f540703 100644 --- a/src/renderer/i18n/TranslationKeys.ts +++ b/src/renderer/i18n/TranslationKeys.ts @@ -1,3 +1,3 @@ -import { ENGLISH_STRINGS } from '@shared/config/Language.config'; +import { ENGLISH_STRINGS } from '@shared/config/languages/Language.config'; export type TranslationKey = keyof typeof ENGLISH_STRINGS; diff --git a/tailwind.config.js b/tailwind.config.js index 9f77c71..3fc6138 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,6 +1,6 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - content: ['./src/renderer/**/*.{ts,tsx,html}'], + content: ['./src/renderer/**/*.{ts,tsx,html}', './src/main/shared/**/*.ts'], theme: { extend: { colors: {