chore(lint): adopt oxlint as the linter + CI gate; fix the bugs it surfaced (#761)
ESLint was misconfigured (only globals.browser → 47 false no-undef) and run NOWHERE in CI, so 259 errors had accumulated unnoticed. Replace it with oxlint (Rust, ~50-100x faster) as the primary linter AND a real CI gate so lint debt can't silently pile up again. Tooling: - frontend/.oxlintrc.json — correctness=error; no-unused-vars with the existing ^[A-Z_] convention; node/vitest env overrides + AudioWorklet/__APP_VERSION__ globals (kills the false no-undef class); max-lines:500 (warn). - package.json: `lint` → oxlint, `lint:fix`, `lint:hooks` (advisory eslint). - eslint.config.js stripped to ONLY the React-Compiler rule family oxlint can't do yet (set-state-in-effect etc.), run via `lint:hooks`, NOT gated. Drop once oxlint's JS-plugin support leaves alpha. - ci.yml: new "Frontend lint (oxlint)" step in the Tests job — the gate. Real bugs oxlint caught (were buried in ESLint's noise): - GlossaryPanel: <X/> close-icon used but never imported → the edit-row cancel button threw ReferenceError at render. Imported X. - Two use*-named NON-hooks (useEngine action, useArchetypeAsProfile API call) tripped rules-of-hooks; suppressed with documented disables (renaming these misleading names is a worthwhile follow-up). Cleanup to reach a 0-error gate: removed 52 genuinely-dead vars/imports across 18 files (heavy in App.jsx — stale useState left over from prior refactors) and 4 behavior-preserving autofixes (no-useless-fallback-in-spread / no-useless-escape). Verified: oxlint 0 errors; bun install --frozen-lockfile in sync (Docker rule); vite build passes; full suite 638/638. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
mergetest
Claude Opus 4.8
parent
b9707e0d8f
commit
8765f766e1
@@ -105,6 +105,13 @@ jobs:
|
||||
working-directory: frontend
|
||||
run: bun run typecheck:ci
|
||||
|
||||
# oxlint gate — fast Rust linter, blocks on errors so lint debt can't
|
||||
# re-accumulate (warnings, incl. the react-compiler advisories in
|
||||
# `lint:hooks`, are non-blocking). See frontend/.oxlintrc.json.
|
||||
- name: Frontend lint (oxlint)
|
||||
working-directory: frontend
|
||||
run: bun run lint
|
||||
|
||||
- name: Run Vitest (frontend)
|
||||
working-directory: frontend
|
||||
run: bunx vitest run
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"frontend": {
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.3.5",
|
||||
"version": "0.3.8",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/source-serif-4": "^5.2.9",
|
||||
@@ -67,6 +67,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"oxlint": "^1.71.0",
|
||||
"playwright-core": "1.61.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
@@ -205,6 +206,44 @@
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="],
|
||||
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.71.0", "", { "os": "android", "cpu": "arm" }, "sha512-ImGmd1njEg4FEJH03jhRnveEegtO3czCtfptvaHivKAZQIYATbVFBrrzbaYMYv0oJioTnxZAZVSyV+oL7W8S2g=="],
|
||||
|
||||
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.71.0", "", { "os": "android", "cpu": "arm64" }, "sha512-4A5BEexBrwY1YFF8Kiq/lp/wQPRG79G3BWIE1FuWaM5MvmpYSd+7ZySVcKkHdwo0UDzdQGddp6pD9mpctMqLnw=="],
|
||||
|
||||
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.71.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9wJA9GJulLwS2usU3CEisI/ESDO1n1z9eyTCvApMDrAkbJ1ve0mORgTMjcWWsKxkzkeZ2N/Gpra5IQE7x8tYgQ=="],
|
||||
|
||||
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.71.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-PlLCjS06V0PeJMAJwzjrExw1sYNW9Gch3JtNlcwwZDXGlTYDuwHNN89zYH8LTXFfgkVtsYvs2nv0FqrzyuFDzg=="],
|
||||
|
||||
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.71.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Lhil7bWre0ncxbUoDoxfS0JzpTz17BRQKW7iwoAUY8GJ66+WwJEfYPCFJ1P0WgVZR5/O/b3Q2pENlHOjeXLOGQ=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.71.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Oo9/L58PYD3RC0x05d2upAPLllHytTjHQGsnC06P6Ynn7jKkp5mdImQxXdJ3+FnBaKspNpGogzgVsi6g872LiA=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.71.0", "", { "os": "linux", "cpu": "arm" }, "sha512-mSHfyfgJrEbyIR29ejaeS50BdPk+GoNPlC1dckpDiUZbJAIel68sjSMdOt4WY0/gva+ECC7FNITQkxMJU+vSBw=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.71.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-n9yY4M2tiy3aij4AqtlnspzpfdpeT5JQfK2/w2d8oyp5W0FRwOb1dIeX99nORNcxGr08iD9bH8N5XFz3I2iy8w=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.71.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ=="],
|
||||
|
||||
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.71.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.71.0", "", { "os": "linux", "cpu": "none" }, "sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.71.0", "", { "os": "linux", "cpu": "none" }, "sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA=="],
|
||||
|
||||
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.71.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.71.0", "", { "os": "linux", "cpu": "x64" }, "sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.71.0", "", { "os": "linux", "cpu": "x64" }, "sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA=="],
|
||||
|
||||
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.71.0", "", { "os": "none", "cpu": "arm64" }, "sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw=="],
|
||||
|
||||
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.71.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-bd5kI8spYwTm3BILDtGhi73zoup5dw8MlPQNT8YB3BD5UIsjNe3K9/4ctrzQMX4SZMoK5HgzVLkLJzacEXB7fA=="],
|
||||
|
||||
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.71.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-W4HvOHGzVLHcrmFu+bMrJlho+/yrlX5ZNdJZqGe8MEldkQG+RHYhxxad9P4jvWAYFmIqUA5i9DQ8QsJqSU9GIw=="],
|
||||
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.71.0", "", { "os": "win32", "cpu": "x64" }, "sha512-D2kyEIPHk/G/wiZLnwTVC/sVst+T/lKldVOjAFpgTIBUAOlry72e5OiapDbDBF4LfJLkN5ypJb/8Eu6yJzkveQ=="],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.61.0", "", { "dependencies": { "playwright": "1.61.0" }, "bin": { "playwright": "cli.js" } }, "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.2", "", {}, "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="],
|
||||
@@ -773,6 +812,8 @@
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "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" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"oxlint": ["oxlint@1.71.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.71.0", "@oxlint/binding-android-arm64": "1.71.0", "@oxlint/binding-darwin-arm64": "1.71.0", "@oxlint/binding-darwin-x64": "1.71.0", "@oxlint/binding-freebsd-x64": "1.71.0", "@oxlint/binding-linux-arm-gnueabihf": "1.71.0", "@oxlint/binding-linux-arm-musleabihf": "1.71.0", "@oxlint/binding-linux-arm64-gnu": "1.71.0", "@oxlint/binding-linux-arm64-musl": "1.71.0", "@oxlint/binding-linux-ppc64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-musl": "1.71.0", "@oxlint/binding-linux-s390x-gnu": "1.71.0", "@oxlint/binding-linux-x64-gnu": "1.71.0", "@oxlint/binding-linux-x64-musl": "1.71.0", "@oxlint/binding-openharmony-arm64": "1.71.0", "@oxlint/binding-win32-arm64-msvc": "1.71.0", "@oxlint/binding-win32-ia32-msvc": "1.71.0", "@oxlint/binding-win32-x64-msvc": "1.71.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2024": true
|
||||
},
|
||||
"globals": {
|
||||
"__APP_VERSION__": "readonly",
|
||||
"AudioWorklet": "readonly",
|
||||
"AudioWorkletNode": "readonly",
|
||||
"AudioWorkletProcessor": "readonly",
|
||||
"registerProcessor": "readonly"
|
||||
},
|
||||
"plugins": ["react", "import", "unicorn"],
|
||||
"ignorePatterns": ["dist/", "src-tauri/", "node_modules/"],
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-vars": ["error", { "varsIgnorePattern": "^[A-Z_]", "argsIgnorePattern": "^_", "caughtErrors": "none" }],
|
||||
"no-empty": ["warn", { "allowEmptyCatch": true }],
|
||||
"no-unused-expressions": ["error", { "allowShortCircuit": true, "allowTernary": true }],
|
||||
"react/exhaustive-deps": "warn",
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }],
|
||||
"max-lines": ["warn", { "max": 500, "skipBlankLines": true, "skipComments": true }]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["vite.config.js", "*.config.js", "eslint.config.js"],
|
||||
"env": { "node": true }
|
||||
},
|
||||
{
|
||||
"files": ["**/*.test.{js,jsx}", "**/*.spec.{js,jsx}", "src/test/**"],
|
||||
"env": { "node": true, "vitest": true }
|
||||
}
|
||||
]
|
||||
}
|
||||
+15
-14
@@ -1,32 +1,33 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
// Advisory-only lint for the React Compiler rule family that oxlint does not yet
|
||||
// implement natively (e.g. react-hooks/set-state-in-effect, immutability,
|
||||
// preserve-manual-memoization). Run locally via `bun run lint:hooks`.
|
||||
//
|
||||
// This is NOT a CI gate. oxlint (`bun run lint`, see .oxlintrc.json) is the gate.
|
||||
// Everything oxlint already covers (no-unused-vars, exhaustive-deps,
|
||||
// rules-of-hooks, no-undef, max-lines, react-refresh) is turned OFF here so the
|
||||
// two tools don't double-report. Drop this file once oxlint's JS-plugin support
|
||||
// graduates from alpha and can run eslint-plugin-react-hooks directly.
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import globals from 'globals'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
extends: [reactHooks.configs.flat.recommended],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
ecmaVersion: 'latest',
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
// Modularization guardrail (warn-only so CI stays green). See
|
||||
// CONTRIBUTING.md → "Frontend file structure & size limits".
|
||||
'max-lines': ['warn', { max: 500, skipBlankLines: true, skipComments: true }],
|
||||
// Owned by oxlint — disabled here to avoid duplicate diagnostics.
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
"dev": "vite",
|
||||
"desktop": "tauri dev",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"lint": "oxlint",
|
||||
"lint:fix": "oxlint --fix",
|
||||
"lint:hooks": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:ci": "tsc --noEmit --checkJs false",
|
||||
"test": "vitest run",
|
||||
@@ -69,6 +71,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"oxlint": "^1.71.0",
|
||||
"playwright-core": "1.61.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
|
||||
+6
-23
@@ -39,7 +39,6 @@ import FloatingPill from './components/FloatingPill';
|
||||
// main studio return below. Do not re-wrap here — double-gating renders two
|
||||
// PIN dialogs.
|
||||
|
||||
import useRealtimeEvents from './hooks/useRealtimeEvents';
|
||||
import { BootstrapSplash, useBootstrapStage } from './components/BootstrapSplash';
|
||||
|
||||
import './components/Misc.css';
|
||||
@@ -60,13 +59,12 @@ import {
|
||||
POPULAR_LANGS, POPULAR_ISO, TAGS, CATEGORIES, PRESETS, CLONE_MAX_SECONDS,
|
||||
} from './utils/constants';
|
||||
import { LANG_CODES } from './utils/languages';
|
||||
import { formatTime } from './utils/format';
|
||||
import { API, apiPost } from './api/client';
|
||||
import { API } from './api/client';
|
||||
import { flushMemory as apiFlushMemory } from './api/system';
|
||||
import { saveProject as apiSaveProject, loadProject as apiLoadProject, deleteProject as apiDeleteProject, renameProject as apiRenameProject } from './api/projects';
|
||||
import { exportAction, exportReveal, exportRecord } from './api/exports';
|
||||
|
||||
import { isTauri, doubleClickMaximize, fileToMediaUrl, playBlobAudio, playPing } from './utils/media';
|
||||
import { isTauri, doubleClickMaximize, fileToMediaUrl, playBlobAudio } from './utils/media';
|
||||
import { browserDownload } from './utils/download';
|
||||
import { checkForUpdate, fetchAppVersion } from './utils/updater';
|
||||
import { syncChannel } from './utils/channelControl';
|
||||
@@ -83,7 +81,6 @@ function App() {
|
||||
// Mode + uiScale + sidebar-collapsed persist across reloads automatically
|
||||
// via the store's `partialize`; active project / voice ids stay transient.
|
||||
const uiScale = useAppStore(s => s.uiScale);
|
||||
const setUiScale = useAppStore(s => s.setUiScale);
|
||||
|
||||
// Responsive shell breakpoints driven off the app-container's OWN width, not
|
||||
// the viewport. The shell is sized `width: calc(100vw / --ui-scale)` then
|
||||
@@ -338,42 +335,28 @@ function App() {
|
||||
const setDubDialect = useAppStore(s => s.setDubDialect);
|
||||
const dubInstruct = useAppStore(s => s.dubInstruct);
|
||||
const setDubInstruct = useAppStore(s => s.setDubInstruct);
|
||||
const dubProgress = useAppStore(s => s.dubProgress);
|
||||
const setDubProgress = useAppStore(s => s.setDubProgress);
|
||||
const dubFilename = useAppStore(s => s.dubFilename);
|
||||
const setDubFilename = useAppStore(s => s.setDubFilename);
|
||||
const dubDuration = useAppStore(s => s.dubDuration);
|
||||
const setDubDuration = useAppStore(s => s.setDubDuration);
|
||||
const dubError = useAppStore(s => s.dubError);
|
||||
const setDubError = useAppStore(s => s.setDubError);
|
||||
const dubTracks = useAppStore(s => s.dubTracks);
|
||||
const setDubTracks = useAppStore(s => s.setDubTracks);
|
||||
const dubTranscript = useAppStore(s => s.dubTranscript);
|
||||
const setDubTranscript = useAppStore(s => s.setDubTranscript);
|
||||
const isTranslating = useAppStore(s => s.isTranslating);
|
||||
const setIsTranslating = useAppStore(s => s.setIsTranslating);
|
||||
const preserveBg = useAppStore(s => s.preserveBg);
|
||||
const setPreserveBg = useAppStore(s => s.setPreserveBg);
|
||||
const defaultTrack = useAppStore(s => s.defaultTrack);
|
||||
const setDefaultTrack = useAppStore(s => s.setDefaultTrack);
|
||||
const exportTracks = useAppStore(s => s.exportTracks);
|
||||
const setExportTracks = useAppStore(s => s.setExportTracks);
|
||||
const previewSegIds = useAppStore(s => s.previewSegIds);
|
||||
const setPreviewSegIds = useAppStore(s => s.setPreviewSegIds);
|
||||
const speakerClones = useAppStore(s => s.speakerClones);
|
||||
const setSpeakerClones = useAppStore(s => s.setSpeakerClones);
|
||||
const dubTaskId = useAppStore(s => s.dubTaskId);
|
||||
const setDubTaskId = useAppStore(s => s.setDubTaskId);
|
||||
const dubPrepStage = useAppStore(s => s.dubPrepStage);
|
||||
const setDubPrepStage = useAppStore(s => s.setDubPrepStage);
|
||||
|
||||
const translateQuality = useAppStore(s => s.translateQuality);
|
||||
const setTranslateQuality = useAppStore(s => s.setTranslateQuality);
|
||||
const glossaryTerms = useAppStore(s => s.glossaryTerms);
|
||||
const setGlossaryTerms = useAppStore(s => s.setGlossaryTerms);
|
||||
const dualSubs = useAppStore(s => s.dualSubs);
|
||||
const burnSubs = useAppStore(s => s.burnSubs);
|
||||
const setDualSubs = useAppStore(s => s.setDualSubs);
|
||||
|
||||
// ── UNDO / REDO + SEGMENT EDITING ──
|
||||
// Must come before useDubWorkflow because the dub generate handler needs
|
||||
@@ -383,12 +366,12 @@ function App() {
|
||||
segmentEditField, segmentDelete, segmentRestoreOriginal,
|
||||
segmentSplit, segmentMerge, segmentMoveResize,
|
||||
timelineSelSegId, setTimelineSelSegId,
|
||||
selectedSegIds, setSelectedSegIds,
|
||||
selectedSegIds,
|
||||
toggleSegSelect, selectAllSegs, clearSegSelection,
|
||||
bulkApplyToSelected, bulkDeleteSelected,
|
||||
directionSegId, openDirection, closeDirection, saveDirection,
|
||||
lastGenFingerprints, setLastGenFingerprints,
|
||||
incrementalPlan, setIncrementalPlan,
|
||||
setLastGenFingerprints,
|
||||
incrementalPlan,
|
||||
recomputeIncremental,
|
||||
} = useSegmentEditing();
|
||||
|
||||
@@ -397,7 +380,7 @@ function App() {
|
||||
const {
|
||||
translateProvider, setTranslateProvider,
|
||||
showTranscript, setShowTranscript,
|
||||
previewAudios, setPreviewAudios,
|
||||
setPreviewAudios,
|
||||
transcribeElapsed,
|
||||
handleDubUpload: _handleDubUpload, handleDubIngestUrl,
|
||||
handleDubAbort, handleDubRetryTranscribe,
|
||||
|
||||
@@ -115,7 +115,7 @@ export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Re
|
||||
if (pin) extra['X-OmniVoice-Pin'] = pin;
|
||||
if (key) extra['Authorization'] = `Bearer ${key}`;
|
||||
const finalOpts: RequestInit = Object.keys(extra).length
|
||||
? { ...opts, headers: { ...(opts.headers as Record<string, string> || {}), ...extra } }
|
||||
? { ...opts, headers: { ...(opts.headers as Record<string, string>), ...extra } }
|
||||
: opts;
|
||||
const signal = finalOpts.signal as AbortSignal | null | undefined;
|
||||
let lastDetail = '';
|
||||
@@ -171,7 +171,7 @@ export async function apiPost<T = unknown>(
|
||||
if (body instanceof FormData) {
|
||||
init.body = body;
|
||||
} else if (body !== undefined) {
|
||||
init.headers = { 'Content-Type': 'application/json', ...(opts.headers as Record<string, string> || {}) };
|
||||
init.headers = { 'Content-Type': 'application/json', ...(opts.headers as Record<string, string>) };
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
return apiJson<T>(path, init);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Scissors, Play, Pause, Check, ZoomIn, ZoomOut, Maximize2, Repeat } from 'lucide-react';
|
||||
import {
|
||||
clamp, encodeWav, computePeaksFromChannel, computePeaksAsync, pickTickInterval,
|
||||
@@ -12,9 +12,6 @@ import './AudioTrimmer.css';
|
||||
|
||||
const EDGE_GRAB_PX = 10;
|
||||
|
||||
const computePeaks = (buffer, buckets = DEFAULT_PEAK_BUCKETS) =>
|
||||
computePeaksFromChannel(buffer.getChannelData(0), buckets);
|
||||
|
||||
function fmtSec(t, precision = 2) {
|
||||
if (!isFinite(t)) return '0.00s';
|
||||
return `${t.toFixed(precision)}s`;
|
||||
|
||||
@@ -136,8 +136,8 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
const [captureMode] = useState(() =>
|
||||
localStorage.getItem(LS_CAPTURE_MODE) || 'fast'
|
||||
);
|
||||
const [lastEngine, setLastEngine] = useState('');
|
||||
const [lastTime, setLastTime] = useState(0);
|
||||
const [, setLastEngine] = useState('');
|
||||
const [, setLastTime] = useState(0);
|
||||
const [partialText, setPartialText] = useState('');
|
||||
|
||||
// Live-dictation prefs (mirrored from the backend dictation.* namespace).
|
||||
|
||||
@@ -125,7 +125,7 @@ export default function EngineCompatibilityMatrix({
|
||||
const [activeFamily, setActiveFamily] = useState(family);
|
||||
// Phase 3 Plan 03-01 / TTS-05: which engine has its license dialog
|
||||
// currently open, or null. Only one dialog is ever open at a time.
|
||||
const [licenseDialogFor, setLicenseDialogFor] = useState(null);
|
||||
const [, setLicenseDialogFor] = useState(null);
|
||||
|
||||
// health state keyed by engine id:
|
||||
// { [id]: { inflight: boolean, ok?: boolean, message?: string,
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function ExportModal({
|
||||
burnSubs, setBurnSubs,
|
||||
API,
|
||||
triggerDownload,
|
||||
handleDubDownload, handleDubAudioDownload, handleAudioExport,
|
||||
handleDubDownload, handleAudioExport,
|
||||
segmentCount = 0,
|
||||
timingStrategy = '',
|
||||
onEnterprise,
|
||||
@@ -80,7 +80,6 @@ export default function ExportModal({
|
||||
return out;
|
||||
}, [dubTracks, t]);
|
||||
|
||||
const dubOnlyTracks = useMemo(() => allTracks.filter(t => t.kind === 'dub'), [allTracks]);
|
||||
const selectedTracks = allTracks.filter(t => exportTracks[t.code] !== false);
|
||||
const selectedDubs = selectedTracks.filter(t => t.kind === 'dub');
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Trash2, BookOpen, Sparkles, Check, ChevronDown } from 'lucide-react';
|
||||
import { Plus, Trash2, BookOpen, Sparkles, Check, ChevronDown, X } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Panel, Button, Input, Badge } from '../ui';
|
||||
import {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
|
||||
import toast from 'react-hot-toast';
|
||||
import { clearSystemLogs, clearTauriLogs } from '../api/system';
|
||||
import { useSystemLogs, useTauriLogs, useNotifications, useClearLogs, useClearTauriLogs } from '../api/hooks';
|
||||
import { useSystemLogs, useTauriLogs, useNotifications } from '../api/hooks';
|
||||
import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppStore } from '../store';
|
||||
@@ -160,7 +160,6 @@ export default function LogsFooter() {
|
||||
// comes from the in-process ring buffer in consoleBuffer.js.
|
||||
const [lines, setLines] = useState({ backend: [], frontend: [], tauri: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hfInput, setHfInput] = useState('');
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
useEffect(() => localStorage.setItem(LS_HEIGHT, String(height)), [height]);
|
||||
@@ -344,7 +343,6 @@ export default function LogsFooter() {
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────
|
||||
const current = lines[active] || [];
|
||||
const notifCounts = { error: 0, warn: notifications.filter(n => n.level === 'warn').length + notifications.filter(n => n.level === 'error').length, total: notifications.length };
|
||||
|
||||
return (
|
||||
<div className={['logs-footer', collapsed ? 'logs-footer--collapsed' : 'logs-footer--open'].join(' ')}
|
||||
|
||||
@@ -87,7 +87,6 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
|
||||
// Determine if all critical checks pass
|
||||
const allPass = checks.every(c => c.status === 'pass' || c.status === 'warn');
|
||||
const anyFail = checks.some(c => c.status === 'fail');
|
||||
const criticalFails = checks.filter(c => c.status === 'fail');
|
||||
|
||||
// Hide when everything is fine (unless explicitly asked to show)
|
||||
if (!showWhenAllPass && allPass && !isLoading) return null;
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function WaveformPlayer({
|
||||
useEffect(() => { autoPlayRef.current = autoPlay; }, [autoPlay]);
|
||||
|
||||
const [resolvedUrl, setResolvedUrl] = useState(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [, setReady] = useState(false);
|
||||
const [failed, setFailed] = useState(false); // WaveSurfer unavailable → native fallback
|
||||
const [missing, setMissing] = useState(false); // source 404s (stale history) → inert notice
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
@@ -288,6 +288,7 @@ export default function WizardLibrary() {
|
||||
type="button"
|
||||
className="frs-btn frs-btn--quiet swiz-lib__act"
|
||||
disabled={switching === b.id}
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks -- useEngine is an action fn, not a React hook
|
||||
onClick={() => useEngine(b.id)}
|
||||
>
|
||||
{t('firstrun.lib_use', 'Use')}
|
||||
|
||||
@@ -4,12 +4,10 @@ import { listProfiles } from '../api/profiles';
|
||||
import { listHistory } from '../api/generate';
|
||||
import { listProjects } from '../api/projects';
|
||||
import { listDubHistory } from '../api/dub';
|
||||
import { listExportHistory, exportAction, exportReveal, exportRecord } from '../api/exports';
|
||||
import { listExportHistory } from '../api/exports';
|
||||
import { modelStatus as apiModelStatus } from '../api/system';
|
||||
import { useModelStatus } from '../api/hooks';
|
||||
import useRealtimeEvents from './useRealtimeEvents';
|
||||
import { isTauri, fileToMediaUrl } from '../utils/media';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
/**
|
||||
* Encapsulates all data-loading effects, localStorage persistence,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { dialectMatchesLang } from '../api/dialects';
|
||||
import { segmentGenInputs, applySpeakerCloneDefaults } from '../utils/segments';
|
||||
import { apiPost } from '../api/client';
|
||||
import { API } from '../api/client';
|
||||
import { playPing, isTauri } from '../utils/media';
|
||||
import { playPing } from '../utils/media';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { toastErrorWithReport } from '../utils/errorToast';
|
||||
import { addBreadcrumb } from '../utils/breadcrumbs';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useAppStore } from '../store';
|
||||
import { generateSpeech, audioUrlWithCacheBust } from '../api/generate';
|
||||
import { generateSpeech } from '../api/generate';
|
||||
import { pickDesignSeed } from '../utils/seed';
|
||||
import { playBlobAudio, playPing } from '../utils/media';
|
||||
import { probeAudioDuration } from '../utils/format';
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function AudiobookTab({ profiles = [] }) {
|
||||
|
||||
const onPreviewChapter = useCallback(async (i) => {
|
||||
setError('');
|
||||
setChapterPrev((p) => ({ ...p, [i]: { ...(p[i] || {}), loading: true } }));
|
||||
setChapterPrev((p) => ({ ...p, [i]: { ...p[i], loading: true } }));
|
||||
try {
|
||||
const lexicon = lexDict();
|
||||
const r = await audiobookPreviewChapter({
|
||||
@@ -137,7 +137,7 @@ export default function AudiobookTab({ profiles = [] }) {
|
||||
});
|
||||
setChapterPrev((p) => ({ ...p, [i]: { url: audioUrl(r.output), loading: false } }));
|
||||
} catch (e) {
|
||||
setChapterPrev((p) => ({ ...p, [i]: { ...(p[i] || {}), loading: false } }));
|
||||
setChapterPrev((p) => ({ ...p, [i]: { ...p[i], loading: false } }));
|
||||
setError(e?.message || String(e));
|
||||
}
|
||||
}, [text, defaultVoice, lex]);
|
||||
@@ -174,7 +174,7 @@ export default function AudiobookTab({ profiles = [] }) {
|
||||
} else if (evt.type === 'chapter') {
|
||||
setProgress({ current: evt.index + 1, total: evt.total, title: evt.title });
|
||||
} else if (evt.type === 'assembling') {
|
||||
setProgress((p) => ({ ...(p || {}), assembling: true }));
|
||||
setProgress((p) => ({ ...p, assembling: true }));
|
||||
} else if (evt.type === 'chapter_error') {
|
||||
setProgress({ current: evt.index + 1, total: evt.total, title: evt.title });
|
||||
} else if (evt.type === 'done') {
|
||||
|
||||
@@ -256,7 +256,7 @@ function JobCard({ job, onCancel, onDelete, t }) {
|
||||
{/* Output downloads for done jobs */}
|
||||
{job.status === 'done' && job.outputs && Object.keys(job.outputs).length > 0 && (
|
||||
<div className="batch-queue__card-outputs">
|
||||
{Object.entries(job.outputs).map(([lang, path]) => (
|
||||
{Object.entries(job.outputs).map(([lang]) => (
|
||||
<a
|
||||
key={lang}
|
||||
className="batch-queue__card-dl"
|
||||
|
||||
@@ -59,7 +59,7 @@ function ActionCard({ hue, Icon, title, count, onClick, children }) {
|
||||
}
|
||||
|
||||
export default function Launchpad({
|
||||
profiles, studioProjects, dubHistory, exportHistory = [],
|
||||
profiles, studioProjects, exportHistory = [],
|
||||
setMode, setIsCompareModalOpen, handleSelectProfile, loadProject,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -231,9 +231,6 @@ export default function Settings() {
|
||||
}
|
||||
}, [updateChannel, t]);
|
||||
|
||||
// refreshInfo polling replaced by TanStack Query (useSystemInfo + useModelStatus)
|
||||
const refreshInfo = useCallback(() => {}, []);
|
||||
|
||||
const refreshLogs = useCallback(async () => {
|
||||
setLoadingLogs(true);
|
||||
try {
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
* page updates in realtime without requiring a shared store.
|
||||
*/
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { copyText } from "../utils/copyText";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Mic, Copy, Trash2, Search, Clock, Languages, FileText, Download } from 'lucide-react';
|
||||
import { Button } from '../ui';
|
||||
|
||||
@@ -163,6 +163,7 @@ export default function VoiceGallery() {
|
||||
onPreview={(a) => playUrl(archetypePreviewUrl(a.id), a.id)}
|
||||
onUse={async (a) => {
|
||||
try {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks -- useArchetypeAsProfile is an API call, not a React hook
|
||||
const r = await useArchetypeAsProfile(a.id, a.name);
|
||||
// Hand the new profile to the synthesis view and jump there so the
|
||||
// user lands ready-to-generate instead of hunting for it in the list.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import VoiceSelector from '../components/VoiceSelector';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, isValidElement } from 'react';
|
||||
import React, { isValidElement } from 'react';
|
||||
import * as RadixMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import './Menu.css';
|
||||
|
||||
@@ -18,7 +18,7 @@ export const PAUSE_MAX_MS = 10000;
|
||||
// body. Moved-equivalent of audiobook.py _HEADING_RE. Global+multiline.
|
||||
const HEADING_RE = /^[ \t]*#[ \t]+(\S.*)$/gm;
|
||||
// [voice:NAME] — content excludes BOTH brackets (mirrors _VOICE_RE).
|
||||
const VOICE_RE = /\[voice:([^\]\[]*)\]/g;
|
||||
const VOICE_RE = /\[voice:([^\][]*)\]/g;
|
||||
// Pause dialect mirroring omnivoice.utils.text._PAUSE_RE. JS has no atomic
|
||||
// group; the unit's own `(?:\s*(ms|s))?` is zero-width when no unit follows, so
|
||||
// the trailing `\s*` is the ONLY consumer of trailing whitespace — no two `\s*`
|
||||
|
||||
@@ -65,7 +65,7 @@ export function describeMicError(err, platform = detectPlatform()) {
|
||||
/** Convenience: resolve the description to a final translated string. */
|
||||
export function micErrorMessage(t, err, platform = detectPlatform()) {
|
||||
const d = describeMicError(err, platform);
|
||||
const params = { ...(d.params || {}) };
|
||||
const params = { ...d.params };
|
||||
if (d.hintKey) params.hint = t(d.hintKey);
|
||||
return t(d.key, params);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import { roundHalfToEven, PAUSE_DEFAULT_MS, PAUSE_MAX_MS } from './longformParse
|
||||
// [voice:[^\]\[]*] (empty [voice:] → default; a nested `[` → no match).
|
||||
// ReDoS-safe: the unit's `(?:\s*(ms|s))?` is zero-width without a unit, so no
|
||||
// two `\s*` overlap on the same whitespace run (mirrors PAUSE_RE).
|
||||
const TOKEN_RE = /\[(?:\s*pause(?:\s+(\d+(?:\.\d+)?)(?:\s*(ms|s))?)?\s*|voice:\s*([^\]\[]*))\]/gi;
|
||||
const TOKEN_RE = /\[(?:\s*pause(?:\s+(\d+(?:\.\d+)?)(?:\s*(ms|s))?)?\s*|voice:\s*([^\][]*))\]/gi;
|
||||
|
||||
// Resolve a parsed (number, unit) pause to clamped ms — mirrors text.py:_pause_ms
|
||||
// (banker's rounding via the shared roundHalfToEven so highlight == render).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// Stub Tauri globals before import so isTauri is false
|
||||
delete globalThis.window?.__TAURI_INTERNALS__;
|
||||
|
||||
Reference in New Issue
Block a user