diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 1aca238..42eeb5f 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -63,7 +63,7 @@ jobs: thresholds: '50 75' - name: Update README badge + summary - run: node scripts/update-coverage-badge.mjs --from-summary --from-markdown code-coverage-results.md + run: node scripts/update-coverage-badge.mjs --from-markdown code-coverage-results.md - name: Commit updated README uses: stefanzweifel/git-auto-commit-action@v6 diff --git a/package.json b/package.json index 01937fb..d6325e4 100644 --- a/package.json +++ b/package.json @@ -6,23 +6,11 @@ "scripts": { "dev": "wxt", "build": "wxt build", - "build:chrome": "wxt build --browser chrome", - "build:firefox": "wxt build --browser firefox", - "build:edge": "wxt build --browser edge", - "build:safari": "wxt build --browser safari", "typecheck": "tsc --noEmit", "postinstall": "wxt prepare", - "zip": "wxt zip", "zip:chrome": "wxt zip --browser chrome", "zip:firefox": "wxt zip --browser firefox", "zip:edge": "wxt zip --browser edge", - "zip:safari": "wxt zip --browser safari", - "submit": "webstore upload --source dist/chrome-mv3.zip --auto-publish", - "publish:chrome": "wxt build --browser chrome && wxt zip --browser chrome", - "publish:firefox": "wxt build --browser firefox && wxt zip --browser firefox", - "publish:edge": "wxt build --browser edge && wxt zip --browser edge", - "publish:safari": "wxt build --browser safari && wxt zip --browser safari", - "publish:all": "pnpm run publish:chrome && pnpm run publish:firefox && pnpm run publish:edge && pnpm run publish:safari", "check:manifest": "node -e \"const m=require('./dist/chrome-mv3/manifest.json');const allowed=['storage'];const extra=(m.permissions||[]).filter(p=>!allowed.includes(p));if(extra.length){console.error('Extra permissions:',extra);process.exit(1);}else{console.log('Manifest permissions OK:',m.permissions);}\"", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", @@ -31,7 +19,6 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "test:coverage-badge": "node scripts/update-coverage-badge.mjs", "prepare": "husky" }, "lint-staged": { @@ -44,7 +31,6 @@ "@atlaskit/pragmatic-drag-and-drop": "^1.8.1", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", "@effect/platform": "^0.96.1", - "@effect/platform-browser": "^0.76.0", "@emotion/is-prop-valid": "^1.4.0", "@primer/live-region-element": "0.8.0", "@primer/octicons-react": "^19.28.0", @@ -60,11 +46,8 @@ "tippy.js": "^6.3.7" }, "devDependencies": { - "@resvg/resvg-js": "^2.6.2", - "@types/marked": "^6.0.0", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", "@vitest/coverage-v8": "^4.1.8", "@wxt-dev/module-react": "^1.2.2", "eslint": "^10.4.1", @@ -77,7 +60,6 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.60.0", "vite": "^8.0.16", - "vite-node": "^6.0.0", "vitest": "^4.1.8", "wxt": "^0.20.26" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc40f4a..b14939e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,6 @@ importers: '@effect/platform': specifier: ^0.96.1 version: 0.96.1(effect@3.21.2) - '@effect/platform-browser': - specifier: ^0.76.0 - version: 0.76.0(@effect/platform@0.96.1(effect@3.21.2))(effect@3.21.2) '@emotion/is-prop-valid': specifier: ^1.4.0 version: 1.4.0 @@ -60,21 +57,12 @@ importers: specifier: ^6.3.7 version: 6.3.7 devDependencies: - '@resvg/resvg-js': - specifier: ^2.6.2 - version: 2.6.2 - '@types/marked': - specifier: ^6.0.0 - version: 6.0.0 '@types/react': specifier: ^19.2.15 version: 19.2.15 '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.15) - '@vitejs/plugin-react': - specifier: ^6.0.2 - version: 6.0.2(vite@8.0.16(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: ^4.1.8 version: 4.1.8(vitest@4.1.8) @@ -111,9 +99,6 @@ importers: vite: specifier: ^8.0.16 version: 8.0.16(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0) - vite-node: - specifier: ^6.0.0 - version: 6.0.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.8 version: 4.1.8(@types/node@25.9.1)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) @@ -237,12 +222,6 @@ packages: engines: {node: '>= 0.10.4'} hasBin: true - '@effect/platform-browser@0.76.0': - resolution: {integrity: sha512-cUyBpcLstrP/HiNsIePMBAI6R1+u6aRFlAUZb4wf08y1d1Vqf/Dmxsq14ZjBfnSYiqBPrCeYf1ZI+qMGQQL0RA==} - peerDependencies: - '@effect/platform': ^0.96.0 - effect: ^3.21.0 - '@effect/platform@0.96.1': resolution: {integrity: sha512-cjB1QZZYEP8JXCFNGvBLVi0T6YUBQTmOVEUA3SDbiQ6RUO+p6CE3eyD2vMWmrz5nE8yY5QSAuOV9v0boEcUv+A==} peerDependencies: @@ -597,86 +576,6 @@ packages: '@types/styled-components': optional: true - '@resvg/resvg-js-android-arm-eabi@2.6.2': - resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} - engines: {node: '>= 10'} - cpu: [arm] - os: [android] - - '@resvg/resvg-js-android-arm64@2.6.2': - resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [android] - - '@resvg/resvg-js-darwin-arm64@2.6.2': - resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@resvg/resvg-js-darwin-x64@2.6.2': - resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': - resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@resvg/resvg-js-linux-arm64-gnu@2.6.2': - resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@resvg/resvg-js-linux-arm64-musl@2.6.2': - resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@resvg/resvg-js-linux-x64-gnu@2.6.2': - resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@resvg/resvg-js-linux-x64-musl@2.6.2': - resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@resvg/resvg-js-win32-arm64-msvc@2.6.2': - resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@resvg/resvg-js-win32-ia32-msvc@2.6.2': - resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - - '@resvg/resvg-js-win32-x64-msvc@2.6.2': - resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@resvg/resvg-js@2.6.2': - resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} - engines: {node: '>= 10'} - '@rolldown/binding-android-arm64@1.0.2': resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -951,10 +850,6 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/marked@6.0.0': - resolution: {integrity: sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA==} - deprecated: This is a stub types definition. marked provides its own type definitions, so you do not need this installed. - '@types/minimatch@3.0.5': resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==} @@ -3147,12 +3042,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@effect/platform-browser@0.76.0(@effect/platform@0.96.1(effect@3.21.2))(effect@3.21.2)': - dependencies: - '@effect/platform': 0.96.1(effect@3.21.2) - effect: 3.21.2 - multipasta: 0.2.7 - '@effect/platform@0.96.1(effect@3.21.2)': dependencies: effect: 3.21.2 @@ -3430,57 +3319,6 @@ snapshots: '@types/react': 19.2.15 '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@resvg/resvg-js-android-arm-eabi@2.6.2': - optional: true - - '@resvg/resvg-js-android-arm64@2.6.2': - optional: true - - '@resvg/resvg-js-darwin-arm64@2.6.2': - optional: true - - '@resvg/resvg-js-darwin-x64@2.6.2': - optional: true - - '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': - optional: true - - '@resvg/resvg-js-linux-arm64-gnu@2.6.2': - optional: true - - '@resvg/resvg-js-linux-arm64-musl@2.6.2': - optional: true - - '@resvg/resvg-js-linux-x64-gnu@2.6.2': - optional: true - - '@resvg/resvg-js-linux-x64-musl@2.6.2': - optional: true - - '@resvg/resvg-js-win32-arm64-msvc@2.6.2': - optional: true - - '@resvg/resvg-js-win32-ia32-msvc@2.6.2': - optional: true - - '@resvg/resvg-js-win32-x64-msvc@2.6.2': - optional: true - - '@resvg/resvg-js@2.6.2': - optionalDependencies: - '@resvg/resvg-js-android-arm-eabi': 2.6.2 - '@resvg/resvg-js-android-arm64': 2.6.2 - '@resvg/resvg-js-darwin-arm64': 2.6.2 - '@resvg/resvg-js-darwin-x64': 2.6.2 - '@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2 - '@resvg/resvg-js-linux-arm64-gnu': 2.6.2 - '@resvg/resvg-js-linux-arm64-musl': 2.6.2 - '@resvg/resvg-js-linux-x64-gnu': 2.6.2 - '@resvg/resvg-js-linux-x64-musl': 2.6.2 - '@resvg/resvg-js-win32-arm64-msvc': 2.6.2 - '@resvg/resvg-js-win32-ia32-msvc': 2.6.2 - '@resvg/resvg-js-win32-x64-msvc': 2.6.2 - '@rolldown/binding-android-arm64@1.0.2': optional: true @@ -3674,10 +3512,6 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/marked@6.0.0': - dependencies: - marked: 18.0.4 - '@types/minimatch@3.0.5': {} '@types/node@25.9.1': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4b18683..b2b8f99 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,14 +2,3 @@ allowBuilds: esbuild: true msgpackr-extract: true spawn-sync: true -minimumReleaseAgeExclude: - - '@vitest/coverage-v8@4.1.8' - - '@vitest/expect@4.1.8' - - '@vitest/mocker@4.1.8' - - '@vitest/pretty-format@4.1.8' - - '@vitest/runner@4.1.8' - - '@vitest/snapshot@4.1.8' - - '@vitest/spy@4.1.8' - - '@vitest/utils@4.1.8' - - vite@8.0.16 - - vitest@4.1.8 diff --git a/scripts/update-coverage-badge.mjs b/scripts/update-coverage-badge.mjs index 738adf1..90476ef 100644 --- a/scripts/update-coverage-badge.mjs +++ b/scripts/update-coverage-badge.mjs @@ -3,25 +3,17 @@ /** * Updates the coverage badge (and optionally the coverage summary block) in README.md. * - * Modes: - * 1. Local default (no flags): - * pnpm test:coverage-badge - * Runs `npx vitest run --coverage --reporter=default`, parses the text - * "All files" row, and swaps the COVERAGE_BADGE markers. + * node scripts/update-coverage-badge.mjs [--from-markdown ] * - * 2. CI-friendly (recommended in workflows): - * node scripts/update-coverage-badge.mjs --from-summary [--from-markdown ] - * Reads `coverage/coverage-summary.json` (written by vitest json-summary - * reporter) without re-running the test suite, swaps the COVERAGE_BADGE - * markers, and — if `--from-markdown` is given — injects the contents of - * the irongut/CodeCoverageSummary markdown file between the - * COVERAGE_REPORT markers. + * Reads `coverage/coverage-summary.json` (written by the vitest json-summary + * reporter) and swaps the COVERAGE_BADGE markers. With `--from-markdown`, also + * injects the irongut/CodeCoverageSummary markdown file between the + * COVERAGE_REPORT markers. */ import { readFileSync, writeFileSync, existsSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { execSync } from "node:child_process"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, ".."); @@ -29,11 +21,10 @@ const README = resolve(ROOT, "README.md"); const SUMMARY_JSON = resolve(ROOT, "coverage", "coverage-summary.json"); function parseArgs(argv) { - const args = { fromSummary: false, fromMarkdown: null }; + const args = { fromMarkdown: null }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; - if (a === "--from-summary") args.fromSummary = true; - else if (a === "--from-markdown") { + if (a === "--from-markdown") { args.fromMarkdown = argv[++i]; if (!args.fromMarkdown) { console.error("--from-markdown requires a file path argument."); @@ -44,28 +35,6 @@ function parseArgs(argv) { return args; } -function coverageFromVitestText() { - const output = execSync("npx vitest run --coverage --reporter=default", { - cwd: ROOT, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }); - - const match = output.match( - /All files\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)/, - ); - if (!match) { - console.error("Could not parse coverage from vitest output."); - process.exit(1); - } - return { - statements: parseFloat(match[1]), - branches: parseFloat(match[2]), - functions: parseFloat(match[3]), - lines: parseFloat(match[4]), - }; -} - function pctOrFail(total, key) { const pct = total?.[key]?.pct; if (typeof pct !== "number" || Number.isNaN(pct)) { @@ -156,7 +125,7 @@ function replaceReportBlock(readme, markdownPath) { const args = parseArgs(process.argv.slice(2)); -const coverage = args.fromSummary ? coverageFromJsonSummary() : coverageFromVitestText(); +const coverage = coverageFromJsonSummary(); const pct = coverage.statements; const original = readFileSync(README, "utf-8"); diff --git a/src/assets/images/old/demo.gif b/src/assets/images/old/demo.gif deleted file mode 100644 index 8cee340..0000000 Binary files a/src/assets/images/old/demo.gif and /dev/null differ diff --git a/src/assets/images/old/screenshot-1.png b/src/assets/images/old/screenshot-1.png deleted file mode 100644 index 00a3604..0000000 Binary files a/src/assets/images/old/screenshot-1.png and /dev/null differ diff --git a/src/assets/images/old/screenshot-2.png b/src/assets/images/old/screenshot-2.png deleted file mode 100644 index 4f58c7f..0000000 Binary files a/src/assets/images/old/screenshot-2.png and /dev/null differ diff --git a/src/assets/images/old/screenshot-3.png b/src/assets/images/old/screenshot-3.png deleted file mode 100644 index 5983dc5..0000000 Binary files a/src/assets/images/old/screenshot-3.png and /dev/null differ diff --git a/src/assets/images/old/screenshot-4.png b/src/assets/images/old/screenshot-4.png deleted file mode 100644 index 088fd2f..0000000 Binary files a/src/assets/images/old/screenshot-4.png and /dev/null differ diff --git a/src/assets/images/old/screenshot-5.png b/src/assets/images/old/screenshot-5.png deleted file mode 100644 index cc3d680..0000000 Binary files a/src/assets/images/old/screenshot-5.png and /dev/null differ diff --git a/src/assets/images/old/screenshot-6.png b/src/assets/images/old/screenshot-6.png deleted file mode 100644 index ea0720b..0000000 Binary files a/src/assets/images/old/screenshot-6.png and /dev/null differ diff --git a/src/background/bulk-handlers.ts b/src/background/bulk-handlers.ts deleted file mode 100644 index 9fdf04a..0000000 --- a/src/background/bulk-handlers.ts +++ /dev/null @@ -1,13 +0,0 @@ -// bulk-action handler registry. The actual handlers live in per-feature files. - -import { registerBulkUpdateHandler } from '@/background/bulk-update' -import { registerBulkStateHandlers } from '@/background/bulk-state' -import { registerBulkRenameHandlers } from '@/background/bulk-rename' -import { registerBulkPositionHandlers } from '@/background/bulk-position' - -export function registerBulkHandlers(): void { - registerBulkUpdateHandler() - registerBulkStateHandlers() - registerBulkRenameHandlers() - registerBulkPositionHandlers() -} diff --git a/src/background/cache-service.ts b/src/background/cache-service.ts deleted file mode 100644 index 82d7c48..0000000 --- a/src/background/cache-service.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Context, Effect, Layer } from 'effect' - -import type { HierarchyData, ItemPreviewData } from '@/lib/messages' - -import { getOrCacheHierarchy, getOrCachePreview } from '@/background/cache' - -interface KeyedCache { - /** - * Memoized fetch keyed by `key`. Concurrent callers share the in-flight - * promise; failed fetches invalidate the entry so subsequent callers - * retry; successful fetches are TTL-evicted (1 minute). - */ - readonly get: (key: string, fetcher: () => Promise) => Effect.Effect -} - -export class PreviewCache extends Context.Tag('@rgp/PreviewCache')< - PreviewCache, - KeyedCache ->() {} - -export class HierarchyCache extends Context.Tag('@rgp/HierarchyCache')< - HierarchyCache, - KeyedCache ->() {} - -/** - * Live layers wrap the existing `getOrCachePreview` / `getOrCacheHierarchy` - * functions in `Effect.tryPromise`. Internally those functions already use - * `Effect.cachedWithTTL` to memoize fetchers per key with a 1-minute TTL, - * Effect-runtime `Effect.sleep` + `Fiber.interrupt` for eviction, and - * synchronous-set semantics that prevent concurrent-request races. Wrapping - * them as a service provides a clean Effect-first API for new callsites - * without breaking the existing async helpers. - */ -export const PreviewCacheLive = Layer.succeed(PreviewCache, { - get: (key, fetcher) => - Effect.tryPromise({ - try: () => getOrCachePreview(key, fetcher), - catch: (err) => err as unknown, - }).pipe(Effect.orDie), -}) - -export const HierarchyCacheLive = Layer.succeed(HierarchyCache, { - get: (key, fetcher) => - Effect.tryPromise({ - try: () => getOrCacheHierarchy(key, fetcher), - catch: (err) => err as unknown, - }).pipe(Effect.orDie), -}) - -export const CacheServicesLive = Layer.mergeAll(PreviewCacheLive, HierarchyCacheLive) diff --git a/src/background/config-handlers.ts b/src/background/config-handlers.ts index 4407e05..d49c177 100644 --- a/src/background/config-handlers.ts +++ b/src/background/config-handlers.ts @@ -16,23 +16,17 @@ export function registerConfigHandlers(): void { onMessage('getPatStatus', () => runHandler( 'getPatStatus', - Effect.tryPromise({ - try: async () => { - const pat = await patStorage.getValue() - return { hasPat: Boolean(pat?.trim()) } - }, - catch: (err) => err as unknown, - }).pipe(Effect.orDie), + Effect.promise(async () => { + const pat = await patStorage.getValue() + return { hasPat: Boolean(pat?.trim()) } + }), ), ) onMessage('validatePat', ({ data }) => runHandler( 'validatePat', - Effect.tryPromise({ - try: () => validatePatAsync(data.token), - catch: (err) => err as unknown, - }).pipe(Effect.orDie), + Effect.promise(() => validatePatAsync(data.token)), ), ) diff --git a/src/background/field-handlers.ts b/src/background/field-handlers.ts index 0cfbea1..2ac0fb5 100644 --- a/src/background/field-handlers.ts +++ b/src/background/field-handlers.ts @@ -35,8 +35,6 @@ import { classifyTransferEligibilityRows, unresolvedTransferEligibilityRows, } from '@/background/transfer-eligibility' -import { ProjectService } from '@/background/project-service' -import { provideBackground } from '@/background/runtime-ext' import type { IssueTypeNode, @@ -302,47 +300,38 @@ export function registerFieldHandlers(): void { onMessage('getProjectFields', ({ data }) => runHandler( 'getProjectFields', - provideBackground( - Effect.gen(function* () { - logger.log('[rgp:bg] getProjectFields received', data) - const projectService = yield* ProjectService - const { project } = yield* projectService.getProjectFieldsData( - data.owner, - data.number, - data.isOrg, - ) - return { - id: project?.id || '', - title: project?.title || 'Project', - fields: project?.fields.nodes.filter(Boolean) || [], - } - }), - ), + Effect.gen(function* () { + logger.log('[rgp:bg] getProjectFields received', data) + const { project } = yield* Effect.promise(() => + getProjectFieldsData(data.owner, data.number, data.isOrg), + ) + return { + id: project?.id || '', + title: project?.title || 'Project', + fields: project?.fields.nodes.filter(Boolean) || [], + } + }), ), ) onMessage('getItemTitles', ({ data }) => runHandler( 'getItemTitles', - provideBackground( - Effect.gen(function* () { - logger.log('[rgp:bg] getItemTitles received', { - itemCount: data.itemIds.length, - projectId: data.projectId, - }) - const projectService = yield* ProjectService - const resolved = yield* projectService.resolveProjectItemIdsWithTitles( - data.itemIds, - data.projectId, - ) - return resolved.map((r) => ({ - domId: r.domId, - issueNodeId: r.issueNodeId, - title: r.title, - typename: r.typename, - })) - }), - ), + Effect.gen(function* () { + logger.log('[rgp:bg] getItemTitles received', { + itemCount: data.itemIds.length, + projectId: data.projectId, + }) + const resolved = yield* Effect.promise(() => + resolveProjectItemIdsWithTitles(data.itemIds, data.projectId), + ) + return resolved.map((r) => ({ + domId: r.domId, + issueNodeId: r.issueNodeId, + title: r.title, + typename: r.typename, + })) + }), ), ) diff --git a/src/background/hierarchy-handlers.ts b/src/background/hierarchy-handlers.ts index fd6ea8b..227bf67 100644 --- a/src/background/hierarchy-handlers.ts +++ b/src/background/hierarchy-handlers.ts @@ -14,9 +14,7 @@ import { runHandler } from '@/lib/effect-runtime' import type { DateFieldValue, NumberFieldValue, ProjectItemDetails } from '@/background/types' -import { cacheResolvedItems } from '@/background/cache' -import { HierarchyCache, PreviewCache } from '@/background/cache-service' -import { provideBackground } from '@/background/runtime-ext' +import { cacheResolvedItems, getOrCacheHierarchy, getOrCachePreview } from '@/background/cache' import { withRateLimitRetry } from '@/background/rest-helpers' import { @@ -230,37 +228,33 @@ export function registerHierarchyHandlers(): void { onMessage('getItemPreview', ({ data }) => runHandler( 'getItemPreview', - provideBackground( - Effect.gen(function* () { - logger.log('[rgp:bg] getItemPreview received', data) - const previewCache = yield* PreviewCache - const key = `${data.owner}/${data.number}/${data.itemId}` - const response = yield* previewCache.get(key, () => fetchItemPreviewData(data)) - logger.log('[rgp:bg] getItemPreview returning', { - fieldsCount: response.fields.length, - relationships: { - parent: Boolean(response.relationships.parent), - blockedBy: response.relationships.blockedBy.length, - blocking: response.relationships.blocking.length, - }, - }) - return response - }), - ), + Effect.gen(function* () { + logger.log('[rgp:bg] getItemPreview received', data) + const key = `${data.owner}/${data.number}/${data.itemId}` + const response = yield* Effect.promise(() => + getOrCachePreview(key, () => fetchItemPreviewData(data)), + ) + logger.log('[rgp:bg] getItemPreview returning', { + fieldsCount: response.fields.length, + relationships: { + parent: Boolean(response.relationships.parent), + blockedBy: response.relationships.blockedBy.length, + blocking: response.relationships.blocking.length, + }, + }) + return response + }), ), ) onMessage('getHierarchyData', ({ data }) => runHandler( 'getHierarchyData', - provideBackground( - Effect.gen(function* () { - logger.log('[rgp:bg] getHierarchyData received', data) - const hierarchyCache = yield* HierarchyCache - const key = `${data.owner}/${data.number}/${data.itemId}` - return yield* hierarchyCache.get(key, () => fetchHierarchyData(data)) - }), - ), + Effect.gen(function* () { + logger.log('[rgp:bg] getHierarchyData received', data) + const key = `${data.owner}/${data.number}/${data.itemId}` + return yield* Effect.promise(() => getOrCacheHierarchy(key, () => fetchHierarchyData(data))) + }), ), ) } diff --git a/src/background/project-service.ts b/src/background/project-service.ts deleted file mode 100644 index 59f33db..0000000 --- a/src/background/project-service.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Context, Effect, Layer } from 'effect' - -import { - listIssueRelationshipsSafe as listIssueRelationshipsSafeAsync, - listSubIssuesSafe as listSubIssuesSafeAsync, -} from '@/background/relationship-helpers' -import { - getProjectFieldsData as getProjectFieldsDataAsync, - getRepositoryId as getRepositoryIdAsync, - resolveProjectItemIds as resolveProjectItemIdsAsync, - resolveProjectItemIdsWithTitles as resolveProjectItemIdsWithTitlesAsync, -} from '@/background/project-helpers' -import type { FieldsResultProject, ResolvedItem, ResolvedItemWithTitle } from '@/background/types' -import type { IssueRelationshipData, SubIssueData } from '@/lib/messages' - -/** - * `ProjectService` exposes the legacy background helpers as Effect-returning - * members. Existing async callers continue to use the helpers directly, but - * new code that lives inside `runHandler(name, Effect.gen(...))` blocks can - * yield* these methods to get full Effect-runtime tracing, structured - * cancellation, and seamless composition with other services. - * - * The helpers themselves throw on failure, so we wrap each in - * `Effect.tryPromise` and let the resulting `unknown` defect bubble up — the - * default `runHandler` cause-pretty printer surfaces them through the - * logger. - */ -export interface ProjectServiceShape { - readonly getProjectFieldsData: ( - owner: string, - number: number, - isOrg: boolean, - ) => Effect.Effect<{ project: FieldsResultProject | undefined }> - readonly resolveProjectItemIds: ( - domIds: string[], - projectId: string, - ) => Effect.Effect - readonly resolveProjectItemIdsWithTitles: ( - domIds: string[], - projectId: string, - ) => Effect.Effect - readonly listIssueRelationshipsSafe: ( - kind: 'blocked_by' | 'blocking', - owner: string, - repo: string, - issueNumber: number, - tabId?: number, - ) => Effect.Effect - readonly listSubIssuesSafe: ( - owner: string, - repo: string, - issueNumber: number, - ) => Effect.Effect - readonly getRepositoryId: (owner: string, name: string) => Effect.Effect -} - -export class ProjectService extends Context.Tag('@rgp/ProjectService')< - ProjectService, - ProjectServiceShape ->() {} - -const wrap = ( - fn: (...args: Args) => Promise, -): ((...args: Args) => Effect.Effect) => { - return (...args: Args) => - Effect.tryPromise({ - try: () => fn(...args), - catch: (err) => err as unknown, - }).pipe(Effect.orDie) -} - -export const ProjectServiceLive = Layer.succeed(ProjectService, { - getProjectFieldsData: wrap(getProjectFieldsDataAsync), - resolveProjectItemIds: wrap(resolveProjectItemIdsAsync), - resolveProjectItemIdsWithTitles: wrap(resolveProjectItemIdsWithTitlesAsync), - listIssueRelationshipsSafe: wrap(listIssueRelationshipsSafeAsync), - listSubIssuesSafe: wrap(listSubIssuesSafeAsync), - getRepositoryId: wrap(getRepositoryIdAsync), -}) diff --git a/src/background/runtime-ext.ts b/src/background/runtime-ext.ts deleted file mode 100644 index 44ccf62..0000000 --- a/src/background/runtime-ext.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Effect } from 'effect' - -import { HierarchyCache, PreviewCache } from '@/background/cache-service' -import { ProjectService } from '@/background/project-service' -import { BackgroundServicesLive } from '@/background/services' - -/** - * Service requirements that `BackgroundServicesLive` discharges. Listed - * explicitly so `provideBackground` can narrow the resulting Effect's R to - * `never` (or to whatever R remains after excluding the background services), - * which is what `runHandler` requires. - */ -type BackgroundServices = ProjectService | PreviewCache | HierarchyCache - -/** - * Helper to compose a background-only Effect with the right service Layer - * before passing to `runHandler`. After this call, the Effect no longer - * references any of the background services. - */ -export const provideBackground = ( - effect: Effect.Effect, -): Effect.Effect> => - effect.pipe(Effect.provide(BackgroundServicesLive)) diff --git a/src/background/services.ts b/src/background/services.ts deleted file mode 100644 index f17ad76..0000000 --- a/src/background/services.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Layer } from 'effect' - -import { CacheServicesLive } from '@/background/cache-service' -import { ProjectServiceLive } from '@/background/project-service' - -/** - * Aggregated background-only services Layer. Merged into the runtime by - * `src/entries/background/index.ts` after the base AppLayer is constructed - * so that content / popup / options bundles do not get these. - */ -export const BackgroundServicesLive = Layer.mergeAll(ProjectServiceLive, CacheServicesLive) - -export { ProjectService } from '@/background/project-service' -export { PreviewCache, HierarchyCache } from '@/background/cache-service' diff --git a/src/entries/background.ts b/src/entries/background.ts index af58794..a07ecd9 100644 --- a/src/entries/background.ts +++ b/src/entries/background.ts @@ -7,7 +7,10 @@ import { registerFieldHandlers } from '@/background/field-handlers' import { registerHierarchyHandlers } from '@/background/hierarchy-handlers' import { registerSprintHandlers } from '@/background/sprint-handlers' import { registerDuplicateHandlers } from '@/background/duplicate-handlers' -import { registerBulkHandlers } from '@/background/bulk-handlers' +import { registerBulkUpdateHandler } from '@/background/bulk-update' +import { registerBulkStateHandlers } from '@/background/bulk-state' +import { registerBulkRenameHandlers } from '@/background/bulk-rename' +import { registerBulkPositionHandlers } from '@/background/bulk-position' import { registerCreateIssueHandler } from '@/background/create-issue' export default defineBackground(() => { @@ -24,6 +27,9 @@ export default defineBackground(() => { registerHierarchyHandlers() registerSprintHandlers() registerDuplicateHandlers() - registerBulkHandlers() + registerBulkUpdateHandler() + registerBulkStateHandlers() + registerBulkRenameHandlers() + registerBulkPositionHandlers() registerCreateIssueHandler() }) diff --git a/src/entries/content.ts b/src/entries/content.ts index 7951997..0d83f61 100644 --- a/src/entries/content.ts +++ b/src/entries/content.ts @@ -53,7 +53,7 @@ export default defineContentScript({ projectContext.isOrg, ) - const injectSprintHeaders = createSprintHeaderInjector(ctx, projectContext, getFields) + const injectSprintHeaders = createSprintHeaderInjector(ctx, projectContext) const injectHierarchyChips = createHierarchyChipInjector(projectContext) const cleanupIssueDetail = setupIssueDetailInjector(projectContext) const cleanupCreateIssueFields = setupCreateIssueFieldInjector(ctx, getFields) diff --git a/src/features/bulk-duplicate-modal.tsx b/src/features/bulk-duplicate-modal.tsx index 0fbf694..b3561e1 100644 --- a/src/features/bulk-duplicate-modal.tsx +++ b/src/features/bulk-duplicate-modal.tsx @@ -26,7 +26,6 @@ import { ModalStepHeader } from '@/ui/modal-step-header' import { Z_MODAL, Z_TOOLTIP } from '@/lib/z-index' import { ensureTippyCss } from '@/lib/tippy-utils' import { formatIssueReference, relationshipKey } from '@/lib/relationship-utils' -import { getFieldOptionTooltip } from '@/features/field-helpers' import { ASSIGNEES_SECTION_ID, BLOCKED_BY_SECTION_ID, @@ -58,6 +57,9 @@ import { RelationshipListEditor } from '@/features/bulk-duplicate-relationship-l import { ReviewStep, SelectSectionsStep } from '@/features/bulk-duplicate-steps' +const getFieldOptionTooltip = (fieldName: string, optionName: string) => + `Set ${fieldName} to ${optionName}.` + interface Props { itemId: string projectId: string diff --git a/src/features/bulk-random-assign-flyout.tsx b/src/features/bulk-random-assign-flyout.tsx index da067da..86b21cf 100644 --- a/src/features/bulk-random-assign-flyout.tsx +++ b/src/features/bulk-random-assign-flyout.tsx @@ -49,7 +49,6 @@ export interface BulkRandomAssignFlyoutProps { itemIds: readonly string[] count: number /** Pinned recent assignee logins (most-recent-first, capped). */ - recentAssignees?: readonly RandomAssignTarget[] onConfirm: (assignments: Map, strategy: DistributionStrategy) => void } @@ -85,7 +84,6 @@ export function BulkRandomAssignFlyout({ isOrg, itemIds, count, - recentAssignees, onConfirm, }: BulkRandomAssignFlyoutProps) { const [query, setQuery] = useState('') @@ -203,31 +201,9 @@ export function BulkRandomAssignFlyout({ return () => clearTimeout(timer) }, [open, owner, repoName, query]) - // Seed cache with recents the first time the flyout opens. - useEffect(() => { - if (!recentAssignees || recentAssignees.length === 0) return - // eslint-disable-next-line react-hooks/set-state-in-effect -- one-time seed of recents into local cache - setCache((prev) => { - const next = new Map(prev) - for (const r of recentAssignees) { - if (!next.has(r.id)) next.set(r.id, r) - } - return next - }) - }, [recentAssignees]) - const visible = useMemo(() => { const seen = new Set() const out: RandomAssignTarget[] = [] - // Pinned recents first when query is empty. - if (!query && recentAssignees) { - for (const r of recentAssignees) { - if (!seen.has(r.id)) { - seen.add(r.id) - out.push(r) - } - } - } // Selected entries always visible. for (const id of picked) { if (!seen.has(id)) { @@ -245,7 +221,7 @@ export function BulkRandomAssignFlyout({ } } return out - }, [candidates, picked, recentAssignees, cache, query]) + }, [candidates, picked, cache]) const preview = useMemo(() => { if (picked.length === 0 || itemIds.length === 0) return new Map() @@ -318,7 +294,6 @@ export function BulkRandomAssignFlyout({ )} {visible.map((target, idx) => { const checked = picked.includes(target.id) - const isRecent = !query && recentAssignees?.some((r) => r.id === target.id) === true return ( )} {target.name} - {isRecent && ( - Recent - )} ) })} diff --git a/src/features/bulk-random-assign-utils.test.ts b/src/features/bulk-random-assign-utils.test.ts index aa4c2ae..af19276 100644 --- a/src/features/bulk-random-assign-utils.test.ts +++ b/src/features/bulk-random-assign-utils.test.ts @@ -102,7 +102,7 @@ describe('distributeBalanced', () => { const result = distributeBalanced(items, assignees) expect(result.get('solo')).toHaveLength(3) - expect(collectAllValues(result).sort()).toEqualValue(items.sort()) + expect(collectAllValues(result).sort()).toEqual(items.sort()) }) it('returns empty arrays for all assignees when items is empty', () => { @@ -111,7 +111,7 @@ describe('distributeBalanced', () => { const result = distributeBalanced([], assignees) for (const assignee of assignees) { - expect(result.get(assignee)).toEqualValue([]) + expect(result.get(assignee)).toEqual([]) } }) @@ -128,7 +128,7 @@ describe('distributeBalanced', () => { const result = distributeBalanced(items, assignees) const allDistributed = collectAllValues(result) - expect(allDistributed.sort()).toEqualValue([...items].sort()) + expect(allDistributed.sort()).toEqual([...items].sort()) }) }) @@ -140,7 +140,7 @@ describe('distributeRandom', () => { const result = distributeRandom(items, assignees) const allDistributed = collectAllValues(result) - expect(allDistributed.sort()).toEqualValue([...items].sort()) + expect(allDistributed.sort()).toEqual([...items].sort()) }) it('has an entry for each assignee in the result', () => { @@ -160,7 +160,7 @@ describe('distributeRandom', () => { const result = distributeRandom([], assignees) for (const assignee of assignees) { - expect(result.get(assignee)).toEqualValue([]) + expect(result.get(assignee)).toEqual([]) } }) @@ -178,9 +178,9 @@ describe('distributeRoundRobin', () => { const result = distributeRoundRobin(items, assignees) - expect(result.get('a1')).toEqualValue(['i1', 'i4']) - expect(result.get('a2')).toEqualValue(['i2', 'i5']) - expect(result.get('a3')).toEqualValue(['i3', 'i6']) + expect(result.get('a1')).toEqual(['i1', 'i4']) + expect(result.get('a2')).toEqual(['i2', 'i5']) + expect(result.get('a3')).toEqual(['i3', 'i6']) }) it('distributes 7 items among 3 assignees — first gets 3, others get 2', () => { @@ -200,7 +200,7 @@ describe('distributeRoundRobin', () => { const result = distributeRoundRobin(items, assignees) - expect(result.get('solo')).toEqualValue(['i1', 'i2', 'i3']) + expect(result.get('solo')).toEqual(['i1', 'i2', 'i3']) }) it('returns empty arrays when items is empty', () => { @@ -209,7 +209,7 @@ describe('distributeRoundRobin', () => { const result = distributeRoundRobin([], assignees) for (const assignee of assignees) { - expect(result.get(assignee)).toEqualValue([]) + expect(result.get(assignee)).toEqual([]) } }) @@ -220,10 +220,10 @@ describe('distributeRoundRobin', () => { const result = distributeRoundRobin(items, assignees) // a1 gets items at indices 0, 3, 6 → i1, i4, i7 - expect(result.get('a1')).toEqualValue(['i1', 'i4', 'i7']) + expect(result.get('a1')).toEqual(['i1', 'i4', 'i7']) // a2 gets items at indices 1, 4, 7 → i2, i5, i8 - expect(result.get('a2')).toEqualValue(['i2', 'i5', 'i8']) + expect(result.get('a2')).toEqual(['i2', 'i5', 'i8']) // a3 gets items at indices 2, 5, 8 → i3, i6, i9 - expect(result.get('a3')).toEqualValue(['i3', 'i6', 'i9']) + expect(result.get('a3')).toEqual(['i3', 'i6', 'i9']) }) }) diff --git a/src/features/bulk-transfer-modal.tsx b/src/features/bulk-transfer-modal.tsx index b4ef7ae..5d9d6e4 100644 --- a/src/features/bulk-transfer-modal.tsx +++ b/src/features/bulk-transfer-modal.tsx @@ -7,6 +7,7 @@ import { Z_MODAL, Z_TOOLTIP } from '@/lib/z-index' import { ModalStepHeader } from '@/ui/modal-step-header' import { sendMessage } from '@/lib/messages' import { ensureTippyCss } from '@/lib/tippy-utils' +import { primerCss } from '@/lib/primer-css-helper' interface RepoItem { id: string @@ -45,14 +46,7 @@ interface EligibilityRow { } const interactiveButtonSx = { - boxShadow: 'none', - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, + ...primerCss.buttonMotion(), } as const // §10.5 — session-scoped recent transfer destinations (most-recent first, cap 5). @@ -330,7 +324,6 @@ export function BulkTransferModal({ errorTitle="Could not load repositories" selectedPlacement="selected-first-when-filter-empty" anchorAriaLabel="Select a repository" - debugName="TransferSelectPanel" emptyState={({ filterQuery }) => ({ title: 'No repositories found', body: filterQuery.trim() diff --git a/src/features/content-ui.tsx b/src/features/content-ui.tsx index b38ec8a..98d9815 100644 --- a/src/features/content-ui.tsx +++ b/src/features/content-ui.tsx @@ -76,7 +76,6 @@ export async function setupContentUi( owner={projectContext.owner} isOrg={projectContext.isOrg} number={projectContext.number} - getFields={getFields} /> ), }) diff --git a/src/features/field-helpers.ts b/src/features/field-helpers.ts deleted file mode 100644 index e63c5b1..0000000 --- a/src/features/field-helpers.ts +++ /dev/null @@ -1,5 +0,0 @@ -// shared helpers used by bulk edit / duplicate / rename modals. - -export function getFieldOptionTooltip(fieldName: string, optionName: string): string { - return `Set ${fieldName} to ${optionName}.` -} diff --git a/src/features/onboarding-coach.tsx b/src/features/onboarding-coach.tsx index f2895d9..bb6d15e 100644 --- a/src/features/onboarding-coach.tsx +++ b/src/features/onboarding-coach.tsx @@ -14,6 +14,7 @@ import { StatusBanner } from '@/ui/status-banner' import { Z_OVERLAY } from '@/lib/z-index' // override: strips non-conformant shadows and border-radius from driver.js import { DRIVER_CSS_OVERRIDES } from '@/lib/driver-overrides' +import { primerCss } from '@/lib/primer-css-helper' export function OnboardingCoach() { const [hasToken, setHasToken] = useState(null) @@ -183,14 +184,7 @@ export function OnboardingCoach() { size="small" onClick={startTour} sx={{ - boxShadow: 'none', - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, + ...primerCss.buttonMotion(), }} > Take a quick tour @@ -201,14 +195,7 @@ export function OnboardingCoach() { onClick={dismiss} sx={{ color: 'fg.muted', - boxShadow: 'none', - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, + ...primerCss.buttonMotion(), }} > Skip diff --git a/src/features/queue-tracker.tsx b/src/features/queue-tracker.tsx index 41e2ca4..973dbdf 100644 --- a/src/features/queue-tracker.tsx +++ b/src/features/queue-tracker.tsx @@ -406,13 +406,7 @@ export function ProcessCard({ minWidth: 'unset', color: 'fg.muted', flexShrink: 0, - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, + ...primerCss.buttonMotion(), }} > diff --git a/src/features/sprint-end-view.tsx b/src/features/sprint-end-view.tsx index 6c4b737..c06372f 100644 --- a/src/features/sprint-end-view.tsx +++ b/src/features/sprint-end-view.tsx @@ -15,6 +15,7 @@ import { type FieldNode, type Iteration, } from '@/lib/sprint-utils' +import { primerCss } from '@/lib/primer-css-helper' const labelIconBoxSx = { color: 'fg.muted', @@ -183,14 +184,7 @@ export function EndSprintView({ disabled={!loaded || hasNoFuture || !selectedIterationId || ending} onClick={handleEnd} sx={{ - boxShadow: 'none', - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, + ...primerCss.buttonMotion(), }} > {ending ? 'Ending…' : 'End Sprint →'} diff --git a/src/features/sprint-injections.tsx b/src/features/sprint-injections.tsx index d5f6a97..c36a250 100644 --- a/src/features/sprint-injections.tsx +++ b/src/features/sprint-injections.tsx @@ -5,7 +5,7 @@ import { SprintGroupHeaderWidget } from '@/features/sprint-table-widget' import { createLightDomUi, type FeatureUi } from '@/lib/shadow-ui-factory' import { sprintPanelStore } from '@/lib/sprint-store' import { ensureTippyCss, getTippyDelayValue } from '@/lib/tippy-utils' -import type { ProjectContext, ProjectData } from '@/lib/github-project' +import type { ProjectContext } from '@/lib/github-project' const SPRINT_HDR_ATTR = 'data-rgp-sprint-hdr' const STATUS_BAR_BUTTON_ATTR = 'data-rgp-sprint-btn' @@ -21,7 +21,6 @@ let hideTooltipTimeout: number | null = null export function createSprintHeaderInjector( ctx: ContentScriptContext, projectContext: ProjectContext, - getFields: () => Promise, ) { const mountedWidgets = new Map() @@ -54,7 +53,6 @@ export function createSprintHeaderInjector( owner={projectContext.owner} isOrg={projectContext.isOrg} number={projectContext.number} - getFields={getFields} /> ), }) diff --git a/src/features/sprint-modal.tsx b/src/features/sprint-modal.tsx index 21f5c6a..5b964d6 100644 --- a/src/features/sprint-modal.tsx +++ b/src/features/sprint-modal.tsx @@ -10,18 +10,17 @@ import { sendMessage } from '@/lib/messages' import type { SprintInfo } from '@/lib/messages' import type { SprintSettings } from '@/lib/storage' import { fmt, SPRINT_FILTER } from '@/lib/sprint-utils' -import type { ProjectData } from '@/lib/github-project' import { sprintConfirmEndStore } from '@/lib/sprint-store' import { SprintProgressView } from '@/features/sprint-progress-view' import { SettingsView } from '@/features/sprint-settings-view' import { EndSprintView } from '@/features/sprint-end-view' +import { primerCss } from '@/lib/primer-css-helper' interface Props { projectId: string owner: string isOrg: boolean number: number - getFields: () => Promise visible: boolean onClose: () => void } @@ -37,15 +36,7 @@ interface SprintStatus { settings: SprintSettings | null } -export function SprintPanel({ - projectId, - owner, - isOrg, - number, - getFields, - visible, - onClose, -}: Props) { +export function SprintPanel({ projectId, owner, isOrg, number, visible, onClose }: Props) { ensureTippyCss() const [state, setState] = useState('loading') @@ -159,14 +150,7 @@ export function SprintPanel({ sx={{ p: '4px', color: 'fg.muted', - boxShadow: 'none', - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, + ...primerCss.buttonMotion(), }} > @@ -186,14 +170,7 @@ export function SprintPanel({ sx={{ p: '4px', color: 'fg.muted', - boxShadow: 'none', - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, + ...primerCss.buttonMotion(), }} > @@ -217,7 +194,6 @@ export function SprintPanel({ owner={owner} isOrg={isOrg} number={number} - getFields={getFields} currentSettings={status?.settings ?? null} onSaved={async () => { setShowSettings(false) @@ -270,14 +246,7 @@ export function SprintPanel({ size="small" onClick={() => setShowSettings(true)} sx={{ - boxShadow: 'none', - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, + ...primerCss.buttonMotion(), }} > Set Up Sprint @@ -310,14 +279,7 @@ export function SprintPanel({ disabled={acknowledging} onClick={handleAcknowledge} sx={{ - boxShadow: 'none', - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, + ...primerCss.buttonMotion(), }} > {acknowledging ? : 'Track Sprint'} @@ -360,14 +322,7 @@ export function SprintPanel({ size="small" onClick={handleStopTracking} sx={{ - boxShadow: 'none', - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, + ...primerCss.buttonMotion(), }} > Stop tracking diff --git a/src/features/sprint-progress-view.tsx b/src/features/sprint-progress-view.tsx index b6134de..c77cae2 100644 --- a/src/features/sprint-progress-view.tsx +++ b/src/features/sprint-progress-view.tsx @@ -5,28 +5,7 @@ import Tippy from '@/ui/tooltip' import { Z_TOOLTIP } from '@/lib/z-index' import { sendMessage, type SprintInfo, type SprintProgressData } from '@/lib/messages' import type { SprintSettings } from '@/lib/storage' -import { iterationEndDate } from '@/lib/sprint-utils' - -// ── Helpers ────────────────────────────────────────────────── - -function fmt(iso: string): string { - return new Date(iso + 'T00:00:00Z').toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - timeZone: 'UTC', - }) -} - -function daysLeft(endDate: string): number { - const today = new Date().toISOString().slice(0, 10) - return Math.max( - 0, - Math.ceil( - (new Date(endDate + 'T00:00:00Z').getTime() - new Date(today + 'T00:00:00Z').getTime()) / - 86_400_000, - ), - ) -} +import { daysLeft, fmt, iterationEndDate } from '@/lib/sprint-utils' function pct(done: number, total: number): number { if (total === 0) return 0 diff --git a/src/features/sprint-settings-view.tsx b/src/features/sprint-settings-view.tsx index 5e458af..b721203 100644 --- a/src/features/sprint-settings-view.tsx +++ b/src/features/sprint-settings-view.tsx @@ -33,7 +33,6 @@ import { sendMessage } from '@/lib/messages' import type { ExcludeCondition, SprintSettings } from '@/lib/storage' import { injectSprintFilter, SPRINT_FILTER, type FieldNode } from '@/lib/sprint-utils' import { formatAdvancedSettingsHint, hasAdvancedSettings } from '@/features/sprint-settings-utils' -import type { ProjectData } from '@/lib/github-project' const labelIconBoxSx = { color: 'fg.muted', @@ -79,7 +78,6 @@ interface SettingsViewProps { owner: string isOrg: boolean number: number - getFields: () => Promise currentSettings: SprintSettings | null onSaved: () => void } @@ -89,7 +87,6 @@ export function SettingsView({ owner, isOrg, number, - getFields: _getFields, currentSettings, onSaved, }: SettingsViewProps) { diff --git a/src/features/sprint-table-widget.tsx b/src/features/sprint-table-widget.tsx index 8731f7b..6a1480f 100644 --- a/src/features/sprint-table-widget.tsx +++ b/src/features/sprint-table-widget.tsx @@ -6,7 +6,6 @@ import { SlidersIcon } from '@/ui/icons' import { sendMessage } from '@/lib/messages' import type { SprintInfo } from '@/lib/messages' import type { SprintSettings } from '@/lib/storage' -import type { ProjectData } from '@/lib/github-project' import { primerCss } from '@/lib/primer-css-helper' import { sprintConfirmEndStore, sprintPanelStore } from '@/lib/sprint-store' @@ -15,7 +14,6 @@ interface Props { owner: string isOrg: boolean number: number - getFields: () => Promise } type WidgetState = 'loading' | 'not-configured' | 'no-active' | 'acknowledged' | 'active' | 'error' @@ -47,13 +45,42 @@ interface SprintStatus { settings: SprintSettings | null } -export function SprintGroupHeaderWidget({ - projectId, - owner, - isOrg, - number, - getFields: _getFields, -}: Props) { +function SprintSettingsButton() { + return ( + + + + ) +} + +export function SprintGroupHeaderWidget({ projectId, owner, isOrg, number }: Props) { ensureTippyCss() const [state, setState] = useState('loading') const [status, setStatus] = useState(null) @@ -170,36 +197,7 @@ export function SprintGroupHeaderWidget({ )} - - - + )} @@ -235,36 +233,7 @@ export function SprintGroupHeaderWidget({ End Sprint - - - + )} diff --git a/src/features/toast-list.tsx b/src/features/toast-list.tsx index 713ff48..f0fe48f 100644 --- a/src/features/toast-list.tsx +++ b/src/features/toast-list.tsx @@ -3,6 +3,7 @@ import { Box, Button, Flash, Text } from '@primer/react' import { toastStore, type ToastEntry } from '@/lib/toast-store' import { AlertIcon, CheckIcon, InfoIcon, XIcon } from '@/ui/icons' import { Z_OVERLAY } from '@/lib/z-index' +import { primerCss } from '@/lib/primer-css-helper' const FLASH_VARIANT: Record = { success: 'success', @@ -78,14 +79,7 @@ function ToastCard({ toast }: { toast: ToastEntry }) { fontSize: 0, fontWeight: 'bold', color: 'accent.fg', - boxShadow: 'none', - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, + ...primerCss.buttonMotion(), }} > {toast.action.label} @@ -100,14 +94,7 @@ function ToastCard({ toast }: { toast: ToastEntry }) { p: '2px', minWidth: 'unset', color: 'fg.muted', - boxShadow: 'none', - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, + ...primerCss.buttonMotion(), }} > diff --git a/src/features/token-setup.tsx b/src/features/token-setup.tsx index b3463ee..b5ff259 100644 --- a/src/features/token-setup.tsx +++ b/src/features/token-setup.tsx @@ -14,6 +14,7 @@ import { sendMessage } from '@/lib/messages' import type { PatError, PatErrorType } from '@/lib/errors' import { patStorage } from '@/lib/storage' import { CheckIcon, GearIcon, XIcon } from '@/ui/icons' +import { primerCss } from '@/lib/primer-css-helper' const PAT_URL = 'https://github.com/settings/tokens/new?scopes=project,read:org,repo&description=Refined+GitHub+Projects' @@ -127,13 +128,6 @@ export function useTokenSetup() { } } -export type TokenSetupMode = 'compact' | 'full' - -export interface TokenSetupCardProps { - mode?: TokenSetupMode - onOpenOptions?: () => void -} - const requiredScopes = ['project', 'read:org', 'repo'] as const const cardSx = { @@ -145,17 +139,10 @@ const cardSx = { } as const const actionButtonSx = { - boxShadow: 'none', - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, + ...primerCss.buttonMotion(), } as const -export function TokenSetupCard({ mode = 'full', onOpenOptions }: TokenSetupCardProps) { +export function TokenSetupCard() { const { token, setToken, @@ -177,7 +164,7 @@ export function TokenSetupCard({ mode = 'full', onOpenOptions }: TokenSetupCardP display: 'flex', alignItems: 'center', justifyContent: 'center', - py: mode === 'compact' ? 3 : 5, + py: 5, }} > @@ -190,7 +177,7 @@ export function TokenSetupCard({ mode = 'full', onOpenOptions }: TokenSetupCardP - {mode === 'compact' ? 'GitHub access' : 'Connect your GitHub token'} + Connect your GitHub token - {mode === 'compact' - ? 'Validated once, works across the popup, options, and in-page toolbar.' - : 'Save once and all features — bulk edits, deep duplicate, field search — share the same token.'} + Save once and all features — bulk edits, deep duplicate, field search — share the same + token. - + {saved && ( @@ -260,7 +246,7 @@ export function TokenSetupCard({ mode = 'full', onOpenOptions }: TokenSetupCardP @@ -327,9 +313,9 @@ export function TokenSetupCard({ mode = 'full', onOpenOptions }: TokenSetupCardP - {onOpenOptions && ( - - )} diff --git a/src/lib/debug-logger.test.ts b/src/lib/debug-logger.test.ts index d8086fa..33af854 100644 --- a/src/lib/debug-logger.test.ts +++ b/src/lib/debug-logger.test.ts @@ -44,12 +44,6 @@ describe('logger', () => { expect(spy).not.toHaveBeenCalled() }) - it('info does not output when debug is disabled', () => { - const spy = vi.spyOn(console, 'info').mockImplementation(() => {}) - logger.info('should be silent') - expect(spy).not.toHaveBeenCalled() - }) - it('verbose does not output when debug is disabled', () => { const spy = vi.spyOn(console, 'debug').mockImplementation(() => {}) logger.verbose('should be silent') @@ -90,16 +84,6 @@ describe('logger', () => { expect(spy).toHaveBeenCalled() }) - it('info logs when debug is enabled', async () => { - debugFlag.value = true - await initDebugLogger() - const spy = vi.spyOn(console, 'info').mockImplementation(() => {}) - - logger.info('info-msg') - - expect(spy).toHaveBeenCalled() - }) - it('verbose logs without context when debug is enabled', async () => { debugFlag.value = true await initDebugLogger() @@ -185,6 +169,6 @@ describe('RgpLoggerLive', () => { expect(errorSpy).toHaveBeenCalled() const calls = errorSpy.mock.calls[0] as unknown[] // second arg is the annotations object - expect(calls[1]).toEqualValue({ op: 'query', status: 500 }) + expect(calls[1]).toEqual({ op: 'query', status: 500 }) }) }) diff --git a/src/lib/debug-logger.ts b/src/lib/debug-logger.ts index d28ee11..4c2a604 100644 --- a/src/lib/debug-logger.ts +++ b/src/lib/debug-logger.ts @@ -48,12 +48,6 @@ export const logger = { console.debug(`%c[rgp ${ts()}]`, 'color:#8b949e', first, ...rest) }, - info: (...args: unknown[]): void => { - if (!isDebugEnabled) return - const [first, ...rest] = args - console.info(`%c[rgp ${ts()}]`, 'color:#3fb950;font-weight:bold', first, ...rest) - }, - verbose: (step: string, context?: unknown): void => { if (!isDebugEnabled) return if (context !== undefined) { diff --git a/src/lib/driver-overrides.test.ts b/src/lib/driver-overrides.test.ts deleted file mode 100644 index 11eb04b..0000000 --- a/src/lib/driver-overrides.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { DRIVER_CSS_OVERRIDES } from '@/lib/driver-overrides' - -describe('driver-overrides', () => { - it('exports CSS override string', () => { - expect(typeof DRIVER_CSS_OVERRIDES).toBe('string') - expect(DRIVER_CSS_OVERRIDES).toContain('.rgp-tour-popover') - }) -}) diff --git a/src/lib/effect-assert.ts b/src/lib/effect-assert.ts deleted file mode 100644 index 4a651c9..0000000 --- a/src/lib/effect-assert.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Equal, Data } from 'effect' -import { expect } from 'vitest' - -/** - * Recursively wrap plain JS values into Effect `Data.*` containers so that - * `Equal.equals` performs a deep value-based comparison instead of falling - * back to reference equality. - */ -function wrap(value: unknown): unknown { - if (value === null || typeof value !== 'object') return value - if (Equal.isEqual(value)) return value - if (Array.isArray(value)) return Data.array(value.map(wrap)) - if (value instanceof Map) { - const entries: Array<[string, unknown]> = [] - for (const [k, v] of value as Map) { - entries.push([String(k), wrap(v)]) - } - entries.sort(([a], [b]) => a.localeCompare(b)) - const obj: Record = {} - for (const [k, v] of entries) obj[k] = v - return Data.struct(obj) - } - if (value instanceof Set) { - const arr = [...value].map(wrap) - return Data.array(arr) - } - if (value instanceof Error) { - return Data.struct({ name: value.name, message: value.message }) - } - const out: Record = {} - for (const [key, val] of Object.entries(value as Record)) { - out[key] = wrap(val) - } - return Data.struct(out) -} - -/** Pure helper usable outside of `expect(...)`. */ -export function equalValue(a: unknown, b: unknown): boolean { - return Equal.equals(wrap(a), wrap(b)) -} - -expect.extend({ - toEqualValue(received: unknown, expected: unknown) { - const pass = equalValue(received, expected) - return { - pass, - message: () => - pass - ? `expected values NOT to be Equal.equals but they were` - : `expected values to be Equal.equals\n received: ${JSON.stringify(received)}\n expected: ${JSON.stringify(expected)}`, - actual: received, - expected, - } - }, -}) - -declare module 'vitest' { - interface Assertion { - toEqualValue(expected: T): void - } - interface AsymmetricMatchersContaining { - toEqualValue(expected: unknown): unknown - } -} diff --git a/src/lib/effect-runtime.ts b/src/lib/effect-runtime.ts index 49ae0d5..236f0e4 100644 --- a/src/lib/effect-runtime.ts +++ b/src/lib/effect-runtime.ts @@ -12,22 +12,12 @@ const GithubLayer = GithubGraphQLLive.pipe( Layer.provide(Layer.mergeAll(HttpClientLive, StorageLive)), ) -export const AppLayer = Layer.mergeAll(RgpLoggerLive, HttpClientLive, StorageLive, GithubLayer) - -/** - * Background-only layer extension. Lives in - * `src/entries/background/services/index.ts` so that content-script / - * popup / options bundles do not pull in handler-side code (which would - * tree-shake poorly given onMessage handlers register at module load). - * - * Re-exported here as a type so callsites can use `runWithBackground` et al. - */ -export { AppLayer as BaseAppLayer } +const AppLayer = Layer.mergeAll(RgpLoggerLive, HttpClientLive, StorageLive, GithubLayer) // single ManagedRuntime per execution context (background SW, content script, -// popup, options page). Each module that imports `AppRuntime` shares the same -// instance within its context — module evaluation is per-context in WXT. -export const AppRuntime = ManagedRuntime.make(AppLayer) +// popup, options page). Each module that imports this shares the same instance +// within its context — module evaluation is per-context in WXT. +const AppRuntime = ManagedRuntime.make(AppLayer) export const runPromise: typeof AppRuntime.runPromise = (effect, options) => AppRuntime.runPromise(effect, options) diff --git a/src/lib/effect-test-helpers.ts b/src/lib/effect-test-helpers.ts deleted file mode 100644 index e14e525..0000000 --- a/src/lib/effect-test-helpers.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Test Layers for Effect-first services. Used by tests that exercise the - * GraphQL client, Storage, etc. without hitting the real network or browser - * APIs. Each Layer overrides a service Tag with a deterministic in-memory - * implementation. - */ -import { Effect, Layer } from 'effect' -import { HttpClient, FetchHttpClient } from '@effect/platform' - -import { Storage } from '@/lib/storage-service' - -/** - * In-memory Storage layer. Pass initial values to seed; otherwise reads return - * empty strings / `false`. - */ -export const makeTestStorageLayer = (initial?: { - pat?: string - username?: string - debug?: boolean -}) => - Layer.succeed( - Storage, - Storage.of({ - getPat: Effect.succeed(initial?.pat ?? ''), - getUsername: Effect.succeed(initial?.username ?? ''), - getDebug: Effect.succeed(initial?.debug ?? false), - }), - ) - -/** - * Records every call made through `HttpClient`. Returns a `[Layer, calls[]]` - * pair so tests can assert against the exact request shape that the - * underlying GraphQL service produced. Pass a `respond(req)` to vary the - * response per call. - */ -export interface RecordedHttpCall { - url: URL | string - method: string - body: unknown - headers: Record -} - -export const makeRecordedHttpLayer = (respond: (call: RecordedHttpCall) => Response) => { - const calls: RecordedHttpCall[] = [] - const fetchImpl: typeof globalThis.fetch = async (input, init) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input : input.url - const method = (init?.method ?? 'GET').toUpperCase() - const bodyRaw = init?.body - let body: unknown = undefined - if (typeof bodyRaw === 'string') { - try { - body = JSON.parse(bodyRaw) - } catch { - body = bodyRaw - } - } - const headers: Record = {} - if (init?.headers) { - // accept any iterable (DOM Headers, Effect Headers, [k,v][]) or a - // record-shaped object; otherwise fall through to an empty record. - const candidate = init.headers as unknown - const entries: Iterable = - candidate != null && - typeof (candidate as { [Symbol.iterator]?: unknown })[Symbol.iterator] === 'function' - ? (candidate as Iterable) - : Object.entries(candidate as Record) - for (const [k, v] of entries) headers[k] = v - } - const call: RecordedHttpCall = { url, method, body, headers } - calls.push(call) - return respond(call) - } - const layer = FetchHttpClient.layer.pipe( - Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchImpl as typeof globalThis.fetch)), - ) - return [layer, calls] as const -} - -export type { Storage } -export { HttpClient } diff --git a/src/lib/errors.test.ts b/src/lib/errors.test.ts index 8fbb615..8b1529a 100644 --- a/src/lib/errors.test.ts +++ b/src/lib/errors.test.ts @@ -10,7 +10,6 @@ import { GithubClientError, GithubDecodeError, classifyHttpError, - renderPatError, } from '@/lib/errors' describe('GithubRateLimitError (canonical 429 variant)', () => { @@ -29,7 +28,7 @@ describe('GithubRateLimitError (canonical 429 variant)', () => { const b = new GithubRateLimitError({ status: 429, message: 'Too Many', retryAfter: 30 }) expect(Equal.equals(a, b)).toBe(true) - expect(a).toEqualValue(b) + expect(a).toEqual(b) }) it('Equal.equals returns false when message differs', () => { @@ -171,34 +170,3 @@ describe('classifyHttpError', () => { expect(err._tag).toBe('GithubClientError') }) }) - -describe('renderPatError', () => { - it('expired_or_invalid yields actionable guidance', () => { - const out = renderPatError('expired_or_invalid', 'token gone') - expect(out.type).toBe('expired_or_invalid') - expect(out.actionHref).toMatch(/github\.com/) - expect(out.message).toContain('token gone') - }) - - it('rate_limit fills default copy when message missing', () => { - const out = renderPatError('rate_limit', undefined) - expect(out.type).toBe('rate_limit') - expect(out.message).toMatch(/minute/) - }) - - it('exhaustively maps every PatErrorType', () => { - const types = [ - 'expired_or_invalid', - 'missing_scopes', - 'rate_limit', - 'network', - 'unknown', - ] as const - for (const t of types) { - const out = renderPatError(t, undefined) - expect(out.type).toBe(t) - expect(out.title.length).toBeGreaterThan(0) - expect(out.message.length).toBeGreaterThan(0) - } - }) -}) diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 1105b1e..2563ce0 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -153,62 +153,3 @@ export interface PatError { actionLabel?: string actionHref?: string } - -/** - * Map a `PatErrorType` onto a user-facing `PatError`. Pure function — relies - * on `Match.value(...).pipe(Match.when(...), Match.exhaustive)` so adding a - * new variant fails typecheck instead of silently falling through. - */ -export const renderPatError = (type: PatErrorType, message: string | undefined): PatError => - Match.value(type).pipe( - Match.when( - 'expired_or_invalid', - () => - ({ - type: 'expired_or_invalid' as const, - title: 'Personal access token is invalid or expired', - message: message ?? 'Generate a new token in GitHub settings and paste it below.', - actionLabel: 'Open GitHub tokens', - actionHref: 'https://github.com/settings/tokens', - }) satisfies PatError, - ), - Match.when( - 'missing_scopes', - () => - ({ - type: 'missing_scopes' as const, - title: 'Token is missing required scopes', - message: message ?? 'Grant the `repo` and `project` scopes to this token.', - actionLabel: 'Open GitHub tokens', - actionHref: 'https://github.com/settings/tokens', - }) satisfies PatError, - ), - Match.when( - 'rate_limit', - () => - ({ - type: 'rate_limit' as const, - title: 'Rate limit hit while validating', - message: message ?? 'Wait a minute and try again.', - }) satisfies PatError, - ), - Match.when( - 'network', - () => - ({ - type: 'network' as const, - title: 'Network error', - message: message ?? 'Could not reach api.github.com — check your connection.', - }) satisfies PatError, - ), - Match.when( - 'unknown', - () => - ({ - type: 'unknown' as const, - title: 'Unknown validation error', - message: message ?? 'GitHub rejected the token without a clear reason.', - }) satisfies PatError, - ), - Match.exhaustive, - ) diff --git a/src/lib/graphql-client.test.ts b/src/lib/graphql-client.test.ts index e9d0573..06f970d 100644 --- a/src/lib/graphql-client.test.ts +++ b/src/lib/graphql-client.test.ts @@ -79,7 +79,7 @@ describe('gql', () => { {}, ) - expect(result).toEqualValue(expectedData) + expect(result).toEqual(expectedData) }) it('throws GithubRateLimitError on 403 with x-ratelimit-remaining=0 (after internal retries)', async () => { diff --git a/src/lib/graphql-strings.test.ts b/src/lib/graphql-strings.test.ts deleted file mode 100644 index 63a404b..0000000 --- a/src/lib/graphql-strings.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, it, expect } from 'vitest' -import * as mutations from '@/lib/graphql-mutations' -import * as queries from '@/lib/graphql-queries' - -describe('graphql exports', () => { - it('mutations are non-empty strings', () => { - expect(typeof mutations.CLONE_ISSUE).toBe('string') - expect(mutations.CLONE_ISSUE.length).toBeGreaterThan(0) - expect(typeof mutations.ATTACH_TO_PROJECT).toBe('string') - expect(mutations.ATTACH_TO_PROJECT.length).toBeGreaterThan(0) - }) - - it('queries are non-empty strings', () => { - expect(typeof queries.VALIDATE_TOKEN).toBe('string') - expect(queries.VALIDATE_TOKEN.length).toBeGreaterThan(0) - expect(typeof queries.GET_PROJECT_FIELDS).toBe('string') - expect(queries.GET_PROJECT_FIELDS.length).toBeGreaterThan(0) - }) -}) diff --git a/src/lib/graphql-testclock.test.ts b/src/lib/graphql-testclock.test.ts index e5e2f22..53281f8 100644 --- a/src/lib/graphql-testclock.test.ts +++ b/src/lib/graphql-testclock.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Effect, Layer, Schema, TestClock, TestContext } from 'effect' +import { FetchHttpClient } from '@effect/platform' vi.mock('@/lib/storage', () => ({ patStorage: { getValue: vi.fn().mockResolvedValue('') }, @@ -8,8 +9,14 @@ vi.mock('@/lib/storage', () => ({ })) import { GithubGraphQL, GithubGraphQLLive } from '@/lib/graphql-service' +import { Storage } from '@/lib/storage-service' -import { makeRecordedHttpLayer, makeTestStorageLayer } from '@/lib/effect-test-helpers' +const httpLayerReturning = (respond: () => Response) => + FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, (async () => respond()) as typeof fetch)), + ) + +const storageLayer = Layer.succeed(Storage, Storage.of({ getPat: Effect.succeed('fake-pat') })) const VIEWER_QUERY = 'query Viewer { viewer { login } }' @@ -20,7 +27,7 @@ const ViewerSchema = Schema.Struct({ describe('GithubGraphQL service — TestClock-driven retry behavior', () => { it('retries on 429 and succeeds on the second attempt', async () => { let calls = 0 - const [httpLayer] = makeRecordedHttpLayer(() => { + const httpLayer = httpLayerReturning(() => { calls++ if (calls === 1) { return new Response(JSON.stringify({}), { @@ -34,7 +41,6 @@ describe('GithubGraphQL service — TestClock-driven retry behavior', () => { }) }) - const storageLayer = makeTestStorageLayer({ pat: 'fake-pat' }) const services = GithubGraphQLLive.pipe(Layer.provide(Layer.mergeAll(httpLayer, storageLayer))) const program = Effect.gen(function* () { @@ -53,13 +59,13 @@ describe('GithubGraphQL service — TestClock-driven retry behavior', () => { program.pipe(Effect.provide(services), Effect.provide(TestContext.TestContext)), ) - expect(result).toEqualValue({ viewer: { login: 'test-user' } }) + expect(result).toEqual({ viewer: { login: 'test-user' } }) expect(calls).toBe(2) }) it('exhausts retries on persistent 429 and surfaces GithubRateLimitError', async () => { let calls = 0 - const [httpLayer] = makeRecordedHttpLayer(() => { + const httpLayer = httpLayerReturning(() => { calls++ return new Response(JSON.stringify({}), { status: 429, @@ -67,7 +73,6 @@ describe('GithubGraphQL service — TestClock-driven retry behavior', () => { }) }) - const storageLayer = makeTestStorageLayer({ pat: 'fake-pat' }) const services = GithubGraphQLLive.pipe(Layer.provide(Layer.mergeAll(httpLayer, storageLayer))) const program = Effect.gen(function* () { diff --git a/src/lib/messages.ts b/src/lib/messages.ts index ee63492..06ffc37 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -1,5 +1,6 @@ import { defineExtensionMessaging } from '@webext-core/messaging' -import type { ProtocolMapFromSchemas } from '@/lib/schemas-messages' +import type { PatErrorType } from '@/lib/errors' +import type { ExcludeCondition, SprintSettings } from '@/lib/storage' export interface IssueRelationshipData { nodeId?: string @@ -195,14 +196,264 @@ export interface HierarchyData { blocking: IssueRelationshipData[] } -export interface BulkRandomAssignData { - itemIds: string[] - projectId: string - assignments: Array<{ itemId: string; assigneeIds: string[] }> - strategy: 'balanced' | 'random' | 'round-robin' +type FieldMeta = BulkUpdateMessageData['fieldMeta'] + +interface IterationConfig { + id: string + title: string + startDate: string + duration: number +} + +/** + * The background ↔ UI message contract. Every `sendMessage` / `onMessage` pair + * is typed from this one declaration. + */ +interface ProtocolMap { + duplicateItem(data: { itemId: string; projectId: string; plan?: DuplicateItemPlan }): { + accepted: boolean + } + + getItemPreview(data: { + itemId: string + owner: string + number: number + isOrg: boolean + }): ItemPreviewData + + openOptions(data: {}): void + + getPatStatus(data: {}): { hasPat: boolean } + + validatePat(data: { + token: string + }): + | { valid: true; user: string } + | { valid: false; errorType?: PatErrorType; errorMessage?: string } + + searchRepoMetadata(data: { + owner: string + name: string + q: string + type: 'ASSIGNEES' | 'LABELS' | 'MILESTONES' | 'ISSUE_TYPES' + }): { + id: string + name: string + color?: string + avatarUrl?: string + description?: string + }[] + + searchRelationshipIssues(data: { + q: string + owner?: string + repoName?: string + }): IssueSearchResultData[] + + validateBulkRelationshipUpdates(data: { + itemIds: string[] + projectId: string + relationships: BulkEditRelationshipsUpdate + }): BulkRelationshipValidationResult + + searchTransferTargets(data: { + owner: string + q: string + firstItemId?: string + projectId?: string + scope?: 'owner-only' | 'all' + includeIneligible?: boolean + }): { + id: string + name: string + nameWithOwner: string + isPrivate: boolean + description: string | null + eligibility?: 'ok' | 'archived' | 'issues-disabled' + }[] + + validateTransferEligibility(data: { + itemIds: string[] + projectId: string + targetRepoOwner: string + targetRepoName: string + }): { + domId: string + eligible: boolean + reason?: 'pull-request' | 'same-repo' | 'unresolved' + title?: string + }[] + + bulkUpdate(data: BulkUpdateMessageData): BulkUpdateDispatchResult + + createIssueWithFields(data: CreateIssueWithFieldsMessageData): BulkUpdateDispatchResult + + bulkClose(data: { + itemIds: string[] + projectId: string + reason: 'COMPLETED' | 'NOT_PLANNED' + }): void + + bulkRandomAssign(data: { + itemIds: string[] + projectId: string + assignments: { itemId: string; assigneeIds: string[] }[] + strategy: 'balanced' | 'random' | 'round-robin' + }): void + + bulkOpen(data: { itemIds: string[]; projectId: string }): void + + bulkTransfer(data: { + itemIds: string[] + projectId: string + targetRepoOwner: string + targetRepoName: string + }): void + + bulkLock(data: { + itemIds: string[] + projectId: string + lockReason: 'OFF_TOPIC' | 'TOO_HEATED' | 'RESOLVED' | 'SPAM' | null + }): void + + bulkUnlock(data: { itemIds: string[]; projectId: string }): void + + bulkPin(data: { itemIds: string[]; projectId: string }): void + + bulkUnpin(data: { itemIds: string[]; projectId: string }): void + + bulkDelete(data: { itemIds: string[]; projectId: string }): void + + getProjectFields(data: { owner: string; number: number; isOrg: boolean }): { + id: string + title: string + fields: { + id: string + name: string + dataType: string + options?: { id: string; name: string; color?: string }[] + configuration?: { iterations: IterationConfig[] } + }[] + } + + getSprintStatus(data: { projectId: string; owner: string; number: number; isOrg: boolean }): { + hasSettings: boolean + activeSprint: SprintInfo | null + nearestUpcoming: SprintInfo | null + acknowledgedSprint: SprintInfo | null + iterationFieldId: string | null + settings: SprintSettings | null + } + + saveSprintSettings(data: { projectId: string; settings: SprintSettings }): { ok: boolean } + + acknowledgeUpcomingSprint(data: { projectId: string; iterationId: string }): { ok: boolean } + + getSprintProgress(data: { + projectId: string + owner: string + number: number + isOrg: boolean + iterationId: string + sprintStartDate: string + settings: SprintSettings + }): SprintProgressData + + endSprint(data: { + projectId: string + owner: string + number: number + isOrg: boolean + sprintFieldId: string + activeIterationId: string + nextIterationId: string + doneFieldId: string + doneFieldType: 'SINGLE_SELECT' | 'TEXT' + doneOptionId: string + doneOptionValue: string + notStartedOptionId?: string + excludeConditions: ExcludeCondition[] + }): void | { error: string } + + getItemTitles(data: { itemIds: string[]; projectId: string }): { + domId: string + issueNodeId: string + title: string + typename: 'Issue' | 'PullRequest' + }[] + + bulkRename(data: { + itemIds: string[] + projectId: string + renames: { + domId: string + issueNodeId: string + newTitle: string + typename: 'Issue' | 'PullRequest' + }[] + }): void + + getReorderContext(data: { + itemIds: string[] + projectId: string + owner: string + number: number + isOrg: boolean + allDomIds?: string[] + }): { + projectId: string + allOrderedItems: { memexItemId: number; nodeId: string; title: string }[] + selectedItems: { domId: string; memexItemId: number; nodeId: string; title: string }[] + } + + bulkReorder(data: { + projectId: string + reorderOps: { nodeId: string; previousNodeId: string | null }[] + label?: string + }): void + + bulkReorderByPosition(data: { + selectedDomIds: string[] + insertAfterDomId: string + projectId: string + owner: string + number: number + isOrg: boolean + label?: string + allDomIds?: string[] + }): void + + getHierarchyData(data: { + itemId: string + owner: string + number: number + isOrg: boolean + }): HierarchyData + + cancelProcess(data: { processId: string }): void + + queueStateUpdate(data: { + total: number + completed: number + paused: boolean + retryAfter?: number + status?: string + detail?: string + processId?: string + label?: string + failedItems?: { id: string; title: string; error: string }[] + retryContext?: { messageType: string; data: Record } + reverse?: { + messageType: string + data: Record + affectedItemIds: string[] + label?: string + undoWindowMs?: number + } + }): void } -const _messaging = defineExtensionMessaging() +const _messaging = defineExtensionMessaging() export const onMessage = _messaging.onMessage // wrap sendMessage with SW reconnect retry logic diff --git a/src/lib/primer-css-helper.ts b/src/lib/primer-css-helper.ts index 68fed74..d56aaf8 100644 --- a/src/lib/primer-css-helper.ts +++ b/src/lib/primer-css-helper.ts @@ -61,32 +61,11 @@ export const primerCss = { flexDirection: 'column', }), - borderedContainer: makePreset({ - border: '1px solid', - borderColor: 'border.default', - borderRadius: 1, - overflow: 'hidden', - }), - - card: makePreset({ - border: '1px solid', - borderColor: 'border.default', - borderRadius: 2, - boxShadow: 'none', - overflow: 'hidden', - bg: 'canvas.default', - }), - divider: makePreset({ borderTop: '1px solid', borderColor: 'border.default', }), - footerBorder: makePreset({ - borderTop: '1px solid', - borderColor: 'border.default', - }), - footerLayout: makePreset({ display: 'flex', justifyContent: 'flex-end', diff --git a/src/lib/primer-shadow-dom-compat.test.ts b/src/lib/primer-shadow-dom-compat.test.ts index d4fc3f5..9547ed8 100644 --- a/src/lib/primer-shadow-dom-compat.test.ts +++ b/src/lib/primer-shadow-dom-compat.test.ts @@ -7,7 +7,7 @@ vi.mock('@/lib/debug-logger', () => ({ }, })) -import { installPrimerShadowDomCompat, clearMousedownPath } from '@/lib/primer-shadow-dom-compat' +import { installPrimerShadowDomCompat } from '@/lib/primer-shadow-dom-compat' describe('installPrimerShadowDomCompat', () => { let shadowHost: HTMLElement @@ -165,9 +165,3 @@ describe('installPrimerShadowDomCompat', () => { expect(parent.contains(shadowHost as unknown as Node)).toBe(true) }) }) - -describe('clearMousedownPath', () => { - it('does not throw', () => { - expect(() => clearMousedownPath()).not.toThrow() - }) -}) diff --git a/src/lib/primer-shadow-dom-compat.ts b/src/lib/primer-shadow-dom-compat.ts index f100e03..31fffb6 100644 --- a/src/lib/primer-shadow-dom-compat.ts +++ b/src/lib/primer-shadow-dom-compat.ts @@ -230,10 +230,3 @@ function patchOverlayContains( return false } } - -/** - * Clear the tracked mousedown state. - */ -export function clearMousedownPath(): void { - clearTrackedMousedown() -} diff --git a/src/lib/project-table-dom.test.ts b/src/lib/project-table-dom.test.ts index 0dcb447..a3732e5 100644 --- a/src/lib/project-table-dom.test.ts +++ b/src/lib/project-table-dom.test.ts @@ -83,11 +83,11 @@ describe('getAllInjectedItemIds', () => { const ids = getAllInjectedItemIds() // branded primitives erase to plain strings at runtime, so deep-equal // comparison against the underlying string array remains valid. - expect([...ids] as string[]).toEqualValue(['id-1', 'id-2']) + expect([...ids] as string[]).toEqual(['id-1', 'id-2']) }) it('returns empty array when no injected rows', () => { - expect(getAllInjectedItemIds()).toEqualValue([]) + expect(getAllInjectedItemIds()).toEqual([]) }) it('skips rows with value "1"', () => { @@ -96,7 +96,7 @@ describe('getAllInjectedItemIds', () => { row.setAttribute(INJECTED_ATTR, '1') document.body.appendChild(row) - expect(getAllInjectedItemIds()).toEqualValue([]) + expect(getAllInjectedItemIds()).toEqual([]) }) }) diff --git a/src/lib/queue-store.test.ts b/src/lib/queue-store.test.ts index e8ccef3..73342bd 100644 --- a/src/lib/queue-store.test.ts +++ b/src/lib/queue-store.test.ts @@ -74,7 +74,7 @@ describe('queueStore.subscribe', () => { const unsub = queueStore.subscribe((e) => snapshots.push([...e])) expect(snapshots.length).toBe(1) - expect(snapshots[0]).toEqualValue([]) + expect(snapshots[0]).toEqual([]) unsub() }) @@ -133,7 +133,7 @@ describe('queueStore auto-dismiss', () => { await vi.advanceTimersByTimeAsync(3500) const afterDismiss = snapshots[snapshots.length - 1] - expect(afterDismiss).toEqualValue([]) + expect(afterDismiss).toEqual([]) unsub() }) @@ -155,7 +155,7 @@ describe('queueStore auto-dismiss', () => { const latest = snapshots[snapshots.length - 1] expect(latest).toHaveLength(1) - expect((latest[0] as { failedItems: unknown[] }).failedItems).toEqualValue([ + expect((latest[0] as { failedItems: unknown[] }).failedItems).toEqual([ { id: 'x', title: 'x', error: 'boom' }, ]) unsub() @@ -185,7 +185,7 @@ describe('queueStore auto-dismiss', () => { const snapshots: unknown[][] = [] const unsub = queueStore.subscribe((e) => snapshots.push([...e])) const latest = snapshots[snapshots.length - 1] as { retryContext: unknown }[] - expect(latest[0].retryContext).toEqualValue({ messageType: 'bulkUpdate', data: { x: 1 } }) + expect(latest[0].retryContext).toEqual({ messageType: 'bulkUpdate', data: { x: 1 } }) unsub() }) @@ -209,7 +209,7 @@ describe('queueStore auto-dismiss', () => { const snapshots: unknown[][] = [] const unsub = queueStore.subscribe((e) => snapshots.push([...e])) const latest = snapshots[snapshots.length - 1] as { failedItems: unknown[] }[] - expect(latest[0].failedItems).toEqualValue([{ id: 'f1', title: 't1', error: 'e1' }]) + expect(latest[0].failedItems).toEqual([{ id: 'f1', title: 't1', error: 'e1' }]) unsub() }) @@ -258,7 +258,7 @@ describe('queueStore phase derivation', () => { const entry = snapshots[snapshots.length - 1].find((p: any) => p.processId === pid) expect(entry.phase.kind).toBe('in-flight') - expect(entry.phase.progress).toEqualValue({ done: 1, total: 4 }) + expect(entry.phase.progress).toEqual({ done: 1, total: 4 }) queueStore.dismiss(pid) unsub() }) @@ -296,7 +296,7 @@ describe('queueStore phase derivation', () => { const entry = snapshots[snapshots.length - 1].find((p: any) => p.processId === pid) expect(entry.phase.kind).toBe('partial') - expect(entry.phase.failedItemIds).toEqualValue(['i1']) + expect(entry.phase.failedItemIds).toEqual(['i1']) queueStore.dismiss(pid) unsub() }) @@ -346,7 +346,7 @@ describe('queueStore phase derivation', () => { const entry = snapshots[snapshots.length - 1].find((p: any) => p.processId === pid) expect(entry.phase.kind).toBe('partial') - expect(entry.phase.retry).toEqualValue({ + expect(entry.phase.retry).toEqual({ messageType: 'bulkUpdate', data: { projectId: 'x', itemIds: ['i1'] }, }) @@ -372,7 +372,7 @@ describe('queueStore.attachPhaseHints', () => { const unsub = queueStore.subscribe((e) => snapshots.push([...e])) const entry = snapshots[snapshots.length - 1].find((p: any) => p.processId === pid) expect(entry.phase.kind).toBe('success') - expect(entry.phase.reverse).toEqualValue({ + expect(entry.phase.reverse).toEqual({ messageType: 'bulkUpdate', data: { reopen: true }, affectedItemIds: ['i1'], diff --git a/src/lib/queue.test.ts b/src/lib/queue.test.ts index 6ef2a66..7d24fe8 100644 --- a/src/lib/queue.test.ts +++ b/src/lib/queue.test.ts @@ -68,7 +68,7 @@ describe('processQueue', () => { await runToCompletion(processQueue(tasks)) - expect(order).toEqualValue(['a', 'b', 'c']) + expect(order).toEqual(['a', 'b', 'c']) }) it('calls onStateChange with correct total and completed counts', async () => { @@ -111,7 +111,7 @@ describe('processQueue', () => { await runToCompletion(processQueue(tasks, (s) => states.push({ ...s }))) // the failing task is skipped but counted as completed - expect(order).toEqualValue(['ok']) + expect(order).toEqual(['ok']) const last = states[states.length - 1] expect(last).toMatchObject({ total: 2, completed: 2 }) }) @@ -147,7 +147,7 @@ describe('processQueue', () => { // only the first task should have run; cancellation is checked before // each subsequent task. - expect(order).toEqualValue(['first']) + expect(order).toEqual(['first']) }) it('completes immediately with an empty task array', async () => { diff --git a/src/lib/schema-snapshots.test.ts b/src/lib/schema-snapshots.test.ts deleted file mode 100644 index 18f6833..0000000 --- a/src/lib/schema-snapshots.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Schema } from 'effect' - -import { Messages } from '@/lib/schemas-messages' - -/** - * Schema-encoded snapshot tests. For every Protocol entry's input/output - * Schema, encode a representative sample value and assert that the encoded - * shape matches a stable JSON snapshot. Catches accidental schema drift - * (e.g. a renamed field) at PR time. - * - * Only a few representative entries are sampled — adding more is cheap and - * recommended whenever a new ProtocolMap entry lands. - */ -describe('MessageSchemas — round-trip encode snapshots', () => { - it('encodes/decodes getPatStatus output', () => { - const value = { hasPat: true } - const encoded = Schema.encodeSync(Messages.getPatStatus.output)(value) - expect(encoded).toEqualValue({ hasPat: true }) - const decoded = Schema.decodeSync(Messages.getPatStatus.output)(encoded) - expect(decoded).toEqualValue(value) - }) - - it('encodes/decodes validatePat output (success variant)', () => { - const value = { valid: true as const, user: 'octocat' } - const encoded = Schema.encodeSync(Messages.validatePat.output)(value) - expect(encoded).toEqualValue({ valid: true, user: 'octocat' }) - }) - - it('encodes/decodes validatePat output (failure variant)', () => { - const value = { - valid: false as const, - errorType: 'expired_or_invalid' as const, - errorMessage: 'Bad credentials', - } - const encoded = Schema.encodeSync(Messages.validatePat.output)(value) - expect(encoded).toEqualValue({ - valid: false, - errorType: 'expired_or_invalid', - errorMessage: 'Bad credentials', - }) - }) - - it('decodes valid getItemPreview input', () => { - const value = { itemId: 'issue:42', owner: 'octocat', number: 1, isOrg: false } - const decoded = Schema.decodeUnknownSync(Messages.getItemPreview.input)(value) - expect(decoded).toEqualValue(value) - }) - - it('rejects invalid validatePat input (missing token)', () => { - expect(() => Schema.decodeUnknownSync(Messages.validatePat.input)({})).toThrow() - }) -}) diff --git a/src/lib/schemas-errors.ts b/src/lib/schemas-errors.ts deleted file mode 100644 index 48a3f7b..0000000 --- a/src/lib/schemas-errors.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Schema } from 'effect' - -/** - * Schemas mirroring the runtime `Data.TaggedError` shapes in `src/lib/errors.ts`. - * These exist so failures can be encoded across messaging boundaries - * (background SW ↔ content script) without losing structure, then decoded back - * into the matching tagged error class on the receiving side. - */ - -export const GithubAuthError = Schema.TaggedStruct('GithubAuthError', { - message: Schema.String, -}) -export type GithubAuthError = Schema.Schema.Type - -export const GithubRateLimitError = Schema.TaggedStruct('GithubRateLimitError', { - message: Schema.String, - status: Schema.Number, - retryAfter: Schema.Number, -}) -export type GithubRateLimitError = Schema.Schema.Type - -export const GithubServerError = Schema.TaggedStruct('GithubServerError', { - message: Schema.String, - status: Schema.Number, -}) -export type GithubServerError = Schema.Schema.Type - -export const GithubClientError = Schema.TaggedStruct('GithubClientError', { - message: Schema.String, - status: Schema.Number, -}) -export type GithubClientError = Schema.Schema.Type - -export const GithubGraphQLError = Schema.TaggedStruct('GithubGraphQLError', { - message: Schema.String, -}) -export type GithubGraphQLError = Schema.Schema.Type - -// the runtime class carries `cause: unknown` (set from the underlying -// `fetch` rejection or thrown error). Mirror it here so encode/decode across -// messaging boundaries preserves the failure shape. -export const GithubNetworkError = Schema.TaggedStruct('GithubNetworkError', { - message: Schema.optional(Schema.String), - cause: Schema.Unknown, -}) -export type GithubNetworkError = Schema.Schema.Type - -export const GithubDecodeError = Schema.TaggedStruct('GithubDecodeError', { - message: Schema.String, -}) -export type GithubDecodeError = Schema.Schema.Type - -/** Closed union of every GitHub-domain error this app surfaces. */ -export const GithubError = Schema.Union( - GithubAuthError, - GithubRateLimitError, - GithubServerError, - GithubClientError, - GithubGraphQLError, - GithubNetworkError, - GithubDecodeError, -) -export type GithubError = Schema.Schema.Type - -/** PAT validation outcomes used by the UI. */ -export const PatErrorType = Schema.Literal( - 'expired_or_invalid', - 'missing_scopes', - 'rate_limit', - 'network', - 'unknown', -) -export type PatErrorType = Schema.Schema.Type - -export const PatError = Schema.Struct({ - type: PatErrorType, - title: Schema.String, - message: Schema.String, - actionLabel: Schema.optional(Schema.String), - actionHref: Schema.optional(Schema.String), -}) -export type PatError = Schema.Schema.Type diff --git a/src/lib/schemas-github.ts b/src/lib/schemas-github.ts deleted file mode 100644 index 433e2a7..0000000 --- a/src/lib/schemas-github.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { Schema } from 'effect' - -/** - * Runtime schemas for GitHub GraphQL/REST shapes consumed by the background - * worker. Used to decode raw responses inside `GithubGraphQL.request` so that - * a malformed payload fails fast with `GithubDecodeError` instead of leaking - * `as unknown as T` casts deeper into the codebase. - * - * Schemas intentionally accept loose primitives (Schema.String / Number) for - * IDs at this boundary — branding happens at higher-level domain mappers. - */ - -// ─── Common building blocks ───────────────────────────────────────────────── - -const RepoOwner = Schema.Struct({ login: Schema.String }) -const Repository = Schema.Struct({ - id: Schema.optional(Schema.String), - owner: RepoOwner, - name: Schema.String, -}) - -const Assignee = Schema.Struct({ - id: Schema.String, - login: Schema.String, - name: Schema.optional(Schema.NullOr(Schema.String)), - avatarUrl: Schema.String, -}) - -const Label = Schema.Struct({ - id: Schema.String, - name: Schema.String, - color: Schema.String, -}) - -const IssueType = Schema.Struct({ - id: Schema.String, - name: Schema.String, -}) - -const ParentIssueRef = Schema.Struct({ - id: Schema.String, - databaseId: Schema.Number, - number: Schema.Number, - title: Schema.String, - repository: Schema.Struct({ - owner: RepoOwner, - name: Schema.String, - }), -}) - -// ─── Field values (truly discriminated by `field.dataType`) ───────────────── -// each variant locks `field.dataType` to a single literal so a payload that -// claims dataType=TEXT but ships an `iterationId` fails decode rather than -// silently passing as the first matching variant in the union. - -const fieldRefStruct = (dataType: T) => - Schema.Struct({ - field: Schema.Struct({ - id: Schema.String, - name: Schema.String, - dataType: Schema.Literal(dataType), - }), - }) - -export const TextFieldValue = Schema.extend( - fieldRefStruct('TEXT'), - Schema.Struct({ text: Schema.String }), -) -export const SingleSelectFieldValue = Schema.extend( - fieldRefStruct('SINGLE_SELECT'), - Schema.Struct({ optionId: Schema.String }), -) -export const IterationFieldValue = Schema.extend( - fieldRefStruct('ITERATION'), - Schema.Struct({ iterationId: Schema.String }), -) -export const NumberFieldValue = Schema.extend( - fieldRefStruct('NUMBER'), - Schema.Struct({ number: Schema.Number }), -) -export const DateFieldValue = Schema.extend( - fieldRefStruct('DATE'), - Schema.Struct({ date: Schema.String }), -) - -export const FieldValue = Schema.Union( - TextFieldValue, - SingleSelectFieldValue, - IterationFieldValue, - NumberFieldValue, - DateFieldValue, -) -export type FieldValue = Schema.Schema.Type - -// ─── ProjectItemDetails (GET_PROJECT_ITEM_DETAILS) ────────────────────────── - -export const ProjectItemDetails = Schema.Struct({ - node: Schema.Struct({ - id: Schema.String, - project: Schema.Struct({ id: Schema.String }), - content: Schema.Struct({ - id: Schema.String, - databaseId: Schema.Number, - number: Schema.Number, - title: Schema.String, - body: Schema.String, - state: Schema.Literal('OPEN', 'CLOSED'), - repository: Repository, - assignees: Schema.Struct({ nodes: Schema.Array(Assignee) }), - labels: Schema.Struct({ nodes: Schema.Array(Label) }), - issueType: Schema.optional(Schema.NullOr(IssueType)), - parent: Schema.optional(Schema.NullOr(ParentIssueRef)), - }), - fieldValues: Schema.Struct({ - // loose array — entries that don't match a known FieldValue variant are - // filtered downstream (kept here as `Unknown` so unrelated field kinds - // such as Milestone don't cause whole-payload decode failure). - nodes: Schema.Array(Schema.Unknown), - }), - }), -}) -export type ProjectItemDetails = Schema.Schema.Type - -// ─── FieldsResultProject (GET_PROJECT_FIELDS) ─────────────────────────────── - -const FieldOption = Schema.Struct({ - id: Schema.String, - name: Schema.String, - color: Schema.String, -}) - -const Iteration = Schema.Struct({ - id: Schema.String, - title: Schema.String, - startDate: Schema.String, - duration: Schema.Number, -}) - -const FieldNode = Schema.Struct({ - id: Schema.String, - name: Schema.String, - dataType: Schema.String, - options: Schema.optional(Schema.Array(FieldOption)), - configuration: Schema.optional( - Schema.Struct({ - iterations: Schema.Array(Iteration), - completedIterations: Schema.optional(Schema.Array(Iteration)), - }), - ), -}) - -export const FieldsResultProject = Schema.Struct({ - id: Schema.String, - databaseId: Schema.Number, - title: Schema.String, - fields: Schema.Struct({ nodes: Schema.Array(FieldNode) }), -}) -export type FieldsResultProject = Schema.Schema.Type - -// ─── RelationshipSearchResult (search) ────────────────────────────────────── - -const RelationshipSearchIssueNode = Schema.Struct({ - id: Schema.String, - databaseId: Schema.Number, - number: Schema.Number, - title: Schema.String, - state: Schema.Literal('OPEN', 'CLOSED'), - repository: Schema.Struct({ - owner: RepoOwner, - name: Schema.String, - }), -}) - -export const RelationshipSearchResult = Schema.Struct({ - repository: Schema.optional( - Schema.NullOr( - Schema.Struct({ - issue: Schema.optional(Schema.NullOr(RelationshipSearchIssueNode)), - issues: Schema.optional( - Schema.Struct({ nodes: Schema.Array(RelationshipSearchIssueNode) }), - ), - }), - ), - ), - search: Schema.optional( - Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RelationshipSearchIssueNode)) }), - ), -}) -export type RelationshipSearchResult = Schema.Schema.Type - -// ─── REST: sub-issue + dependency entries ─────────────────────────────────── - -const RestIssuePayload = Schema.Struct({ - id: Schema.optional(Schema.Number), - node_id: Schema.optional(Schema.String), - number: Schema.optional(Schema.Number), - title: Schema.optional(Schema.String), - repository_url: Schema.optional(Schema.String), - html_url: Schema.optional(Schema.String), -}) - -export const RestIssueDependencyEntry = Schema.extend( - RestIssuePayload, - Schema.Struct({ - repository: Schema.optional(Schema.Struct({ full_name: Schema.optional(Schema.String) })), - issue: Schema.optional(RestIssuePayload), - blocking_issue: Schema.optional(RestIssuePayload), - blocked_issue: Schema.optional(RestIssuePayload), - }), -) -export type RestIssueDependencyEntry = Schema.Schema.Type - -export const RestIssueDependencyResponse = Schema.Union( - Schema.Array(RestIssueDependencyEntry), - Schema.Struct({ - dependencies: Schema.optional(Schema.Array(RestIssueDependencyEntry)), - blocking_issues: Schema.optional(Schema.Array(RestIssueDependencyEntry)), - }), -) -export type RestIssueDependencyResponse = Schema.Schema.Type - -export const RestSubIssue = Schema.Struct({ - number: Schema.Number, - title: Schema.String, - state: Schema.String, - repository: Schema.optional( - Schema.Struct({ - full_name: Schema.optional(Schema.String), - owner: Schema.optional(Schema.Struct({ login: Schema.optional(Schema.String) })), - name: Schema.optional(Schema.String), - }), - ), -}) -export type RestSubIssue = Schema.Schema.Type diff --git a/src/lib/schemas-messages.ts b/src/lib/schemas-messages.ts deleted file mode 100644 index 40b3662..0000000 --- a/src/lib/schemas-messages.ts +++ /dev/null @@ -1,688 +0,0 @@ -import { Schema } from 'effect' - -import { ExcludeCondition, SprintSettings } from '@/lib/schemas-storage' -import { PatErrorType } from '@/lib/schemas-errors' - -/** - * Schemas for every entry in `ProtocolMap` (src/lib/messages.ts). - * - * **These schemas are the single source of truth** for the message contract. - * The derived types (`ProtocolMapFromSchemas`, input/output type aliases) are - * re-exported from messages.ts so that `@webext-core/messaging` consumes - * schema-derived types end-to-end. Background handlers use the schemas to: - * - decode untrusted incoming payloads (`Schema.decodeUnknownSync(input)`), - * - encode handler return values (`Schema.encodeSync(output)`). - * - * Where a payload contains a value already validated upstream (e.g. `value: - * Record` for arbitrary field updates), `Schema.Unknown` - * is used here and stricter validation lives at the call-site. - */ - -// ─── Shared building blocks ───────────────────────────────────────────────── - -export const IssueRelationshipData = Schema.Struct({ - nodeId: Schema.optional(Schema.String), - databaseId: Schema.optional(Schema.Number), - number: Schema.Number, - title: Schema.String, - repoOwner: Schema.String, - repoName: Schema.String, -}) -export type IssueRelationshipData = Schema.Schema.Type - -export const IssueSearchResultData = Schema.extend( - IssueRelationshipData, - Schema.Struct({ state: Schema.optional(Schema.Literal('OPEN', 'CLOSED')) }), -) -export type IssueSearchResultData = Schema.Schema.Type - -const BulkEditRelationshipListUpdate = Schema.Struct({ - add: Schema.Array(IssueRelationshipData), - remove: Schema.Array(IssueRelationshipData), - clear: Schema.Boolean, -}) - -const BulkEditRelationshipsUpdate = Schema.Struct({ - parent: Schema.Struct({ - set: Schema.optional(IssueRelationshipData), - clear: Schema.Boolean, - }), - blockedBy: BulkEditRelationshipListUpdate, - blocking: BulkEditRelationshipListUpdate, -}) - -const SubIssueData = Schema.Struct({ - number: Schema.Number, - title: Schema.String, - repoOwner: Schema.String, - repoName: Schema.String, - state: Schema.Literal('OPEN', 'CLOSED'), -}) - -const DuplicateItemPlanRelationshipSection = Schema.Struct({ - enabled: Schema.Boolean, - issue: Schema.optional(IssueRelationshipData), -}) - -const DuplicateItemPlan = Schema.Struct({ - title: Schema.Struct({ enabled: Schema.Boolean, value: Schema.String }), - body: Schema.Struct({ enabled: Schema.Boolean, value: Schema.String }), - assignees: Schema.Struct({ enabled: Schema.Boolean, ids: Schema.Array(Schema.String) }), - labels: Schema.Struct({ enabled: Schema.Boolean, ids: Schema.Array(Schema.String) }), - issueType: Schema.Struct({ - enabled: Schema.Boolean, - id: Schema.optional(Schema.String), - name: Schema.optional(Schema.String), - }), - fieldValues: Schema.Array( - Schema.Struct({ - fieldId: Schema.String, - enabled: Schema.Boolean, - value: Schema.Record({ key: Schema.String, value: Schema.Unknown }), - }), - ), - relationships: Schema.Struct({ - parent: DuplicateItemPlanRelationshipSection, - blockedBy: Schema.Struct({ - enabled: Schema.Boolean, - issues: Schema.Array(IssueRelationshipData), - }), - blocking: Schema.Struct({ - enabled: Schema.Boolean, - issues: Schema.Array(IssueRelationshipData), - }), - }), -}) -export type DuplicateItemPlan = Schema.Schema.Type - -// Shared shape for BulkUpdate/createIssueWithFields inputs. -const BulkUpdateDispatchResult = Schema.Union( - Schema.Struct({ ok: Schema.Literal(true) }), - Schema.Struct({ ok: Schema.Literal(false), reason: Schema.Literal('concurrent') }), -) - -const FieldMetaValue = Schema.Struct({ - name: Schema.String, - options: Schema.optional(Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.String }))), - iterations: Schema.optional( - Schema.Array( - Schema.Struct({ - id: Schema.String, - title: Schema.String, - startDate: Schema.String, - duration: Schema.Number, - }), - ), - ), -}) - -const BulkUpdateFieldUpdate = Schema.Struct({ - fieldId: Schema.String, - value: Schema.Unknown, -}) - -const PreviewFieldEntry = Schema.Struct({ - fieldId: Schema.String, - fieldName: Schema.String, - dataType: Schema.Literal('TEXT', 'SINGLE_SELECT', 'ITERATION', 'NUMBER', 'DATE'), - text: Schema.optional(Schema.String), - number: Schema.optional(Schema.Number), - date: Schema.optional(Schema.String), - optionId: Schema.optional(Schema.String), - optionName: Schema.optional(Schema.String), - optionColor: Schema.optional(Schema.String), - iterationId: Schema.optional(Schema.String), - iterationTitle: Schema.optional(Schema.String), - iterationStartDate: Schema.optional(Schema.String), - options: Schema.optional( - Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.String, color: Schema.String })), - ), - iterations: Schema.optional( - Schema.Array( - Schema.Struct({ - id: Schema.String, - title: Schema.String, - startDate: Schema.String, - duration: Schema.Number, - }), - ), - ), -}) - -export const ItemPreviewData = Schema.Struct({ - resolvedItemId: Schema.String, - issueNumber: Schema.Number, - title: Schema.String, - body: Schema.String, - state: Schema.Literal('OPEN', 'CLOSED'), - repoOwner: Schema.String, - repoName: Schema.String, - assignees: Schema.Array( - Schema.Struct({ id: Schema.String, login: Schema.String, avatarUrl: Schema.String }), - ), - labels: Schema.Array( - Schema.Struct({ id: Schema.String, name: Schema.String, color: Schema.String }), - ), - projectId: Schema.String, - fields: Schema.Array(PreviewFieldEntry), - issueTypeId: Schema.optional(Schema.String), - issueTypeName: Schema.optional(Schema.String), - relationships: Schema.Struct({ - parent: Schema.optional(IssueRelationshipData), - blockedBy: Schema.Array(IssueRelationshipData), - blocking: Schema.Array(IssueRelationshipData), - }), -}) - -const SprintInfo = Schema.Struct({ - id: Schema.String, - title: Schema.String, - startDate: Schema.String, - duration: Schema.Number, - endDate: Schema.String, -}) - -export const SprintProgressData = Schema.Struct({ - totalIssues: Schema.Number, - doneIssues: Schema.Number, - totalPoints: Schema.Number, - donePoints: Schema.Number, - hasPointsField: Schema.Boolean, - pointsFieldName: Schema.String, - scopeAddedIssues: Schema.Number, - scopeAddedPoints: Schema.Number, - recentlyAdded: Schema.Array( - Schema.Struct({ - id: Schema.String, - title: Schema.String, - points: Schema.Number, - assignees: Schema.Array(Schema.Struct({ login: Schema.String, avatarUrl: Schema.String })), - }), - ), -}) - -export const HierarchyData = Schema.Struct({ - resolvedItemId: Schema.String, - issueNumber: Schema.Number, - repoOwner: Schema.String, - repoName: Schema.String, - parent: Schema.optional(IssueRelationshipData), - subIssues: Schema.Array(SubIssueData), - totalSubIssues: Schema.Number, - completedSubIssues: Schema.Number, - blockedBy: Schema.Array(IssueRelationshipData), - blocking: Schema.Array(IssueRelationshipData), -}) - -const FailedItem = Schema.Struct({ - id: Schema.String, - title: Schema.String, - error: Schema.String, -}) - -const RetryContext = Schema.Struct({ - messageType: Schema.String, - data: Schema.Record({ key: Schema.String, value: Schema.Unknown }), -}) - -const ReverseHint = Schema.Struct({ - messageType: Schema.String, - data: Schema.Record({ key: Schema.String, value: Schema.Unknown }), - affectedItemIds: Schema.Array(Schema.String), - label: Schema.optional(Schema.String), - undoWindowMs: Schema.optional(Schema.Number), -}) - -const Empty = Schema.Struct({}) - -// ─── Per-message schema definitions ───────────────────────────────────────── - -/** - * `Messages` is a record keyed by ProtocolMap key. Each entry has: - * - `input` — Schema for `data` argument supplied by `sendMessage(type, data)` - * - `output` — Schema for the value returned to the sender - * - * For void-returning messages the output schema is `Schema.Void`. - */ -export const Messages = { - duplicateItem: { - input: Schema.Struct({ - itemId: Schema.String, - projectId: Schema.String, - plan: Schema.optional(DuplicateItemPlan), - }), - output: Schema.Struct({ accepted: Schema.Boolean }), - }, - getItemPreview: { - input: Schema.Struct({ - itemId: Schema.String, - owner: Schema.String, - number: Schema.Number, - isOrg: Schema.Boolean, - }), - output: ItemPreviewData, - }, - openOptions: { input: Empty, output: Schema.Void }, - getPatStatus: { input: Empty, output: Schema.Struct({ hasPat: Schema.Boolean }) }, - validatePat: { - input: Schema.Struct({ token: Schema.String }), - output: Schema.Union( - Schema.Struct({ valid: Schema.Literal(true), user: Schema.String }), - Schema.Struct({ - valid: Schema.Literal(false), - errorType: Schema.optional(PatErrorType), - errorMessage: Schema.optional(Schema.String), - }), - ), - }, - searchRepoMetadata: { - input: Schema.Struct({ - owner: Schema.String, - name: Schema.String, - q: Schema.String, - type: Schema.Literal('ASSIGNEES', 'LABELS', 'MILESTONES', 'ISSUE_TYPES'), - }), - output: Schema.Array( - Schema.Struct({ - id: Schema.String, - name: Schema.String, - color: Schema.optional(Schema.String), - avatarUrl: Schema.optional(Schema.String), - description: Schema.optional(Schema.String), - }), - ), - }, - searchRelationshipIssues: { - input: Schema.Struct({ - q: Schema.String, - owner: Schema.optional(Schema.String), - repoName: Schema.optional(Schema.String), - }), - output: Schema.Array(IssueSearchResultData), - }, - validateBulkRelationshipUpdates: { - input: Schema.Struct({ - itemIds: Schema.Array(Schema.String), - projectId: Schema.String, - relationships: BulkEditRelationshipsUpdate, - }), - output: Schema.Struct({ errors: Schema.Array(Schema.String) }), - }, - searchTransferTargets: { - input: Schema.Struct({ - owner: Schema.String, - q: Schema.String, - firstItemId: Schema.optional(Schema.String), - projectId: Schema.optional(Schema.String), - scope: Schema.optional(Schema.Literal('owner-only', 'all')), - includeIneligible: Schema.optional(Schema.Boolean), - }), - output: Schema.Array( - Schema.Struct({ - id: Schema.String, - name: Schema.String, - nameWithOwner: Schema.String, - isPrivate: Schema.Boolean, - description: Schema.NullOr(Schema.String), - eligibility: Schema.optional(Schema.Literal('ok', 'archived', 'issues-disabled')), - }), - ), - }, - validateTransferEligibility: { - input: Schema.Struct({ - itemIds: Schema.Array(Schema.String), - projectId: Schema.String, - targetRepoOwner: Schema.String, - targetRepoName: Schema.String, - }), - output: Schema.Array( - Schema.Struct({ - domId: Schema.String, - eligible: Schema.Boolean, - reason: Schema.optional(Schema.Literal('pull-request', 'same-repo', 'unresolved')), - title: Schema.optional(Schema.String), - }), - ), - }, - bulkUpdate: { - input: Schema.Struct({ - itemIds: Schema.Array(Schema.String), - projectId: Schema.String, - updates: Schema.Array(BulkUpdateFieldUpdate), - relationships: Schema.optional(BulkEditRelationshipsUpdate), - fieldMeta: Schema.optional(Schema.Record({ key: Schema.String, value: FieldMetaValue })), - }), - output: BulkUpdateDispatchResult, - }, - createIssueWithFields: { - input: Schema.Struct({ - projectId: Schema.String, - repoOwner: Schema.String, - repoName: Schema.String, - title: Schema.String, - body: Schema.String, - createMore: Schema.Boolean, - updates: Schema.Array(BulkUpdateFieldUpdate), - fieldMeta: Schema.optional(Schema.Record({ key: Schema.String, value: FieldMetaValue })), - assignees: Schema.optional(Schema.Array(Schema.String)), - labels: Schema.optional(Schema.Array(Schema.String)), - }), - output: BulkUpdateDispatchResult, - }, - bulkClose: { - input: Schema.Struct({ - itemIds: Schema.Array(Schema.String), - projectId: Schema.String, - reason: Schema.Literal('COMPLETED', 'NOT_PLANNED'), - }), - output: Schema.Void, - }, - bulkRandomAssign: { - input: Schema.Struct({ - itemIds: Schema.Array(Schema.String), - projectId: Schema.String, - assignments: Schema.Array( - Schema.Struct({ itemId: Schema.String, assigneeIds: Schema.Array(Schema.String) }), - ), - strategy: Schema.Literal('balanced', 'random', 'round-robin'), - }), - output: Schema.Void, - }, - bulkOpen: { - input: Schema.Struct({ - itemIds: Schema.Array(Schema.String), - projectId: Schema.String, - }), - output: Schema.Void, - }, - bulkTransfer: { - input: Schema.Struct({ - itemIds: Schema.Array(Schema.String), - projectId: Schema.String, - targetRepoOwner: Schema.String, - targetRepoName: Schema.String, - }), - output: Schema.Void, - }, - bulkLock: { - input: Schema.Struct({ - itemIds: Schema.Array(Schema.String), - projectId: Schema.String, - lockReason: Schema.NullOr(Schema.Literal('OFF_TOPIC', 'TOO_HEATED', 'RESOLVED', 'SPAM')), - }), - output: Schema.Void, - }, - bulkUnlock: { - input: Schema.Struct({ - itemIds: Schema.Array(Schema.String), - projectId: Schema.String, - }), - output: Schema.Void, - }, - bulkPin: { - input: Schema.Struct({ - itemIds: Schema.Array(Schema.String), - projectId: Schema.String, - }), - output: Schema.Void, - }, - bulkUnpin: { - input: Schema.Struct({ - itemIds: Schema.Array(Schema.String), - projectId: Schema.String, - }), - output: Schema.Void, - }, - bulkDelete: { - input: Schema.Struct({ - itemIds: Schema.Array(Schema.String), - projectId: Schema.String, - }), - output: Schema.Void, - }, - getProjectFields: { - input: Schema.Struct({ - owner: Schema.String, - number: Schema.Number, - isOrg: Schema.Boolean, - }), - output: Schema.Struct({ - id: Schema.String, - title: Schema.String, - fields: Schema.Array( - Schema.Struct({ - id: Schema.String, - name: Schema.String, - dataType: Schema.String, - options: Schema.optional( - Schema.Array( - Schema.Struct({ - id: Schema.String, - name: Schema.String, - color: Schema.optional(Schema.String), - }), - ), - ), - configuration: Schema.optional( - Schema.Struct({ - iterations: Schema.Array( - Schema.Struct({ - id: Schema.String, - title: Schema.String, - startDate: Schema.String, - duration: Schema.Number, - }), - ), - }), - ), - }), - ), - }), - }, - getSprintStatus: { - input: Schema.Struct({ - projectId: Schema.String, - owner: Schema.String, - number: Schema.Number, - isOrg: Schema.Boolean, - }), - output: Schema.Struct({ - hasSettings: Schema.Boolean, - activeSprint: Schema.NullOr(SprintInfo), - nearestUpcoming: Schema.NullOr(SprintInfo), - acknowledgedSprint: Schema.NullOr(SprintInfo), - iterationFieldId: Schema.NullOr(Schema.String), - settings: Schema.NullOr(SprintSettings), - }), - }, - saveSprintSettings: { - input: Schema.Struct({ projectId: Schema.String, settings: SprintSettings }), - output: Schema.Struct({ ok: Schema.Boolean }), - }, - acknowledgeUpcomingSprint: { - input: Schema.Struct({ projectId: Schema.String, iterationId: Schema.String }), - output: Schema.Struct({ ok: Schema.Boolean }), - }, - getSprintProgress: { - input: Schema.Struct({ - projectId: Schema.String, - owner: Schema.String, - number: Schema.Number, - isOrg: Schema.Boolean, - iterationId: Schema.String, - sprintStartDate: Schema.String, - settings: SprintSettings, - }), - output: SprintProgressData, - }, - endSprint: { - input: Schema.Struct({ - projectId: Schema.String, - owner: Schema.String, - number: Schema.Number, - isOrg: Schema.Boolean, - sprintFieldId: Schema.String, - activeIterationId: Schema.String, - nextIterationId: Schema.String, - doneFieldId: Schema.String, - doneFieldType: Schema.Literal('SINGLE_SELECT', 'TEXT'), - doneOptionId: Schema.String, - doneOptionValue: Schema.String, - notStartedOptionId: Schema.optional(Schema.String), - excludeConditions: Schema.Array(ExcludeCondition), - }), - output: Schema.Union(Schema.Void, Schema.Struct({ error: Schema.String })), - }, - getItemTitles: { - input: Schema.Struct({ - itemIds: Schema.Array(Schema.String), - projectId: Schema.String, - }), - output: Schema.Array( - Schema.Struct({ - domId: Schema.String, - issueNodeId: Schema.String, - title: Schema.String, - typename: Schema.Literal('Issue', 'PullRequest'), - }), - ), - }, - bulkRename: { - input: Schema.Struct({ - itemIds: Schema.Array(Schema.String), - projectId: Schema.String, - renames: Schema.Array( - Schema.Struct({ - domId: Schema.String, - issueNodeId: Schema.String, - newTitle: Schema.String, - typename: Schema.Literal('Issue', 'PullRequest'), - }), - ), - }), - output: Schema.Void, - }, - getReorderContext: { - input: Schema.Struct({ - itemIds: Schema.Array(Schema.String), - projectId: Schema.String, - owner: Schema.String, - number: Schema.Number, - isOrg: Schema.Boolean, - allDomIds: Schema.optional(Schema.Array(Schema.String)), - }), - output: Schema.Struct({ - projectId: Schema.String, - allOrderedItems: Schema.Array( - Schema.Struct({ - memexItemId: Schema.Number, - nodeId: Schema.String, - title: Schema.String, - }), - ), - selectedItems: Schema.Array( - Schema.Struct({ - domId: Schema.String, - memexItemId: Schema.Number, - nodeId: Schema.String, - title: Schema.String, - }), - ), - }), - }, - bulkReorder: { - input: Schema.Struct({ - projectId: Schema.String, - reorderOps: Schema.Array( - Schema.Struct({ - nodeId: Schema.String, - previousNodeId: Schema.NullOr(Schema.String), - }), - ), - label: Schema.optional(Schema.String), - }), - output: Schema.Void, - }, - bulkReorderByPosition: { - input: Schema.Struct({ - selectedDomIds: Schema.Array(Schema.String), - insertAfterDomId: Schema.String, - projectId: Schema.String, - owner: Schema.String, - number: Schema.Number, - isOrg: Schema.Boolean, - label: Schema.optional(Schema.String), - allDomIds: Schema.optional(Schema.Array(Schema.String)), - }), - output: Schema.Void, - }, - getHierarchyData: { - input: Schema.Struct({ - itemId: Schema.String, - owner: Schema.String, - number: Schema.Number, - isOrg: Schema.Boolean, - }), - output: HierarchyData, - }, - cancelProcess: { - input: Schema.Struct({ processId: Schema.String }), - output: Schema.Void, - }, - queueStateUpdate: { - input: Schema.Struct({ - total: Schema.Number, - completed: Schema.Number, - paused: Schema.Boolean, - retryAfter: Schema.optional(Schema.Number), - status: Schema.optional(Schema.String), - detail: Schema.optional(Schema.String), - processId: Schema.optional(Schema.String), - label: Schema.optional(Schema.String), - failedItems: Schema.optional(Schema.Array(FailedItem)), - retryContext: Schema.optional(RetryContext), - reverse: Schema.optional(ReverseHint), - }), - output: Schema.Void, - }, -} as const - -// ─── Derived ProtocolMap ──────────────────────────────────────────────────── - -/** - * Extracts the plain TypeScript type from a Schema value declaration. - * `typeof Messages[key].input` is `{ input: Schema }` — this unwraps to `X`. - */ -type SchemaInput = T extends { input: Schema.Schema } ? I : never -type SchemaOutput = T extends { output: Schema.Schema } ? O : never - -/** - * Effect `Schema.Struct`/`Schema.Array` produce deeply `readonly` types, but - * `@webext-core/messaging`'s `ProtocolMap` and its call sites expect plain - * mutable data. Strips `readonly` recursively so the schema-derived map is a - * drop-in for the hand-written one it replaces. - */ -type DeepMutable = T extends readonly (infer U)[] - ? DeepMutable[] - : T extends object - ? { -readonly [K in keyof T]: DeepMutable } - : T - -/** - * Derives the `ProtocolMap` interface from the `Messages` schema record. - * - * Each key `K` becomes `(data: SchemaInput) => SchemaOutput`, - * matching the function-signature syntax expected by `@webext-core/messaging`. - */ -export type ProtocolMapFromSchemas = { - [K in keyof typeof Messages]: ( - data: DeepMutable>, - ) => DeepMutable> -} - -/** - * Convenience type aliases — re-exported from messages.ts so call sites can - * import `XxxData` etc. without knowing about the schema layer. - */ -export type MessageInput = SchemaInput<(typeof Messages)[K]> -export type MessageOutput = SchemaOutput<(typeof Messages)[K]> diff --git a/src/lib/schemas-storage.ts b/src/lib/schemas-storage.ts deleted file mode 100644 index bfe182e..0000000 --- a/src/lib/schemas-storage.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Schema } from 'effect' - -export const ExcludeCondition = Schema.Struct({ - fieldId: Schema.String, - fieldName: Schema.String, - fieldType: Schema.Literal('SINGLE_SELECT', 'TEXT'), - optionId: Schema.String, - optionName: Schema.String, -}) -export type ExcludeCondition = Schema.Schema.Type - -export const SprintSettings = Schema.Struct({ - sprintFieldId: Schema.String, - sprintFieldName: Schema.String, - doneFieldId: Schema.String, - doneFieldName: Schema.String, - doneFieldType: Schema.Literal('SINGLE_SELECT', 'TEXT'), - doneOptionId: Schema.String, - doneOptionName: Schema.String, - acknowledgedSprintId: Schema.optional(Schema.String), - sprintSnapshotAt: Schema.optional(Schema.String), - excludeConditions: Schema.optional(Schema.Array(ExcludeCondition)), - pointsFieldId: Schema.optional(Schema.String), - pointsFieldName: Schema.optional(Schema.String), - notStartedOptionId: Schema.optional(Schema.String), - notStartedOptionName: Schema.optional(Schema.String), -}) -export type SprintSettings = Schema.Schema.Type - -export const AllSprintSettings = Schema.Record({ key: Schema.String, value: SprintSettings }) -export type AllSprintSettings = Schema.Schema.Type diff --git a/src/lib/selection-store.test.ts b/src/lib/selection-store.test.ts index 69f6b61..5d092ed 100644 --- a/src/lib/selection-store.test.ts +++ b/src/lib/selection-store.test.ts @@ -53,7 +53,7 @@ describe('selectionStore', () => { selectionStore.selectBatch(['x', 'y']) selectionStore.clear() expect(selectionStore.count()).toBe(0) - expect(selectionStore.getAll()).toEqualValue([]) + expect(selectionStore.getAll()).toEqual([]) }) // --------------------------------------------------------------------------- diff --git a/src/lib/storage-service.ts b/src/lib/storage-service.ts index 8f5809f..13a5615 100644 --- a/src/lib/storage-service.ts +++ b/src/lib/storage-service.ts @@ -1,29 +1,20 @@ import { Context, Effect, Layer } from 'effect' -import { patStorage, usernameStorage, debugStorage } from '@/lib/storage' +import { patStorage } from '@/lib/storage' /** - * Read-side wrapper for `wxt`'s `storage.defineItem` items. Other Effect - * code consumes Pat / username / debug-flag through this service so that - * tests can substitute the layer with `Layer.succeed(Storage, { ... })`. - * - * Writes intentionally stay on the imperative `storage` API for now — - * existing handlers (`saveSprintSettings`, etc.) call them directly. We can - * widen this service in a later phase if needed. + * Read-side wrapper for the PAT stored via `wxt`'s `storage.defineItem`. + * Effect code reads it through this service so tests can substitute the layer + * with `Layer.succeed(Storage, { ... })`. Writes stay on the imperative + * `storage` API — handlers call those directly. */ -export interface StorageService { +interface StorageService { readonly getPat: Effect.Effect - readonly getUsername: Effect.Effect - readonly getDebug: Effect.Effect } export class Storage extends Context.Tag('rgp/Storage')() {} export const StorageLive: Layer.Layer = Layer.succeed( Storage, - Storage.of({ - getPat: Effect.promise(() => patStorage.getValue()), - getUsername: Effect.promise(() => usernameStorage.getValue()), - getDebug: Effect.promise(() => debugStorage.getValue()), - }), + Storage.of({ getPat: Effect.promise(() => patStorage.getValue()) }), ) diff --git a/src/lib/vitest.setup.ts b/src/lib/vitest.setup.ts deleted file mode 100644 index b1773aa..0000000 --- a/src/lib/vitest.setup.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Vitest setup file for Effect-first tests. - * - * Tests don't pre-register a single global Layer — instead they compose - * per-test Layers via the factories exported from `./effect-test-layers`. - * This keeps each test isolated (no shared mutable state) while still - * letting them share the same `GithubGraphQLLive` contract. - * - * Test-layer factories are NOT re-exported here because they transitively - * import `@/lib/storage`, which depends on the WXT `storage` global that - * only exists once a per-test `vi.mock('@/lib/storage', ...)` is registered. - * Tests that need them should `import { makeRecordedHttpLayer, ... } from - * '@/lib/effect-test-helpers'` directly, after their `vi.mock` call. - * - * This file's sole job is to register the Effect-aware `toEqualValue` - * matcher globally so every test file picks it up. - */ -import '@/lib/effect-assert' diff --git a/src/lib/z-index.test.ts b/src/lib/z-index.test.ts index 294bd61..9962924 100644 --- a/src/lib/z-index.test.ts +++ b/src/lib/z-index.test.ts @@ -3,26 +3,10 @@ import { describe, it, expect } from 'vitest' import { Z_BASE, Z_OVERLAY, Z_MODAL, Z_MODAL_PORTAL, Z_TOOLTIP } from '@/lib/z-index' describe('z-index constants', () => { - it('all constants are numbers', () => { - expect(typeof Z_BASE).toBe('number') - expect(typeof Z_OVERLAY).toBe('number') - expect(typeof Z_MODAL).toBe('number') - expect(typeof Z_MODAL_PORTAL).toBe('number') - expect(typeof Z_TOOLTIP).toBe('number') - }) - - it('constants are ordered from lowest to highest', () => { + it('are ordered from lowest to highest tier', () => { expect(Z_BASE).toBeLessThan(Z_OVERLAY) expect(Z_OVERLAY).toBeLessThan(Z_MODAL) expect(Z_MODAL).toBeLessThan(Z_MODAL_PORTAL) expect(Z_MODAL_PORTAL).toBeLessThan(Z_TOOLTIP) }) - - it('Z_BASE is 1000', () => { - expect(Z_BASE).toBe(1000) - }) - - it('Z_TOOLTIP is the highest tier', () => { - expect(Z_TOOLTIP).toBeGreaterThan(Z_MODAL_PORTAL) - }) }) diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index ee6c6a7..1385c66 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -1,8 +1,8 @@ import React from 'react' import { AlertIcon as PrimerAlertIcon, - ArrowSwitchIcon as PrimerArrowSwitchIcon, ArrowRightIcon as PrimerArrowRightIcon, + ArrowSwitchIcon as PrimerArrowSwitchIcon, CalendarIcon as PrimerCalendarIcon, CheckIcon as PrimerCheckIcon, ChevronDownIcon as PrimerChevronDownIcon, @@ -15,13 +15,13 @@ import { GearIcon as PrimerGearIcon, HashIcon as PrimerHashIcon, InfoIcon as PrimerInfoIcon, - IterationsIcon as PrimerIterationsIcon, IssueClosedIcon as PrimerIssueClosedIcon, IssueReopenedIcon as PrimerIssueReopenedIcon, + IterationsIcon as PrimerIterationsIcon, LockIcon as PrimerLockIcon, PencilIcon as PrimerPencilIcon, - PinIcon as PrimerPinIcon, PersonIcon as PrimerPersonIcon, + PinIcon as PrimerPinIcon, PlusIcon as PrimerPlusIcon, ProjectIcon as PrimerProjectIcon, SearchIcon as PrimerSearchIcon, @@ -30,188 +30,64 @@ import { SlidersIcon as PrimerSlidersIcon, StopwatchIcon as PrimerStopwatchIcon, SyncIcon as PrimerSyncIcon, - TasklistIcon as PrimerTasklistIcon, TagIcon as PrimerTagIcon, + TasklistIcon as PrimerTasklistIcon, TrashIcon as PrimerTrashIcon, TypographyIcon as PrimerTypographyIcon, XIcon as PrimerXIcon, } from '@primer/octicons-react' +/** + * Thin adapters over `@primer/octicons-react`. They exist only to translate + * this codebase's `{ size, color }` convention onto the octicon's + * `{ size, fill }` props — the glyphs themselves come from the package. + */ +type OcticonSize = number | 'small' | 'medium' | 'large' + export type IconProps = { - size?: number | string + size?: OcticonSize color?: string -} - -type PrimerOcticonComponent = React.ComponentType<{ - size?: number | 'small' | 'medium' | 'large' - fill?: string -}> - -function Octicon({ - icon: Icon, - size = 16, - color = 'currentColor', -}: IconProps & { icon: PrimerOcticonComponent }) { - if (typeof size === 'string') { - if (size === 'small' || size === 'medium' || size === 'large') { - return - } - - const numericSize = Number(size) - if (Number.isFinite(numericSize)) { - return - } - - // preserve css-length sizing support from existing IconProps API. - const iconElement = - return ( - - {React.cloneElement(iconElement as React.ReactElement, { - style: { width: '100%', height: '100%' }, - })} - - ) - } - - return -} - -export function CheckIcon(props: IconProps) { - return -} - -export function AlertIcon(props: IconProps) { - return -} - -export function InfoIcon(props: IconProps) { - return -} - -export function XIcon(props: IconProps) { - return -} - -export function GearIcon(props: IconProps) { - return -} - -export function FilterIcon(props: IconProps) { - return -} - -export function IterationsIcon(props: IconProps) { - return -} - -export function SlidersIcon(props: IconProps) { - return -} - -export function PlusIcon(props: IconProps) { - return -} - -export function TrashIcon(props: IconProps) { - return -} - -export function ChevronDownIcon(props: IconProps) { - return -} - -export function CircleSlashIcon(props: IconProps) { - return -} - -export function CopyIcon(props: IconProps) { - return -} - -export function SyncIcon(props: IconProps) { - return -} - -export function PersonIcon({ size = 16, color = 'currentColor' }: IconProps) { - return -} - -export function TagIcon({ size = 16, color = 'currentColor' }: IconProps) { - return -} - -export function ShieldIcon({ size = 16, color = 'currentColor' }: IconProps) { - return -} - -export function HashIcon({ size = 16, color = 'currentColor' }: IconProps) { - return -} - -export function CalendarIcon({ size = 16, color = 'currentColor' }: IconProps) { - return -} - -export function TextLineIcon({ size = 16, color = 'currentColor' }: IconProps) { - return -} - -export function IssueClosedIcon({ size = 16, color = 'currentColor' }: IconProps) { - return -} - -export function IssueReopenedIcon({ size = 16, color = 'currentColor' }: IconProps) { - return -} - -export function OptionsSelectIcon({ size = 16, color = 'currentColor' }: IconProps) { - return -} - -export function ListCheckIcon({ size = 16, color = 'currentColor' }: IconProps) { - return -} - -export function ProjectBoardIcon({ size = 16, color = 'currentColor' }: IconProps) { - return -} - -export function SprintIcon(props: IconProps) { - return -} - -export function LockIcon({ size = 16, color = 'currentColor' }: IconProps) { - return -} - -export function PinIcon({ size = 16, color = 'currentColor' }: IconProps) { - return -} - -export function ArrowRightIcon({ size = 16, color = 'currentColor' }: IconProps) { - return -} - -export function DownloadIcon(props: IconProps) { - return -} - -export function SearchIcon(props: IconProps) { - return -} - -export function PencilIcon(props: IconProps) { - return -} - -export function MoveIcon(props: IconProps) { - return -} - -export function EyeIcon(props: IconProps) { - return -} - -export function EyeOffIcon(props: IconProps) { - return -} + children?: React.ReactNode +} + +type Octicon = React.ComponentType<{ size?: OcticonSize; fill?: string }> + +const icon = + (Glyph: Octicon) => + ({ size = 16, color = 'currentColor' }: IconProps) => + +export const AlertIcon = icon(PrimerAlertIcon) +export const ArrowRightIcon = icon(PrimerArrowRightIcon) +export const CalendarIcon = icon(PrimerCalendarIcon) +export const CheckIcon = icon(PrimerCheckIcon) +export const ChevronDownIcon = icon(PrimerChevronDownIcon) +export const CircleSlashIcon = icon(PrimerCircleSlashIcon) +export const CopyIcon = icon(PrimerCopyIcon) +export const DownloadIcon = icon(PrimerDownloadIcon) +export const EyeIcon = icon(PrimerEyeIcon) +export const EyeOffIcon = icon(PrimerEyeClosedIcon) +export const FilterIcon = icon(PrimerFilterIcon) +export const GearIcon = icon(PrimerGearIcon) +export const HashIcon = icon(PrimerHashIcon) +export const InfoIcon = icon(PrimerInfoIcon) +export const IssueClosedIcon = icon(PrimerIssueClosedIcon) +export const IssueReopenedIcon = icon(PrimerIssueReopenedIcon) +export const IterationsIcon = icon(PrimerIterationsIcon) +export const ListCheckIcon = icon(PrimerTasklistIcon) +export const LockIcon = icon(PrimerLockIcon) +export const MoveIcon = icon(PrimerArrowSwitchIcon) +export const OptionsSelectIcon = icon(PrimerSingleSelectIcon) +export const PencilIcon = icon(PrimerPencilIcon) +export const PersonIcon = icon(PrimerPersonIcon) +export const PinIcon = icon(PrimerPinIcon) +export const PlusIcon = icon(PrimerPlusIcon) +export const ProjectBoardIcon = icon(PrimerProjectIcon) +export const SearchIcon = icon(PrimerSearchIcon) +export const ShieldIcon = icon(PrimerShieldIcon) +export const SlidersIcon = icon(PrimerSlidersIcon) +export const SprintIcon = icon(PrimerStopwatchIcon) +export const SyncIcon = icon(PrimerSyncIcon) +export const TagIcon = icon(PrimerTagIcon) +export const TextLineIcon = icon(PrimerTypographyIcon) +export const TrashIcon = icon(PrimerTrashIcon) +export const XIcon = icon(PrimerXIcon) diff --git a/src/ui/modal-shell.tsx b/src/ui/modal-shell.tsx index 018d585..e38e75a 100644 --- a/src/ui/modal-shell.tsx +++ b/src/ui/modal-shell.tsx @@ -73,9 +73,7 @@ export function ModalShell({ > {header} {children} - {footer && ( - {footer} - )} + {footer && {footer}} ) diff --git a/src/ui/modal-step-header.tsx b/src/ui/modal-step-header.tsx index 22785cc..e9c366b 100644 --- a/src/ui/modal-step-header.tsx +++ b/src/ui/modal-step-header.tsx @@ -1,7 +1,27 @@ import React from 'react' import { Box, Button, Heading, Text } from '@primer/react' import { XIcon } from '@/ui/icons' -import { StepIndicator } from '@/ui/step-indicator' +import { primerCss } from '@/lib/primer-css-helper' + +function StepIndicator({ current, total }: { current: number; total: number }) { + return ( + + {Array.from({ length: total }, (_, i) => ( + + ))} + + ) +} type ModalStepHeaderProps = | { @@ -55,13 +75,7 @@ export function ModalStepHeader({ boxShadow: 'none', px: 2, flexShrink: 0, - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, + ...primerCss.buttonMotion(), }} > ← @@ -102,14 +116,7 @@ export function ModalStepHeader({ p: '4px', minWidth: 'unset', color: 'fg.muted', - boxShadow: 'none', - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, + ...primerCss.buttonMotion(), }} > diff --git a/src/ui/search-select-panel.tsx b/src/ui/search-select-panel.tsx index a6eec6c..c4788d7 100644 --- a/src/ui/search-select-panel.tsx +++ b/src/ui/search-select-panel.tsx @@ -4,6 +4,7 @@ import type { SelectPanelItemInput, SelectPanelItemProps } from '@primer/react' import { TriangleDownIcon } from '@primer/octicons-react' import { BULK_BAR_PRIMER_PORTAL_NAME } from '@/lib/primer-shadow-dom-compat' import { Z_MODAL_PORTAL } from '@/lib/z-index' +import { primerCss } from '@/lib/primer-css-helper' export type SearchSelectPanelMessage = { title: string @@ -20,25 +21,11 @@ export type SearchSelectPanelOption = { type SearchSelectPanelWidth = 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' | 'auto' type SelectedPlacement = 'selected-first' | 'selected-first-when-filter-empty' | 'results-only' -type SelectPanelGesture = - | 'anchor-click' - | 'anchor-key-press' - | 'click-outside' - | 'escape' - | 'selection' - | 'cancel' - const anchorButtonSx = { boxShadow: 'none', width: '100%', justifyContent: 'space-between', - transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)', transition: '100ms' }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover:not(:disabled)': { transform: 'none' }, - }, + ...primerCss.buttonMotion(), } as const interface CommonProps { @@ -57,7 +44,6 @@ interface CommonProps { selectedPlacement?: SelectedPlacement anchorAriaLabel?: string portalContainerName?: string - debugName?: string } interface MultiProps extends CommonProps { @@ -75,23 +61,6 @@ function mapSelectedValues(selected: T[] | T | undefined): T[] { return selected ? [selected] : [] } -function summarizeDebugValue(value: unknown): unknown { - if (Array.isArray(value)) return value.map(summarizeDebugValue) - - if (value && typeof value === 'object') { - const record = value as Record - const summary: Record = {} - - for (const key of ['id', 'name', 'text', 'description', 'nameWithOwner']) { - if (key in record) summary[key] = record[key] - } - - if (Object.keys(summary).length > 0) return summary - } - - return value -} - export function SearchSelectPanel(props: MultiProps): React.ReactElement export function SearchSelectPanel(props: SingleProps): React.ReactElement export function SearchSelectPanel(props: MultiProps | SingleProps) { @@ -111,7 +80,6 @@ export function SearchSelectPanel(props: MultiProps | SingleProps) { selectedPlacement = 'selected-first', anchorAriaLabel, portalContainerName = BULK_BAR_PRIMER_PORTAL_NAME, - debugName, } = props const [filterQuery, setFilterQuery] = useState('') @@ -121,20 +89,6 @@ export function SearchSelectPanel(props: MultiProps | SingleProps) { const [panelOpen, setPanelOpen] = useState(false) const requestGen = useRef(0) - const log = useCallback( - (message: string, payload?: unknown) => { - if (!debugName) return - - if (typeof payload === 'undefined') { - console.log(`[${debugName}] ${message}`) - return - } - - console.log(`[${debugName}] ${message}`, payload) - }, - [debugName], - ) - const isMultiSelect = Array.isArray(props.selected) const selectedValues = useMemo(() => mapSelectedValues(props.selected), [props.selected]) @@ -213,27 +167,19 @@ export function SearchSelectPanel(props: MultiProps | SingleProps) { } }, [fetchError, fetching, panelItems.length]) - const handleOpenChange = useCallback( - (open: boolean, gesture?: SelectPanelGesture) => { - log('onOpenChange', { open, gesture }) - setPanelOpen(open) + const handleOpenChange = useCallback((open: boolean) => { + setPanelOpen(open) - if (!open) { - requestGen.current += 1 - setFilterQuery('') - setFetchError(null) - } - }, - [log], - ) + if (!open) { + requestGen.current += 1 + setFilterQuery('') + setFetchError(null) + } + }, []) - const handleFilterChange = useCallback( - (nextFilterQuery: string) => { - log('onFilterChange', { filterQuery: nextFilterQuery }) - setFilterQuery(nextFilterQuery) - }, - [log], - ) + const handleFilterChange = useCallback((nextFilterQuery: string) => { + setFilterQuery(nextFilterQuery) + }, []) useEffect(() => { if (!panelOpen || disabled) return @@ -242,23 +188,17 @@ export function SearchSelectPanel(props: MultiProps | SingleProps) { const delay = trimmedQuery === '' ? 0 : 300 const gen = ++requestGen.current const timeoutId = window.setTimeout(() => { - log('search:start', { filterQuery: trimmedQuery, requestGen: gen, delay }) setFetching(true) setFetchError(null) search(trimmedQuery) .then((items) => { if (requestGen.current !== gen) return - log('search:success', { - filterQuery: trimmedQuery, - requestGen: gen, - resultCount: items.length, - }) setResults(items) }) .catch((error) => { if (requestGen.current !== gen) return - console.error(`[${debugName ?? 'SearchSelectPanel'}] search:error`, { + console.error('[SearchSelectPanel] search:error', { filterQuery: trimmedQuery, requestGen: gen, error, @@ -268,24 +208,22 @@ export function SearchSelectPanel(props: MultiProps | SingleProps) { }) .finally(() => { if (requestGen.current !== gen) return - log('search:complete', { filterQuery: trimmedQuery, requestGen: gen }) setFetching(false) }) }, delay) return () => window.clearTimeout(timeoutId) - }, [debugName, disabled, filterQuery, log, panelOpen, search, searchErrorMessage]) + }, [disabled, filterQuery, panelOpen, search, searchErrorMessage]) useEffect(() => { if (!disabled) return - log('panel:disabled-reset') requestGen.current += 1 // eslint-disable-next-line react-hooks/set-state-in-effect -- reset panel state when the control is disabled setPanelOpen(false) setFilterQuery('') setFetchError(null) - }, [disabled, log]) + }, [disabled]) const renderAnchor = useCallback( ({ children, ...anchorProps }: React.ComponentProps) => ( @@ -366,11 +304,6 @@ export function SearchSelectPanel(props: MultiProps | SingleProps) { .map((item) => getOptionValue(item.id)) .filter((item): item is T => typeof item !== 'undefined') - log('onSelectedChange:multi', { - rawItems: items.map((item) => ({ id: item.id, text: item.text })), - resolvedItems: summarizeDebugValue(nextSelected), - }) - multiProps.onSelectedChange(nextSelected) }} /> @@ -388,13 +321,6 @@ export function SearchSelectPanel(props: MultiProps | SingleProps) { onSelectedChange={(item: SelectPanelItemInput | undefined) => { const nextSelected = getOptionValue(item?.id) - log('onSelectedChange:single', { - rawItem: item - ? { id: item.id, text: item.text, description: item.description } - : undefined, - resolvedItem: summarizeDebugValue(nextSelected), - }) - singleProps.onSelectedChange(nextSelected) }} /> diff --git a/src/ui/step-indicator.tsx b/src/ui/step-indicator.tsx deleted file mode 100644 index 439d3bf..0000000 --- a/src/ui/step-indicator.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react' -import { Box } from '@primer/react' - -interface StepIndicatorProps { - current: number // 1-based - total: number -} - -export function StepIndicator({ current, total }: StepIndicatorProps) { - return ( - - {Array.from({ length: total }, (_, i) => ( - - ))} - - ) -} diff --git a/vitest.config.ts b/vitest.config.ts index a3bd231..f60f4d7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,8 +9,10 @@ export default defineConfig({ }, test: { environment: 'happy-dom', + // the suite runs 40+ files in parallel workers; a Primer-heavy render can + // spend >5s waiting on CPU even though its own work takes ~500ms. + testTimeout: 15000, include: ['src/**/*.test.{ts,tsx}'], - setupFiles: ['./src/lib/vitest.setup.ts'], server: { deps: { inline: [/@primer\/react/, /@primer\/octicons-react/, /@primer\/live-region-element/],