diff --git a/.github/workflows/gitweb-build.yml b/.github/workflows/gitweb-build.yml index a13681e..9639f27 100644 --- a/.github/workflows/gitweb-build.yml +++ b/.github/workflows/gitweb-build.yml @@ -68,6 +68,11 @@ jobs: - name: Install dependencies run: npm ci + # oxlint fails only on errors (real bugs); warnings are listed in the log + # and stay non-blocking until the backlog is cleared — see .oxlintrc.json. + - name: Lint + run: npm run lint + - name: Typecheck run: npm run typecheck diff --git a/GitWeb/.oxlintrc.json b/GitWeb/.oxlintrc.json new file mode 100644 index 0000000..6d7516b --- /dev/null +++ b/GitWeb/.oxlintrc.json @@ -0,0 +1,55 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "react", "jsx-a11y", "import", "unicorn"], + "categories": { + "correctness": "error", + "suspicious": "warn" + }, + "rules": { + "react/rules-of-hooks": "error", + "react/exhaustive-deps": "warn", + + "react/react-in-jsx-scope": "off", + "jsx-a11y/prefer-tag-over-role": "off", + "unicorn/no-array-sort": "off", + "unicorn/no-array-reverse": "off", + "unicorn/consistent-function-scoping": "off", + "eslint/no-shadow": "off", + "eslint/no-underscore-dangle": "off", + "import/no-unassigned-import": "off", + + "react/set-state-in-effect": "warn", + "react/refs": "warn", + "react/purity": "warn", + // Only meaningful under the React Compiler, which this project does not use. + "react/incompatible-library": "off", + "react/preserve-manual-memoization": "off", + "react/exhaustive-effect-dependencies": "warn", + + "jsx-a11y/no-noninteractive-element-interactions": "warn", + "jsx-a11y/click-events-have-key-events": "warn", + "jsx-a11y/no-noninteractive-element-to-interactive-role": [ + "warn", + { + "ul": ["listbox", "menu", "menubar", "radiogroup", "tablist", "tree", "treegrid"], + "ol": ["listbox", "menu", "menubar", "radiogroup", "tablist", "tree", "treegrid"], + "li": ["menuitem", "menuitemradio", "menuitemcheckbox", "option", "row", "tab", "treeitem"], + "table": ["grid"], + "td": ["gridcell"], + "fieldset": ["radiogroup", "presentation"] + } + ], + "jsx-a11y/interactive-supports-focus": "warn", + "jsx-a11y/control-has-associated-label": "warn", + "jsx-a11y/aria-role": "warn", + "jsx-a11y/label-has-associated-control": ["warn", { "depth": 3 }], + "jsx-a11y/no-noninteractive-tabindex": "warn", + "jsx-a11y/no-autofocus": "warn" + }, + "env": { + "browser": true, + "node": true, + "es2024": true + }, + "ignorePatterns": ["out/**", "dist/**", "node_modules/**"] +} diff --git a/GitWeb/package-lock.json b/GitWeb/package-lock.json index e933a8a..5808467 100644 --- a/GitWeb/package-lock.json +++ b/GitWeb/package-lock.json @@ -33,6 +33,7 @@ "electron": "^45.3.2", "electron-builder": "^26.18.0", "electron-vite": "^6.0.0", + "oxlint": "^1.93.0", "typescript": "^7.0.2", "vite": "^8.3.8", "vitest": "^5.1.0" @@ -833,6 +834,353 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.93.0.tgz", + "integrity": "sha512-KKw70pbD5Pvq3G2lJ+WQ71XPMjCmg37rkSe2+mDqCRP3PoTSdDMuEi4UOnE8JBtyNM7bek61EC2s0Jt7S7u+Sg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.93.0.tgz", + "integrity": "sha512-fvr1fTUZGelwRxKDt64pf28aL1WaWgviwz3X+E/FcRYvrlEahH0NECRz/c1SY+K+KWmZk5uAf6h1kQnyBkp/2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.93.0.tgz", + "integrity": "sha512-VdvSN9JRf4Es6ZzoAdvEeh8tgKdw8L4H82WmA8MMPVfPdZGCwE/nI27Z1HrRHYQhPxOh4kmnxQEioSUFrOvDaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.93.0.tgz", + "integrity": "sha512-QR1V890Ml0YGRG0F/iV0K9igG+pOhe+VWd+aZnxY849rRHZf3F2gEoQks5+GVHf105X9Mh/6HdShTP5juekG2g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.93.0.tgz", + "integrity": "sha512-jDfIbi30zZaKi+rTvig3sge+Xhi8pFFSgHxb0jzovc4qh8ETm/irKV9c7N/JsuwnYc5sbvf4IC8w1vQQ0IL+BA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.93.0.tgz", + "integrity": "sha512-qtxZ714ND/koFgVH00QgxlzZiIbRPmguaPhnHRU5DFFtrtEdOrSnZD4U/H2AzXrXkZ59eQbMBnJwbum5XohrIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.93.0.tgz", + "integrity": "sha512-5pJ9NjMvH7WMUqXB6bTTOSemBm39w2cod27Hu3+Yom/GhbTeNXlYcpDx1o4CgBP6fhp0YHG66TNdVaUm6sHjsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.93.0.tgz", + "integrity": "sha512-pRf+7jtbt2fHE23SX7qBxRgnBZicUgsoCK1PVIjnOk+t3a6UBkpt/GrKI99WlxsKUxftz/5nv/kfKvScmYywkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.93.0.tgz", + "integrity": "sha512-zM8Fd/K8sqGvsBcJBZ3oVZ3ctGWbszA08IaGt5gFFF5RcunppAI/ZnAXbouABvnWeJ//S44KKlfPZnDDbeiphg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.93.0.tgz", + "integrity": "sha512-7qKCnvhZKDXJG2pdbLazd2hw4FxjJHbZOFrrbzyy5PdHfPLH9vnNUa1le01A8uItZuYKPBPncH+HCoEiFT4SgA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.93.0.tgz", + "integrity": "sha512-2pgulxC6G8V5wE750NJE/fRkf9aO6i6yoj10wyaR2EdiNtan4OemsOtsQ0ZTXGdY8Q3IoTmlND3GYvjJsvikYg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.93.0.tgz", + "integrity": "sha512-yWsn6Gg2PnGG5jlyDz5+pHGiMTMiy4adaa4WRMzDgzB8efaShk1JMmTHIBSVLQMh0tYXRs4CN5uJCO79r4Mi4g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.93.0.tgz", + "integrity": "sha512-WCn5plwp/ezS2RDPQKp0/EXEVRr9/dDexUdRKmsMFqzNM5xBSZ1wbsi3PuMk1VfkMRnI5BuazCsziCWMXRDOlw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.93.0.tgz", + "integrity": "sha512-F2CGjb9LAkzH+IYTZSzd1klQ1vk5z6Utjax64WeLByCkYXbVDbsun+Oy2hLfW2MDSKCvW+jwy/9ld0EPgdeu+A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.93.0.tgz", + "integrity": "sha512-plf5DE2objcG8Tf7mXRuRa8tT8rzDkpjqXQtKoGsKHWkpgsialbON1+cCtoSEUTVbQUjXvuAm8vV9+3rR0yCBw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.93.0.tgz", + "integrity": "sha512-Kp2yJrKvoTe9FfKdO/xwLNUbs2bYTMKx0+aoQD1s0h/Tx0kSgtHSdGmrk8tTKdluosjFbo88qYRIaNIX43AfFw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.93.0.tgz", + "integrity": "sha512-vigjcHCFsR6KjnWTEVfaCxcMYapEuaK9FZa1MpewwQ1S+Xud3v0okfkj1RySsD6RF1NRHIjSlQSalTnKIaj3nA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.93.0.tgz", + "integrity": "sha512-0Ot3BEe/AAr2/uQhe55r8CmPlperYk2mQmxdutOru7oU0kir6IiEVHb1TG3GORRlfra97lPHVDLhgmihiKzLrg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.93.0.tgz", + "integrity": "sha512-ZzzIIVs/Qkm8trUWYKF0OYub5ALlv50/3gsEZbKAvWgz+yNJX6uRmQ8ySPXU2Q0/Y9m4JCOjhpmC8XUYbPHX5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@peculiar/asn1-schema": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.11.0.tgz", @@ -6073,6 +6421,55 @@ "wrappy": "1" } }, + "node_modules/oxlint": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.93.0.tgz", + "integrity": "sha512-19JMsQFX+YvEbcUTFQxjTwhvs0Ibg0D6CFneSLgpwnACWcY/6LjhpGpkPodh0T5aIT1pjMTLlYaeBFzaVgugTA==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.93.0", + "@oxlint/binding-android-arm64": "1.93.0", + "@oxlint/binding-darwin-arm64": "1.93.0", + "@oxlint/binding-darwin-x64": "1.93.0", + "@oxlint/binding-freebsd-x64": "1.93.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.93.0", + "@oxlint/binding-linux-arm-musleabihf": "1.93.0", + "@oxlint/binding-linux-arm64-gnu": "1.93.0", + "@oxlint/binding-linux-arm64-musl": "1.93.0", + "@oxlint/binding-linux-ppc64-gnu": "1.93.0", + "@oxlint/binding-linux-riscv64-gnu": "1.93.0", + "@oxlint/binding-linux-riscv64-musl": "1.93.0", + "@oxlint/binding-linux-s390x-gnu": "1.93.0", + "@oxlint/binding-linux-x64-gnu": "1.93.0", + "@oxlint/binding-linux-x64-musl": "1.93.0", + "@oxlint/binding-openharmony-arm64": "1.93.0", + "@oxlint/binding-win32-arm64-msvc": "1.93.0", + "@oxlint/binding-win32-ia32-msvc": "1.93.0", + "@oxlint/binding-win32-x64-msvc": "1.93.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2005", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", diff --git a/GitWeb/package.json b/GitWeb/package.json index d69fc86..0ea8677 100644 --- a/GitWeb/package.json +++ b/GitWeb/package.json @@ -15,6 +15,7 @@ "dev": "electron-vite dev", "build": "electron-vite build", "start": "electron-vite preview", + "lint": "oxlint", "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", "test": "vitest run", "build:win": "npm run build && electron-builder --win", @@ -57,6 +58,7 @@ "electron": "^45.3.2", "electron-builder": "^26.18.0", "electron-vite": "^6.0.0", + "oxlint": "^1.93.0", "typescript": "^7.0.2", "vite": "^8.3.8", "vitest": "^5.1.0" diff --git a/GitWeb/src/main/credentials.ts b/GitWeb/src/main/credentials.ts index 73e6f6e..6df2a5e 100644 --- a/GitWeb/src/main/credentials.ts +++ b/GitWeb/src/main/credentials.ts @@ -83,7 +83,8 @@ export async function getSecret(key: string): Promise { decrypted = await safeStorage.decryptStringAsync(Buffer.from(blob, 'base64')) } catch (err) { throw new Error( - `Failed to decrypt secret "${key}": ${err instanceof Error ? err.message : String(err)}` + `Failed to decrypt secret "${key}": ${err instanceof Error ? err.message : String(err)}`, + { cause: err } ) } // The OS key rotated (or a stronger one exists): store the secret again diff --git a/GitWeb/src/main/gitProcess.ts b/GitWeb/src/main/gitProcess.ts index 2cf5640..507a408 100644 --- a/GitWeb/src/main/gitProcess.ts +++ b/GitWeb/src/main/gitProcess.ts @@ -464,7 +464,7 @@ export function parseStatusBranchHeader(header: string): { } { let branch: string | null = null let upstream: string | null = null - if (!/^## HEAD \(no branch\)/.test(header)) { + if (!header.startsWith('## HEAD (no branch)')) { const headerMatch = header.match(/^## (?:No commits yet on )?(.+?)(?:\.\.\.(\S+))?(?:\s|$)/) if (headerMatch) { branch = headerMatch[1]?.trim() || null @@ -1296,6 +1296,8 @@ export async function getBranchIntegration( // A few at a time: a repo with dozens of branches would otherwise spawn // several git processes per branch all at once. + // Pre-sized on purpose: parallel workers write their result into its own slot. + // oxlint-disable-next-line unicorn/no-new-array const branches: BranchIntegration[] = new Array(candidates.length) let next = 0 const worker = async (): Promise => { @@ -1390,7 +1392,7 @@ export async function deleteIntegratedBranches( const protectedNames = new Set(report.protectedNames) const results: BranchDeletionResult[] = [] - for (const name of [...new Set(names.map((n) => n.trim()).filter(Boolean))]) { + for (const name of new Set(names.map((n) => n.trim()).filter(Boolean))) { const info = local.get(name) let reason: string | undefined if (protectedNames.has(name.toLowerCase())) reason = t.protectedBranch(name) diff --git a/GitWeb/src/main/httpFetch.ts b/GitWeb/src/main/httpFetch.ts index f1de951..c93fdb0 100644 --- a/GitWeb/src/main/httpFetch.ts +++ b/GitWeb/src/main/httpFetch.ts @@ -224,7 +224,7 @@ export async function httpFetch( // Checked on the signal itself: net.fetch reports both a timeout and a // user cancel as a plain AbortError, and only the latter is "cancelled". if (timeoutSignal.aborted && !init?.signal?.aborted) { - throw new Error(tMain().network.requestTimedOut(originLabel(url), timeoutMs / 1000)) + throw new Error(tMain().network.requestTimedOut(originLabel(url), timeoutMs / 1000), { cause: err }) } if (err instanceof Error && err.name === 'AbortError') throw err lastError = err diff --git a/GitWeb/src/main/publishRepository.ts b/GitWeb/src/main/publishRepository.ts index 5f3c588..4e1df88 100644 --- a/GitWeb/src/main/publishRepository.ts +++ b/GitWeb/src/main/publishRepository.ts @@ -43,7 +43,7 @@ export async function publishRepository(cwd: string, input: PublishRepositoryInp await pushBranch(cwd, branch, true) } catch (err) { const reason = (err instanceof Error ? err.message : String(err)).split('\n')[0].trim() - throw new Error(t.pushFailed(repo.htmlUrl, reason.endsWith('.') ? reason : `${reason}.`)) + throw new Error(t.pushFailed(repo.htmlUrl, reason.endsWith('.') ? reason : `${reason}.`), { cause: err }) } return { fullName: repo.fullName, htmlUrl: repo.htmlUrl, branch } } diff --git a/GitWeb/src/main/recentRepositories.ts b/GitWeb/src/main/recentRepositories.ts index 0c2abf4..2d0e5ff 100644 --- a/GitWeb/src/main/recentRepositories.ts +++ b/GitWeb/src/main/recentRepositories.ts @@ -2,7 +2,7 @@ import { existsSync } from 'fs' import { randomUUID } from 'crypto' import { basename, isAbsolute, relative, resolve } from 'path' import { readJson, updateJson } from './appDataStore' -import type { RecentReposData, RecentRepository, RepoGroup } from '../shared/git' +import type { RecentReposData, RepoGroup } from '../shared/git' const FILE_NAME = 'recent-repos.json' diff --git a/GitWeb/src/renderer/src/components/ActivitySky.tsx b/GitWeb/src/renderer/src/components/ActivitySky.tsx index 6312bb7..1898c68 100644 --- a/GitWeb/src/renderer/src/components/ActivitySky.tsx +++ b/GitWeb/src/renderer/src/components/ActivitySky.tsx @@ -50,6 +50,7 @@ function ActivitySky({ items, needsYou, newStarsSince, activeKey, onActivate, ch const sky = useMemo(() => layoutSky(items, width, HEIGHT), [items, width]) const active = sky.points.find((point) => point.key === activeKey) + // oxlint-disable-next-line react/purity -- the sky reads the clock on every render so ages and the "now" mark stay current const now = Date.now() return ( diff --git a/GitWeb/src/renderer/src/components/AiSettingsForm.tsx b/GitWeb/src/renderer/src/components/AiSettingsForm.tsx index d087f0d..f2c7df6 100644 --- a/GitWeb/src/renderer/src/components/AiSettingsForm.tsx +++ b/GitWeb/src/renderer/src/components/AiSettingsForm.tsx @@ -259,13 +259,16 @@ function AiSettingsForm(): JSX.Element | null { }, [settings, loadSettings]) useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- seeds the editable draft once settings arrive from the store if (settings) setDraft((current) => current ?? settings) }, [settings]) + // Keyed on the provider alone: the key reloads when it changes, not on every edit to the draft. + const activeProvider = draft?.activeProvider useEffect(() => { - if (!draft) return - window.api.secrets.get(credentialKey(draft.activeProvider)).then((key) => setApiKeyDraft(key ?? '')) - }, [draft?.activeProvider]) + if (!activeProvider) return + window.api.secrets.get(credentialKey(activeProvider)).then((key) => setApiKeyDraft(key ?? '')) + }, [activeProvider]) if (!draft) return null diff --git a/GitWeb/src/renderer/src/components/CloneDestinationField.tsx b/GitWeb/src/renderer/src/components/CloneDestinationField.tsx index 92e144a..a6ae235 100644 --- a/GitWeb/src/renderer/src/components/CloneDestinationField.tsx +++ b/GitWeb/src/renderer/src/components/CloneDestinationField.tsx @@ -37,6 +37,7 @@ function CloneDestinationField({ useEffect(() => { if (!open) return const selectedIndex = baseFolders.findIndex((folder) => pathsEqual(folder, selectedBase)) + // oxlint-disable-next-line react/set-state-in-effect -- re-highlights the chosen folder each time the list opens setHighlighted(Math.max(0, selectedIndex)) }, [open, baseFolders, selectedBase]) diff --git a/GitWeb/src/renderer/src/components/CloneModal.tsx b/GitWeb/src/renderer/src/components/CloneModal.tsx index 7965767..4d2f123 100644 --- a/GitWeb/src/renderer/src/components/CloneModal.tsx +++ b/GitWeb/src/renderer/src/components/CloneModal.tsx @@ -611,7 +611,14 @@ function CloneModal({ open, onClose }: CloneModalProps): JSX.Element | null { ] return ( -
+
{ + e.stopPropagation() + if (e.target === e.currentTarget) onClose() + }} + >
e.stopPropagation()} > {/* Header: title, method switch and — on the GitHub tab — whose repos these are. */}
diff --git a/GitWeb/src/renderer/src/components/Combobox.tsx b/GitWeb/src/renderer/src/components/Combobox.tsx index 397923c..7dfe053 100644 --- a/GitWeb/src/renderer/src/components/Combobox.tsx +++ b/GitWeb/src/renderer/src/components/Combobox.tsx @@ -97,9 +97,12 @@ function Combobox({ if (open) inputRef.current?.focus() }, [open]) - useEffect(() => { + // A new search starts the highlight over at the top, decided during render. + const [searchShown, setSearchShown] = useState(search) + if (searchShown !== search) { + setSearchShown(search) setHighlighted(0) - }, [search]) + } function handleKeyDown(e: React.KeyboardEvent): void { if (e.key === 'Escape') { diff --git a/GitWeb/src/renderer/src/components/ConfirmDialog.tsx b/GitWeb/src/renderer/src/components/ConfirmDialog.tsx index be2aa9c..625e455 100644 --- a/GitWeb/src/renderer/src/components/ConfirmDialog.tsx +++ b/GitWeb/src/renderer/src/components/ConfirmDialog.tsx @@ -41,7 +41,14 @@ function ConfirmDialog({ if (!open) return null return createPortal( -
+
{ + e.stopPropagation() + if (e.target === e.currentTarget) onCancel() + }} + >
e.stopPropagation()} >

