diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index cf50fd2..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,97 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Browser extension built with **WXT 0.20** (extension framework) + **Svelte 5** + **TypeScript**, targeting Chrome MV3 with Firefox support. - -## Commands - -```bash -pnpm dev # Dev server with hot reload (Chrome) -pnpm dev:firefox # Dev server (Firefox) -pnpm build # Production build (Chrome) -pnpm build:firefox # Production build (Firefox) -pnpm zip # Create distribution ZIP -pnpm format # Biome formatter -pnpm lint # Biome linter -pnpm check # Svelte type checking (svelte-check) -pnpm test # Run tests (vitest) -pnpm test:watch # Run tests in watch mode -``` - -## Architecture - -### Entry Points - -WXT auto-discovers entry points from `src/entrypoints/`. Each file/directory becomes part of the extension manifest automatically: - -- **`background.ts`** — Service worker. Uses `defineBackground()` wrapper. Handles message passing from content scripts. -- **`github.content/`** — Content script for GitHub pages. Uses `defineContentScript()` with match patterns. Re-runs features on SPA navigation via `wxt:locationchange`. - - **`features/`** — Each feature exports an `init()` function that checks page context and mounts Svelte components as needed. -- **`popup/`** — Browser action popup UI. `index.html` → `main.ts` → mounts Svelte `App.svelte`. -- **`options/`** — Extension options page. Same pattern as popup. - -### Shared Code - -- `src/lib/` — Shared utilities (`github-api.ts`, `storage.ts`, `messages.ts`, `types.ts`) and Svelte components - -### Global Imports - -WXT auto-injects these globally (no import needed): - -- `browser` — WebExtensions API -- `storage` — WXT storage utilities -- `defineBackground`, `defineContentScript` — Entry point wrappers -- `createShadowRootUi`, `createIntegratedUi`, `createIframeUi` — UI helpers for content scripts - -### Path Aliases - -- `@` / `~` → `src/` -- `@@` / `~~` → project root - -### Environment Variables - -Available via `import.meta.env`: - -- `MANIFEST_VERSION` (2 | 3), `BROWSER`, `CHROME`, `FIREFOX`, `SAFARI`, `EDGE`, `OPERA`, `COMMAND` ("build" | "serve"), `ENTRYPOINT` - -## Testing - -- **Vitest** with `WxtVitest()` plugin for browser API mocking -- Test files co-located with source: `*.test.ts` (not in `__tests__/` directories) -- `fakeBrowser` from `wxt/testing` for storage/runtime mocks — call `fakeBrowser.reset()` in `beforeEach` -- `vi.stubGlobal("fetch", ...)` for API tests -- Svelte components are mocked in unit tests to avoid jsdom dependency - -## Code Style - -- **Biome** for formatting and linting (not ESLint/Prettier) -- Double quotes, `organizeImports` enabled -- Svelte files have `noUnusedVariables` turned off (Svelte runes trigger false positives) - -## Patterns - -- **Result types**: Discriminated unions for success/failure (`{ ok: true; data } | { ok: false; reason }`) instead of thrown errors -- **Storage**: `storage.defineItem()` with typed cache keys (`` `local:${string}` ``), 24-hour TTL on cached data -- **Svelte fragments**: `svelte.config.js` sets `compilerOptions.fragments: "tree"` for CSP compliance (avoids innerHTML) - -## Key Files - -- `wxt.config.ts` — WXT config (srcDir, modules, Firefox manifest overrides) -- `svelte.config.js` — Svelte compiler options (fragments mode) -- `biome.json` — Formatter and linter config -- `.wxt/` — Auto-generated types and config (don't edit) -- `.output/` — Build output (load as unpacked extension for dev) -- `public/` — Static assets copied to build (icons, etc.) - -## Conventions - -- Package manager: **pnpm** -- Node 22 (pinned in `.nvmrc`) -- ES modules (`"type": "module"`) -- Svelte 5 runes syntax (`$state`, `$derived`, `$effect`, `$props`) -- MV3-first design; Firefox builds use MV2 compatibility layer via WXT -- Markdownlint is active in the IDE — prefer pure markdown over inline HTML where possible -- GitHub repo: `cheefbird/grody-github` diff --git a/README.md b/README.md index 7a98509..057cd31 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ GitHub's UI is fine. Until it isn't. If you've ever scrolled through a sidebar o Grody GitHub is a Chrome extension that bolts small, targeted fixes onto GitHub's interface. One feature at a time. No bloat. -## Features +## Core Features + +Core features are enabled by default and may/may not have an option to disable. Requests to change this are always welcome, just create a discussion first. ### Workflow Sidebar Filter @@ -25,9 +27,22 @@ Adds a search box to the Actions sidebar so you can actually find your workflows - Works with dark theme - Handles repos with hundreds of workflows without breaking a sweat -## Install +### Optional Features + +Optional features are opt-in only. You've gotta go to the options to turn it on. + +### GitHub Incident Status + +GitHub is basically always having an incident these days. This puts a banner below the header so you know before you start blaming your code. + +- Opt-in by enabling from the options screen +- Polls GitHub's status API in the background — defaults to 15 min, but you can change it in options +- Color-coded severity with a details popover showing per-component breakdown +- Dismissible, remembers your preference, auto-clears on resolve + +## Install Locally -Not on the Chrome Web Store yet. For now, sideload it: +If you want to contribute, or just build the extension from source for whatever reason, here you go: ```bash git clone https://github.com/cheefbird/grody-github.git @@ -41,7 +56,7 @@ Then in Chrome: 1. Go to `chrome://extensions` 2. Enable **Developer mode** (top right) 3. Click **Load unpacked** -4. Select the `.output/chrome-mv3` directory +4. Select the `~/path/to/grody-github/.output/chrome-mv3` directory from the repo root on your machine ## Setup @@ -58,6 +73,9 @@ Then in Chrome: ```bash pnpm dev # Dev server with hot reload (Chrome) pnpm dev:firefox # Dev server (Firefox) +pnpm format # Biome formatter +pnpm lint # Biome linter +pnpm biocheck # Biome check (format + lint) pnpm check # Svelte type checking ``` diff --git a/src/entrypoints/github.content/features/status-indicator.ts b/src/entrypoints/github.content/features/status-indicator.ts deleted file mode 100644 index 7c78b05..0000000 --- a/src/entrypoints/github.content/features/status-indicator.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { mount, unmount } from "svelte"; -import type { ContentScriptContext } from "#imports"; -import { enabledStorage } from "@/lib/github-status"; -import StatusBannerHost from "../StatusBannerHost.svelte"; - -function findHeader(): HTMLElement | null { - return document.querySelector( - "header.AppHeader, header[class*='appHeader'], header.HeaderMktg, div.Header", - ); -} - -export async function initStatusIndicator(ctx: ContentScriptContext) { - const enabled = await enabledStorage.getValue(); - if (!enabled) return; - - const header = findHeader(); - if (!header) return; - - const existing = document.getElementById("grody-github-status"); - if (existing) existing.remove(); - - const container = document.createElement("div"); - container.id = "grody-github-status"; - container.style.position = "relative"; - container.style.zIndex = "33"; - header.after(container); - - let app: ReturnType | null = mount(StatusBannerHost, { - target: container, - }); - - ctx.onInvalidated(() => { - if (app) { - unmount(app); - app = null; - } - container.remove(); - }); -} diff --git a/src/entrypoints/github.content/features/workflow-filter.test.ts b/src/entrypoints/github.content/features/workflow-filter.test.ts deleted file mode 100644 index 26e0015..0000000 --- a/src/entrypoints/github.content/features/workflow-filter.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -// Mock the Svelte component import so the module can be loaded in Node -vi.mock("../WorkflowFilter.svelte", () => ({ default: {} })); - -import { ACTIONS_PATTERN, parseRepo } from "./workflow-filter"; - -describe("ACTIONS_PATTERN", () => { - it("matches /owner/repo/actions", () => { - expect(ACTIONS_PATTERN.test("/owner/repo/actions")).toBe(true); - }); - - it("matches /owner/repo/actions/", () => { - expect(ACTIONS_PATTERN.test("/owner/repo/actions/")).toBe(true); - }); - - it("matches /owner/repo/actions/workflows/ci.yml", () => { - expect(ACTIONS_PATTERN.test("/owner/repo/actions/workflows/ci.yml")).toBe( - true, - ); - }); - - it("does not match /owner/repo/pulls", () => { - expect(ACTIONS_PATTERN.test("/owner/repo/pulls")).toBe(false); - }); - - it("does not match /owner/repo/action (no trailing s)", () => { - expect(ACTIONS_PATTERN.test("/owner/repo/action")).toBe(false); - }); -}); - -describe("parseRepo", () => { - it("parses /owner/repo/actions correctly", () => { - expect(parseRepo("/owner/repo/actions")).toEqual({ - owner: "owner", - repo: "repo", - }); - }); - - it("handles trailing slashes", () => { - expect(parseRepo("/owner/repo/")).toEqual({ owner: "owner", repo: "repo" }); - }); - - it("returns null for paths with fewer than 2 segments", () => { - expect(parseRepo("/owner")).toBeNull(); - }); - - it("returns null for root path", () => { - expect(parseRepo("/")).toBeNull(); - }); -}); diff --git a/src/entrypoints/github.content/features/workflow-filter.ts b/src/entrypoints/github.content/features/workflow-filter.ts deleted file mode 100644 index 10c66cc..0000000 --- a/src/entrypoints/github.content/features/workflow-filter.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { mount, unmount } from "svelte"; -import type { ContentScriptContext } from "#imports"; -import WorkflowFilter from "../WorkflowFilter.svelte"; - -const SIDEBAR_NAV_SELECTOR = 'nav[aria-label="Actions Workflows"] ul'; -const SHOW_MORE_SELECTOR = '[data-action*="nav-list-group#showMore"]'; -export const ACTIONS_PATTERN = /^\/[^/]+\/[^/]+\/actions(\/|$)/; - -export function parseRepo( - pathname: string = location.pathname, -): { owner: string; repo: string } | null { - const parts = pathname.split("/").filter(Boolean); - if (parts.length < 2) return null; - return { owner: parts[0], repo: parts[1] }; -} - -function findNavList(): HTMLElement | null { - if (!ACTIONS_PATTERN.test(location.pathname)) return null; - - const navList = document.querySelector(SIDEBAR_NAV_SELECTOR); - if (!navList) return null; - const showMore = navList - .closest("nav") - ?.querySelector(SHOW_MORE_SELECTOR); - if (!showMore) return null; - const totalPages = Number(showMore.dataset.totalPages ?? "1"); - if (totalPages <= 1) return null; - return navList; -} - -let currentApp: ReturnType | null = null; -let currentContainer: HTMLElement | null = null; - -function teardown() { - if (currentApp) { - unmount(currentApp); - currentApp = null; - } - if (currentContainer) { - currentContainer.remove(); - currentContainer = null; - } -} - -export function initWorkflowFilter(_ctx: ContentScriptContext) { - teardown(); - - const navList = findNavList(); - if (!navList) return; - - const repoInfo = parseRepo(); - if (!repoInfo) return; - - // Find the workflows section to insert before it - const workflowsSection = navList.querySelector( - ":scope > li:has(nav-list-group)", - ); - if (!workflowsSection) return; - - // Mount directly into the DOM at the right position - const container = document.createElement("div"); - workflowsSection.before(container); - currentContainer = container; - - currentApp = mount(WorkflowFilter, { - target: container, - props: { - owner: repoInfo.owner, - repo: repoInfo.repo, - navList, - }, - }); -} diff --git a/src/entrypoints/github.content/index.ts b/src/entrypoints/github.content/index.ts deleted file mode 100644 index 71cf434..0000000 --- a/src/entrypoints/github.content/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { initStatusIndicator } from "./features/status-indicator"; -import { initWorkflowFilter } from "./features/workflow-filter"; - -export default defineContentScript({ - matches: ["*://github.com/*"], - - main(ctx) { - initStatusIndicator(ctx); - - initWorkflowFilter(ctx); - ctx.addEventListener(window, "wxt:locationchange", () => { - initWorkflowFilter(ctx); - }); - }, -}); diff --git a/src/entrypoints/grody.content/feature-manager.test.ts b/src/entrypoints/grody.content/feature-manager.test.ts new file mode 100644 index 0000000..306ef90 --- /dev/null +++ b/src/entrypoints/grody.content/feature-manager.test.ts @@ -0,0 +1,277 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ContentScriptContext } from "wxt/utils/content-script-context"; +import type { FeatureDefinition } from "@/lib/feature-types"; +import { createFeatureManager } from "./feature-manager"; + +vi.mock("./page-context", () => ({ + buildPageContext: vi.fn((url: URL) => ({ + url, + pathname: url.pathname, + owner: url.pathname.split("/")[1] || undefined, + repo: url.pathname.split("/")[2] || undefined, + })), +})); + +function makeFeature( + overrides: Partial = {}, +): FeatureDefinition { + return { + id: "test-feature", + reinitOnNavigation: false, + init: vi.fn(), + ...overrides, + }; +} + +function mockCtx() { + const callbacks: Array<() => void> = []; + return { + onInvalidated: (cb: () => void) => { + callbacks.push(cb); + }, + _invalidate: () => { + for (const cb of callbacks) cb(); + }, + } as unknown as ContentScriptContext & { _invalidate: () => void }; +} + +const ACTIONS_URL = new URL("https://github.com/owner/repo/actions"); +const HOME_URL = new URL("https://github.com/"); + +describe("createFeatureManager", () => { + beforeEach(() => { + vi.stubGlobal("location", HOME_URL); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("run", () => { + it("initializes features with no include/exclude on any page", async () => { + const feature = makeFeature(); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.run(); + + expect(feature.init).toHaveBeenCalledOnce(); + }); + + it("does not initialize features when include predicate does not match", async () => { + const isActions = (ctx: { pathname: string }) => + ctx.pathname.includes("/actions"); + const feature = makeFeature({ + id: "actions-only", + include: [isActions], + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.run(); + + expect(feature.init).not.toHaveBeenCalled(); + }); + + it("skips features when exclude predicate matches", async () => { + const always = () => true; + const feature = makeFeature({ + id: "excluded", + exclude: [always], + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.run(); + + expect(feature.init).not.toHaveBeenCalled(); + }); + + it("exclude takes precedence over include", async () => { + const always = () => true; + const feature = makeFeature({ + id: "both", + include: [always], + exclude: [always], + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.run(); + + expect(feature.init).not.toHaveBeenCalled(); + }); + + it("isolates errors — one feature throwing does not prevent others", async () => { + const badFeature = makeFeature({ + id: "bad", + init: vi.fn().mockRejectedValue(new Error("boom")), + }); + const goodFeature = makeFeature({ id: "good" }); + const ctx = mockCtx(); + const manager = createFeatureManager([badFeature, goodFeature], ctx); + + await manager.run(); + + expect(goodFeature.init).toHaveBeenCalledOnce(); + }); + + it("initializes features when run() URL matches include predicate", async () => { + vi.stubGlobal( + "location", + new URL("https://github.com/owner/repo/actions"), + ); + const isActions = (ctx: { pathname: string }) => + ctx.pathname.includes("/actions"); + const feature = makeFeature({ + id: "actions-only", + include: [isActions], + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.run(); + + expect(feature.init).toHaveBeenCalledOnce(); + }); + + it("provides an AbortSignal to each feature init", async () => { + const feature = makeFeature({ + init: vi.fn((_ctx, signal) => { + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal.aborted).toBe(false); + }), + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.run(); + + expect(feature.init).toHaveBeenCalledOnce(); + }); + }); + + describe("onNavigate", () => { + it("re-initializes features with reinitOnNavigation: true", async () => { + const feature = makeFeature({ + id: "reinit", + reinitOnNavigation: true, + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.run(); + expect(feature.init).toHaveBeenCalledOnce(); + + await manager.onNavigate(ACTIONS_URL); + expect(feature.init).toHaveBeenCalledTimes(2); + }); + + it("does NOT re-initialize features with reinitOnNavigation: false", async () => { + let capturedSignal: AbortSignal | null = null; + const feature = makeFeature({ + id: "no-reinit", + reinitOnNavigation: false, + init: vi.fn((_ctx, signal) => { + capturedSignal = signal; + }), + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.run(); + expect(feature.init).toHaveBeenCalledOnce(); + expect(capturedSignal).not.toBeNull(); + const signal = capturedSignal as unknown as AbortSignal; + + await manager.onNavigate(ACTIONS_URL); + expect(feature.init).toHaveBeenCalledOnce(); // still 1 + expect(signal.aborted).toBe(false); // signal preserved + }); + + it("aborts the previous signal before re-initializing", async () => { + let capturedSignal: AbortSignal | null = null; + const feature = makeFeature({ + id: "signal-test", + reinitOnNavigation: true, + init: vi.fn((_ctx, signal) => { + capturedSignal = signal; + }), + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.run(); + expect(capturedSignal).not.toBeNull(); + const firstSignal = capturedSignal as unknown as AbortSignal; + expect(firstSignal.aborted).toBe(false); + + await manager.onNavigate(ACTIONS_URL); + expect(firstSignal.aborted).toBe(true); + }); + + it("evaluates include/exclude against the new URL", async () => { + const isActions = (ctx: { pathname: string }) => + ctx.pathname.includes("/actions"); + const feature = makeFeature({ + id: "actions-only", + include: [isActions], + reinitOnNavigation: true, + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.onNavigate(ACTIONS_URL); + expect(feature.init).toHaveBeenCalledOnce(); + + await manager.onNavigate(HOME_URL); + expect(feature.init).toHaveBeenCalledOnce(); // still 1 + }); + }); + + describe("invalidation", () => { + it("aborts all active signals on context invalidation", async () => { + let capturedSignal: AbortSignal | null = null; + const feature = makeFeature({ + init: vi.fn((_ctx, signal) => { + capturedSignal = signal; + }), + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.run(); + expect(capturedSignal).not.toBeNull(); + const signal = capturedSignal as unknown as AbortSignal; + expect(signal.aborted).toBe(false); + + ctx._invalidate(); + expect(signal.aborted).toBe(true); + }); + }); + + describe("teardown error isolation", () => { + it("does not break other features when an abort handler throws", async () => { + const badFeature = makeFeature({ + id: "bad-teardown", + reinitOnNavigation: true, + init: vi.fn((_ctx, signal) => { + signal.addEventListener("abort", () => { + throw new Error("teardown boom"); + }); + }), + }); + const goodFeature = makeFeature({ + id: "good", + reinitOnNavigation: true, + }); + const ctx = mockCtx(); + const manager = createFeatureManager([badFeature, goodFeature], ctx); + + await manager.run(); + await manager.onNavigate(ACTIONS_URL); + + expect(goodFeature.init).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/entrypoints/grody.content/feature-manager.ts b/src/entrypoints/grody.content/feature-manager.ts new file mode 100644 index 0000000..aa21f0a --- /dev/null +++ b/src/entrypoints/grody.content/feature-manager.ts @@ -0,0 +1,75 @@ +import type { ContentScriptContext } from "wxt/utils/content-script-context"; +import type { FeatureDefinition, PageContext } from "@/lib/feature-types"; +import { buildPageContext } from "./page-context"; + +export function createFeatureManager( + features: FeatureDefinition[], + ctx: ContentScriptContext, +) { + if (import.meta.env.DEV) { + const ids = features.map((f) => f.id); + const dupes = ids.filter((id, i) => ids.indexOf(id) !== i); + if (dupes.length > 0) { + console.error(`[grody] duplicate feature IDs: ${dupes.join(", ")}`); + } + } + + const activeControllers = new Map(); + + function shouldRun( + feature: FeatureDefinition, + pageCtx: PageContext, + ): boolean { + if (feature.exclude?.some((fn) => fn(pageCtx))) return false; + if (feature.include && !feature.include.some((fn) => fn(pageCtx))) + return false; + return true; + } + + function teardown(featureId: string) { + try { + activeControllers.get(featureId)?.abort(); + } catch (err) { + console.error(`[grody] feature "${featureId}" teardown error:`, err); + } + activeControllers.delete(featureId); + } + + async function initFeature(feature: FeatureDefinition, pageCtx: PageContext) { + teardown(feature.id); + + if (!shouldRun(feature, pageCtx)) return; + + const controller = new AbortController(); + activeControllers.set(feature.id, controller); + + try { + await feature.init(ctx, controller.signal); + } catch (err) { + console.error(`[grody] feature "${feature.id}" failed to init:`, err); + teardown(feature.id); + } + } + + async function run() { + const pageCtx = buildPageContext(new URL(location.href)); + await Promise.allSettled(features.map((f) => initFeature(f, pageCtx))); + } + + async function onNavigate(url: URL) { + const pageCtx = buildPageContext(url); + await Promise.allSettled( + features + .filter((f) => f.reinitOnNavigation) + .map((f) => initFeature(f, pageCtx)), + ); + } + + ctx.onInvalidated(() => { + for (const id of [...activeControllers.keys()]) { + teardown(id); + } + }); + + return { run, onNavigate }; +} diff --git a/src/entrypoints/github.content/StatusBanner.svelte b/src/entrypoints/grody.content/features/status-indicator/StatusBanner.svelte similarity index 100% rename from src/entrypoints/github.content/StatusBanner.svelte rename to src/entrypoints/grody.content/features/status-indicator/StatusBanner.svelte diff --git a/src/entrypoints/github.content/StatusBannerHost.svelte b/src/entrypoints/grody.content/features/status-indicator/StatusBannerHost.svelte similarity index 100% rename from src/entrypoints/github.content/StatusBannerHost.svelte rename to src/entrypoints/grody.content/features/status-indicator/StatusBannerHost.svelte diff --git a/src/entrypoints/github.content/StatusPopover.svelte b/src/entrypoints/grody.content/features/status-indicator/StatusPopover.svelte similarity index 100% rename from src/entrypoints/github.content/StatusPopover.svelte rename to src/entrypoints/grody.content/features/status-indicator/StatusPopover.svelte diff --git a/src/entrypoints/github.content/StatusStrip.svelte b/src/entrypoints/grody.content/features/status-indicator/StatusStrip.svelte similarity index 100% rename from src/entrypoints/github.content/StatusStrip.svelte rename to src/entrypoints/grody.content/features/status-indicator/StatusStrip.svelte diff --git a/src/entrypoints/grody.content/features/status-indicator/index.ts b/src/entrypoints/grody.content/features/status-indicator/index.ts new file mode 100644 index 0000000..4a26f43 --- /dev/null +++ b/src/entrypoints/grody.content/features/status-indicator/index.ts @@ -0,0 +1,41 @@ +import { mount, unmount } from "svelte"; +import type { FeatureDefinition } from "@/lib/feature-types"; +import { enabledStorage } from "@/lib/github-status"; +import StatusBannerHost from "./StatusBannerHost.svelte"; + +const definition: FeatureDefinition = { + id: "status-indicator", + reinitOnNavigation: false, + async init(_ctx, signal) { + const enabled = await enabledStorage.getValue(); + if (!enabled || signal.aborted) return; + + const header = document.querySelector( + "header.AppHeader, header[class*='appHeader'], header.HeaderMktg, div.Header", + ); + if (!header) return; + + const existing = document.getElementById("grody-github-status"); + if (existing) existing.remove(); + + const container = document.createElement("div"); + container.id = "grody-github-status"; + container.style.position = "relative"; + container.style.zIndex = "33"; + header.after(container); + + let app: ReturnType | null = mount(StatusBannerHost, { + target: container, + }); + + signal.addEventListener("abort", () => { + if (app) { + unmount(app); + app = null; + } + container.remove(); + }); + }, +}; + +export default definition; diff --git a/src/entrypoints/github.content/WorkflowFilter.svelte b/src/entrypoints/grody.content/features/workflow-filter/WorkflowFilter.svelte similarity index 100% rename from src/entrypoints/github.content/WorkflowFilter.svelte rename to src/entrypoints/grody.content/features/workflow-filter/WorkflowFilter.svelte diff --git a/src/entrypoints/grody.content/features/workflow-filter/index.ts b/src/entrypoints/grody.content/features/workflow-filter/index.ts new file mode 100644 index 0000000..bd057a4 --- /dev/null +++ b/src/entrypoints/grody.content/features/workflow-filter/index.ts @@ -0,0 +1,65 @@ +import { mount, unmount } from "svelte"; +import type { FeatureDefinition } from "@/lib/feature-types"; +import { isActionsPage } from "../../page-context"; +import WorkflowFilter from "./WorkflowFilter.svelte"; + +const SIDEBAR_NAV_SELECTOR = 'nav[aria-label="Actions Workflows"] ul'; +const SHOW_MORE_SELECTOR = '[data-action*="nav-list-group#showMore"]'; + +function findNavList(): HTMLElement | null { + const navList = document.querySelector(SIDEBAR_NAV_SELECTOR); + if (!navList) return null; + const showMore = navList + .closest("nav") + ?.querySelector(SHOW_MORE_SELECTOR); + if (!showMore) return null; + const totalPages = Number(showMore.dataset.totalPages ?? "1"); + if (totalPages <= 1) return null; + return navList; +} + +function parseRepo(): { owner: string; repo: string } | null { + const parts = location.pathname.split("/").filter(Boolean); + if (parts.length < 2) return null; + return { owner: parts[0], repo: parts[1] }; +} + +const definition: FeatureDefinition = { + id: "workflow-filter", + include: [isActionsPage], + reinitOnNavigation: true, + init(_ctx, signal) { + const navList = findNavList(); + if (!navList) return; + + const repoInfo = parseRepo(); + if (!repoInfo) return; + + const workflowsSection = navList.querySelector( + ":scope > li:has(nav-list-group)", + ); + if (!workflowsSection) return; + + const container = document.createElement("div"); + workflowsSection.before(container); + + let app: ReturnType | null = mount(WorkflowFilter, { + target: container, + props: { + owner: repoInfo.owner, + repo: repoInfo.repo, + navList, + }, + }); + + signal.addEventListener("abort", () => { + if (app) { + unmount(app); + app = null; + } + container.remove(); + }); + }, +}; + +export default definition; diff --git a/src/entrypoints/grody.content/index.ts b/src/entrypoints/grody.content/index.ts new file mode 100644 index 0000000..d0fbcfa --- /dev/null +++ b/src/entrypoints/grody.content/index.ts @@ -0,0 +1,25 @@ +import type { FeatureDefinition } from "@/lib/feature-types"; +import { createFeatureManager } from "./feature-manager"; + +const modules = import.meta.glob<{ default: FeatureDefinition }>( + "./features/*/index.ts", + { eager: true }, +); + +const features = Object.values(modules).map((m) => m.default); + +export default defineContentScript({ + matches: ["*://github.com/*"], + + async main(ctx) { + const manager = createFeatureManager(features, ctx); + + await manager.run(); + + ctx.addEventListener(window, "wxt:locationchange", ({ newUrl }) => { + manager.onNavigate(newUrl).catch((err) => { + console.error("[grody] navigation handler failed:", err); + }); + }); + }, +}); diff --git a/src/entrypoints/grody.content/page-context.test.ts b/src/entrypoints/grody.content/page-context.test.ts new file mode 100644 index 0000000..83665e9 --- /dev/null +++ b/src/entrypoints/grody.content/page-context.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import type { PageContext } from "@/lib/feature-types"; +import { + buildPageContext, + isActionsPage, + isIssuePage, + isPRPage, + isRepoPage, +} from "./page-context"; + +function ctx(pathname: string): PageContext { + return buildPageContext(new URL(`https://github.com${pathname}`)); +} + +describe("buildPageContext", () => { + it("parses owner and repo from a repo URL", () => { + const result = ctx("/owner/repo/actions"); + expect(result.owner).toBe("owner"); + expect(result.repo).toBe("repo"); + expect(result.pathname).toBe("/owner/repo/actions"); + }); + + it("returns undefined owner/repo for root path", () => { + const result = ctx("/"); + expect(result.owner).toBeUndefined(); + expect(result.repo).toBeUndefined(); + }); + + it("returns undefined owner/repo for single-segment path", () => { + const result = ctx("/settings"); + expect(result.owner).toBeUndefined(); + expect(result.repo).toBeUndefined(); + }); + + it("handles trailing slashes on repo path", () => { + const result = ctx("/owner/repo/"); + expect(result.owner).toBe("owner"); + expect(result.repo).toBe("repo"); + }); + + it("parses bare /owner/repo without trailing slash or subpath", () => { + const result = ctx("/owner/repo"); + expect(result.owner).toBe("owner"); + expect(result.repo).toBe("repo"); + }); + + it("preserves the full URL object", () => { + const url = new URL("https://github.com/owner/repo?tab=actions"); + const result = buildPageContext(url); + expect(result.url).toBe(url); + }); +}); + +describe("isActionsPage", () => { + it("matches /owner/repo/actions", () => { + expect(isActionsPage(ctx("/owner/repo/actions"))).toBe(true); + }); + + it("matches /owner/repo/actions/", () => { + expect(isActionsPage(ctx("/owner/repo/actions/"))).toBe(true); + }); + + it("matches /owner/repo/actions/workflows/ci.yml", () => { + expect(isActionsPage(ctx("/owner/repo/actions/workflows/ci.yml"))).toBe( + true, + ); + }); + + it("does not match /owner/repo/pulls", () => { + expect(isActionsPage(ctx("/owner/repo/pulls"))).toBe(false); + }); + + it("does not match /owner/repo/action (no trailing s)", () => { + expect(isActionsPage(ctx("/owner/repo/action"))).toBe(false); + }); +}); + +describe("isRepoPage", () => { + it("matches when owner and repo are present", () => { + expect(isRepoPage(ctx("/owner/repo"))).toBe(true); + }); + + it("does not match root path", () => { + expect(isRepoPage(ctx("/"))).toBe(false); + }); + + it("does not match single-segment path", () => { + expect(isRepoPage(ctx("/owner"))).toBe(false); + }); +}); + +describe("isPRPage", () => { + it("matches /owner/repo/pull/123", () => { + expect(isPRPage(ctx("/owner/repo/pull/123"))).toBe(true); + }); + + it("matches PR subpages like /files", () => { + expect(isPRPage(ctx("/owner/repo/pull/42/files"))).toBe(true); + }); + + it("does not match pulls list", () => { + expect(isPRPage(ctx("/owner/repo/pulls"))).toBe(false); + }); +}); + +describe("isIssuePage", () => { + it("matches /owner/repo/issues/123", () => { + expect(isIssuePage(ctx("/owner/repo/issues/123"))).toBe(true); + }); + + it("does not match issues list", () => { + expect(isIssuePage(ctx("/owner/repo/issues"))).toBe(false); + }); +}); diff --git a/src/entrypoints/grody.content/page-context.ts b/src/entrypoints/grody.content/page-context.ts new file mode 100644 index 0000000..f0e325f --- /dev/null +++ b/src/entrypoints/grody.content/page-context.ts @@ -0,0 +1,25 @@ +import type { PageContext } from "@/lib/feature-types"; + +const REPO_PATTERN = /^\/([^/]+)\/([^/]+)/; + +export function buildPageContext(url: URL): PageContext { + const match = url.pathname.match(REPO_PATTERN); + return { + url, + pathname: url.pathname, + owner: match?.[1], + repo: match?.[2], + }; +} + +export const isActionsPage = (ctx: PageContext) => + /^\/[^/]+\/[^/]+\/actions(\/|$)/.test(ctx.pathname); + +export const isRepoPage = (ctx: PageContext) => + ctx.owner !== undefined && ctx.repo !== undefined; + +export const isPRPage = (ctx: PageContext) => + /^\/[^/]+\/[^/]+\/pull\/\d+/.test(ctx.pathname); + +export const isIssuePage = (ctx: PageContext) => + /^\/[^/]+\/[^/]+\/issues\/\d+/.test(ctx.pathname); diff --git a/src/lib/feature-types.ts b/src/lib/feature-types.ts new file mode 100644 index 0000000..bfc49c6 --- /dev/null +++ b/src/lib/feature-types.ts @@ -0,0 +1,19 @@ +import type { ContentScriptContext } from "wxt/utils/content-script-context"; + +export interface PageContext { + url: URL; + pathname: string; + owner?: string; + repo?: string; +} + +export interface FeatureDefinition { + id: string; + include?: Array<(ctx: PageContext) => boolean>; + exclude?: Array<(ctx: PageContext) => boolean>; + reinitOnNavigation: boolean; + init: ( + ctx: ContentScriptContext, + signal: AbortSignal, + ) => void | Promise; +} diff --git a/src/lib/github-status.ts b/src/lib/github-status.ts index a4164a4..47fd38a 100644 --- a/src/lib/github-status.ts +++ b/src/lib/github-status.ts @@ -46,7 +46,7 @@ export const pollIntervalStorage = storage.defineItem( export const enabledStorage = storage.defineItem( "local:github-status:enabled", - { fallback: true }, + { fallback: false }, ); export const collapsedStorage = storage.defineItem( diff --git a/src/test-setup.ts b/src/test-setup.ts new file mode 100644 index 0000000..f619717 --- /dev/null +++ b/src/test-setup.ts @@ -0,0 +1,7 @@ +// node defers abort listener errors via nextTick, so they fire after test +// lifecycle hooks complete. suppress only the intentional "teardown boom" +// error from the feature-manager teardown isolation test. +process.on("uncaughtException", (err) => { + if (err instanceof Error && err.message === "teardown boom") return; + throw err; +}); diff --git a/vitest.config.ts b/vitest.config.ts index 9b324c7..6efdd3f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,4 +3,7 @@ import { WxtVitest } from "wxt/testing/vitest-plugin"; export default defineConfig({ plugins: [WxtVitest()], + test: { + setupFiles: ["./src/test-setup.ts"], + }, });