{title} @@ -119,6 +125,7 @@ export function PromptDialog({ const errorId = useId() useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- resets the typed confirmation each time the dialog opens setValue(open ? initialValue : '') if (open && initialValue) requestAnimationFrame(() => inputRef.current?.select()) }, [open, initialValue]) @@ -137,7 +144,14 @@ export function PromptDialog({ } return createPortal( -
+
{ + e.stopPropagation() + if (e.target === e.currentTarget) onCancel() + }} + >
e.stopPropagation()} >

{title} diff --git a/GitWeb/src/renderer/src/components/CreateRepositoryDialog.tsx b/GitWeb/src/renderer/src/components/CreateRepositoryDialog.tsx index b46cd6b..70fafe7 100644 --- a/GitWeb/src/renderer/src/components/CreateRepositoryDialog.tsx +++ b/GitWeb/src/renderer/src/components/CreateRepositoryDialog.tsx @@ -32,6 +32,7 @@ export default function CreateRepositoryDialog({ useEffect(() => { if (!open) return + // oxlint-disable-next-line react/set-state-in-effect -- fresh form each time the dialog opens setPath(null) setInitialCommit(true) setPublish(false) @@ -61,14 +62,20 @@ export default function CreateRepositoryDialog({ ] return createPortal( -
+
{ + e.stopPropagation() + if (e.target === e.currentTarget) onClose() + }} + >
e.stopPropagation()} >
diff --git a/GitWeb/src/renderer/src/components/DeleteBranchDialog.tsx b/GitWeb/src/renderer/src/components/DeleteBranchDialog.tsx index 1e2227c..fb3dc90 100644 --- a/GitWeb/src/renderer/src/components/DeleteBranchDialog.tsx +++ b/GitWeb/src/renderer/src/components/DeleteBranchDialog.tsx @@ -34,6 +34,7 @@ export default function DeleteBranchDialog({ name, repoPath, onCancel, onDelete, useEffect(() => { if (!name || !repoPath) return let current = true + // oxlint-disable-next-line react/set-state-in-effect -- shows loading while the branch check runs over IPC setState({ status: 'loading' }) window.api.git .branchDeleteCheck(repoPath, name) @@ -149,7 +150,14 @@ export default function DeleteBranchDialog({ name, repoPath, onCancel, onDelete, } return createPortal( -
+
{ + e.stopPropagation() + if (e.target === e.currentTarget) onCancel() + }} + >
e.stopPropagation()} >
diff --git a/GitWeb/src/renderer/src/components/DiffViewer.tsx b/GitWeb/src/renderer/src/components/DiffViewer.tsx index cc9617f..6f39e48 100644 --- a/GitWeb/src/renderer/src/components/DiffViewer.tsx +++ b/GitWeb/src/renderer/src/components/DiffViewer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { FileDiff, Plus } from 'lucide-react' import { annotateDiff, parseFileDiff, type AnnotatedDiffLine } from '../../../shared/patch' @@ -214,9 +214,11 @@ function DiffViewer({ // The viewer keeps its place in the tree when another file is selected, so the // confirmation bar has to be dropped with the diff it belonged to — otherwise // confirming it would discard a hunk of the file the user just left. - useEffect(() => { + const [diffShown, setDiffShown] = useState(diffText) + if (diffShown !== diffText) { + setDiffShown(diffText) setConfirmDiscard(null) - }, [diffText]) + } const parsed = useMemo(() => (hunkMode !== 'none' ? parseFileDiff(diffText) : null), [diffText, hunkMode]) const hunkRows = useMemo(() => { diff --git a/GitWeb/src/renderer/src/components/FileContextMenu.tsx b/GitWeb/src/renderer/src/components/FileContextMenu.tsx index e03da85..1cffef4 100644 --- a/GitWeb/src/renderer/src/components/FileContextMenu.tsx +++ b/GitWeb/src/renderer/src/components/FileContextMenu.tsx @@ -57,7 +57,7 @@ const PANEL = 'rounded-md border border-default-border bg-obsidian-panel p-1 shadow-floating' function Separator(): JSX.Element { - return
+ return
} function FileContextMenu({ @@ -86,6 +86,7 @@ function FileContextMenu({ useLayoutEffect(() => { if (!open) { + // oxlint-disable-next-line react/set-state-in-effect -- the submenu closes with the menu; the rest of this layout effect measures it setFolderOpen(false) return } @@ -263,6 +264,7 @@ function FileContextMenu({ {folderOpen && (
+
{ + e.stopPropagation() + if (e.target === e.currentTarget) handleClose() + }} + >
e.stopPropagation()} >

diff --git a/GitWeb/src/renderer/src/components/InvestigationRunGroup.tsx b/GitWeb/src/renderer/src/components/InvestigationRunGroup.tsx index c238790..1173a36 100644 --- a/GitWeb/src/renderer/src/components/InvestigationRunGroup.tsx +++ b/GitWeb/src/renderer/src/components/InvestigationRunGroup.tsx @@ -65,6 +65,7 @@ function InvestigationRunGroup({ const showDetail = !canShowProblems useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- opens the problem list once the run completes; the user can still collapse it if (canShowProblems) setShowProblems(true) }, [canShowProblems]) diff --git a/GitWeb/src/renderer/src/components/PatchPreviewDialog.tsx b/GitWeb/src/renderer/src/components/PatchPreviewDialog.tsx index ed5ce5f..5355624 100644 --- a/GitWeb/src/renderer/src/components/PatchPreviewDialog.tsx +++ b/GitWeb/src/renderer/src/components/PatchPreviewDialog.tsx @@ -34,7 +34,14 @@ function PatchPreviewDialog({ if (!open) return null return createPortal( -
+
{ + e.stopPropagation() + if (e.target === e.currentTarget) onCancel() + }} + >
e.stopPropagation()} >

{title} diff --git a/GitWeb/src/renderer/src/components/ProtectedBranchDialog.tsx b/GitWeb/src/renderer/src/components/ProtectedBranchDialog.tsx index b4e44a3..b85868f 100644 --- a/GitWeb/src/renderer/src/components/ProtectedBranchDialog.tsx +++ b/GitWeb/src/renderer/src/components/ProtectedBranchDialog.tsx @@ -43,6 +43,7 @@ export default function ProtectedBranchDialog({ const open = block !== null useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- clears the typed name when the dialog closes if (!open) setName('') }, [open]) @@ -101,7 +102,14 @@ export default function ProtectedBranchDialog({ ] return createPortal( -
+
{ + e.stopPropagation() + if (e.target === e.currentTarget) onCancel() + }} + >
e.stopPropagation()} >
diff --git a/GitWeb/src/renderer/src/components/PublishRepositoryDialog.tsx b/GitWeb/src/renderer/src/components/PublishRepositoryDialog.tsx index 0a71339..4594ad0 100644 --- a/GitWeb/src/renderer/src/components/PublishRepositoryDialog.tsx +++ b/GitWeb/src/renderer/src/components/PublishRepositoryDialog.tsx @@ -64,6 +64,7 @@ export default function PublishRepositoryDialog({ open, onClose }: { open: boole // Fresh form on every open, named after the folder. useEffect(() => { if (!open || !repoPath) return + // oxlint-disable-next-line react/set-state-in-effect -- fresh form, named after the folder, each time the dialog opens setName(sanitizeRepositoryName(basename(repoPath))) setDescription('') setIsPrivate(true) @@ -146,7 +147,10 @@ export default function PublishRepositoryDialog({ open, onClose }: { open: boole
{ + e.stopPropagation() + if (!publishing && e.target === e.currentTarget) onClose() + }} >
e.stopPropagation()} >
diff --git a/GitWeb/src/renderer/src/components/RebasePlannerDialog.tsx b/GitWeb/src/renderer/src/components/RebasePlannerDialog.tsx index df72a92..c4d72e5 100644 --- a/GitWeb/src/renderer/src/components/RebasePlannerDialog.tsx +++ b/GitWeb/src/renderer/src/components/RebasePlannerDialog.tsx @@ -56,6 +56,7 @@ function RebasePlannerDialog(): JSX.Element | null { useEffect(() => { if (!open || !repoPath) return let cancelled = false + // oxlint-disable-next-line react/set-state-in-effect -- drops the previous plan while the new one loads over IPC setInfo(null) setDone(null) window.api.git.localRebaseInfo(repoPath).then((result) => { @@ -132,7 +133,14 @@ function RebasePlannerDialog(): JSX.Element | null { } return createPortal( -
+
{ + e.stopPropagation() + if (e.target === e.currentTarget) close() + }} + >
e.stopPropagation()} >
@@ -174,6 +181,8 @@ function RebasePlannerDialog(): JSX.Element | null { {rows.map((row, index) => { const isOver = overIndex === index && dragIndex !== null && dragIndex !== index return ( + // Dragging is the pointer shortcut; the row's move buttons do the same from the keyboard. + // oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
  • 3 ? 'flex-wrap' : ''}`}> - {visible.map((ref) => { + {visible.map((chip) => { const kindClass = - ref.kind === 'tag' + chip.kind === 'tag' ? 'ref-chip-tag' - : ref.kind === 'remote' + : chip.kind === 'remote' ? 'ref-chip-remote' - : ref.kind === 'head' + : chip.kind === 'head' ? 'ref-chip-head' - : ref.current + : chip.current ? 'ref-chip-current' : 'ref-chip-local' - const Icon = ref.kind === 'tag' ? Tag : ref.kind === 'remote' ? Cloud : GitBranch + const Icon = chip.kind === 'tag' ? Tag : chip.kind === 'remote' ? Cloud : GitBranch return ( - + - {ref.name} - {ref.kind === 'local' && hasRemoteTwin(ref.name) && } - {ref.current && ahead > 0 && ( + {chip.name} + {chip.kind === 'local' && hasRemoteTwin(chip.name) && } + {chip.current && ahead > 0 && ( {ahead} )} - {ref.current && behind > 0 && ( + {chip.current && behind > 0 && ( {behind} diff --git a/GitWeb/src/renderer/src/components/ReorganizeReposDialog.tsx b/GitWeb/src/renderer/src/components/ReorganizeReposDialog.tsx index a0f2fd4..a158202 100644 --- a/GitWeb/src/renderer/src/components/ReorganizeReposDialog.tsx +++ b/GitWeb/src/renderer/src/components/ReorganizeReposDialog.tsx @@ -114,14 +114,20 @@ export default function ReorganizeReposDialog({ open, onClose }: { open: boolean const canMove = Boolean(plan && plan.moves.length > 0) && !moving return createPortal( -
    +
    { + e.stopPropagation() + if (e.target === e.currentTarget) close() + }} + >
    e.stopPropagation()} >
    diff --git a/GitWeb/src/renderer/src/components/RepoGroupSection.tsx b/GitWeb/src/renderer/src/components/RepoGroupSection.tsx index 728cd80..c486134 100644 --- a/GitWeb/src/renderer/src/components/RepoGroupSection.tsx +++ b/GitWeb/src/renderer/src/components/RepoGroupSection.tsx @@ -43,6 +43,7 @@ function RepoGroupSection({ // parent learns its id — so the initial state alone never saw // startEditing=true and "Nova pasta" landed silently at the bottom. useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- enters edit mode when the parent marks a folder it just created if (startEditing) setEditing(true) }, [startEditing]) diff --git a/GitWeb/src/renderer/src/components/RepoInsights.tsx b/GitWeb/src/renderer/src/components/RepoInsights.tsx index 2eb85e8..4ff9045 100644 --- a/GitWeb/src/renderer/src/components/RepoInsights.tsx +++ b/GitWeb/src/renderer/src/components/RepoInsights.tsx @@ -63,6 +63,8 @@ export function ChurnChart({ points, bins }: { points: ChurnPoint[]; bins: Perio const compact = useCompact() const fmt = useFormatters() const [active, setActive] = useState(null) + // Measured on hover, not read from the ref while rendering. + const [wrapWidth, setWrapWidth] = useState(0) const wrapRef = useRef(null) const series = useMemo(() => { @@ -104,7 +106,10 @@ export function ChurnChart({ points, bins }: { points: ChurnPoint[]; bins: Perio
    setActive(i)} + onPointerEnter={() => { + setActive(i) + setWrapWidth(wrapRef.current?.clientWidth ?? 0) + }} >
    2 && {bins[Math.floor(bins.length / 2)]?.label}} {bins[bins.length - 1]?.label}
    - {active !== null && bins[active] && wrapRef.current && ( - + {active !== null && bins[active] && wrapWidth > 0 && ( +

    {bins[active].title}

    +{fmt.number(series[active].additions)}{' '} diff --git a/GitWeb/src/renderer/src/components/RepoPickerModal.tsx b/GitWeb/src/renderer/src/components/RepoPickerModal.tsx index 7e701ef..d6a2437 100644 --- a/GitWeb/src/renderer/src/components/RepoPickerModal.tsx +++ b/GitWeb/src/renderer/src/components/RepoPickerModal.tsx @@ -20,6 +20,7 @@ function RepoPickerModal({ open, repoPaths, onConfirm, onCancel }: RepoPickerMod useFocusTrap(panelRef, open, { onEscape: onCancel }) useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- preselects every repository found, each time the picker opens if (open) setSelected(new Set(repoPaths)) }, [open, repoPaths]) @@ -37,14 +38,20 @@ function RepoPickerModal({ open, repoPaths, onConfirm, onCancel }: RepoPickerMod const allSelected = selected.size === repoPaths.length return ( -

    +
    { + e.stopPropagation() + if (e.target === e.currentTarget) onCancel() + }} + >
    e.stopPropagation()} >

    {t.reposFound(repoPaths.length)} diff --git a/GitWeb/src/renderer/src/components/SensitiveFilesDialog.tsx b/GitWeb/src/renderer/src/components/SensitiveFilesDialog.tsx index c4b6b44..42d385b 100644 --- a/GitWeb/src/renderer/src/components/SensitiveFilesDialog.tsx +++ b/GitWeb/src/renderer/src/components/SensitiveFilesDialog.tsx @@ -54,7 +54,14 @@ export default function SensitiveFilesDialog({ const Icon = blocked ? ShieldBan : ShieldAlert return createPortal( -
    +
    { + e.stopPropagation() + if (e.target === e.currentTarget) onCancel() + }} + >
    e.stopPropagation()} >
    )} -
    +
    {visibleLocal.length === 0 && visibleRemote.length === 0 && (

    {t.noBranchFound}

    )} @@ -505,6 +505,7 @@ function SyncCard({ compact }: { compact: boolean }): JSX.Element { return found }, [currentRepoPath]) useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- asks main over IPC whether an origin exists once the branch has no upstream if (noUpstream) void checkOrigin() }, [noUpstream, checkOrigin]) const needsRepository = noUpstream && !hasOrigin @@ -514,6 +515,7 @@ function SyncCard({ compact }: { compact: boolean }): JSX.Element { useEffect(() => { if (!publishPrompt || !currentRepoPath) return clearPublishPrompt() + // oxlint-disable-next-line react/set-state-in-effect -- opens the publish dialog the start screen asked for, then clears the request setPublishOpen(true) }, [publishPrompt, currentRepoPath, clearPublishPrompt]) // Re-checked on click and when the dialog closes: an origin added outside diff --git a/GitWeb/src/renderer/src/components/UserSearchField.tsx b/GitWeb/src/renderer/src/components/UserSearchField.tsx index 331555f..6d31869 100644 --- a/GitWeb/src/renderer/src/components/UserSearchField.tsx +++ b/GitWeb/src/renderer/src/components/UserSearchField.tsx @@ -46,6 +46,7 @@ function UserSearchField({ id, host, value, onChange, statusOf, invalid, describ const showList = open && searchable useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- resets results as the query changes; the search itself runs over IPC setHighlighted(-1) if (!open || !searchable) { setResults([]) diff --git a/GitWeb/src/renderer/src/global-jsx.d.ts b/GitWeb/src/renderer/src/global-jsx.d.ts index ecc466a..8567d1d 100644 --- a/GitWeb/src/renderer/src/global-jsx.d.ts +++ b/GitWeb/src/renderer/src/global-jsx.d.ts @@ -15,4 +15,6 @@ declare global { } } +// Required: it makes this file a module, so `declare global` is allowed. +// oxlint-disable-next-line unicorn/require-module-specifiers export {} diff --git a/GitWeb/src/renderer/src/lib/ai/commitInvestigation.ts b/GitWeb/src/renderer/src/lib/ai/commitInvestigation.ts index aefd458..c85b663 100644 --- a/GitWeb/src/renderer/src/lib/ai/commitInvestigation.ts +++ b/GitWeb/src/renderer/src/lib/ai/commitInvestigation.ts @@ -462,7 +462,7 @@ export async function investigateCommitWithWorkers(input: CommitInvestigationInp ) const chunk = chunks[index] findings.push(...parseFindings(response.content).map((finding) => ({ ...finding, chunk }))) - } catch (error) { + } catch { if (input.isCancelled()) return { path: file.path, coverage: 'partial', findings } coverage = 'partial' } finally { diff --git a/GitWeb/src/renderer/src/lib/ai/proposeFix.ts b/GitWeb/src/renderer/src/lib/ai/proposeFix.ts index cc89d91..0abb5b7 100644 --- a/GitWeb/src/renderer/src/lib/ai/proposeFix.ts +++ b/GitWeb/src/renderer/src/lib/ai/proposeFix.ts @@ -5,7 +5,6 @@ import { proposeFixUserMessage, type AiLanguage } from '../../../../shared/aiPrompts' -import type { AiSettings } from '../../../../shared/ai' import { sendChatRequest } from './providers' import { unifiedPreview } from '../../../../shared/patch' import type { ResolvedAiConfig } from '../../../../shared/ai' diff --git a/GitWeb/src/renderer/src/lib/ai/tools.ts b/GitWeb/src/renderer/src/lib/ai/tools.ts index 80c5468..d3b0b39 100644 --- a/GitWeb/src/renderer/src/lib/ai/tools.ts +++ b/GitWeb/src/renderer/src/lib/ai/tools.ts @@ -1248,7 +1248,7 @@ async function prepareToolCallInner(call: ToolCallRequest): Promise c.hash)) if (!validation.ok) throw new Error(`${describeRebaseProblem(validation.problem)}\n\n${listing}`) @@ -1585,7 +1585,8 @@ export async function executeToolCall(call: ToolCallRequest): Promise 0 ? t.pathMissingSimilar(a.path, formatFileList(similar, 20)) - : t.pathMissing(a.path) + : t.pathMissing(a.path), + { cause: err } ) } throw err diff --git a/GitWeb/src/renderer/src/lib/branchName.ts b/GitWeb/src/renderer/src/lib/branchName.ts index f27172e..d680e04 100644 --- a/GitWeb/src/renderer/src/lib/branchName.ts +++ b/GitWeb/src/renderer/src/lib/branchName.ts @@ -14,6 +14,7 @@ export function validateBranchName(name: string, existing: string[] = []): strin const t = getT().stores if (name.startsWith('-')) return t.branchStartsWithDash if (name === '@') return t.branchIsAt + // oxlint-disable-next-line no-control-regex -- git refuses control characters in ref names if (/[~^:?*[\\\x00-\x1f\x7f]/.test(name)) return t.branchControlChars if (name.includes('..')) return t.branchDoubleDot if (name.includes('@{')) return t.branchAtBrace diff --git a/GitWeb/src/renderer/src/lib/disintegrate.ts b/GitWeb/src/renderer/src/lib/disintegrate.ts index bc54670..89521b8 100644 --- a/GitWeb/src/renderer/src/lib/disintegrate.ts +++ b/GitWeb/src/renderer/src/lib/disintegrate.ts @@ -46,8 +46,8 @@ export async function captureDust(rect: DOMRect, options?: DustOptions): Promise function loadImage(src: string): Promise { return new Promise((resolve, reject) => { const image = new Image() - image.onload = () => resolve(image) - image.onerror = () => reject(new Error('Falha ao carregar a captura.')) + image.addEventListener('load', () => resolve(image), { once: true }) + image.addEventListener('error', () => reject(new Error('Falha ao carregar a captura.')), { once: true }) image.src = src }) } diff --git a/GitWeb/src/renderer/src/lib/useCloneProgress.ts b/GitWeb/src/renderer/src/lib/useCloneProgress.ts index 662b2c8..31e4fcf 100644 --- a/GitWeb/src/renderer/src/lib/useCloneProgress.ts +++ b/GitWeb/src/renderer/src/lib/useCloneProgress.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { CloneProgress } from '../../../shared/git' interface CloneProgressState { @@ -21,10 +21,12 @@ interface CloneProgressState { export function useCloneProgress(destination: string | null): CloneProgressState { const [progress, setProgress] = useState(null) const [stalledFor, setStalledFor] = useState(0) - const lastEventAt = useRef(Date.now()) + const lastEventAt = useRef(0) // Read inside the listener so the subscription survives destination changes. const watched = useRef(destination) - watched.current = destination + useLayoutEffect(() => { + watched.current = destination + }) useEffect(() => { return window.api.git.onCloneProgress((next) => { diff --git a/GitWeb/src/renderer/src/lib/useFocusTrap.ts b/GitWeb/src/renderer/src/lib/useFocusTrap.ts index 2fe8504..81a2a93 100644 --- a/GitWeb/src/renderer/src/lib/useFocusTrap.ts +++ b/GitWeb/src/renderer/src/lib/useFocusTrap.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useLayoutEffect, useRef } from 'react' import type { RefObject } from 'react' const FOCUSABLE = @@ -28,7 +28,9 @@ export function useFocusTrap( // keeps the effect from re-running mid-dialog and yanking focus back to the // initial element. const onEscapeRef = useRef(onEscape) - onEscapeRef.current = onEscape + useLayoutEffect(() => { + onEscapeRef.current = onEscape + }) useEffect(() => { if (!active) return diff --git a/GitWeb/src/renderer/src/store/aiStore.ts b/GitWeb/src/renderer/src/store/aiStore.ts index d6574c3..5946ba0 100644 --- a/GitWeb/src/renderer/src/store/aiStore.ts +++ b/GitWeb/src/renderer/src/store/aiStore.ts @@ -713,7 +713,6 @@ export const useAiStore = create((set, get) => ({ applyPendingFix: async () => { const pending = get().pendingFix const repoPath = useRepoStore.getState().currentRepoPath - const { settings, apiKey } = get() if (!pending || pending.status !== 'ready' || !repoPath) return const language = getLocale() set({ pendingFix: { ...pending, status: 'applying' }, isBusy: true, error: null }) diff --git a/GitWeb/src/renderer/src/store/repoStore.ts b/GitWeb/src/renderer/src/store/repoStore.ts index 02082e1..f1b2975 100644 --- a/GitWeb/src/renderer/src/store/repoStore.ts +++ b/GitWeb/src/renderer/src/store/repoStore.ts @@ -612,7 +612,6 @@ export const useRepoStore = create((set, get) => ({ window.api.git.remoteBranches(path).catch(() => [] as string[]) ]) if (generation !== repoOpenGeneration || get().currentRepoPath !== path) return - const local = new Set(branches.map((b) => b.name)) set({ branches, remoteBranches: remoteBranchesToOffer(remote, branches.map((b) => b.name)) }) } catch (err) { if (generation !== repoOpenGeneration || get().currentRepoPath !== path) return diff --git a/GitWeb/src/renderer/src/views/AboutView.tsx b/GitWeb/src/renderer/src/views/AboutView.tsx index 058e055..e6466e9 100644 --- a/GitWeb/src/renderer/src/views/AboutView.tsx +++ b/GitWeb/src/renderer/src/views/AboutView.tsx @@ -126,6 +126,7 @@ function AboutView(): JSX.Element { (updateStatus.state === 'downloading' && updateStatus.version) ) { const next = { version: updateStatus.version ?? '', notes: updateStatus.releaseNotes ?? null } + // oxlint-disable-next-line react/set-state-in-effect -- remembers the incoming version and notes across update events from main setTarget((prev) => (prev?.version === next.version && prev.notes === next.notes ? prev : next)) } }, [updateStatus]) diff --git a/GitWeb/src/renderer/src/views/AiChatView.tsx b/GitWeb/src/renderer/src/views/AiChatView.tsx index 3f2a071..6b2961c 100644 --- a/GitWeb/src/renderer/src/views/AiChatView.tsx +++ b/GitWeb/src/renderer/src/views/AiChatView.tsx @@ -228,13 +228,13 @@ function ToolCallCard({ messageId, uiCall }: { messageId: string; uiCall: import ) } -function ChatAvatar({ role }: { role: 'user' | 'assistant' }): JSX.Element { +function ChatAvatar({ speaker }: { speaker: 'user' | 'assistant' }): JSX.Element { const avatarUrl = useGithubAuthStore((s) => s.user?.avatarUrl) // A signed-in user sees their own GitHub photo here, the same identity the // top bar shows; the generic icon stays the fallback for signed-out sessions // and for a photo that fails to load. const [avatarFailed, setAvatarFailed] = useState(false) - const isUser = role === 'user' + const isUser = speaker === 'user' if (isUser && avatarUrl && !avatarFailed) { return ( @@ -266,7 +266,7 @@ function ThinkingBubble(): JSX.Element { const ui = useAiUi() return (
    - +
    {ui.chatThinking} @@ -367,7 +367,9 @@ function AiChatView(): JSX.Element { return () => cancelAnimationFrame(frame) } + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- runs when the ghost appears; dustAndSwap is a per-render helper, not a trigger useEffect(() => (ghost ? dustAndSwap(() => setGhost(null)) : undefined), [ghost]) + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- runs when the welcome dusts off; dustAndSwap is a per-render helper, not a trigger useEffect(() => (welcomeDusting ? dustAndSwap(() => setWelcomeDusting(false)) : undefined), [welcomeDusting]) // Only real approval gates count here. Read-only tools run without asking @@ -407,6 +409,7 @@ function AiChatView(): JSX.Element { } } bottomRef.current?.scrollIntoView({ behavior: 'auto' }) + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- these are the triggers for following the conversation to its end }, [messages, phase, lastMessage]) function handleChatScroll(): void { @@ -664,7 +667,7 @@ function AiChatView(): JSX.Element { // full column) so the group card lines up with the replies // and tool cards above and below it.
    - +
    - +
    (null) const triggerRef = useRef(null) const openRef = useRef(false) - openRef.current = open + useLayoutEffect(() => { + openRef.current = open + }) const close = useCallback(() => { if (!openRef.current) return setOpen(false) @@ -713,7 +715,7 @@ function ChangesView(): JSX.Element { if (!first) return const isStaged = staged.some((e) => e.path === first.path) && !conflicted.some((e) => e.path === first.path) void selectFile(first.path, isStaged) - }, [narrow, totalCount, selectedFile, conflicted, staged, unstaged, selectFile]) + }, [narrow, totalCount, selectedFile, conflicted, staged, unstaged, selectFile, clearSelectedFile]) useEffect(() => { if (!shouldVirtualize) return @@ -1096,10 +1098,11 @@ function ChangesView(): JSX.Element { {conflicted.map((entry) => { const flatIdx = flatEntries.findIndex((f) => f.kind === 'conflict' && f.entry.path === entry.path) return ( -
  • void openFileContextMenu(e, entry)}> +