diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 304e9ab..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,13 +0,0 @@ -include = [{ path = "local.toml", optional = true }] - -# Cargo configuration with lint policy -# Clippy lint levels enforce code quality while remaining pragmatic - -[build] -# Use all available CPU cores for faster builds -jobs = -1 - -[alias] -# Convenient cross-platform lint commands -lint = "clippy --all-targets --all-features -- -D warnings" -fmt-check = "fmt -- --check" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e875e74..17913cb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,802 +1,337 @@ -# SproutGit — Copilot Instructions +# SproutGit Copilot Instructions -## Repository +> These instructions apply to every AI-assisted change in this repository. -- **Owner / GitHub org**: [InterestingSoftware](https://github.com/InterestingSoftware) -- **Repo URL**: https://github.com/InterestingSoftware/SproutGit.git +**GitHub:** https://github.com/InterestingSoftware/SproutGit +**Owner / org:** InterestingSoftware +**Repo:** SproutGit +**Clone URL:** https://github.com/InterestingSoftware/SproutGit.git -## What is SproutGit? +## Project overview -Open-source, cross-platform Git desktop app with a **worktree-first** workflow. The MVP lets users clone/init repos, manage Git worktrees in a prescribed directory layout, view a commit graph, and create branches paired with worktrees. +SproutGit is an Electron-based, cross-platform Git desktop app with a **worktree-first** workflow. -## Tech Stack +**Monorepo:** pnpm v11 workspaces + Turborepo v2 +**Dev command:** `pnpm dev` +**Test command:** `pnpm test` (unit) / `pnpm test:e2e` (Playwright E2E) +**Typecheck:** `pnpm typecheck` +**Lint:** `pnpm lint` -| Layer | Technology | Notes | -| ------------------ | ---------------------------------------------------------- | ------------------------------------------------------------ | -| Desktop shell | **Tauri v2** | Rust backend, webview frontend | -| Frontend framework | **SvelteKit + Svelte 5** | SSR disabled (`ssr = false`), `adapter-static` for SPA | -| Language | **TypeScript** (frontend), **Rust** (backend) | | -| Styling | **Tailwind CSS v4** | via `@tailwindcss/vite` plugin | -| State (frontend) | Svelte 5 runes | `$state`, `$derived`, `$derived.by`, `$props`, `$effect` | -| State (persistent) | **SQLite** via `rusqlite` (bundled) + `rusqlite_migration` | workspace `state.db` + app-global `config.db` | -| Package manager | **pnpm** | Tauri hooks use `pnpm run dev` / `pnpm run build` | -| Git integration | CLI-based | Rust backend shells out to `git` via `std::process::Command` | +--- -## Project Structure +## Stack & versions -This structure is a high-level orientation map. For file-level accuracy, verify against the current workspace tree before making assumptions. +| Technology | Version | +|---|---| +| Electron | 42 | +| electron-vite | 5 | +| electron-builder | 26 | +| React | 19 | +| TanStack Router | 1 | +| Zustand | 5 | +| Tailwind CSS | 4 (`@tailwindcss/vite`) | +| TypeScript | 5 | +| Drizzle ORM | latest | +| simple-git | latest | +| node-pty | latest | +| Playwright | latest | -``` -sproutgit/ -├── src/ # SvelteKit frontend -│ ├── app.css # Global design tokens (--sg-* CSS vars), animations, light/dark theme -│ ├── lib/ -│ │ ├── sproutgit.ts # Typed API layer wrapping Tauri invoke() calls -│ │ ├── toast.svelte.ts # Toast notification state (Svelte 5 rune module, no stores) -│ │ └── components/ -│ │ ├── CommitGraph.svelte # SVG commit graph with lane algorithm, search, context menu, worktree markers -│ │ ├── Autocomplete.svelte # Filterable dropdown with keyboard nav, aria attributes -│ │ ├── ContextMenu.svelte # Right-click context menu, auto-positions within viewport -│ │ ├── Spinner.svelte # Animated loading spinner (sm/md/lg) -│ │ └── ToastContainer.svelte # Fixed-position toast renderer (auto-dismiss, slide animations) -│ └── routes/ -│ ├── +layout.svelte # Minimal: imports app.css, renders -│ ├── +layout.ts # export const ssr = false -│ ├── +page.svelte # Screen 1: Project picker (clone, open, recent projects) -│ ├── settings/ -│ │ └── +page.svelte # Settings screen -│ └── workspace/ -│ └── +page.svelte # Screen 2: Workspace (worktree mgmt + commit graph) -├── src-tauri/ -│ ├── src/ -│ │ ├── lib.rs # Tauri entry: registers all commands -│ │ ├── db.rs # Database connections + migration runner -│ │ ├── workspace.rs # Workspace create/inspect/import commands -│ │ ├── hooks.rs # Hook definitions, dependencies, run history -│ │ ├── git/ # Git operations, helpers, diff, staging -│ │ └── ... # config, editor, github, terminal, watcher -│ ├── migrations/ -│ │ ├── workspace/ # Migrations for per-workspace state.db -│ │ │ └── 001_initial_schema.sql -│ │ └── config/ # Migrations for app-global config.db -│ │ └── 001_initial_schema.sql -│ ├── tauri.conf.json # App config: window 1200x800, min 900x600, resizable -│ ├── Cargo.toml # Rust deps: tauri, rusqlite, rusqlite_migration, sea-orm, … -│ └── capabilities/default.json # Permissions: core, opener, dialog -├── docs/ -│ ├── index.md # Documentation index; read first to discover relevant docs for a task -│ ├── requirements.md # Full MVP requirements with P0/P1 features -│ └── design-review-and-screen-plan.md # Screen architecture (8 screens planned) -└── package.json # pnpm scripts: dev, build, check, tauri -``` - -## Documentation Index (Required) - -The `docs/` folder contains product, architecture, security, and workflow decisions that may be directly relevant to implementation work. - -Agent requirements: - -- At the start of each new task, read `docs/index.md` first to discover whether any repository docs are relevant. -- If a linked doc is relevant to the task, read it before making design or implementation decisions. -- When adding, renaming, removing, or substantially repurposing a document in `docs/`, update `docs/index.md` in the same change. -- Treat `docs/index.md` as the maintained entry point for the repository documentation set. - -## Tauri Playwright Adapter (Required For E2E Changes) - -When working on `e2e/**`, adapter fixtures, or Playwright/Tauri bridge behavior, read `docs/tauri-playwright-adapter-cheatsheet.md` before editing. - -Key reminders: - -- `TauriPage` is Playwright-like but not identical to `Page`. -- `TauriLocator.waitFor` expects a numeric timeout, not a Playwright options object. -- Keep plugin socket/port values consistent across setup/launch and worker processes for each run. -- In `tauri` mode, do not use `page.goto()` for app reset or startup navigation. -- Prefer per-spec `beforeEach` reset hooks over global Playwright lifecycle hooks for stateful E2E flows. -- For E2E isolation, reset both the test workspace directory and the isolated config DB, then return to the project picker with stable in-app navigation (`ensureHome()`-style helpers). Avoid making full webview reloads the default reset path for the suite. -- Current default runtime is headless Playwright in `e2e/playwright.config.ts`; do not switch to headed by default. -- During reset, clear cached workspace hints (`sg_workspace_hint`) **before** navigating home; the clear must happen before the home page mounts so `onMount` never auto-navigates back to the previous workspace. Do not perform a full `window.location.reload()` — UI-driven navigation already tears down and remounts all page components, and hard reloads cost 20–45 s on slow Windows CI runners. -- In E2E `beforeEach` hooks, the required reset order is: `resetConfigDb()` → `reloadToHome()` → `resetTestDirs()`. Rationale: (1) `resetConfigDb()` first so the app never queries a deleted schema; the config DB lives in a separate run-scoped directory with no watcher or process holding it open. (2) `reloadToHome()` second so the workspace `onDestroy` fires `closeAllTerminals()` + `stopWatchingWorktrees()` before any filesystem cleanup — PTY children (PowerShell on Windows) hold CWD handles on worktree directories, and watchers hold directory handles. (3) `resetTestDirs()` last, after those handles are released. - -### E2E Selector Strategy (Required) - -- Prefer `getByTestId(...)` for all E2E element interactions and waits. -- Use CSS selectors only when no stable test ID exists, and keep those selectors narrow and local. -- If an interaction depends on visual state (hover-only controls, transient overlays), add or use a dedicated test ID before introducing brittle structural selectors. -- **If a needed `data-testid` does not exist in the UI source, add it.** Never work around a missing test ID with fragile structural selectors — add the `data-testid` attribute to the component/template and use it in the test. - -## E2E Failure Triage Protocol (Required) - -When debugging a flaky or platform-specific E2E failure, follow this order exactly. - -1. Reproduce locally with the narrowest failing scope (single spec or test name). -2. Classify the failure as one of: deterministic logic bug, timing/timeout issue, state reset leak, or platform-only behavior. -3. Validate reset assumptions first (workspace cleanup, config DB reset, `sg_workspace_hint` clear, verified reload). -4. If Windows-only, inspect path format and shell semantics before raising timeouts. -5. Apply the smallest fix that addresses root cause; avoid masking deterministic failures with broad timeout increases. -6. Re-run only the affected spec first, then re-run the required E2E gate. - -### Timeout Budget Policy (Required) +--- -- Keep Playwright per-test timeout and helper timeouts aligned; do not change one without auditing the other. -- For CI reliability updates, document why each timeout changed and which operation consumed the budget. -- Increase timeouts only when evidence shows slow-environment variance; if failure is deterministic, fix behavior instead. -- When a timeout is increased, include before/after values in the commit message or PR notes. - -## Workspace Layout (User Projects) - -SproutGit manages user repos in a prescribed directory layout: +## Monorepo structure ``` -/ -├── root/ # Main bare-ish checkout (protected, don't work directly here) -├── worktrees/ # Managed worktrees created by SproutGit -│ ├── feature-foo/ -│ └── bugfix-bar/ -└── .sproutgit/ # SproutGit metadata - └── state.db # SQLite: workspace state (meta, hooks, sessions) +app/ ← Electron app (main + renderer + preload) +packages/ + git/ ← @sproutgit/git — simple-git wrapper; all Git operations + terminal/ ← @sproutgit/terminal — node-pty wrapper + database/ ← @sproutgit/database — Drizzle + node:sqlite + types/ ← @sproutgit/types — shared types + ALL IPC channel constants + ui/ ← @sproutgit/ui — shared React components + ts-config/ ← shared tsconfig + eslint-config/ ← shared ESLint config +e2e/ ← Playwright end-to-end tests +website/ ← Astro marketing site +old/ ← Legacy Tauri/SvelteKit source (do NOT modify) ``` -## Rust Backend (`src-tauri/src/lib.rs`) - -`src-tauri/src/lib.rs` is the source of truth for registered Tauri commands via `tauri::generate_handler![]`. - -### Structs (all `#[serde(rename_all = "camelCase")]`) - -- `GitInfo` — installed, version -- `WorktreeInfo` — path, head, branch, detached -- `WorktreeListResult` — repo_path, worktrees -- `WorkspaceInitResult` — workspace_path, root_path, worktrees_path, metadata_path, state_db_path, cloned -- `WorkspaceStatus` — workspace_path, root_path, worktrees_path, metadata_path, state_db_path, is_sproutgit_project, root_exists, worktrees_exists, metadata_exists, state_db_exists -- `RefInfo` — name, full_name, kind, target -- `RefsResult` — repo_path, refs -- `CommitEntry` — hash, short_hash, parents, author_name, author_date, subject, refs -- `CommitGraphResult` — repo_path, commits -- `CreateWorktreeResult` — worktree_path, branch, from_ref - -### Tauri Commands (Representative, Not Exhaustive) - -Representative command groups currently include: - -- Git operations and worktree lifecycle (`git_info`, `list_worktrees`, `create_managed_worktree`, `delete_managed_worktree`, `checkout_worktree`, `reset_worktree_branch`, `get_worktree_push_status`, `fetch_worktree`, `pull_worktree`, `push_worktree_branch`) -- Diff and staging (`get_diff_files`, `get_diff_content`, `get_worktree_status`, `stage_files`, `unstage_files`, `create_commit`, `get_working_diff`) -- Workspace and config (`create_sproutgit_workspace`, `import_git_repo_workspace`, `inspect_sproutgit_workspace`, recent workspaces, app settings) -- Hooks (`list_workspace_hooks`, create/update/delete/toggle, `run_workspace_hook`) -- Worktree metadata (`list_worktree_provenance`, `get_worktree_provenance`, nested repo sync rule CRUD) -- Editor/Git tool integration (`open_in_editor`, editor detection, git config read/write) -- Terminal and watcher (`spawn_terminal`, `terminal_input`, `start_watching_worktrees`) -- Optional E2E-only helpers (`set_window_size` when `e2e-testing` feature is enabled) +--- -When command surfaces change, update this section in the same change. +## Main process (`app/src/main/`) -### Helper Functions +- **Entry point:** `app/src/main/index.ts` + - Registers all IPC handlers on app startup + - Creates `BrowserWindow` with `titleBarStyle: 'hiddenInset'` on macOS + - Sets `app.name = 'SproutGit'` before `whenReady()` + - Sets dock icon in dev mode via `app.dock?.setIcon()` + - Registers macOS application menu via `Menu.setApplicationMenu()` — **required for Cmd+C/V/Z/X to work in text inputs** + - Handles `open-file` event and sends `IPC.EVENT_OPEN_WORKSPACE` to renderer -- `run_git(args)` — Execute `git` with args, capture stdout -- `ensure_git_success(args)` — Run git, return error on non-zero exit -- `normalize_existing_path` / `normalize_or_create_dir` — Path canonicalization -- `initialize_workspace_db(path)` — Run workspace migrations, creating `state.db` if needed -- `slugify_for_path(name)` — Branch name → filesystem-safe slug -- `now_epoch_seconds()` — Current Unix timestamp +- **IPC handlers:** `app/src/main/ipc/` + - `git.ts` — Git operations (delegates to `@sproutgit/git`) + - `workspace.ts` — workspace CRUD, recent workspaces, hooks, worktree metadata + - `workspace-init.ts` — import, init, inspect workspace (creates `.sproutgit/` layout) + - `terminal.ts` — PTY create/write/resize/kill (delegates to `@sproutgit/terminal`) + - `settings.ts` — user settings stored in config DB + - `system.ts` — OS utilities (dialog, open external, home dir) + - `github.ts` — GitHub OAuth + repo listing + - `hooks.ts` — workspace lifecycle hook execution + - `watcher.ts` — chokidar filesystem watcher → push events to renderer + - `update.ts` — electron-updater auto-update -### Important Patterns +--- -- Clone uses `--progress` flag with piped stderr, emitting `clone-progress` Tauri events via `tauri::Emitter` for real-time UI feedback -- `create_sproutgit_workspace` takes `app_handle: tauri::AppHandle` for event emission -- Commit graph uses `\x1e` (record separator) as field delimiter in git log format -- All git operations use `git -C ` to target specific repos +## IPC contract -## Frontend API (`src/lib/sproutgit.ts`) +**Rule: all IPC channel names live in `packages/types/src/ipc.ts`.** -Typed wrappers around `invoke()` from `@tauri-apps/api/core`. Every Rust struct has a matching TypeScript type. +- Export name pattern: `IPC.DOMAIN_ACTION` (e.g., `IPC.GIT_STAGE_FILES`) +- String value pattern: `'domain:action'` (e.g., `'git:stageFiles'`) +- Event channels are prefixed `EVENT_` and use the string prefix `'event:'` -`src/lib/sproutgit.ts` is the source of truth for frontend-callable API wrappers. +When adding a new IPC call: +1. Add the constant to `packages/types/src/ipc.ts` +2. Rebuild types: `pnpm --filter @sproutgit/types build` +3. Add the handler in the appropriate `app/src/main/ipc/*.ts` file +4. Expose it via `window.api` in `app/src/preload/index.ts` using `contextBridge.exposeInMainWorld` +5. Consume via `window.api.myNewCall()` in the renderer — never use `ipcRenderer` directly in the renderer -Representative export groups include: +--- -- `getGitInfo()`, `createWorkspace()`, `inspectWorkspace()` -- Workspace import and recents/settings -- Worktree lifecycle (`createManagedWorktree`, delete, checkout, reset, push status, fetch, pull, push/publish) -- Diff and staging helpers -- Hook CRUD + progress listeners (`onHookProgress`) -- Worktree provenance + nested repo sync rule helpers -- File watcher helpers -- Terminal lifecycle helpers -- GitHub auth/repo helpers -- Event listeners (`onCloneProgress`, `onImportProgress`, `onWorktreeChanged`, terminal events) +## Preload (`app/src/preload/index.ts`) -## Theme System +Exposes `window.api` (and types `Window.Api`) via `contextBridge`. -CSS custom properties with auto dark mode: +- Invoke calls: `ipcRenderer.invoke(IPC.CHANNEL, ...args)` +- Event subscriptions return an unsubscribe function: + ```ts + onMyEvent: (cb: (data: MyData) => void) => { + const listener = (_e: Electron.IpcRendererEvent, data: MyData) => cb(data); + ipcRenderer.on(IPC.MY_EVENT, listener); + return () => ipcRenderer.removeListener(IPC.MY_EVENT, listener); + }, + ``` -- Light mode: `:root { --sg-bg: #f5f5f5; --sg-primary: #1a8a5c; ... }` -- Dark mode: `@media (prefers-color-scheme: dark) { :root { --sg-bg: #1e1e2e; --sg-primary: #74c7a4; ... } }` -- All tokens prefixed `--sg-*`: bg, surface, surface-raised, border, border-subtle, text, text-dim, text-faint, primary, primary-hover, danger, warning, accent, input-bg, input-border, input-focus +--- -Always use `var(--sg-*)` tokens in components. Never hardcode colors outside of `app.css`. +## Renderer (`app/src/renderer/`) -**Light and dark mode are both required.** Every component and UI surface must look correct in both. When designing or editing any UI: +### Routing -- Use only `var(--sg-*)` tokens — never hardcode hex colors in components. -- Mentally verify the design in both light mode (`--sg-bg: #f5f5f5`, `--sg-text: #1e1e2e`) and dark mode (`--sg-bg: #1e1e2e`, `--sg-text: #cdd6f4`). -- Components that use Canvas or WebGL rendering (e.g. xterm.js terminals) require explicit theme objects for both modes — detect `window.matchMedia('(prefers-color-scheme: dark)').matches` at init time and apply the correct theme. -- For screenshot testing, `forceTheme()` in `e2e/helpers/screenshots.ts` handles CSS var injection and xterm canvas re-theming — keep it in sync with any new terminal-like components. +- **TanStack Router v1** with `createHashHistory()` +- Routes in `app/src/renderer/routes/`: + - `__root.tsx` — root layout (ToastContainer, `onOpenWorkspace` subscription) + - `index.tsx` — home/landing view (recent workspaces, clone, import) + - `workspace.tsx` — main workspace view (worktree sidebar + content area) + - `settings.tsx` — settings panel +- Navigation: `useNavigate()` from TanStack Router, or `window.location.hash = '#/route?param=value'` -## Commit Graph Component +### State management -`CommitGraph.svelte` implements a hand-rolled lane assignment algorithm: +- **Zustand v5**; stores in `app/src/renderer/stores/` +- `useWorkspaceStore` — active workspace path, gitRepoPath, worktrees, status +- `useUpdateStore` — update availability state -- **Algorithm**: Two-pass column allocation. First pass assigns lanes (first parent continues lane, others allocate new). Second pass resolves parent positions for line drawing. -- **Rendering**: SVG for lane lines (straight + bezier curves) alongside a commit list with subject, ref badges, short hash, author, date. -- **Search**: CMD/CTRL+F opens inline search bar. Matches by subject, short hash, or full hash. Enter/Shift+Enter navigates matches. Non-matching rows dim to 30% opacity. -- **Context Menu**: Right-click on commits, branches, or tags shows copy actions (hash, message, branch, worktree path). Uses `ContextMenu.svelte`. -- **Worktree Integration**: Accepts `worktrees` prop. Commits on worktree branches get a diamond node shape (vs circle), a "WT" badge, and a distinct accent-colored ref badge with ⌥ prefix. Worktree branches are highlighted in `--sg-accent` color. -- **Constants**: ROW_H=28, COL_W=16, NODE_R=4, 10 cycling lane colors. +### Styling -## UI Components +- **Tailwind CSS v4** — utility classes only (no config file, uses CSS variables) +- CSS variables for brand/semantic colours defined in `app/src/renderer/tailwind.css` +- Custom variable prefix: `--sg-*` +- Use `cn()` (from `clsx`/`tailwind-merge`) for conditional class names +- **Icons:** use `lucide-react` for all renderer/UI icons. Do not introduce new icon libraries, emoji glyphs, or ad-hoc inline icon text when a Lucide icon exists. -### Toast System (`toast.svelte.ts` + `ToastContainer.svelte`) +### Workspace layout components -- **State module**: `toast.svelte.ts` exports `toast.info()`, `toast.success()`, `toast.error()`, `toast.warning()`. Uses `$state` (no Svelte stores). Auto-dismiss after 4s by default. -- **Renderer**: `ToastContainer.svelte` mounted in `+layout.svelte`. Fixed top-right, slide-in/out animations, close button. +Located in `app/src/renderer/workspace/`: +- `WorktreeSidebar.tsx` — list of worktrees + compact icon toolbar +- `dialogs/` — `NewWorktreeDialog`, `DeleteWorktreeDialog`, `HooksDialog`, etc. -### Autocomplete (`Autocomplete.svelte`) +--- -- Filterable dropdown with `items: {label, value, detail?}[]`. Supports keyboard nav (up/down/enter/escape), click outside to close. -- Two-way binding via `bind:value`. `onselect` callback. ARIA combobox role. -- Used for source branch selection in the workspace sidebar. +## Database (`@sproutgit/database`) -### Context Menu (`ContextMenu.svelte`) +- **`node:sqlite`** (built into Electron ≥32) — no native binary dependencies +- Drizzle ORM for schema and migrations +- **Config DB:** `userData/config.db` — user settings, recent workspaces +- **Workspace DB:** `/.sproutgit/state.db` — worktree metadata, hooks, per-workspace UI state, etc. +- Schema in `packages/database/src/schema/` +- Migrations in `packages/database/migrations/` (two folders: `config/` and `workspace/`) +- **Always generate migrations with drizzle-kit — never write migration SQL by hand.** + - Config schema: `cd packages/database && pnpm drizzle-kit generate --config=drizzle.config.config.ts` + - Workspace schema: `cd packages/database && pnpm drizzle-kit generate --config=drizzle.workspace.config.ts` -- `items: MenuItem[]` with `{label, action, icon?, danger?}` or `{separator: true}`. -- Auto-adjusts position to stay within viewport bounds. Closes on click outside or Escape. +--- -### Spinner (`Spinner.svelte`) +## Git package (`@sproutgit/git`) -- Sizes: `sm`, `md`, `lg`. Optional `label` text. Uses `--sg-primary` color. +All git operations go through this package. Do not call `simple-git` directly from the main process — always import from `@sproutgit/git`. -### Form Controls (`Checkbox.svelte` + `Select.svelte`) +Key modules: +- `client.ts` — simpleGit factory with defaults +- `commits.ts` — log, graph data +- `diff.ts` — diff, patch +- `staging.ts` — stage, unstage, commit +- `worktrees.ts` — list, add, remove worktrees +- `branches.ts` — create, delete, rename branches +- `remote.ts` — fetch, pull, push -- Use shared form controls from `src/lib/components/` instead of ad-hoc checkbox/select markup in feature components. -- `Checkbox.svelte` is the source of truth for custom checkbox visuals and spacing behavior. Keep checked/unchecked icon rendering layout-stable to avoid row height shifts. -- `Select.svelte` is the source of truth for themed dropdowns. Prefer it over native ` setCloneUrl(e.target.value)} placeholder="https://github.com/owner/repo.git" required disabled={cloning} spellCheck={false} /> + )} + + +
+ Parent folder +
+ setProjectsFolder(e.target.value)} + onBlur={() => { if (projectsFolder.trim()) void api.setSetting(PROJECTS_FOLDER_SETTING, projectsFolder.trim()).catch(() => undefined); }} + placeholder="~/Projects" disabled={cloning} spellCheck={false} /> + +
+
+ {cloneWorkspacePath && ( +

+ Will create: {cloneWorkspacePath} +

+ )} + {cloneProgress.length > 0 && (() => { + // H4: Extract latest percentage from progress messages + const lastPct = cloneProgress.reduceRight((acc, line) => { + if (acc !== null) return acc; + const m = /(\d+)%/.exec(line); + return m?.[1] ? parseInt(m[1], 10) : null; + }, null); + return ( +
+ {clonePhase &&

{clonePhase}…

} + {lastPct !== null && ( +
+
+
+
+ {lastPct}% +
+ )} +
+ {cloneProgress.map((line, i) =>
{line}
)} +
+
+
+ ); + })()} + {cloneError &&

{cloneError}

} +
+
+ + +
+ +
+ )} + + {/* Import Dialog */} + {showImport && ( +
{ if (e.target === e.currentTarget) setShowImport(false); }} + data-testid="import-dialog" + > +
void doImport(e)} + > +
+ + + +
+

Import Git Repository

+

Create a SproutGit workspace from an existing repo

+
+ +
+
+ {/* Import mode selector */} +
+ How to import + {(['inPlace', 'move', 'copy'] as const).map(mode => ( + + ))} +
+
+ Repository path +
+ setImportPath(e.target.value)} placeholder="/Users/me/my-repo" required disabled={importing} spellCheck={false} /> + +
+
+ {(importMode === 'move' || importMode === 'copy') && ( + <> + +
+ Parent folder +
+ setProjectsFolder(e.target.value)} + onBlur={() => { if (projectsFolder.trim()) void api.setSetting(PROJECTS_FOLDER_SETTING, projectsFolder.trim()).catch(() => undefined); }} + placeholder="~/Projects" disabled={importing} spellCheck={false} /> + +
+
+ + )} + {importError &&

{importError}

} + {importing && importProgressMsg && ( +

{importProgressMsg}

+ )} +
+
+ + +
+
+
+ )} + + ); +} + +export const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: HomeView, +}); diff --git a/app/src/renderer/routes/routeTree.ts b/app/src/renderer/routes/routeTree.ts new file mode 100644 index 0000000..a83806b --- /dev/null +++ b/app/src/renderer/routes/routeTree.ts @@ -0,0 +1,6 @@ +import { rootRoute } from './__root.js'; +import { indexRoute } from './index.js'; +import { workspaceRoute } from './workspace.js'; +import { settingsRoute } from './settings.js'; + +export const routeTree = rootRoute.addChildren([indexRoute, workspaceRoute, settingsRoute]); diff --git a/app/src/renderer/routes/settings.tsx b/app/src/renderer/routes/settings.tsx new file mode 100644 index 0000000..75910c4 --- /dev/null +++ b/app/src/renderer/routes/settings.tsx @@ -0,0 +1,95 @@ +import { Settings as SettingsIcon } from 'lucide-react'; +import { api } from '../api.js'; +import { createRoute, useNavigate } from '@tanstack/react-router'; +import { useState, useContext } from 'react'; +import type { GitHubAuthStatus } from '@sproutgit/types'; +import { WindowControls, UpdateBadge } from '@sproutgit/ui'; +import { rootRoute } from './__root.js'; +import { ToastContext } from '../toast-context.js'; +import { useUpdateStore } from '../stores/update-store.js'; +import { GitHubSection } from '../settings/GitHubSection.js'; +import { GitSection } from '../settings/GitSection.js'; +import { ShellSection } from '../settings/ShellSection.js'; +import { AppSection } from '../settings/AppSection.js'; + +// ── Route definition ────────────────────────────────────────────────────────── + +export const settingsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/settings', + component: SettingsPage, + validateSearch: (search: Record) => ({ + workspace: typeof search['workspace'] === 'string' ? search['workspace'] : '', + }), +}); + +// ── Settings page ───────────────────────────────────────────────────────────── + +function SettingsPage() { + const navigate = useNavigate(); + const toast = useContext(ToastContext); + const { workspace: workspacePath } = settingsRoute.useSearch(); + const { updateState } = useUpdateStore(); + + const [githubAuth, setGithubAuth] = useState(null); + + function goBack() { + if (workspacePath) { + void navigate({ to: '/workspace', search: { path: workspacePath } }); + } else { + void navigate({ to: '/' }); + } + } + + return ( +
+ {/* Titlebar */} +
+ +
+ Settings +
+ void api.installUpdate()} /> +
+ +
+ +
+
+ + {/* Page header */} +
+ +
+ +
+
+

Settings

+

Configure SproutGit, integrations, and your default tools.

+
+
+ + + +
+ + +
+ + +
+
+
+
+
+ ); +} diff --git a/app/src/renderer/routes/workspace.tsx b/app/src/renderer/routes/workspace.tsx new file mode 100644 index 0000000..1c63334 --- /dev/null +++ b/app/src/renderer/routes/workspace.tsx @@ -0,0 +1,1235 @@ +import { api } from '../api.js'; +import { createRoute, useNavigate, useSearch } from '@tanstack/react-router'; +import { rootRoute } from './__root.js'; +import { useState, useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { + CommitGraph, + StagingPanel, + TerminalPane, + Spinner, + ContextMenuProvider, + useContextMenu, + WorkspaceHooksModal, + WindowControls, + UpdateBadge, +} from '@sproutgit/ui'; +import { GitBranch, Terminal, GitMerge, X, ChevronRight, ChevronDown, Settings, Plus, Columns2, Rows3, LayoutGrid, Pencil, PanelTop, SquareSplitHorizontal, ChevronsRight, Trash2 } from 'lucide-react'; +import type { CommitEntry, DiffFileEntry, WorktreeInfo, WorktreeSwitchHookSource } from '@sproutgit/types'; +import { useToast } from '../toast-context.js'; +import { useUpdateStore } from '../stores/update-store.js'; +import { useWorkspaceStore, resetWorkspaceStore } from '../stores/workspace-store.js'; +import { WorktreeSidebar } from '../workspace/WorktreeSidebar.js'; +import { CommitDiffPanel } from '../workspace/CommitDiffPanel.js'; +import { NewWorktreeDialog } from '../workspace/dialogs/NewWorktreeDialog.js'; +import { DeleteWorktreeDialog } from '../workspace/dialogs/DeleteWorktreeDialog.js'; +import { PublishDialog } from '../workspace/dialogs/PublishDialog.js'; +import { RunHookDialog } from '../workspace/dialogs/RunHookDialog.js'; +import { + qk, + useWorkspaceStatus, + useWorktrees, + useCommits, + useCommitCount, + useRefs, + usePushStatus, + useFetch, + usePull, + usePush, + useDeleteWorktree, + useWorktreeChangeCounts, +} from '../queries.js'; + +// ── Search params ───────────────────────────────────────────────────────────── + +type WorkspaceSearch = { path: string }; + +// ── Shared helpers ──────────────────────────────────────────────────────────── + +async function runSwitchAndTriggerHooks(args: { + workspacePath: string; + targetWorktreePath: string; + initiatingWorktreePath: string | null; + source: WorktreeSwitchHookSource; +}): Promise { + await api.runSwitchHooks(args); + await api.runTriggerHooks({ + workspacePath: args.workspacePath, + trigger: 'after_worktree_switch', + worktreePath: args.targetWorktreePath, + initiatingWorktreePath: args.initiatingWorktreePath, + source: args.source, + }); +} + +// ── Workspace view ──────────────────────────────────────────────────────────── + +function WorkspaceView() { + return ( + + + + ); +} + +function WorkspaceInner() { + const navigate = useNavigate(); + const toast = useToast(); + const qc = useQueryClient(); + const contextMenu = useContextMenu(); + const { path: workspacePath } = useSearch({ from: workspaceRoute.id }); + + // ── Zustand UI state ────────────────────────────────────────────────── + const activeWorktree = useWorkspaceStore(s => s.activeWorktree); + const activeTab = useWorkspaceStore(s => s.activeTab); + const defaultShell = useWorkspaceStore(s => s.defaultShell); + const fetching = useWorkspaceStore(s => s.fetching); + const pulling = useWorkspaceStore(s => s.pulling); + const pushing = useWorkspaceStore(s => s.pushing); + const terminalSessions = useWorkspaceStore(s => s.terminalSessions); + const activeTerminalId = useWorkspaceStore(s => s.activeTerminalId); + const terminalLayout = useWorkspaceStore(s => s.terminalLayout); + const worktreeActiveTerminalId = useWorkspaceStore(s => s.worktreeActiveTerminalId); + const creatingWorktree = useWorkspaceStore(s => s.creatingWorktree); + const pendingCreationBranch = useWorkspaceStore(s => s.pendingCreationBranch); + const { updateState, setUpdateState } = useUpdateStore(); + + // Sessions for the currently selected worktree. All other sessions keep + // their PTYs running in the background and reappear when you switch back. + const visibleSessions = terminalSessions.filter(s => s.cwd === activeWorktree?.path); + + // ── Shell picker ────────────────────────────────────────────────────── + const [availableShells, setAvailableShells] = useState<{ name: string; path: string }[]>([]); + const [showShellPicker, setShowShellPicker] = useState(false); + + useEffect(() => { + void api.listShells().then(setAvailableShells).catch(() => undefined); + }, []); + + // ── Server state via TanStack Query ────────────────────────────────── + const { data: workspaceStatus } = useWorkspaceStatus(workspacePath); + // Use '' (falsy) until workspaceStatus resolves so dependent queries stay + // disabled — workspacePath itself is not a git repo in the .sproutgit layout. + const gitRepoPath = workspaceStatus?.gitRepoPath ?? ''; + + const { + data: worktrees = [], + isLoading: worktreesLoading, + } = useWorktrees(gitRepoPath); + + const { + data: commits = [], + isLoading: commitsLoading, + isFetching: commitsFetching, + } = useCommits(gitRepoPath); + + const { data: commitTotal = 0 } = useCommitCount(gitRepoPath); + const { data: refs = [] } = useRefs(gitRepoPath); + const { data: pushStatus } = usePushStatus(activeWorktree?.path); + + const loading = worktreesLoading || commitsLoading; + + // ── Worktree change counts (sidebar badges) ─────────────────────────── + const rootP = workspaceStatus?.rootPath; + const worktreeChangeCounts = useWorktreeChangeCounts(worktrees, rootP); + + // ── Pick initial active worktree once worktrees load ───────────────── + const [pendingNewWorktreePath, setPendingNewWorktreePath] = useState(null); + + useEffect(() => { + // Filter out root worktree — it should never be active + const selectableWorktrees = worktrees.filter(w => w.path !== rootP); + if (selectableWorktrees.length === 0) { + useWorkspaceStore.setState({ activeWorktree: null }); + return; + } + + const workspaceChanged = lastWorktreeWorkspaceRef.current !== workspacePath; + lastWorktreeWorkspaceRef.current = workspacePath; + + // If the workspace hasn't changed, preserve an already-valid selection + // (e.g. a new worktree was added/removed — don't reset the active one). + if (!workspaceChanged) { + // If a new worktree was just created, switch to it automatically. + if (pendingNewWorktreePath) { + const newWt = selectableWorktrees.find(w => w.path === pendingNewWorktreePath); + if (newWt) { + const prevPath = useWorkspaceStore.getState().activeWorktree?.path ?? null; + setPendingNewWorktreePath(null); + useWorkspaceStore.setState(s => ({ + activeWorktree: newWt, + worktreeActiveTerminalId: { + ...s.worktreeActiveTerminalId, + ...(prevPath ? { [prevPath]: s.activeTerminalId } : {}), + }, + activeTerminalId: s.worktreeActiveTerminalId[newWt.path] ?? null, + })); + void runSwitchAndTriggerHooks({ workspacePath, targetWorktreePath: newWt.path, initiatingWorktreePath: prevPath, source: 'create' }).catch(() => undefined); + return; + } + } + const current = useWorkspaceStore.getState().activeWorktree; + if (current && selectableWorktrees.some(w => w.path === current.path)) return; + } + + // On workspace open: restore the last-selected worktree from the DB, fall + // back to first non-detached, then first overall. + void api.getWorkspaceState(workspacePath, 'activeWorktreePath').then(saved => { + const restored = saved ? (selectableWorktrees.find(w => w.path === saved) ?? null) : null; + const initial = restored ?? selectableWorktrees.find(w => !w.detached) ?? selectableWorktrees[0] ?? null; + useWorkspaceStore.setState({ activeWorktree: initial }); + if (initial) { + void runSwitchAndTriggerHooks({ workspacePath, targetWorktreePath: initial.path, initiatingWorktreePath: null, source: 'load' }).catch(() => undefined); + } + }).catch(() => { + const initial = selectableWorktrees.find(w => !w.detached) ?? selectableWorktrees[0] ?? null; + useWorkspaceStore.setState({ activeWorktree: initial }); + if (initial) { + void runSwitchAndTriggerHooks({ workspacePath, targetWorktreePath: initial.path, initiatingWorktreePath: null, source: 'load' }).catch(() => undefined); + } + }); + }, [worktrees, rootP, workspacePath, pendingNewWorktreePath]); + + // ── Local UI state ──────────────────────────────────────────────────── + const [hooksModalOpen, setHooksModalOpen] = useState(false); + const [showPublishModal, setShowPublishModal] = useState(false); + const [runHookTarget, setRunHookTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [showNewWorktree, setShowNewWorktree] = useState(false); + + // Commit diff state + const [selectedCommits, setSelectedCommits] = useState([]); + const [commitDiffRange, setCommitDiffRange] = useState(null); + const [commitDiffFiles, setCommitDiffFiles] = useState([]); + const [commitDiffContent, setCommitDiffContent] = useState(''); + const [commitDiffFile, setCommitDiffFile] = useState(null); + const [commitDiffLoading, setCommitDiffLoading] = useState(false); + const [commitDiffFileLoading, setCommitDiffFileLoading] = useState(false); + const [renamingTerminalId, setRenamingTerminalId] = useState(null); + const [renameValue, setRenameValue] = useState(''); + // Temporary shim — StagingPanel still uses this until it is refactored to useQuery + const [stagingRefresh, setStagingRefresh] = useState(0); + + // Non-reactive terminal data buffer + const terminalDataRef = useRef>(new Map()); + const renameInputRef = useRef(null); + const lastWorktreeWorkspaceRef = useRef(''); + + const selectedCommit = selectedCommits[0] ?? null; + + // ── Mutations ───────────────────────────────────────────────────────── + const fetchMutation = useFetch(activeWorktree?.path ?? '', gitRepoPath); + const pullMutation = usePull(activeWorktree?.path ?? '', gitRepoPath); + const pushMutation = usePush(activeWorktree?.path ?? ''); + const deleteWorktreeMutation = useDeleteWorktree(gitRepoPath); + + // ── Reset UI state when workspace path changes ──────────────────────── + + useEffect(() => { + resetWorkspaceStore(workspacePath); + }, [workspacePath]); + + // ── Default shell preference ────────────────────────────────────────── + useEffect(() => { + void api.getSetting('default_shell') + .then((v: string | null) => useWorkspaceStore.setState({ defaultShell: v ?? '' })) + .catch(() => undefined); + }, [workspacePath]); + + // ── Close terminals when switching to a DIFFERENT workspace ────────── + // We use a ref so the cleanup only fires when the path genuinely changes + // (not when the component unmounts on navigation to the Projects screen). + const prevWorkspacePathRef = useRef(workspacePath); + useEffect(() => { + const prevPath = prevWorkspacePathRef.current; + prevWorkspacePathRef.current = workspacePath; + if (prevPath && prevPath !== workspacePath) { + void api.closeTerminalsForPath(prevPath); + } + }, [workspacePath]); + + // ── File watcher → invalidate queries ──────────────────────────────── + + useEffect(() => { + void api.startWatching(workspacePath); + const offWorktree = api.onWorktreeChanged(() => { + void qc.invalidateQueries({ queryKey: qk.worktrees(gitRepoPath) }); + }); + const offRefs = api.onGitRefsChanged(() => { + void qc.invalidateQueries({ queryKey: qk.commits(gitRepoPath) }); + void qc.invalidateQueries({ queryKey: qk.refs(gitRepoPath) }); + }); + return () => { + void api.stopWatching(workspacePath); + offWorktree(); + offRefs(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspacePath, gitRepoPath]); + + // ── Session persistence ─────────────────────────────────────────────── + + useEffect(() => { + if (activeWorktree) sessionStorage.setItem('sg_active_wt', activeWorktree.path); + }, [activeWorktree]); + + // ── Auto-switch tab if activeTab becomes disabled ───────────────────── + useEffect(() => { + if (!activeWorktree && (activeTab === 'staging' || activeTab === 'terminal')) { + useWorkspaceStore.setState({ activeTab: 'graph' }); + } + }, [activeWorktree, activeTab]); + + useEffect(() => { + sessionStorage.setItem('sg_active_tab', activeTab); + }, [activeTab]); + + useEffect(() => { + sessionStorage.setItem('sg_terminal_layout', terminalLayout); + }, [terminalLayout]); + + useEffect(() => { + // If the active terminal was closed (not in any session), fall back to + // the last visible session for the current worktree. + if (activeTerminalId && !terminalSessions.some(s => s.id === activeTerminalId)) { + const activePath = useWorkspaceStore.getState().activeWorktree?.path; + const last = terminalSessions.filter(s => s.cwd === activePath).at(-1)?.id ?? null; + useWorkspaceStore.setState({ activeTerminalId: last }); + } + if (renamingTerminalId && !terminalSessions.some(s => s.id === renamingTerminalId)) { + setRenamingTerminalId(null); + setRenameValue(''); + } + }, [activeTerminalId, renamingTerminalId, terminalSessions]); + + useEffect(() => { + if (!renamingTerminalId || !renameInputRef.current) return; + renameInputRef.current.focus(); + renameInputRef.current.select(); + }, [renamingTerminalId]); + + // ── Terminal IPC ────────────────────────────────────────────────────── + + useEffect(() => { + const offData = api.onTerminalData((id: string, data: string) => { + const prev = terminalDataRef.current.get(id) ?? ''; + terminalDataRef.current.set(id, prev + data); + useWorkspaceStore.setState(s => ({ + terminalSessions: s.terminalSessions.map(sess => + sess.id === id ? { ...sess, pendingData: terminalDataRef.current.get(id) ?? '' } : sess + ), + })); + }); + + const offExit = api.onTerminalExit((id: string) => { + useWorkspaceStore.setState(s => { + const remaining = s.terminalSessions.filter(sess => sess.id !== id); + const currentPath = s.activeWorktree?.path; + const visibleRemaining = remaining.filter(sess => sess.cwd === currentPath); + return { + terminalSessions: remaining, + activeTerminalId: s.activeTerminalId === id + ? (visibleRemaining.at(-1)?.id ?? null) + : s.activeTerminalId, + }; + }); + }); + + return () => { offData(); offExit(); }; + }, []); + + // ── Hook terminal launch listener ───────────────────────────────────── + + useEffect(() => { + const offHookTerminal = api.onHookTerminalLaunch((event) => { + const label = `hook: ${event.hookName}`; + useWorkspaceStore.setState(s => { + const cwd = event.cwd; + return { + terminalSessions: [...s.terminalSessions, { + id: event.terminalId, + cwd, + label: makeTerminalLabel(s.terminalSessions.filter(sess => sess.cwd === cwd), label), + pendingData: '', + }], + activeTerminalId: event.terminalId, + activeTab: 'terminal', + worktreeActiveTerminalId: { + ...s.worktreeActiveTerminalId, + [cwd]: event.terminalId, + }, + }; + }); + }); + + return () => { offHookTerminal(); }; + }, []); + + // ── Auto-update listeners ───────────────────────────────────────────── + + useEffect(() => { + const offChecking = api.onUpdateChecking(() => setUpdateState({ status: 'checking' })); + const offAvailable = api.onUpdateAvailable((version: string) => setUpdateState({ status: 'available', version })); + const offNotAvailable = api.onUpdateNotAvailable(() => setUpdateState({ status: 'up-to-date' })); + const offDownloading = api.onUpdateDownloading((progress: number) => setUpdateState({ status: 'downloading', progress })); + const offReady = api.onUpdateReady(() => setUpdateState({ status: 'ready' })); + const offError = api.onUpdateError(() => setUpdateState({ status: 'idle' })); + return () => { offChecking(); offAvailable(); offNotAvailable(); offDownloading(); offReady(); offError(); }; + }, [setUpdateState]); + + // ── Actions ─────────────────────────────────────────────────────────── + + async function doFetch() { + if (!activeWorktree) return; + useWorkspaceStore.setState({ fetching: true }); + try { + await fetchMutation.mutateAsync(); + toast('Fetched', 'success'); + } catch (err) { + toast(`Fetch failed: ${String(err)}`, 'error'); + } finally { + useWorkspaceStore.setState({ fetching: false }); + } + } + + async function doPull() { + if (!activeWorktree) return; + useWorkspaceStore.setState({ pulling: true }); + try { + await pullMutation.mutateAsync(); + toast('Pulled', 'success'); + } catch (err) { + toast(`Pull failed: ${String(err)}`, 'error'); + } finally { + useWorkspaceStore.setState({ pulling: false }); + } + } + + async function doPush() { + if (!activeWorktree) return; + if (!pushStatus?.upstream) { + setShowPublishModal(true); + return; + } + useWorkspaceStore.setState({ pushing: true }); + try { + await pushMutation.mutateAsync(); + toast('Pushed', 'success'); + } catch (err) { + toast(`Push failed: ${String(err)}`, 'error'); + } finally { + useWorkspaceStore.setState({ pushing: false }); + } + } + + async function handleWorktreeSwitch(wt: WorktreeInfo) { + if (activeWorktree?.path === wt.path) return; + const prevPath = activeWorktree?.path ?? null; + // Save the active terminal for the outgoing worktree and restore the + // last known active terminal for the incoming worktree. + useWorkspaceStore.setState(s => { + const savedForTarget = s.worktreeActiveTerminalId[wt.path] ?? null; + const visibleForTarget = s.terminalSessions.filter(sess => sess.cwd === wt.path); + const restoredId = savedForTarget && visibleForTarget.some(sess => sess.id === savedForTarget) + ? savedForTarget + : (visibleForTarget.at(-1)?.id ?? null); + return { + activeWorktree: wt, + activeTerminalId: restoredId, + worktreeActiveTerminalId: { + ...s.worktreeActiveTerminalId, + ...(prevPath ? { [prevPath]: s.activeTerminalId } : {}), + }, + }; + }); + void api.setWorkspaceState(workspacePath, 'activeWorktreePath', wt.path).catch(() => undefined); + void runSwitchAndTriggerHooks({ workspacePath, targetWorktreePath: wt.path, initiatingWorktreePath: prevPath, source: 'manual' }) + .catch((err: unknown) => toast(`Switch hooks failed: ${String(err)}`, 'error')); + } + + async function doDeleteWorktree(wt: WorktreeInfo) { + const isDeletingActive = activeWorktree?.path === wt.path; + const nextWt = isDeletingActive + ? worktrees.find(w => w.path !== wt.path && w.path !== workspaceStatus?.rootPath) + ?? null + : null; + + if (isDeletingActive && nextWt) { + try { + await api.runSwitchHooks({ + workspacePath, + targetWorktreePath: nextWt.path, + initiatingWorktreePath: wt.path, + source: 'delete', + }); + await api.runTriggerHooks({ + workspacePath, + trigger: 'after_worktree_switch', + worktreePath: nextWt.path, + initiatingWorktreePath: wt.path, + source: 'delete', + }); + } catch { /* non-critical */ } + } + + // before_worktree_remove: runs in the worktree being deleted + try { + await api.runTriggerHooks({ + workspacePath, + trigger: 'before_worktree_remove', + worktreePath: wt.path, + initiatingWorktreePath: activeWorktree?.path ?? null, + }); + } catch { /* non-critical */ } + + await api.closeTerminalsForPath(wt.path); + // Switch the active worktree away *before* the mutation so that no git + // queries fire on the deleted path while or after the deletion runs. + if (isDeletingActive) useWorkspaceStore.setState({ activeWorktree: nextWt }); + await deleteWorktreeMutation.mutateAsync({ + rootRepoPath: gitRepoPath, + worktreePath: wt.path, + // Delete the branch for managed worktrees (those living under .sproutgit/worktrees/) + deleteBranch: !!(workspaceStatus?.worktreesPath && wt.path.startsWith(workspaceStatus.worktreesPath) && wt.branch), + branchName: wt.branch ?? null, + }); + + // after_worktree_remove: runs in the now-active worktree + if (nextWt ?? activeWorktree) { + void api.runTriggerHooks({ + workspacePath, + trigger: 'after_worktree_remove', + worktreePath: (nextWt ?? activeWorktree)!.path, + initiatingWorktreePath: null, + }).catch(() => undefined); + } + + toast('Worktree removed', 'success'); + setDeleteTarget(null); + } + + async function loadCommitDiff(commit: CommitEntry) { + setSelectedCommits([commit]); + setCommitDiffFile(null); + setCommitDiffContent(''); + setCommitDiffLoading(true); + try { + const range = commit.parents.length > 0 + ? `${commit.parents[0]}..${commit.hash}` + : commit.hash; + setCommitDiffRange(range); + const files = await api.getDiffFiles(gitRepoPath, range); + setCommitDiffFiles(files as DiffFileEntry[]); + } catch (err) { + toast(`Failed to load commit diff: ${String(err)}`, 'error'); + setCommitDiffFiles([]); + } finally { + setCommitDiffLoading(false); + } + } + + async function loadCommitRangeDiff(from: CommitEntry, to: CommitEntry) { + setSelectedCommits([from, to]); + setCommitDiffFile(null); + setCommitDiffContent(''); + setCommitDiffLoading(true); + try { + const range = `${from.hash}..${to.hash}`; + setCommitDiffRange(range); + const files = await api.getDiffFiles(gitRepoPath, range); + setCommitDiffFiles(files as DiffFileEntry[]); + } catch (err) { + toast(`Failed to load range diff: ${String(err)}`, 'error'); + setCommitDiffFiles([]); + } finally { + setCommitDiffLoading(false); + } + } + + async function loadCommitDiffFile(file: DiffFileEntry) { + if (!selectedCommit) return; + setCommitDiffFile(file); + setCommitDiffFileLoading(true); + try { + const range = commitDiffRange ?? ( + selectedCommit.parents.length > 0 + ? `${selectedCommit.parents[0]}..${selectedCommit.hash}` + : selectedCommit.hash + ); + const content = await api.getDiffContent(gitRepoPath, range, file.path); + setCommitDiffContent(content as string); + } catch (err) { + toast(`Failed to load diff: ${String(err)}`, 'error'); + setCommitDiffContent(''); + } finally { + setCommitDiffFileLoading(false); + } + } + + async function openTerminal(cwd: string, label?: string, shellOverride?: string) { + try { + const terminalArgs: { + cwd: string; + label?: string; + shell?: string; + } = { cwd }; + if (label) terminalArgs.label = label; + const resolvedShell = shellOverride ?? defaultShell; + if (resolvedShell) terminalArgs.shell = resolvedShell; + const id = await api.createTerminal(terminalArgs); + const shellLabel = shellDisplayName(resolvedShell); + useWorkspaceStore.setState(s => ({ + terminalSessions: [...s.terminalSessions, { + id, + cwd, + label: makeTerminalLabel(s.terminalSessions.filter(sess => sess.cwd === cwd), label ?? shellLabel), + pendingData: '', + }], + activeTerminalId: id, + activeTab: 'terminal', + worktreeActiveTerminalId: { ...s.worktreeActiveTerminalId, [cwd]: id }, + })); + } catch (err) { + toast(`Failed to open terminal: ${String(err)}`, 'error'); + } + } + + function shellDisplayName(shellPath: string | null | undefined): string { + if (!shellPath) return 'terminal'; + const base = shellPath.split(/[/\\]/).pop() ?? shellPath; + return base.replace(/\.exe$/i, ''); + } + + function makeTerminalLabel(sessions: typeof terminalSessions, baseLabel: string) { + const trimmed = baseLabel.trim() || 'terminal'; + const existing = sessions.filter(s => s.label === trimmed).length; + return existing === 0 ? trimmed : `${trimmed} (${existing + 1})`; + } + + function terminalPanelStyle(id: string): React.CSSProperties { + if (terminalLayout === 'tabs') { + return { + display: id === activeTerminalId ? 'block' : 'none', + minHeight: 0, + flex: 1, + }; + } + return { + display: 'block', + minHeight: 0, + minWidth: 0, + flex: 1, + }; + } + + function terminalWrapperClass() { + if (terminalLayout === 'split') return 'flex flex-1 min-h-0'; + if (terminalLayout === 'grid') return 'grid flex-1 min-h-0 grid-cols-2 auto-rows-fr'; + return 'flex flex-1 min-h-0 flex-col'; + } + + async function closeTerminal(id: string) { + await api.closeTerminal(id).catch(() => undefined); + useWorkspaceStore.setState(s => { + const remaining = s.terminalSessions.filter(sess => sess.id !== id); + const currentPath = s.activeWorktree?.path; + const visibleRemaining = remaining.filter(sess => sess.cwd === currentPath); + return { + terminalSessions: remaining, + activeTerminalId: s.activeTerminalId === id + ? (visibleRemaining.at(-1)?.id ?? null) + : s.activeTerminalId, + }; + }); + if (renamingTerminalId === id) { + setRenamingTerminalId(null); + setRenameValue(''); + } + } + + async function closeTerminals(ids: string[]) { + if (ids.length === 0) return; + await Promise.all(ids.map(id => api.closeTerminal(id).catch(() => undefined))); + useWorkspaceStore.setState(s => { + const idSet = new Set(ids); + const remaining = s.terminalSessions.filter(sess => !idSet.has(sess.id)); + const currentPath = s.activeWorktree?.path; + const visibleRemaining = remaining.filter(sess => sess.cwd === currentPath); + return { + terminalSessions: remaining, + activeTerminalId: idSet.has(s.activeTerminalId ?? '') + ? (visibleRemaining.at(-1)?.id ?? null) + : s.activeTerminalId, + }; + }); + if (renamingTerminalId && ids.includes(renamingTerminalId)) { + setRenamingTerminalId(null); + setRenameValue(''); + } + } + + function startTerminalRename(id: string) { + const session = terminalSessions.find(s => s.id === id); + if (!session) return; + useWorkspaceStore.setState({ activeTerminalId: id, activeTab: 'terminal' }); + setRenamingTerminalId(id); + setRenameValue(session.label); + } + + function commitTerminalRename() { + if (!renamingTerminalId) return; + const trimmed = renameValue.trim(); + if (trimmed) { + useWorkspaceStore.setState(s => ({ + terminalSessions: s.terminalSessions.map(sess => + sess.id === renamingTerminalId ? { ...sess, label: trimmed } : sess, + ), + })); + } + setRenamingTerminalId(null); + setRenameValue(''); + } + + function cancelTerminalRename() { + setRenamingTerminalId(null); + setRenameValue(''); + } + + function openTerminalTabMenu(e: React.MouseEvent, sessionId: string) { + const index = visibleSessions.findIndex(s => s.id === sessionId); + if (index === -1) return; + const idsToRight = visibleSessions.slice(index + 1).map(s => s.id); + const otherIds = visibleSessions.filter(s => s.id !== sessionId).map(s => s.id); + contextMenu.open(e, [ + { + label: 'Rename', + icon: , + onClick: () => startTerminalRename(sessionId), + }, + { + label: 'New Terminal', + icon: , + onClick: () => { + void openTerminal(activeWorktree?.path ?? workspacePath); + }, + }, + 'separator', + { + label: 'Tabbed Layout', + icon: , + onClick: () => useWorkspaceStore.setState({ terminalLayout: 'tabs', activeTerminalId: sessionId, activeTab: 'terminal' }), + }, + { + label: 'Split Layout', + icon: , + onClick: () => useWorkspaceStore.setState({ terminalLayout: 'split', activeTerminalId: sessionId, activeTab: 'terminal' }), + }, + { + label: 'Grid Layout', + icon: , + onClick: () => useWorkspaceStore.setState({ terminalLayout: 'grid', activeTerminalId: sessionId, activeTab: 'terminal' }), + }, + 'separator', + { + label: 'Close', + icon: , + danger: true, + onClick: () => { void closeTerminal(sessionId); }, + }, + { + label: 'Close Others', + icon: , + disabled: otherIds.length === 0, + danger: true, + onClick: () => { void closeTerminals(otherIds); }, + }, + { + label: 'Close To Right', + icon: , + disabled: idsToRight.length === 0, + danger: true, + onClick: () => { void closeTerminals(idsToRight); }, + }, + ]); + } + + // ── Style helpers ───────────────────────────────────────────────────── + + function tabCls(active: boolean, disabled: boolean = false) { + return `sg-tab flex items-center gap-1.5 px-3 h-full text-xs cursor-pointer bg-transparent border-t-0 border-x-0 border-b-2 transition-colors whitespace-nowrap ${disabled + ? 'text-(--sg-text-dim) border-transparent cursor-not-allowed opacity-50' + : active + ? 'text-(--sg-primary) border-(--sg-primary) font-medium cursor-pointer' + : 'text-(--sg-text-faint) border-transparent hover:text-(--sg-text) cursor-pointer' + }`; + } + + const iconBtn = 'inline-flex items-center justify-center p-[3px] bg-transparent border-none cursor-pointer text-(--sg-text-faint) rounded-[4px] transition-colors hover:text-(--sg-text) hover:bg-(--sg-surface-raised) disabled:opacity-40 disabled:cursor-not-allowed'; + + // ── Render ──────────────────────────────────────────────────────────── + + return ( + <> +
+ {/* ── Full-width top header (matches home page style) ── */} +
+ +
+ +
+ + {workspacePath.split('/').pop()} + + + + + {activeWorktree?.branch ?? (activeWorktree?.detached ? 'detached' : '—')} + +
+
+ void api.installUpdate()} /> + +
+ +
+ + {/* ── Body: sidebar + main content ── */} +
+ {/* Worktree sidebar */} + void handleWorktreeSwitch(wt)} + onFetch={() => void doFetch()} + onPull={() => void doPull()} + onPush={() => void doPush()} + onRefresh={() => { + void qc.invalidateQueries({ queryKey: qk.worktrees(gitRepoPath) }); + void qc.invalidateQueries({ queryKey: qk.commits(gitRepoPath) }); + void qc.invalidateQueries({ queryKey: qk.refs(gitRepoPath) }); + if (activeWorktree) void qc.invalidateQueries({ queryKey: qk.pushStatus(activeWorktree.path) }); + }} + onNewWorktree={() => setShowNewWorktree(true)} + onOpenTerminal={(cwd, label) => void openTerminal(cwd, label)} + onOpenHooksModal={() => setHooksModalOpen(true)} + onOpenRunHookModal={wt => setRunHookTarget(wt)} + onDeleteWorktree={wt => setDeleteTarget(wt)} + /> + + {/* Main content */} +
+ {/* Tab bar */} +
+ + + +
+ + {/* Tab content */} +
+ {loading ? ( +
+ +
+ ) : ( + <> + {/* Graph tab */} + {activeTab === 'graph' && ( +
+
+ 0} + onLoadMore={async () => { + const more = await api.getCommitGraph({ + repoPath: gitRepoPath, + limit: 500, + skip: commits.length, + }) as CommitEntry[]; + qc.setQueryData(qk.commits(gitRepoPath), prev => [ + ...(prev ?? []), + ...more, + ]); + }} + onSelect={selected => { + const nextSelectionKey = selected.map(c => c.hash).join(','); + const currentSelectionKey = selectedCommits.map(c => c.hash).join(','); + if (nextSelectionKey === currentSelectionKey) { + return; + } + if (selected.length === 1 && selected[0]) { + void loadCommitDiff(selected[0]); + } else if (selected.length === 2 && selected[0] && selected[1]) { + void loadCommitRangeDiff(selected[0], selected[1]); + } else { + setSelectedCommits([]); + setCommitDiffFiles([]); + setCommitDiffContent(''); + setCommitDiffFile(null); + } + }} + onCreateWorktree={() => { + setShowNewWorktree(true); + }} + onCheckout={ref => { + if (activeWorktree) { + void api.checkout(activeWorktree.path, ref) + .then(() => { + toast('Checked out', 'success'); + void qc.invalidateQueries({ queryKey: qk.commits(gitRepoPath) }); + void qc.invalidateQueries({ queryKey: qk.refs(gitRepoPath) }); + }) + .catch((err: unknown) => toast(String(err), 'error')); + } + }} + onReset={(ref, mode) => { + if (activeWorktree) { + void api.reset(activeWorktree.path, ref, mode) + .then(() => { + toast(`Reset (${mode}) complete`, 'success'); + void qc.invalidateQueries({ queryKey: qk.commits(gitRepoPath) }); + void qc.invalidateQueries({ queryKey: qk.refs(gitRepoPath) }); + }) + .catch((err: unknown) => toast(String(err), 'error')); + } + }} + /> +
+ {selectedCommit && ( + void loadCommitDiffFile(f)} + onClose={() => { + setSelectedCommits([]); + setCommitDiffFiles([]); + setCommitDiffContent(''); + setCommitDiffFile(null); + }} + /> + )} +
+ )} + + {/* Staging tab */} + {activeTab === 'staging' && activeWorktree && ( + api.getStatus(p)} + stageFiles={(p, paths) => api.stageFiles(p, paths)} + unstageFiles={(p, paths) => api.unstageFiles(p, paths)} + createCommit={(p, msg) => api.createCommit(p, msg)} + getDiff={(p, staged, file) => + staged + ? api.getDiffContent(p, 'HEAD', file) + : api.getWorkingDiff(p, file) + } + onCommit={() => { + toast('Committed', 'success'); + setStagingRefresh(n => n + 1); + void qc.invalidateQueries({ queryKey: qk.commits(gitRepoPath) }); + void qc.invalidateQueries({ queryKey: qk.commitCount(gitRepoPath) }); + void qc.invalidateQueries({ queryKey: qk.refs(gitRepoPath) }); + if (activeWorktree) void qc.invalidateQueries({ queryKey: qk.worktreeStatus(activeWorktree.path) }); + }} + onClose={() => useWorkspaceStore.setState({ activeTab: 'graph' })} + onToast={(msg, v) => toast(msg, v)} + /> + )} + + {/* Terminal tab */} + {activeTab === 'terminal' && activeWorktree && ( +
+
+
+ {visibleSessions.map(s => { + const isActive = s.id === activeTerminalId; + const isRenaming = s.id === renamingTerminalId; + return ( +
openTerminalTabMenu(e, s.id)} + > + {isActive && } + {isRenaming ? ( + setRenameValue(e.target.value)} + onBlur={commitTerminalRename} + onKeyDown={e => { + if (e.key === 'Enter') commitTerminalRename(); + if (e.key === 'Escape') cancelTerminalRename(); + e.stopPropagation(); + }} + onClick={e => e.stopPropagation()} + className="min-w-0 w-25 rounded bg-(--sg-input-bg) px-2 py-1 text-[11px] font-medium text-(--sg-text) outline-(--sg-primary)" + /> + ) : ( + + )} + +
+ ); + })} +
+
+ + {availableShells.length > 0 && ( + + )} + {showShellPicker && availableShells.length > 0 && ( + <> +
setShowShellPicker(false)} /> +
+ {availableShells.map(shell => ( + + ))} +
+ + )} +
+
+
+ + + +
+
+
+ {visibleSessions.map(s => ( +
+ { void api.writeTerminal(id, data); }} + onResize={(id, cols, rows) => { void api.resizeTerminal(id, cols, rows); }} + /> +
+ ))} +
+
+ )} + + )} +
+
+
{/* end body */} +
+ + {/* Hooks settings modal */} + setHooksModalOpen(false)} + {...(defaultShell ? { defaultShell } : {})} + api={{ + listHooks: p => api.listHooks(p), + createHook: args => api.createHook(args), + updateHook: args => api.updateHook(args), + deleteHook: (p, id) => api.deleteHook(p, id), + toggleHook: (p, id, enabled) => api.toggleHook(p, id, enabled), + }} + /> + + {/* New worktree dialog */} + setShowNewWorktree(false)} + onBeforeCreate={async () => { + await api.runTriggerHooks({ + workspacePath, + trigger: 'before_worktree_create', + worktreePath: activeWorktree?.path ?? workspacePath, + initiatingWorktreePath: activeWorktree?.path ?? null, + }); + }} + onCreated={(newWorktreePath) => { + setPendingNewWorktreePath(newWorktreePath); + void qc.invalidateQueries({ queryKey: qk.worktrees(gitRepoPath) }); + void qc.invalidateQueries({ queryKey: qk.refs(gitRepoPath) }); + void api.runCreateHooks({ + workspacePath, + newWorktreePath, + initiatingWorktreePath: activeWorktree?.path ?? null, + }); + }} + onToast={(msg, v) => toast(msg, v)} + /> + + {/* Publish branch dialog */} + setShowPublishModal(false)} + onToast={(msg, v) => toast(msg, v)} + onPublished={() => activeWorktree && void qc.invalidateQueries({ queryKey: qk.pushStatus(activeWorktree.path) })} + /> + + {/* Run hook dialog */} + setRunHookTarget(null)} + onToast={(msg, v) => toast(msg, v)} + /> + + {/* Delete worktree dialog */} + void doDeleteWorktree(wt)} + onCancel={() => setDeleteTarget(null)} + /> + + ); +} + +export const workspaceRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/workspace', + validateSearch: (search: Record): WorkspaceSearch => ({ + path: typeof search['path'] === 'string' ? search['path'] : '', + }), + component: WorkspaceView, +}); diff --git a/app/src/renderer/settings/AppSection.tsx b/app/src/renderer/settings/AppSection.tsx new file mode 100644 index 0000000..1819ea8 --- /dev/null +++ b/app/src/renderer/settings/AppSection.tsx @@ -0,0 +1,146 @@ +import { Info } from 'lucide-react'; +import { api } from '../api.js'; +import { useState, useEffect } from 'react'; +import type { GitInfo } from '@sproutgit/types'; +import { Spinner } from '@sproutgit/ui'; +import { useUpdateStore } from '../stores/update-store.js'; + +export function AppSection() { + const [gitInfo, setGitInfo] = useState(null); + const [appVersion, setAppVersion] = useState(null); + const [checkingForUpdates, setCheckingForUpdates] = useState(false); + const [releaseNotes, setReleaseNotes] = useState(null); + const [releaseNotesLoading, setReleaseNotesLoading] = useState(false); + + const { updateState, setUpdateState } = useUpdateStore(); + + useEffect(() => { + void api.appVersion().then((v: string) => setAppVersion(v)).catch(() => setAppVersion('unknown')); + void api.gitInfo().then(setGitInfo).catch(() => setGitInfo({ installed: false, version: 'Unavailable' })); + }, []); + + useEffect(() => { + const offChecking = api.onUpdateChecking(() => { setUpdateState({ status: 'checking' }); setCheckingForUpdates(true); }); + const offAvailable = api.onUpdateAvailable((version: string) => { + setUpdateState({ status: 'available', version }); + setCheckingForUpdates(false); + }); + const offNotAvailable = api.onUpdateNotAvailable(() => { setUpdateState({ status: 'up-to-date' }); setCheckingForUpdates(false); }); + const offDownloading = api.onUpdateDownloading((progress: number) => setUpdateState({ status: 'downloading', progress })); + const offReady = api.onUpdateReady(() => setUpdateState({ status: 'ready' })); + const offError = api.onUpdateError(() => { setUpdateState({ status: 'idle' }); setCheckingForUpdates(false); }); + return () => { offChecking(); offAvailable(); offNotAvailable(); offDownloading(); offReady(); offError(); }; + }, [setUpdateState]); + + useEffect(() => { + if (updateState.status !== 'available') { setReleaseNotes(null); return; } + const targetVersion = (updateState as { status: 'available'; version: string }).version; + const currentVersion = appVersion ?? '0.0.0'; + setReleaseNotesLoading(true); + fetch('https://api.github.com/repos/InterestingSoftware/SproutGit/releases?per_page=100', { + headers: { Accept: 'application/vnd.github+json' }, + }) + .then(r => r.json()) + .then((raw: unknown) => { + if (!Array.isArray(raw)) { setReleaseNotes(null); return; } + function stripV(v: string) { return v.trim().replace(/^v/i, ''); } + function semver(v: string): [number, number, number] | null { + const parts = stripV(v).split('-')[0]?.split('.') ?? []; + if (parts.length !== 3) return null; + const n = parts.map(Number); + return n.every(Number.isFinite) ? [n[0] ?? 0, n[1] ?? 0, n[2] ?? 0] : null; + } + function cmp(a: [number, number, number], b: [number, number, number]) { + return a[0] !== b[0] ? a[0] - b[0] : a[1] !== b[1] ? a[1] - b[1] : a[2] - b[2]; + } + const cur = semver(currentVersion); + const tgt = semver(targetVersion); + if (!cur || !tgt) { setReleaseNotes(null); return; } + const sections: string[] = []; + for (const item of raw) { + if (!item || typeof item !== 'object') continue; + const r = item as { tag_name?: string; name?: string | null; body?: string | null; draft?: boolean }; + if (r.draft) continue; + const tag = typeof r.tag_name === 'string' ? r.tag_name : null; + if (!tag) continue; + const ver = semver(tag); + if (!ver) continue; + if (cmp(ver, cur) <= 0 || cmp(ver, tgt) > 0) continue; + const header = r.name?.trim() ? `v${stripV(tag)} – ${r.name.trim()}` : `v${stripV(tag)}`; + const body = typeof r.body === 'string' ? r.body.trim() : ''; + sections.push(`${header}\n${body || 'No release notes provided.'}`); + } + setReleaseNotes(sections.length > 0 ? sections.join('\n\n') : null); + }) + .catch(() => setReleaseNotes(null)) + .finally(() => setReleaseNotesLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [updateState.status]); + + return ( +
+

+ About +

+
+
+ SproutGit version + {import.meta.env.DEV ? 'dev build' : (appVersion ?? '…')} +
+
+ Git + + {gitInfo ? (gitInfo.installed ? gitInfo.version : 'Not found') : '…'} + +
+
+ + {!import.meta.env.DEV &&
+
+ + {updateState.status === 'up-to-date' && 'SproutGit is up to date.'} + {updateState.status === 'checking' && 'Checking for updates…'} + {updateState.status === 'available' && `Update ${(updateState as { status: 'available'; version: string }).version} is available`} + {updateState.status === 'downloading' && `Downloading… ${Math.round((updateState as { status: 'downloading'; progress: number }).progress)}%`} + {updateState.status === 'ready' && 'Update downloaded and ready to install.'} + {updateState.status === 'idle' && 'Check for the latest version.'} + +
+ {updateState.status === 'ready' ? ( + + ) : ( + + )} +
+
+ + {updateState.status === 'available' && ( +
+ {releaseNotesLoading && ( +
+ Loading release notes… +
+ )} + {releaseNotes && ( +
+                {releaseNotes}
+              
+ )} +
+ )} +
} +
+ ); +} diff --git a/app/src/renderer/settings/GitHubSection.tsx b/app/src/renderer/settings/GitHubSection.tsx new file mode 100644 index 0000000..a9f1cd9 --- /dev/null +++ b/app/src/renderer/settings/GitHubSection.tsx @@ -0,0 +1,152 @@ +import { Link2 } from 'lucide-react'; +import { api } from '../api.js'; +import { useState, useEffect } from 'react'; +import type { DeviceCodeResponse, GitHubAuthStatus } from '@sproutgit/types'; +import { Spinner, type ToastData } from '@sproutgit/ui'; + +interface Props { + onToast: (msg: string, variant?: ToastData['variant']) => void; + onAuthChange: (auth: GitHubAuthStatus | null) => void; +} + +export function GitHubSection({ onToast, onAuthChange }: Props) { + const [githubAuth, setGithubAuth] = useState(null); + const [deviceCode, setDeviceCode] = useState(null); + const [authPolling, setAuthPolling] = useState(false); + const [authStarting, setAuthStarting] = useState(false); + + useEffect(() => { + void api.githubAuthStatus() + .then((status: GitHubAuthStatus) => { + setGithubAuth(status); + onAuthChange(status); + }) + .catch(() => { + const fallback: GitHubAuthStatus = { authenticated: false, username: null, provider: 'github' }; + setGithubAuth(fallback); + onAuthChange(fallback); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function startGithubLogin() { + setAuthStarting(true); + try { + const dc = await api.githubDeviceFlowStart(); + setDeviceCode(dc); + navigator.clipboard.writeText(dc.userCode).catch(() => {}); + await api.openUrl(dc.verificationUri); + + const poll = async () => { + try { + const result = await api.githubDeviceFlowPoll(dc.deviceCode); + if (result.status === 'complete') { + setAuthPolling(false); + setDeviceCode(null); + const next: GitHubAuthStatus = { authenticated: true, username: result.username, provider: 'github' }; + setGithubAuth(next); + onAuthChange(next); + onToast(`Signed in as ${result.username ?? 'GitHub user'}`, 'success'); + return; + } + if (result.status === 'pending') { + setAuthPolling(true); + setTimeout(() => void poll(), (dc.interval + 1) * 1000); + return; + } + setAuthPolling(false); + setDeviceCode(null); + onToast(result.error ?? 'Authentication failed', 'error'); + } catch (err) { + setAuthPolling(false); + setDeviceCode(null); + onToast(String(err), 'error'); + } + }; + setTimeout(() => void poll(), dc.interval * 1000); + } catch (err) { + onToast(String(err), 'error'); + } finally { + setAuthStarting(false); + } + } + + async function handleGithubLogout() { + try { + await api.githubLogout(); + const next: GitHubAuthStatus = { authenticated: false, username: null, provider: 'github' }; + setGithubAuth(next); + onAuthChange(next); + } catch (err) { + onToast(String(err), 'error'); + } + } + + return ( +
+
+ +

Git Provider

+
+ + {githubAuth === null ? ( +
+ Checking connection... +
+ ) : githubAuth.authenticated ? ( +
+

{githubAuth.username}

+ +
+ ) : deviceCode ? ( +
+

+ Enter this code on{' '} + +

+
+ + {deviceCode.userCode} + + +
+
+ + {authPolling ? 'Waiting for authorization…' : 'Opening GitHub…'} +
+
+ ) : ( + + )} +
+ ); +} diff --git a/app/src/renderer/settings/GitSection.tsx b/app/src/renderer/settings/GitSection.tsx new file mode 100644 index 0000000..640225e --- /dev/null +++ b/app/src/renderer/settings/GitSection.tsx @@ -0,0 +1,519 @@ +import { User, Code2, Diff, GitMerge, Pencil, GitBranch } from 'lucide-react'; +import { api } from '../api.js'; +import { useState, useEffect } from 'react'; +import type { + EditorInfo, + GitHubAuthStatus, + GitHubEmailSuggestion, + GitToolInfo, +} from '@sproutgit/types'; +import { Spinner, type ToastData } from '@sproutgit/ui'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function titleCase(value: string): string { + return value + .replace(/[-_]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/\b\w/g, c => c.toUpperCase()); +} + +function commandToken(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ''; + const stripped = trimmed + .replace(/^"([\s\S]*)"(?:\s.*)?$/, '$1') + .replace(/^'([\s\S]*)'(?:\s.*)?$/, '$1'); + const first = stripped.split(/\s+/)[0] ?? ''; + const normalized = first.replace(/^"|"$/g, '').replace(/^'|'$/g, ''); + const parts = normalized.split('/'); + return (parts[parts.length - 1] ?? normalized).toLowerCase(); +} + +function fallbackDisplay(value: string): { id: string; name: string } { + const token = commandToken(value); + const base = token || value.trim(); + return { id: token || 'custom', name: titleCase(base) }; +} + +function matchesEditor(editor: EditorInfo, configured: string): boolean { + const stripped = configured.replace(/^["']|["']$/g, ''); + const cmd = stripped.split(/\s+--?\w/)[0]?.trim() ?? ''; + return cmd === editor.command || stripped.startsWith(editor.command); +} + +function quoteCommand(command: string): string { + return command.includes(' ') ? `"${command}"` : command; +} + +function editorCommand(editor: EditorInfo): string { + const waits = ['vscode', 'cursor', 'windsurf', 'kiro', 'sublime', 'zed']; + const cmd = quoteCommand(editor.command); + return waits.includes(editor.id) ? `${cmd} --wait` : cmd; +} + +function buildDiffToolCommand(tool: GitToolInfo): string | null { + const waits = ['vscode', 'cursor', 'windsurf', 'kiro', 'sublime', 'zed']; + const cmd = quoteCommand(tool.command); + if (waits.includes(tool.id)) return `${cmd} --wait --diff "$LOCAL" "$REMOTE"`; + if (tool.id === 'opendiff') return 'opendiff "$LOCAL" "$REMOTE"'; + return null; +} + +function buildMergeToolCommand(tool: GitToolInfo): string | null { + const waits = ['vscode', 'cursor', 'windsurf', 'kiro', 'sublime', 'zed']; + const cmd = quoteCommand(tool.command); + if (waits.includes(tool.id)) return `${cmd} --wait "$MERGED"`; + if (tool.id === 'opendiff') return 'opendiff "$LOCAL" "$REMOTE" -merge "$MERGED"'; + return null; +} + +// ── Component ───────────────────────────────────────────────────────────────── + +interface Props { + onToast: (msg: string, variant?: ToastData['variant']) => void; + githubAuth: GitHubAuthStatus | null; +} + +export function GitSection({ onToast, githubAuth }: Props) { + const [editors, setEditors] = useState([]); + const [gitTools, setGitTools] = useState([]); + const [toolsLoading, setToolsLoading] = useState(true); + + const [currentEditor, setCurrentEditor] = useState(''); + const [customEditor, setCustomEditor] = useState(''); + const [currentDiffTool, setCurrentDiffTool] = useState(''); + const [customDiffTool, setCustomDiffTool] = useState(''); + const [currentMergeTool, setCurrentMergeTool] = useState(''); + const [customMergeTool, setCustomMergeTool] = useState(''); + const [currentGitName, setCurrentGitName] = useState(''); + const [currentGitEmail, setCurrentGitEmail] = useState(''); + const [customGitName, setCustomGitName] = useState(''); + const [customGitEmail, setCustomGitEmail] = useState(''); + + const [githubEmailSuggestions, setGithubEmailSuggestions] = useState([]); + const [githubEmailsLoading, setGithubEmailsLoading] = useState(false); + + const [editingAuthor, setEditingAuthor] = useState(false); + const [editingEditor, setEditingEditor] = useState(false); + const [editingDiffTool, setEditingDiffTool] = useState(false); + const [editingMergeTool, setEditingMergeTool] = useState(false); + + // Load git config and tools on mount + useEffect(() => { + void Promise.all([ + api.detectEditors(), + api.detectGitTools(), + api.getGitConfig('core.editor'), + api.getGitConfig('diff.tool'), + api.getGitConfig('merge.tool'), + api.getGitConfig('user.name'), + api.getGitConfig('user.email'), + ]).then(([detectedEditors, detectedTools, configEditor, diffTool, mergeTool, gitName, gitEmail]) => { + setEditors(detectedEditors); + setGitTools(detectedTools); + setCurrentEditor(configEditor ?? ''); + setCurrentDiffTool(diffTool ?? ''); + setCurrentMergeTool(mergeTool ?? ''); + setCurrentGitName(gitName ?? ''); + setCurrentGitEmail(gitEmail ?? ''); + setCustomGitName(gitName ?? ''); + setCustomGitEmail(gitEmail ?? ''); + if (configEditor && !detectedEditors.some((e: EditorInfo) => e.installed && matchesEditor(e, configEditor))) { + setCustomEditor(configEditor); + } + }).finally(() => setToolsLoading(false)); + }, []); + + // Load GitHub email suggestions when auth status becomes known + useEffect(() => { + if (!githubAuth?.authenticated) { setGithubEmailSuggestions([]); return; } + setGithubEmailsLoading(true); + void api.githubListEmails() + .then((emails: GitHubEmailSuggestion[]) => setGithubEmailSuggestions(emails)) + .catch(() => setGithubEmailSuggestions([])) + .finally(() => setGithubEmailsLoading(false)); + }, [githubAuth?.authenticated]); + + // ── Derived ─────────────────────────────────────────────────────────── + + const installedEditors = editors.filter(e => e.installed); + const unavailableEditors = editors.filter(e => !e.installed); + const installedDiffTools = gitTools.filter(t => t.installed && t.supportsDiff); + const installedMergeTools = gitTools.filter(t => t.installed && t.supportsMerge); + + const editorDisplay = (() => { + if (!currentEditor.trim()) return null; + const match = editors.find(e => matchesEditor(e, currentEditor)); + if (match) return { id: match.id, name: match.name }; + return fallbackDisplay(currentEditor); + })(); + + const diffToolDisplay = (() => { + if (!currentDiffTool.trim()) return null; + const token = commandToken(currentDiffTool); + const match = gitTools.find(t => t.id === currentDiffTool || t.id === token); + if (match) return { id: match.id, name: match.name }; + return fallbackDisplay(currentDiffTool); + })(); + + const mergeToolDisplay = (() => { + if (!currentMergeTool.trim()) return null; + const token = commandToken(currentMergeTool); + const match = gitTools.find(t => t.id === currentMergeTool || t.id === token); + if (match) return { id: match.id, name: match.name }; + return fallbackDisplay(currentMergeTool); + })(); + + // ── Actions ─────────────────────────────────────────────────────────── + + async function saveGitIdentity() { + try { + await Promise.all([ + api.setGitConfig('user.name', customGitName.trim()), + api.setGitConfig('user.email', customGitEmail.trim()), + ]); + setCurrentGitName(customGitName.trim()); + setCurrentGitEmail(customGitEmail.trim()); + setEditingAuthor(false); + onToast('Git author updated', 'success'); + } catch (err) { + onToast(String(err), 'error'); + } + } + + async function applyGithubEmail(s: GitHubEmailSuggestion) { + try { + await api.setGitConfig('user.email', s.email); + setCurrentGitEmail(s.email); + setCustomGitEmail(s.email); + onToast(`Git email set to ${s.label}`, 'success'); + } catch (err) { + onToast(String(err), 'error'); + } + } + + async function applyGithubUsernameAsAuthor() { + if (!githubAuth?.username) return; + try { + await api.setGitConfig('user.name', githubAuth.username); + setCurrentGitName(githubAuth.username); + setCustomGitName(githubAuth.username); + onToast('Git author name set from GitHub username', 'success'); + } catch (err) { + onToast(String(err), 'error'); + } + } + + async function selectEditor(editor: EditorInfo) { + try { + const cmd = editorCommand(editor); + await api.setGitConfig('core.editor', cmd); + setCurrentEditor(cmd); + setCustomEditor(''); + setEditingEditor(false); + onToast(`Editor set to ${editor.name}`, 'success'); + } catch (err) { + onToast(String(err), 'error'); + } + } + + async function saveCustomEditor() { + try { + const value = customEditor.trim(); + await api.setGitConfig('core.editor', value); + setCurrentEditor(value); + setEditingEditor(false); + onToast(value ? `Editor set to "${value}"` : 'Editor config cleared', 'success'); + } catch (err) { + onToast(String(err), 'error'); + } + } + + async function applyDetectedDiffTool(tool: GitToolInfo) { + try { + await api.setGitConfig('diff.tool', tool.id); + const cmd = buildDiffToolCommand(tool); + if (cmd) await api.setGitConfig(`difftool.${tool.id}.cmd`, cmd); + setCurrentDiffTool(tool.id); + setCustomDiffTool(''); + setEditingDiffTool(false); + onToast(`Diff tool set to ${tool.name}`, 'success'); + } catch (err) { + onToast(String(err), 'error'); + } + } + + async function saveCustomDiffTool() { + try { + await api.setGitConfig('diff.tool', customDiffTool.trim()); + setCurrentDiffTool(customDiffTool.trim()); + setEditingDiffTool(false); + } catch (err) { + onToast(String(err), 'error'); + } + } + + async function applyDetectedMergeTool(tool: GitToolInfo) { + try { + await api.setGitConfig('merge.tool', tool.id); + const cmd = buildMergeToolCommand(tool); + if (cmd) await api.setGitConfig(`mergetool.${tool.id}.cmd`, cmd); + setCurrentMergeTool(tool.id); + setCustomMergeTool(''); + setEditingMergeTool(false); + onToast(`Merge tool set to ${tool.name}`, 'success'); + } catch (err) { + onToast(String(err), 'error'); + } + } + + async function saveCustomMergeTool() { + try { + await api.setGitConfig('merge.tool', customMergeTool.trim()); + setCurrentMergeTool(customMergeTool.trim()); + setEditingMergeTool(false); + } catch (err) { + onToast(String(err), 'error'); + } + } + + // ── Render ──────────────────────────────────────────────────────────── + + return ( +
+
+

+ Git Settings +

+

These update your global Git configuration.

+
+ + {toolsLoading ? ( +
+ Detecting editors and tools... +
+ ) : ( +
+ {/* Author identity */} +
+
+
+
+
+

Author Identity

+

+ {currentGitName || '(not set)'} · {currentGitEmail || '(not set)'} +

+
+
+ +
+ {editingAuthor && ( +
+ setCustomGitName(e.target.value)} + className="w-full rounded border border-(--sg-input-border) bg-(--sg-input-bg) px-2.5 py-1.5 text-xs text-(--sg-text)" + placeholder="Git user.name" + /> + setCustomGitEmail(e.target.value)} + className="w-full rounded border border-(--sg-input-border) bg-(--sg-input-bg) px-2.5 py-1.5 text-xs text-(--sg-text)" + placeholder="Git user.email" + /> +
+ + {githubAuth?.authenticated && githubAuth.username && ( + + )} +
+ {githubAuth?.authenticated && ( +
+ {githubEmailsLoading ? ( + Loading GitHub emails... + ) : githubEmailSuggestions.map(s => ( + + ))} +
+ )} +
+ )} +
+ + {/* Editor */} +
+
+
+
+
+

Editor

+

{editorDisplay?.name ?? '(not set)'}

+
+
+ +
+ {editingEditor && ( +
+
+ {installedEditors.map(editor => ( + + ))} +
+
+ setCustomEditor(e.target.value)} + className="min-w-0 flex-1 rounded border border-(--sg-input-border) bg-(--sg-input-bg) px-2.5 py-1.5 font-mono text-xs text-(--sg-text)" + placeholder="Custom core.editor" + /> + +
+ {unavailableEditors.length > 0 && ( +

+ Not found: {unavailableEditors.map(e => e.name).join(', ')} +

+ )} +
+ )} +
+ + {/* Diff tool */} +
+
+
+
+
+

Diff Tool

+

{diffToolDisplay?.name ?? '(not set)'}

+
+
+ +
+ {editingDiffTool && ( +
+
+ {installedDiffTools.map(tool => ( + + ))} +
+
+ setCustomDiffTool(e.target.value)} + className="min-w-0 flex-1 rounded border border-(--sg-input-border) bg-(--sg-input-bg) px-2.5 py-1.5 font-mono text-xs text-(--sg-text)" + placeholder="Custom diff.tool" + /> + +
+
+ )} +
+ + {/* Merge tool */} +
+
+
+
+
+

Merge Tool

+

{mergeToolDisplay?.name ?? '(not set)'}

+
+
+ +
+ {editingMergeTool && ( +
+
+ {installedMergeTools.map(tool => ( + + ))} +
+
+ setCustomMergeTool(e.target.value)} + className="min-w-0 flex-1 rounded border border-(--sg-input-border) bg-(--sg-input-bg) px-2.5 py-1.5 font-mono text-xs text-(--sg-text)" + placeholder="Custom merge.tool" + /> + +
+
+ )} +
+
+ )} +
+ ); +} diff --git a/app/src/renderer/settings/ShellSection.tsx b/app/src/renderer/settings/ShellSection.tsx new file mode 100644 index 0000000..0d223bb --- /dev/null +++ b/app/src/renderer/settings/ShellSection.tsx @@ -0,0 +1,61 @@ +import { Terminal } from 'lucide-react'; +import { api } from '../api.js'; +import { useState, useEffect } from 'react'; +import { Spinner, type ToastData } from '@sproutgit/ui'; + +interface Props { + onToast: (msg: string, variant?: ToastData['variant']) => void; +} + +export function ShellSection({ onToast }: Props) { + const [availableShells, setAvailableShells] = useState<{ name: string; path: string }[]>([]); + const [currentShell, setCurrentShell] = useState(''); + const [shellsLoading, setShellsLoading] = useState(true); + + useEffect(() => { + void Promise.all([ + api.listShells(), + api.getSetting('default_shell'), + ]).then(([shells, savedShell]) => { + setAvailableShells(shells); + setCurrentShell(savedShell ?? shells[0]?.path ?? ''); + }).finally(() => setShellsLoading(false)); + }, []); + + async function selectShell(shellPath: string) { + setCurrentShell(shellPath); + try { + await api.setSetting('default_shell', shellPath); + const shellName = availableShells.find(s => s.path === shellPath)?.name ?? shellPath; + onToast(`Default shell set to ${shellName}`, 'success'); + } catch (err) { + onToast(String(err), 'error'); + } + } + + return ( +
+

+ Default Shell +

+ {shellsLoading ? ( +
+ Detecting shells... +
+ ) : ( +
+ {availableShells.map(shell => ( + + ))} +
+ )} +
+ ); +} diff --git a/app/src/renderer/stores/update-store.ts b/app/src/renderer/stores/update-store.ts new file mode 100644 index 0000000..3e12bf8 --- /dev/null +++ b/app/src/renderer/stores/update-store.ts @@ -0,0 +1,12 @@ +import { create } from 'zustand'; +import type { UpdateState } from '@sproutgit/ui'; + +interface UpdateStore { + updateState: UpdateState; + setUpdateState: (state: UpdateState) => void; +} + +export const useUpdateStore = create()((set) => ({ + updateState: { status: 'idle' }, + setUpdateState: (state) => set({ updateState: state }), +})); diff --git a/app/src/renderer/stores/workspace-store.ts b/app/src/renderer/stores/workspace-store.ts new file mode 100644 index 0000000..852f8d5 --- /dev/null +++ b/app/src/renderer/stores/workspace-store.ts @@ -0,0 +1,83 @@ +import { create } from 'zustand'; +import type { WorktreeInfo } from '@sproutgit/types'; + +export type Tab = 'graph' | 'staging' | 'terminal'; +export type TerminalLayout = 'tabs' | 'split' | 'grid'; +export type TerminalSession = { id: string; label: string; pendingData: string; cwd: string }; + +interface WorkspaceUiState { + workspacePath: string; + /** Active worktree selected in the sidebar. */ + activeWorktree: WorktreeInfo | null; + // Tab bar + activeTab: Tab; + // Remote op loading flags + fetching: boolean; + pulling: boolean; + pushing: boolean; + // Shell preference (loaded from settings) + defaultShell: string; + // Terminals + terminalSessions: TerminalSession[]; + activeTerminalId: string | null; + terminalLayout: TerminalLayout; + /** Saves the last-active terminal ID per worktree path for save/restore on switch. */ + worktreeActiveTerminalId: Record; + // Pending worktree creation + pendingCreationBranch: string | null; + creatingWorktree: boolean; +} + +function readInitialTab(): Tab { + const s = sessionStorage.getItem('sg_active_tab'); + return s === 'staging' || s === 'terminal' ? s : 'graph'; +} + +function readInitialTerminalLayout(): TerminalLayout { + const s = sessionStorage.getItem('sg_terminal_layout'); + return s === 'split' || s === 'grid' ? s : 'tabs'; +} + +const baseUiState: Omit = { + activeWorktree: null, + activeTab: readInitialTab(), + fetching: false, + pulling: false, + pushing: false, + defaultShell: '', + terminalSessions: [], + activeTerminalId: null, + terminalLayout: readInitialTerminalLayout(), + worktreeActiveTerminalId: {}, + pendingCreationBranch: null, + creatingWorktree: false, +}; + +export const useWorkspaceStore = create()(() => ({ + workspacePath: '', + ...baseUiState, +})); + +/** Reset UI state when switching to a new workspace. + * When returning to the same workspace (e.g. navigating back from the Projects screen) + * terminal sessions are preserved so PTY sessions survive navigation. */ +export function resetWorkspaceStore(workspacePath: string) { + const current = useWorkspaceStore.getState(); + if (current.workspacePath === workspacePath) { + // Same workspace — reset only non-terminal UI state so live sessions survive. + useWorkspaceStore.setState({ + activeWorktree: null, + activeTab: readInitialTab(), + fetching: false, + pulling: false, + pushing: false, + defaultShell: '', + pendingCreationBranch: null, + creatingWorktree: false, + }); + } else { + // Different workspace — full reset (including terminal sessions). + useWorkspaceStore.setState({ workspacePath, ...baseUiState, activeTab: readInitialTab() }); + } +} + diff --git a/app/src/renderer/tailwind.css b/app/src/renderer/tailwind.css new file mode 100644 index 0000000..98f6fe9 --- /dev/null +++ b/app/src/renderer/tailwind.css @@ -0,0 +1,20 @@ +@import "tailwindcss"; +@source "./**/*.{ts,tsx}"; +@source "../../../packages/ui/src/**/*.{ts,tsx}"; + +/* Native base reset — avoids browser default padding/border/backdrop. */ +dialog { + padding: 0; + border: none; + background: transparent; + max-width: none; + max-height: none; + color: inherit; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%); +} + +dialog::backdrop { + background: rgba(0, 0, 0, 0.45); +} diff --git a/app/src/renderer/toast-context.tsx b/app/src/renderer/toast-context.tsx new file mode 100644 index 0000000..574a8cd --- /dev/null +++ b/app/src/renderer/toast-context.tsx @@ -0,0 +1,19 @@ +import { createContext, useContext } from 'react'; +import type { ToastData } from '@sproutgit/ui'; + +export type ToastFn = (message: string, variant?: ToastData['variant']) => void; + +export const ToastContext = createContext(() => undefined); + +export function useToast(): ToastFn { + return useContext(ToastContext); +} + +// Module-level escape hatch so code outside React (e.g. QueryCache.onError) +// can fire toasts. Wired up by RootLayout on mount. +// eslint-disable-next-line no-underscore-dangle +let _globalToast: ToastFn = () => undefined; +export function setGlobalToast(fn: ToastFn) { _globalToast = fn; } +export function globalToast(message: string, variant?: ToastData['variant']) { + _globalToast(message, variant); +} diff --git a/app/src/renderer/types-compat.d.ts b/app/src/renderer/types-compat.d.ts new file mode 100644 index 0000000..3acc2e6 --- /dev/null +++ b/app/src/renderer/types-compat.d.ts @@ -0,0 +1,3 @@ +declare module 'highlight.js/lib/core'; +declare module 'monaco-editor/esm/vs/basic-languages/shell/shell.contribution'; +declare module 'monaco-editor/esm/vs/basic-languages/powershell/powershell.contribution'; diff --git a/app/src/renderer/window.d.ts b/app/src/renderer/window.d.ts new file mode 100644 index 0000000..f84e81e --- /dev/null +++ b/app/src/renderer/window.d.ts @@ -0,0 +1,7 @@ +import type { SproutGitApi } from '../preload/index.js'; + +declare global { + interface Window { + api: SproutGitApi; + } +} diff --git a/app/src/renderer/workspace/CommitDiffPanel.tsx b/app/src/renderer/workspace/CommitDiffPanel.tsx new file mode 100644 index 0000000..3905370 --- /dev/null +++ b/app/src/renderer/workspace/CommitDiffPanel.tsx @@ -0,0 +1,154 @@ +import { Spinner } from '@sproutgit/ui'; +import type { CommitEntry, DiffFileEntry } from '@sproutgit/types'; +import { X } from 'lucide-react'; +import hljs from 'highlight.js/lib/core'; +import typescriptLang from 'highlight.js/lib/languages/typescript'; +import javascriptLang from 'highlight.js/lib/languages/javascript'; +import rustLang from 'highlight.js/lib/languages/rust'; +import cssLang from 'highlight.js/lib/languages/css'; +import jsonLang from 'highlight.js/lib/languages/json'; +import xmlLang from 'highlight.js/lib/languages/xml'; +import bashLang from 'highlight.js/lib/languages/bash'; +import markdownLang from 'highlight.js/lib/languages/markdown'; +import yamlLang from 'highlight.js/lib/languages/yaml'; +import sqlLang from 'highlight.js/lib/languages/sql'; +import pythonLang from 'highlight.js/lib/languages/python'; +import goLang from 'highlight.js/lib/languages/go'; + +hljs.registerLanguage('typescript', typescriptLang); +hljs.registerLanguage('javascript', javascriptLang); +hljs.registerLanguage('rust', rustLang); +hljs.registerLanguage('css', cssLang); +hljs.registerLanguage('json', jsonLang); +hljs.registerLanguage('xml', xmlLang); +hljs.registerLanguage('bash', bashLang); +hljs.registerLanguage('markdown', markdownLang); +hljs.registerLanguage('yaml', yamlLang); +hljs.registerLanguage('sql', sqlLang); +hljs.registerLanguage('python', pythonLang); +hljs.registerLanguage('go', goLang); + +type Props = { + commit: CommitEntry; + files: DiffFileEntry[]; + loading: boolean; + selectedFile: DiffFileEntry | null; + diffContent: string; + diffLoading: boolean; + onSelectFile: (f: DiffFileEntry) => void; + onClose: () => void; +}; + +const iconBtn = 'inline-flex items-center justify-center p-[3px] bg-transparent border-none cursor-pointer text-(--sg-text-faint) rounded-[4px] transition-colors hover:text-(--sg-text) hover:bg-(--sg-surface-raised)'; + +function renderDiffHtml(raw: string, filePath: string | null): string { + if (!raw.trim()) return 'No changes'; + const lang = languageForPath(filePath); + return raw.split('\n').map(line => { + if (line.startsWith('+') && !line.startsWith('+++')) return `
+${highlightCode(line.slice(1), lang)}
`; + if (line.startsWith('-') && !line.startsWith('---')) return `
-${highlightCode(line.slice(1), lang)}
`; + if (line.startsWith('@@')) return `
${escapeHtml(line)}
`; + if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('---') || line.startsWith('+++')) { + return `
${escapeHtml(line)}
`; + } + if (line.startsWith(' ')) { + return `
${highlightCode(line.slice(1), lang)}
`; + } + return `
${highlightCode(line, lang)}
`; + }).join(''); +} + +const extToLang: Record = { + ts: 'typescript', + tsx: 'typescript', + js: 'javascript', + jsx: 'javascript', + mjs: 'javascript', + cjs: 'javascript', + rs: 'rust', + css: 'css', + scss: 'css', + less: 'css', + json: 'json', + html: 'xml', + svg: 'xml', + sh: 'bash', + zsh: 'bash', + bash: 'bash', + md: 'markdown', + yml: 'yaml', + yaml: 'yaml', + sql: 'sql', + py: 'python', + go: 'go', +}; + +function languageForPath(path: string | null): string | null { + if (!path) return null; + const ext = path.split('.').pop()?.toLowerCase(); + if (!ext) return null; + return extToLang[ext] ?? null; +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +function highlightCode(code: string, language: string | null): string { + if (!language || !code) return escapeHtml(code); + try { + return hljs.highlight(code, { language, ignoreIllegals: true }).value; + } catch { + return escapeHtml(code); + } +} + +export function CommitDiffPanel({ commit, files, loading, selectedFile, diffContent, diffLoading, onSelectFile, onClose }: Props) { + return ( +
+
+ {commit.shortHash} + {commit.subject} + {commit.authorName} + +
+
+ {loading ? ( +
+ ) : ( + <> +
+ {files.map(f => ( + + ))} + {files.length === 0 && ( + No file changes + )} +
+ {selectedFile && ( +
+ {diffLoading ? ( +
+ ) : ( +
+                )}
+              
+ )} + + )} +
+
+ ); +} diff --git a/app/src/renderer/workspace/WorktreeSidebar.tsx b/app/src/renderer/workspace/WorktreeSidebar.tsx new file mode 100644 index 0000000..527f60e --- /dev/null +++ b/app/src/renderer/workspace/WorktreeSidebar.tsx @@ -0,0 +1,340 @@ +import { api } from '../api.js'; +import { useToast } from '../toast-context.js'; +import { useEffect, useState } from 'react'; +import { + ResizableSidebar, + Spinner, + useContextMenu, + UpdateBadge, +} from '@sproutgit/ui'; +import { GitBranch, RefreshCw, ArrowDownToLine, ArrowUpFromLine, Download, Plus, Sliders, Trash2, MoreHorizontal, FolderPen, FolderSearch, SquareTerminal, Play, Copy, CopyPlus } from 'lucide-react'; +import type { WorktreeInfo, WorkspaceStatus } from '@sproutgit/types'; +import type { UpdateState } from '@sproutgit/ui'; + +type Props = { + workspacePath: string; + worktrees: WorktreeInfo[]; + activeWorktree: WorktreeInfo | null; + workspaceStatus: WorkspaceStatus | null; + worktreeChangeCounts: Record; + fetching: boolean; + pulling: boolean; + pushing: boolean; + creatingWorktree: boolean; + pendingCreationBranch: string | null; + updateState: UpdateState; + onWorktreeSwitch: (wt: WorktreeInfo) => void; + onFetch: () => void; + onPull: () => void; + onPush: () => void; + onRefresh: () => void; + onNewWorktree: () => void; + onOpenTerminal: (cwd: string, label?: string) => void; + onOpenHooksModal: () => void; + onOpenRunHookModal: (wt: WorktreeInfo) => void; + onDeleteWorktree: (wt: WorktreeInfo) => void; +}; + +const iconBtn = 'inline-flex items-center justify-center p-1.5 bg-transparent border-none cursor-pointer text-(--sg-text-faint) rounded-[4px] transition-colors hover:text-(--sg-text) hover:bg-(--sg-surface-raised) disabled:opacity-40 disabled:cursor-not-allowed'; + +function isPersistentBranch(branch: string | null) { + return /^(main|master|develop|release\/.+)$/.test(branch ?? ''); +} + +function tildify(p: string, home: string) { + if (home && p.startsWith(home)) return '~' + p.slice(home.length); + return p; +} + +type InventoryRow = { + wt: WorktreeInfo; + typeLabel: 'Managed' | 'Persistent' | 'External'; + section: 'managed' | 'persistent' | 'external'; +}; + +const PENDING_PATH = '__PENDING__'; + +export function WorktreeSidebar({ + workspacePath, + worktrees, + activeWorktree, + workspaceStatus, + worktreeChangeCounts, + fetching, + pulling, + pushing, + creatingWorktree, + pendingCreationBranch, + updateState, + onWorktreeSwitch, + onFetch, + onPull, + onPush, + onRefresh, + onNewWorktree, + onOpenTerminal, + onOpenHooksModal, + onOpenRunHookModal, + onDeleteWorktree, +}: Props) { + const toast = useToast(); + const contextMenu = useContextMenu(); + const [homeDir, setHomeDir] = useState(''); + + useEffect(() => { + api.getHomeDir().then(setHomeDir).catch(() => {/* ignore */}); + }, []); + + const rootPath = workspaceStatus?.rootPath ?? ''; + const managedPath = workspaceStatus?.worktreesPath ?? ''; + const nonRootWorktrees = worktrees.filter(wt => wt.path !== rootPath); + const underManaged = nonRootWorktrees.filter(wt => managedPath && wt.path.startsWith(managedPath)); + const persistentWorktrees = underManaged.filter(wt => isPersistentBranch(wt.branch)); + const taskWorktrees = underManaged.filter(wt => !isPersistentBranch(wt.branch)); + const externalWorktrees = nonRootWorktrees.filter(wt => !managedPath || !wt.path.startsWith(managedPath)); + + // Flat sorted inventory — managed → persistent → external, alpha within section + const inventoryRows: InventoryRow[] = []; + if (creatingWorktree && pendingCreationBranch && !taskWorktrees.some(wt => wt.branch === pendingCreationBranch)) { + inventoryRows.push({ wt: { path: PENDING_PATH, branch: pendingCreationBranch, head: null, detached: false }, typeLabel: 'Managed', section: 'managed' }); + } + for (const wt of taskWorktrees) inventoryRows.push({ wt, typeLabel: 'Managed', section: 'managed' }); + for (const wt of persistentWorktrees) inventoryRows.push({ wt, typeLabel: 'Persistent', section: 'persistent' }); + for (const wt of externalWorktrees) inventoryRows.push({ wt, typeLabel: 'External', section: 'external' }); + const sectionRank: Record = { managed: 0, persistent: 1, external: 2 }; + inventoryRows.sort((a, b) => { + const s = sectionRank[a.section]! - sectionRank[b.section]!; + if (s !== 0) return s; + return (a.wt.branch ?? a.wt.path).localeCompare(b.wt.branch ?? b.wt.path); + }); + + // Show the active worktree summary only when the active worktree is not the root + const activeIsRoot = !activeWorktree || activeWorktree.path === rootPath; + const activeIsPersistent = activeWorktree ? isPersistentBranch(activeWorktree.branch) : false; + const activeDirty = activeWorktree ? (worktreeChangeCounts[activeWorktree.path] ?? 0) : 0; + + return ( + +
+ {/* Compact icon toolbar */} +
+ +
+ + + + + +
+ + {/* Active worktree summary */} + {activeWorktree && !activeIsRoot && ( +
+ {/* Left accent rail */} +
+ )} + + {/* Worktree list */} +
+ {inventoryRows.length === 0 && !creatingWorktree && ( +
+
+ +
+
+

No worktrees yet

+

+ Create a worktree for each branch you want to work on in parallel. +

+
+ +
+ )} + {inventoryRows.map((row, idx) => { + const isActive = activeWorktree?.path === row.wt.path; + const isPending = row.wt.path === PENDING_PATH; + const isRowBusy = isPending; + const changeCount = worktreeChangeCounts[row.wt.path] ?? 0; + + return ( +
0 ? 'border-t border-(--sg-border-subtle)' : ''}`} + data-testid="worktree-item" + data-branch={row.wt.branch ?? ''} + data-path={row.wt.path} + data-active={isActive ? 'true' : 'false'} + role="radio" + aria-checked={isActive} + tabIndex={isActive ? 0 : -1} + onClick={() => { if (!isPending) onWorktreeSwitch(row.wt); }} + onKeyDown={e => { if (e.key === 'Enter' && !isPending) onWorktreeSwitch(row.wt); }} + onContextMenu={e => { + if (isPending) return; + contextMenu.open(e, [ + { + label: 'Open in Editor', + icon: , + onClick: () => void api.openInEditor(row.wt.path) + .then(() => toast('Opened in editor', 'success')) + .catch((err: unknown) => toast(String(err), 'error')), + }, + { + label: /mac/i.test(navigator.platform) ? 'Reveal in Finder' : 'Reveal in Explorer', + icon: , + onClick: () => void api.revealInFinder(row.wt.path) + .catch((err: unknown) => toast(String(err), 'error')), + }, + { + label: 'Open Terminal Here', + icon: , + onClick: () => onOpenTerminal(row.wt.path, row.wt.branch ?? row.wt.path.split('/').pop()), + }, + 'separator', + { label: 'Fetch', icon: , onClick: () => void api.fetch(row.wt.path).then(() => { toast('Fetched', 'success'); onRefresh(); }).catch((err: unknown) => toast(String(err), 'error')) }, + { label: 'Pull', icon: , onClick: () => void api.pull(row.wt.path).then(() => { toast('Pulled', 'success'); onRefresh(); }).catch((err: unknown) => toast(String(err), 'error')) }, + { label: 'Push', icon: , onClick: () => void api.push(row.wt.path).then(() => { toast('Pushed', 'success'); }).catch((err: unknown) => toast(String(err), 'error')) }, + 'separator', + { label: 'Run Hook…', icon: , onClick: () => onOpenRunHookModal(row.wt) }, + 'separator', + { label: 'Copy Branch Name', icon: , onClick: () => void navigator.clipboard.writeText(row.wt.branch ?? '') }, + { label: 'Copy Path', icon: , onClick: () => void navigator.clipboard.writeText(row.wt.path) }, + 'separator', + { label: 'Remove Worktree', icon: , danger: true, onClick: () => onDeleteWorktree(row.wt) }, + ]); + }} + > + {/* Faux radio indicator */} +
+ ); + })} +
+ + {/* Update badge */} + {updateState.status !== 'idle' && updateState.status !== 'up-to-date' && ( +
+ void api.installUpdate()} + /> +
+ )} +
+ + ); +} diff --git a/app/src/renderer/workspace/dialogs/DeleteWorktreeDialog.tsx b/app/src/renderer/workspace/dialogs/DeleteWorktreeDialog.tsx new file mode 100644 index 0000000..06720a0 --- /dev/null +++ b/app/src/renderer/workspace/dialogs/DeleteWorktreeDialog.tsx @@ -0,0 +1,48 @@ +import { useEffect, useRef } from 'react'; +import { Spinner } from '@sproutgit/ui'; +import type { WorktreeInfo } from '@sproutgit/types'; +import { secondaryBtn } from './dialog-classes.js'; + +type Props = { + target: WorktreeInfo | null; + loading: boolean; + onConfirm: (wt: WorktreeInfo) => void; + onCancel: () => void; +}; + +const dangerBtn = 'inline-flex items-center gap-[5px] px-3 py-[5px] rounded-[6px] border-none cursor-pointer text-xs font-medium transition-colors whitespace-nowrap bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed'; + +export function DeleteWorktreeDialog({ target, loading, onConfirm, onCancel }: Props) { + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (target) { + dialog.showModal(); + } else { + dialog.close(); + } + }, [target]); + + if (!target) return ; + + const label = target.branch ?? target.path.split('/').pop() ?? target.path; + + return ( + +
+

Remove Worktree

+

+ Remove worktree "{label}"? This cannot be undone. +

+
+ + +
+
+
+ ); +} diff --git a/app/src/renderer/workspace/dialogs/NewWorktreeDialog.tsx b/app/src/renderer/workspace/dialogs/NewWorktreeDialog.tsx new file mode 100644 index 0000000..b37790e --- /dev/null +++ b/app/src/renderer/workspace/dialogs/NewWorktreeDialog.tsx @@ -0,0 +1,161 @@ +import { api } from '../../api.js'; +import { useEffect, useRef, useState } from 'react'; +import { Spinner, Autocomplete } from '@sproutgit/ui'; +import type { RefInfo } from '@sproutgit/types'; +import { primaryBtn, secondaryBtn, fieldLabel, fieldInput } from './dialog-classes.js'; + +type Props = { + open: boolean; + workspacePath: string; + gitRepoPath: string; + managedWorktreesPath: string; + refs: RefInfo[]; + onClose: () => void; + onBeforeCreate?: () => Promise; + onCreated: (newWorktreePath: string) => void; + onToast: (msg: string, variant: 'success' | 'error') => void; +}; + +function validateBranchName(name: string): string | null { + const t = name.trim(); + if (!t) return 'Branch name is required.'; + if (t.startsWith('-')) return 'Cannot start with a hyphen.'; + if (t.startsWith('.') || t.includes('/.')) return "Cannot start with a dot or contain '/.'."; + if (t.endsWith('.')) return 'Cannot end with a dot.'; + if (t.endsWith('/')) return 'Cannot end with a slash.'; + if (t.includes('..')) return "Cannot contain '..'."; + if (t.includes('@{')) return "Cannot contain '@{'."; + if (t === '@') return "Cannot be '@'."; + if (t.endsWith('.lock')) return "Cannot end with '.lock'."; + if (t.includes('\\')) return 'Cannot contain backslash.'; + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f\x7f ~^:]/.test(t)) return 'Cannot contain spaces or special chars.'; + if (/[?*[\]]/.test(t)) return 'Cannot contain glob chars.'; + if (t.includes('//')) return 'Cannot contain consecutive slashes.'; + return null; +} + +export function NewWorktreeDialog({ open, workspacePath, gitRepoPath, managedWorktreesPath, refs, onClose, onBeforeCreate, onCreated, onToast }: Props) { + const dialogRef = useRef(null); + const inputRef = useRef(null); + + const [branchName, setBranchName] = useState(''); + const [branchFrom, setBranchFrom] = useState('HEAD'); + const [branchType, setBranchType] = useState<'managed' | 'persistent'>('managed'); + const [creating, setCreating] = useState(false); + const [branchNameError, setBranchNameError] = useState(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (open) { + dialog.showModal(); + setTimeout(() => inputRef.current?.focus(), 50); + } else { + dialog.close(); + } + }, [open]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const nameErr = validateBranchName(branchName.trim()); + if (nameErr) { setBranchNameError(nameErr); return; } + if (!branchFrom.trim()) { setBranchNameError('Source ref is required'); return; } + setBranchNameError(null); + setCreating(true); + onClose(); // Optimistically close; pending branch shown in sidebar + try { + if (onBeforeCreate) await onBeforeCreate(); + const result = await api.createWorktree({ + rootRepoPath: gitRepoPath, + managedWorktreesPath, + fromRef: branchFrom || 'HEAD', + newBranch: branchName.trim(), + }); + onToast('Worktree created', 'success'); + setBranchName(''); + setBranchFrom('HEAD'); + setBranchType('managed'); + onCreated(result.worktreePath); + } catch (err) { + onToast(`Failed: ${String(err)}`, 'error'); + } finally { + setCreating(false); + } + } + + return ( + +
void handleSubmit(e)} + > +

New Worktree

+
+
+ Branch type +
+ + +
+

+ {branchType === 'managed' + ? 'Short-lived branch for one task — eligible for cleanup once merged.' + : 'Long-lived branch kept alongside others — never auto-deleted.'} +

+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/app/src/renderer/workspace/dialogs/PublishDialog.tsx b/app/src/renderer/workspace/dialogs/PublishDialog.tsx new file mode 100644 index 0000000..61c450f --- /dev/null +++ b/app/src/renderer/workspace/dialogs/PublishDialog.tsx @@ -0,0 +1,96 @@ +import { api } from '../../api.js'; +import { useEffect, useRef, useState } from 'react'; +import { Spinner } from '@sproutgit/ui'; +import type { WorktreeInfo, WorktreePushStatus } from '@sproutgit/types'; +import { primaryBtn, secondaryBtn, fieldLabel, fieldInput } from './dialog-classes.js'; + +type Props = { + open: boolean; + activeWorktree: WorktreeInfo | null; + pushStatus: WorktreePushStatus | null; + onClose: () => void; + onToast: (msg: string, variant: 'success' | 'error') => void; + onPublished: () => void; +}; + +export function PublishDialog({ open, activeWorktree, pushStatus, onClose, onToast, onPublished }: Props) { + const dialogRef = useRef(null); + + const initialRemotes = pushStatus?.remotes && pushStatus.remotes.length > 0 ? pushStatus.remotes : ['origin']; + const initialRemote = pushStatus?.suggestedRemote ?? initialRemotes[0] ?? 'origin'; + + const [remote, setRemote] = useState(initialRemote); + const [publishing, setPublishing] = useState(false); + const remotes = pushStatus?.remotes && pushStatus.remotes.length > 0 ? pushStatus.remotes : ['origin']; + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (open) { + dialog.showModal(); + setRemote(pushStatus?.suggestedRemote ?? remotes[0] ?? 'origin'); + } else { + dialog.close(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + async function handlePublish() { + if (!activeWorktree) return; + setPublishing(true); + try { + await api.push(activeWorktree.path, remote); + onToast(`Published to ${remote}`, 'success'); + onClose(); + onPublished(); + } catch (err) { + onToast(`Publish failed: ${String(err)}`, 'error'); + } finally { + setPublishing(false); + } + } + + return ( + +
+

Publish Branch

+

+ Choose a remote to publish {activeWorktree?.branch} to. +

+ +
+ + +
+
+
+ ); +} diff --git a/app/src/renderer/workspace/dialogs/RunHookDialog.tsx b/app/src/renderer/workspace/dialogs/RunHookDialog.tsx new file mode 100644 index 0000000..3355778 --- /dev/null +++ b/app/src/renderer/workspace/dialogs/RunHookDialog.tsx @@ -0,0 +1,98 @@ +import { api } from '../../api.js'; +import { useEffect, useRef, useState } from 'react'; +import { Spinner } from '@sproutgit/ui'; +import type { WorktreeInfo, WorkspaceHook } from '@sproutgit/types'; +import { X } from 'lucide-react'; +import { secondaryBtn } from './dialog-classes.js'; + +type Props = { + target: WorktreeInfo | null; + workspacePath: string; + activeWorktreePath: string | null; + onClose: () => void; + onToast: (msg: string, variant: 'success' | 'error') => void; +}; + +const iconBtn = 'inline-flex items-center justify-center p-[3px] bg-transparent border-none cursor-pointer text-(--sg-text-faint) rounded-[4px] transition-colors hover:text-(--sg-text) hover:bg-(--sg-surface-raised) disabled:opacity-40 disabled:cursor-not-allowed'; + +export function RunHookDialog({ target, workspacePath, activeWorktreePath, onClose, onToast }: Props) { + const dialogRef = useRef(null); + const [hooks, setHooks] = useState([]); + const [loading, setLoading] = useState(false); + const [runningHookId, setRunningHookId] = useState(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (target) { + dialog.showModal(); + setRunningHookId(null); + setLoading(true); + void api.listHooks(workspacePath) + .then((all: WorkspaceHook[]) => setHooks(all.filter(h => h.enabled))) + .catch((err: unknown) => { onToast(`Failed to load hooks: ${String(err)}`, 'error'); onClose(); }) + .finally(() => setLoading(false)); + } else { + dialog.close(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [target]); + + async function doRunHook(hook: WorkspaceHook) { + if (!target) return; + setRunningHookId(hook.id); + try { + await api.runHook({ + workspacePath, + hookId: hook.id, + worktreePath: target.path, + trigger: hook.trigger, + initiatingWorktreePath: activeWorktreePath, + }); + onToast(`Ran hook: ${hook.name}`, 'success'); + } catch (err) { + onToast(`Hook failed: ${String(err)}`, 'error'); + } finally { + setRunningHookId(null); + onClose(); + } + } + + const label = target?.branch ?? target?.path.split('/').pop() ?? ''; + + return ( + +
+
+

+ Run Hook on {label} +

+ +
+ {loading ? ( +
+ ) : hooks.length === 0 ? ( +

No enabled hooks found.

+ ) : ( +
+ {hooks.map(hook => ( + + ))} +
+ )} +
+ +
+
+
+ ); +} diff --git a/app/src/renderer/workspace/dialogs/dialog-classes.ts b/app/src/renderer/workspace/dialogs/dialog-classes.ts new file mode 100644 index 0000000..4b1f56a --- /dev/null +++ b/app/src/renderer/workspace/dialogs/dialog-classes.ts @@ -0,0 +1,6 @@ +/** Shared Tailwind class strings for dialog components. */ + +export const primaryBtn = 'inline-flex items-center gap-[5px] px-3 py-[5px] rounded-[6px] border-none cursor-pointer text-xs font-medium transition-colors whitespace-nowrap bg-(--sg-primary) text-white hover:bg-(--sg-primary-hover) disabled:opacity-50 disabled:cursor-not-allowed'; +export const secondaryBtn = 'inline-flex items-center gap-[5px] px-3 py-[5px] rounded-[6px] cursor-pointer text-xs font-medium transition-colors whitespace-nowrap bg-transparent border border-(--sg-border) text-(--sg-text-dim) hover:bg-(--sg-surface-raised) disabled:opacity-50 disabled:cursor-not-allowed'; +export const fieldLabel = 'text-[11px] font-semibold text-(--sg-text-dim) uppercase tracking-[0.04em]'; +export const fieldInput = 'w-full px-[10px] py-[6px] bg-(--sg-input-bg) border border-(--sg-input-border) rounded-[6px] text-xs text-(--sg-text) outline-none focus:border-(--sg-input-focus)'; diff --git a/app/tsconfig.node.json b/app/tsconfig.node.json new file mode 100644 index 0000000..70fada3 --- /dev/null +++ b/app/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "extends": "@sproutgit/ts-config/base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"] + }, + "include": ["src/main", "src/preload", "electron.vite.config.ts"] +} diff --git a/app/tsconfig.web.json b/app/tsconfig.web.json new file mode 100644 index 0000000..7cf7c66 --- /dev/null +++ b/app/tsconfig.web.json @@ -0,0 +1,9 @@ +{ + "extends": "@sproutgit/ts-config/react.json", + "compilerOptions": { + "noEmit": true, + "types": ["vite/client"], + "allowArbitraryExtensions": true + }, + "include": ["src/renderer"] +} diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index 8798212..0000000 --- a/docs/architecture.md +++ /dev/null @@ -1,357 +0,0 @@ -# Architecture: Git System Design - -## Overview - -SproutGit's backend uses a **registered action pattern** for git and system command execution, designed for security, auditability, and testability. This document assesses reusability, composability, and extensibility. - -## System Design - -### Registered Actions - -All git and system operations route through enum-based action registries: - -```rust -pub enum GitAction { GitInfo, WorktreeList, ListRefs, ..., Clone, Init } -pub enum SystemAction { CommandLookup, OpenEditor } -``` - -**Benefits:** - -- ✅ Audit trail: every git operation is explicitly named -- ✅ Security: input validation and environment setup centralized -- ✅ Testing: unit tests can validate action uniqueness and invoke patterns -- ✅ Extensibility: add new actions by extending the enum - -### Command Pipeline - -``` -User Input - ↓ -Validation (validate_non_option_value, validate_repo_url, etc.) - ↓ -GitAction::Xxx + args - ↓ -base_git_command() → sets PATH, GIT_TERMINAL_PROMPT, etc. - ↓ -Command::output() or Command::spawn() - ↓ -Result -``` - -## Reusability Assessment - -### ✅ Strengths - -1. **Validator functions are orthogonal** - - `validate_no_control_chars()` — Generic control char check - - `validate_non_option_value()` — Reusable "no leading dash" check - - `validate_repo_url()` — Composable on top of `validate_non_option_value()` - - Used across git.rs, diff.rs, editor.rs, workspace.rs - -2. **Base command builders encapsulate setup** - - ```rust - pub fn base_git_command() -> Command - pub fn git_command(action: GitAction, args: &[&str]) -> Command - pub fn system_command(action: SystemAction, program: &str, args: &[&str]) -> Command - ``` - - - New operations inherit PATH, environment, and action tracking automatically - -3. **Helper utilities are standalone** - - `augmented_path()` — Can be called from any module - - `command_exists()` — Cross-platform lookup without git - - `normalize_existing_path()`, `normalize_or_create_dir()` — Path utilities - -4. **Security validators are reusable** - - All modules use the same validation functions - - Consistent error messages and behavior - -### ⚠️ Limitations - -1. **High-level operations are monolithic** - - Each command (checkout, reset, clone) is a single function - - Cannot decompose into sub-steps without refactoring - - Example: `create_managed_worktree()` is 30 lines that can't be broken down - -2. **No composability pattern for multi-step operations** - - Cannot chain "create worktree + checkout + run setup" atomically - - Example: To create a worktree and check out a branch requires two separate invocations: - ```rust - create_managed_worktree(...)?; // Spawns git, returns - checkout_worktree(...)?; // Spawns git again - ``` - - If the second fails, the worktree is orphaned with no rollback - -3. **No operation-level batching** - - Each function spawns git independently - - Even related commands don't batch (could use `git worktree list && git branch -a` in one process) - - Process spawning overhead dominates for I/O-bound operations - -4. **Limited error aggregation** - - Each function handles its own errors independently - - Cannot collect partial results + errors from a multi-step sequence - - Example: `delete_managed_worktree()` tries to prune if the removal fails, but errors are swallowed - -## Composability Assessment - -### Current Composability: 4/10 - -**Can compose:** - -- Input validators (stack them: `validate_non_option_value()` → `validate_repo_url()`) -- Command builders (call `git_command()` with different args) -- Error handling (try-catch chains) - -**Cannot easily compose:** - -- Multi-step atomic operations (transaction/rollback pattern needed) -- Result aggregation (no collector/builder for complex results) -- Batch operations (no queue or pipeline abstraction) -- Conditional branching (if this git command succeeds, run that one) - -### Example Gap: Create Feature Worktree - -Current flow (requires 3 separate invocations in client code): - -```rust -// User code must choreograph this: -create_managed_worktree(root, worktrees, "feature-foo", "main")?; -checkout_worktree(worktree_path, "feature-foo", false)?; -// Now set up: run hooks, install deps, etc. (outside of git system) -``` - -Ideal composable flow: - -```rust -// One atomic operation with rollback -GitOp::new(repo_path) - .create_worktree("feature-foo", from: "main") - .checkout("feature-foo", auto_stash: true) - .on_error(|e| { cleanup_worktree(...); Err(e) }) - .execute()? -``` - -## Performance Assessment - -### ✅ Strengths - -1. **Minimal overhead** - - Direct process spawning (no RPC, IPC, or request serialization) - - Single-pass stdout/stderr reading - - No intermediate allocations in hot paths - - All I/O is sequential (expected for git operations) - -2. **PATH optimization** - - Cross-platform deduplication in `augmented_path()` - - Cached once at startup (PATH doesn't change mid-session) - -3. **Smart validation** - - Synchronous, small-footprint checks before spawning - - Fail-fast prevents wasted process spawns - -### 📊 Limitations & Opportunities - -1. **Process spawn overhead** — Each operation spawns git independently - - Typical git command: ~50ms on macOS (overhead dominated by process spawn) - - Could batch related operations: `git status && git log` in one process (~80ms instead of ~100ms) - - Doesn't matter for user-initiated actions, but matters for batch operations - -2. **No caching** — Identical queries re-run every time - - `list_refs()` called twice in quick succession = two git processes - - Could cache with invalidation on write operations - - Estimated win: 30-40% reduction for read-heavy workflows - -3. **No async/parallel** — All operations are blocking - - Frontend currently blocks on each git command - - Could parallelize unrelated operations (e.g., fetch refs while listing worktrees) - - Tauri command handler is async-capable; not utilized - -## Extensibility Assessment - -### ✅ Adding New Operations - -**Very easy** — Add to `GitAction` enum, implement handler: - -```rust -// In helpers.rs -pub enum GitAction { - ..., - TagCreate, // New action - TagDelete, - TagList, -} - -// In git.rs -#[tauri::command] -pub async fn tag_list(repo_path: String) -> Result, String> { - let repo = normalize_existing_path(&repo_path)?; - let output = run_git(GitAction::TagList, &[ - "-C", &repo.to_string_lossy(), "tag", "--list" - ])?; - // Parse and return -} -``` - -**Security tests added automatically** — Tag uniqueness test in helpers::tests runs immediately. - -### ⚠️ Limitations - -1. **Cannot extend behavior** — All operations use the same execution path (spawn, capture output, done) - - No hooks/callbacks - - No middleware or instrumentation - - No rate limiting or retry logic - -2. **Cannot compose externally** — Client code must orchestrate multi-step sequences - - No abstract interface for describing workflows - - No operation queuing or scheduling - -3. **Cannot plugin** — Operations are hardcoded in the enum - - Would need significant refactor to support dynamic plugins - -## Recommendations for a Better Platform - -### Tier 1: High-Value, Low-Effort - -**1. Add a `GitTransaction` builder pattern** - -```rust -pub struct GitTransaction { - ops: Vec>, - on_error: Option ()>>, -} - -impl GitTransaction { - pub fn new() -> Self { ... } - pub fn run_git(action: GitAction, args: &[&str]) -> Self { ... } - pub fn on_error(self, f: impl Fn() -> ()) -> Self { ... } - pub fn execute(self) -> Result, String> { ... } -} -``` - -- **Benefit**: Multi-step operations with rollback; atomic from user perspective -- **Effort**: ~200 lines -- **Example**: `GitTransaction::new().create_worktree(...).checkout(...).execute()?` - -**2. Add read-only caching with invalidation** - -```rust -pub struct GitCache { - refs: RefCell>>>, - status: RefCell>>, -} -``` - -- **Benefit**: 30-40% reduction for read-heavy workflows -- **Effort**: ~150 lines -- **Example**: Second `list_refs()` call returns cached result - -### Tier 2: Medium-Value, Medium-Effort - -**3. Add async/parallel operation support** - -- Use `tokio` or `async-std` for parallel unrelated git operations -- **Benefit**: Faster batch operations, non-blocking frontend -- **Effort**: ~300 lines + dependency - -**4. Add semantic high-level operations** - -```rust -pub async fn create_feature_branch_with_worktree( - repo_path: &str, - branch_name: &str, - from_ref: &str, -) -> Result -``` - -- Combines create + checkout + validation -- **Benefit**: Simpler client code, fewer edge cases -- **Effort**: ~100 lines per operation - -### Tier 3: Lower-Priority - -**5. Add middleware/instrumentation layer** - -- Logging, metrics, tracing for each git operation -- **Benefit**: Observability, debugging, performance profiling - -**6. Add plugin system** - -- Dynamic operation registration -- **Benefit**: Third-party extensions -- **Effort**: ~500+ lines - -## Verdict: Is It a Good Platform? - -| Aspect | Rating | Notes | -| ----------------- | ------ | --------------------------------------------------------------------- | -| **Security** | 9/10 | Excellent input validation, injection-safe | -| **Auditability** | 9/10 | All operations registered, testable | -| **Reusability** | 7/10 | Validators and helpers are reusable; high-level ops are not | -| **Composability** | 4/10 | Can't easily chain multi-step operations without manual orchestration | -| **Performance** | 7/10 | Efficient for single operations; could batch better | -| **Extensibility** | 6/10 | Easy to add new git operations; hard to extend behavior | -| **Async-Ready** | 6/10 | Uses async/await at Tauri boundary, but operations are blocking | - -**Recommendation**: ✅ **Yes, it's a solid foundation**, but add a `GitTransaction` pattern (Tier 1) before building complex workflows. This adds composability and rollback semantics without breaking existing code. - -The security and auditability are excellent. The main gap is composability for multi-step operations. Once that's addressed, it's a very capable platform. - -## Proposed Extension: Worktree Lifecycle Hooks - -### Why this fits the architecture - -Worktree lifecycle hooks are a natural extension of the worktree-first model because users often need local environment setup/teardown around create/remove operations. - -The existing command architecture should remain the security boundary: - -- Git operations continue through registered `GitAction` paths -- Hook execution should use a dedicated registered system action type (for auditability) -- Trigger orchestration should happen in backend commands, not frontend choreography - -### Persistence strategy - -Current state DB initialization uses direct `rusqlite` SQL in `initialize_state_db()`. - -For hooks and future app/workspace state, move to an ORM-backed model for maintainable evolution: - -- Introduce ORM entities for `hook_definitions` and `hook_runs` -- Introduce ORM entities for global app config state (recent workspaces, app settings) -- Keep migrations explicit and versioned -- Migrate existing lightweight tables/repositories incrementally behind compatibility adapters - -Use a dual-database model: - -- User-profile config DB for app-level state -- Workspace DB (`.sproutgit/state.db`) for repository-local state - -### Execution strategy - -Attach hook orchestration to semantic lifecycle command paths: - -- `before_worktree_create` -> run -> perform create -> `after_worktree_create` -- `before_worktree_remove` -> run -> perform remove -> `after_worktree_remove` - -Execution rules: - -- Critical hook failure aborts `before_*` operations -- Non-critical hook failures are logged and surfaced but do not block -- Force remove bypasses only failing non-critical hooks -- `after_*` failures are warning-only by default and are not rolled back -- Parallel execution allowed for independent groups -- Dependency DAG with AND semantics determines run readiness -- Timeouts and output limits enforced per hook run - -### Security constraints for hook execution - -Because this executes arbitrary local scripts, treat as explicit user-authorized code execution: - -- Require clear UI warning and explicit enablement -- No privilege elevation workflow in app -- Validate trigger payload and paths before execution -- Use non-interactive shell modes where possible -- Persist run metadata for audit/debug visibility - -### Implementation note - -This feature should be implemented as a composable service module (for example `hooks.rs`) that can be called from worktree operations, rather than embedding hook logic directly into each command handler. diff --git a/docs/benchmark-repository-strategy.md b/docs/benchmark-repository-strategy.md deleted file mode 100644 index 86d8382..0000000 --- a/docs/benchmark-repository-strategy.md +++ /dev/null @@ -1,199 +0,0 @@ -# SproutGit Benchmark Repository Strategy - -## Purpose - -Define a durable strategy for sample repositories used by SproutGit for: - -- Product screenshots and videos -- Manual QA and demos -- Performance and edge-case testing -- Regression validation across releases - -This strategy avoids stale, hand-curated datasets while preserving deterministic media capture. - -## Decision Summary - -Use a hybrid model: - -1. Generated benchmark repositories are the source of truth. -2. Versioned snapshots are used for screenshots and videos. -3. Popular open-source repositories are used as canary tests, not as primary media sources. - -Rationale: - -- Pure curated repos go stale. -- Pure popular repos are non-deterministic and can drift unexpectedly. -- Generated plus snapshot repositories provide both freshness and repeatability. - -## Repository Classes - -### Class A: Hero Media Repository - -Use one stable benchmark repo for all public screenshots and videos. - -Characteristics: - -- Rich branch and merge topology for compelling graph visuals -- Realistic multi-area file structure for meaningful diffs -- Predictable named scenarios for repeatable captures - -Policy: - -- Media captures must use a pinned snapshot tag. -- Do not use live popular repos for marketing media. - -### Class B: Generated Stress Repositories - -Use generated datasets to test limits and edge cases. - -Recommended scenario modules: - -1. Graph Topology Stress: dense branching, frequent merges, tags -2. Naming and Ref Edge Cases: long names, separators, detached states -3. Scale Stress: high file counts and large diffs -4. Hook Orchestration Stress: dependency chains, failures, timeout paths - -Policy: - -- Repositories are regenerated on a fixed cadence. -- Scenario generation is deterministic from known inputs. - -### Class C: Popular Repo Canaries - -Use a small rotating set of widely used repositories as realism checks. - -Policy: - -- Canary tests are non-blocking by default. -- Pin to known commit SHAs when possible. -- Failures trigger investigation, not immediate release failure. - -## Freshness and Stability Model - -### Continuous Freshness - -Maintain generator scripts and regenerate benchmark repositories regularly. - -Suggested cadence: - -- Regeneration at a recurring cadence defined by maintainers -- Additional regeneration at major release milestones - -### Snapshot Stability - -Publish versioned snapshot tags from generated repositories. - -Suggested naming: - -- benchmark-vN -- benchmark-vN-patchN - -Usage: - -- QA and media references always target a specific snapshot tag. -- CI runs should include latest plus previous snapshot for drift checks. - -## Media Capture Policy - -Use a deterministic runbook for screenshots and videos. - -Rules: - -1. Capture from pinned snapshot only. -2. Use predefined scenario branches and states. -3. Reset local state before every recording session. -4. Keep a canonical shot list so visuals remain comparable over time. - -Suggested canonical shot list: - -1. Worktree list with managed and external entries -2. Branch and worktree creation flow -3. Commit graph search and navigation -4. Diff viewer with small and medium patches -5. Hook execution status and error handling -6. Context menu and copy actions - -## Governance and Ownership - -### Owners - -- Maintainers own benchmark scenario definitions and acceptance criteria. -- Contributors can propose new scenarios via pull requests. -- AI agents may update scenario docs and scripts, subject to maintainer review. - -### Change Control - -Any benchmark scenario change should include: - -1. Why the change is needed -2. Which flows are affected -3. Expected impact on screenshots/videos/tests -4. Whether a new snapshot tag is required - -## CI and Validation Strategy - -Planned checks: - -1. Generator outputs are deterministic for fixed seeds -2. Snapshot metadata matches declared scenario versions -3. Core benchmark smoke tests pass on current snapshot -4. Canary suite runs separately and reports drift - -Recommended lane split: - -- Blocking lane: generated snapshot-based benchmarks -- Non-blocking lane: popular repository canaries - -## Risk Register - -Risk: benchmark scenarios become unrealistic. -Mitigation: periodic review against real-world canary findings. - -Risk: snapshot sprawl and maintenance overhead. -Mitigation: retention policy with latest plus N historical snapshots. - -Risk: flaky canary outcomes from upstream changes. -Mitigation: keep canaries non-blocking and pin SHAs where possible. - -Risk: media inconsistency across releases. -Mitigation: enforce pinned snapshot plus canonical capture runbook. - -## Success Criteria - -1. Screenshot and video capture remains reproducible across releases. -2. Benchmark datasets are refreshed via defined regeneration triggers without manual churn. -3. Regressions are detected in generated benchmarks before release. -4. Canary lane reveals real-world drift without destabilizing release cadence. - -## Rollout Plan - -### Phase 1 - -- Define scenario modules and deterministic generation inputs -- Define snapshot naming and retention policy -- Draft media capture runbook - -### Phase 2 - -- Generate initial benchmark set -- Publish first pinned snapshot tags -- Validate core flows for screenshots and QA - -### Phase 3 - -- Add canary repository lane with non-blocking reporting -- Compare canary findings against generated scenarios -- Adjust scenario modules where coverage is weak - -### Phase 4 - -- Finalize benchmark governance and maintenance cadence -- Integrate into release checklist -- Publish internal guidance for maintainers and contributors - -## Open Questions - -1. Which exact repos should be in the canary rotation? -2. What retention window is appropriate for snapshot tags? -3. Which benchmark failures should block release immediately? -4. Should media assets include snapshot tag watermarking in metadata? diff --git a/docs/branch-worktree-policy.md b/docs/branch-worktree-policy.md deleted file mode 100644 index 66113fe..0000000 --- a/docs/branch-worktree-policy.md +++ /dev/null @@ -1,166 +0,0 @@ -# Branch/Worktree Binding Policy - -This document defines how SproutGit should bind branches to worktrees so local workflows remain predictable while still supporting advanced remote workflows. - -This policy is subordinate to [requirements.md](requirements.md). If terminology or scope differs, `requirements.md` is the source of truth for MVP behavior. - -## Goals - -- Keep a clear, repeatable default workflow for most users. -- Reduce accidental branch/worktree drift. -- Support real-world merge strategies (merge commit, squash, rebase). -- Avoid destructive cleanup when local work may still exist. - -## Core Model - -SproutGit uses two branch classes: - -1. Managed (ephemeral) -2. Persistent (trunk/release) - -These branch classes are separate from the worktree labels defined in [requirements.md](requirements.md): - -- `Managed` worktree: path under `/worktrees/` -- `External` worktree: path outside the managed container - -In other words, worktree classification is filesystem-based, while branch classification is lifecycle-based. - -### Managed Branches (Ephemeral) - -- Always bind 1:1 to a single managed worktree. -- Worktree checkout is locked to the bound branch. -- Intended for feature, bugfix, spike, and short-lived task branches. -- Eligible for merge-based cleanup suggestions. - -### Persistent Branches (Trunk/Release) - -- Represent long-lived branches such as `main`, `master`, `develop`, `release/*`. -- Should use dedicated worktrees for normal editing. -- The primary checkout at `/root/` is the protected internal checkout, not the default day-to-day persistent-branch workspace. -- Dedicated persistent-branch worktrees should normally live under `/worktrees/`. -- Deleting the worktree must never imply deleting the local branch. -- Not eligible for automatic branch-deletion suggestions. - -## Branch Binding Rules - -1. Each managed worktree stores: - - `boundBranch` - - `branchType` (`managed` or `persistent`) - - `trackedRemote` (for example `origin`) - - `targetBaseBranch` (for example `main`) -2. Managed worktrees cannot switch checkout to a different branch in-place. -3. If a user wants another branch from a managed worktree, offer: - - Create a new managed worktree for that branch, or - - Convert current worktree to unbound/expert mode (optional advanced feature). - -## Merge And Integration Workflow - -For the product direction, SproutGit should eventually provide merge/rebase/cherry-pick as guided operations rather than requiring branch switching inside the same worktree. - -However, this is post-MVP guidance. [requirements.md](requirements.md) explicitly excludes merge conflict editor and rebase/cherry-pick UI from `v0.1`. - -Recommended flow: - -1. Select source branch/worktree and target branch/worktree. -2. Fetch and prune remote refs before evaluation. -3. Validate target worktree is clean (or require explicit force path). -4. Run integration operation. -5. Resolve conflicts in target worktree. -6. Mark source managed branch as `cleanup_candidate` when safe. - -## Cleanup Detection Policy - -Cleanup must be suggestion-first (not immediate deletion). - -A managed branch/worktree is a cleanup candidate only when all checks pass: - -1. Fresh remote state available (`fetch --prune` completed successfully). -2. Branch is integrated relative to configured base branch. -3. No unique local commits remain on the managed branch tip. -4. Worktree is clean, or user explicitly confirms force cleanup. -5. Branch type is `managed`. - -### Integrated Detection Notes - -Do not rely solely on "remote branch deleted" as the merge signal. - -- Squash/rebase merges can remove direct ancestry while still integrating content. -- Some teams keep remote branches after merge. -- Some teams delete remote branches immediately. - -Use multiple signals: - -1. Graph ancestry where applicable. -2. Ahead/behind against target base. -3. Remote branch existence status. -4. Optional patch-equivalence heuristics for squash/rebase-heavy teams. - -## Deletion Semantics - -Per [requirements.md](requirements.md), deleting a managed worktree prunes the Git worktree metadata and removes its directory. - -Branch deletion is a separate policy decision layered on top of worktree removal. - -When user confirms cleanup of a managed branch, present explicit toggles: - -- Delete worktree -- Delete local branch -- Delete remote branch (off by default) - -Safety constraints: - -- If unique local commits exist, default to keep local branch. -- If worktree has uncommitted changes, block cleanup or require explicit force. -- Never auto-delete persistent branches. - -## Existing Branches Like `main` - -Users should be able to commit directly to persistent branches using dedicated persistent worktrees. - -Recommended default behavior: - -1. Keep `/root/` as the protected internal checkout. -2. Create or reuse a dedicated `main` worktree for normal editing. -3. Allow normal commit/pull/push operations there. -4. If that worktree is managed, deleting it still removes the Git worktree metadata and directory, but not the local branch ref. - -## Remote Source Of Truth - -Remote refs are source of truth for synchronization status, but local lifecycle is policy-driven: - -- Remote state influences cleanup eligibility. -- Remote state does not unilaterally force local deletion. -- Local branch deletion always requires explicit user confirmation (except future policy opt-ins). - -## Upstream And Publish Policy - -To avoid accidental pushes to the wrong remote branch, SproutGit uses an explicit publish model for new worktree branches. - -1. On managed worktree create, upstream tracking is cleared intentionally. -2. The first push from a branch without upstream requires explicit publish setup: user selects the remote, then SproutGit runs publish semantics (`git push -u `). -3. Publish picks remote in this order: `branch..pushRemote`, `remote.pushDefault`, `branch..remote`, then `origin`, then `upstream`, then first configured remote. -4. Once upstream exists, subsequent pushes use normal `git push` behavior. -5. Source ref selection for new worktrees should prefer remote refs, with `upstream/*` ranked ahead of local branches. - -## Default Product Policy (Recommended) - -1. Enforce 1:1 binding for managed branches. -2. Lock branch switching in managed worktrees. -3. Treat guided merge/rebase/cherry-pick operations as post-MVP workflow enhancements. -4. Suggest cleanup only after integration checks pass. -5. Never auto-delete persistent branches. -6. Keep advanced flexibility behind explicit workspace settings. - -## Optional Advanced Settings - -- Enable unbound/expert worktree mode. -- Auto-suggest remote branch deletion after local cleanup. -- Team-specific persistent branch patterns. -- Strict cleanup mode (for highly standardized teams). - -## Why This Balance Works - -- Predictable for day-to-day local development. -- Scales for multi-agent and multi-worktree usage. -- Compatible with varied remote team workflows. -- Safe by default while still allowing advanced flexibility. diff --git a/docs/design-review-and-screen-plan.md b/docs/design-review-and-screen-plan.md deleted file mode 100644 index 3b2196d..0000000 --- a/docs/design-review-and-screen-plan.md +++ /dev/null @@ -1,600 +0,0 @@ -# SproutGit Screen Architecture - -> Authoritative reference for every screen in the MVP, how they connect, what data they need, and when a user sees each one. - ---- - -## User flow overview - -``` -App launch -│ -├─ No projects known ──────────────► [1] Project Picker -│ │ -│ ├─ Clone + create workspace ─► [2] App Shell -│ │ └─► [3] First Worktree Setup -│ │ └─► [4] Main Workspace -│ │ -│ └─ Open existing by path ────► [2] App Shell -│ └─► [4] Main Workspace -│ (or [3] if no managed worktrees) -│ -└─ Has known projects ─────────────► [1] Project Picker - │ - └─ Quick-open project ───────► [2] App Shell - └─► [4] Main Workspace - (or [3] if no managed worktrees) - -Inside [4] Main Workspace: -├─ Status / Stage / Commit ─────────── center pane, always visible -├─ Diff Inspector ──────────────────── right pane, driven by file selection -├─ History / Graph ─────────────────── [5] switchable tab in center pane -├─ Create Worktree ─────────────────── [6] modal overlay -├─ Prune Worktree ──────────────────── [7] confirmation dialog -└─ Global Switcher ─────────────────── [8] Cmd+K command palette overlay -``` - ---- - -## Screen inventory - -| # | Screen | Route / mount point | Type | -| --- | ----------------------- | -------------------------------- | ---------------- | -| 1 | Project Picker | `/` | Full page | -| 2 | App Shell | `/workspace` layout | Persistent frame | -| 3 | First Worktree Setup | `/workspace` (conditional) | Guided panel | -| 4 | Main Workspace | `/workspace` | Four-pane layout | -| 5 | History & Graph | `/workspace` tab | Tab pane | -| 6 | Create Worktree | overlay on `/workspace` | Modal | -| 7 | Prune Worktree | overlay on `/workspace` | Confirm dialog | -| 8 | Global Context Switcher | overlay, any `/workspace` screen | Command palette | - ---- - -## [1] Project Picker - -**When shown:** App launch, or user clicks "Project picker" from the workspace header. - -**Purpose:** Get into a SproutGit workspace. No git operations happen here — just project selection or creation. - -### Layout - -``` -┌─────────────────────────────────────────────────────────────┐ -│ SproutGit branding │ -├─────────────────────────────────┬───────────────────────────┤ -│ │ │ -│ Create new project │ Known projects │ -│ │ │ -│ [Workspace folder ________] │ project-a [Open] │ -│ [Repository URL ________] │ project-b [Open] │ -│ │ project-c [Open] │ -│ [ Create project + clone ] │ │ -│ │ ─────────────────────── │ -│ │ Open by path │ -│ │ [path________] [Open] │ -│ │ │ -└─────────────────────────────────┴───────────────────────────┘ -``` - -### Behavior - -- **Create project**: calls `createWorkspace(path, url)` → navigates to `/workspace?workspace=` -- **Open known project**: calls `inspectWorkspace(path)` → validates `.sproutgit/project.json` exists → navigates to `/workspace?workspace=` -- **Open by path**: same validation flow as above -- **Known projects list**: persisted in user-profile config SQLite, sorted by last opened, capped at 20 -- **Git version**: displayed as ambient indicator, not a blocker unless missing entirely - -### Data requirements - -| API call | When | -| -------------------- | --------------------------- | -| `getGitInfo()` | On mount | -| `createWorkspace()` | On "Create project" | -| `inspectWorkspace()` | On "Open" or "Open by path" | - -### Transitions - -| Action | Target | -| ---------------------- | --------------------------------- | -| Successful create/open | → `/workspace?workspace=` | -| Error | Stay on picker, show error inline | - ---- - -## [2] App Shell (persistent layout) - -**When shown:** Always visible when any `/workspace` route is active. - -**Purpose:** Persistent context header and navigation chrome that wraps all workspace screens. This is the "desktop client frame" — dense, always-visible, never scrolls away. - -### Layout - -``` -┌─────────────────────────────────────────────────────────────┐ -│ [≡] SproutGit Repo: my-project › WT: feat/login › │ -│ Branch: feat/login-ui [⌘K] [⚙] │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ (child route content fills here) │ -│ │ -└─────────────────────────────────────────────────────────────┘ -``` - -### Context header contents - -| Element | Source | Behavior on click | -| ----------------------- | -------------------------------- | -------------------------- | -| Project name | dirname of `workspacePath` | Opens [1] Project Picker | -| Active worktree chip | `selectedWorktree.path` basename | Opens worktree dropdown | -| Active branch chip | `selectedWorktree.branch` | Opens branch list dropdown | -| Detached HEAD indicator | `selectedWorktree.detached` | Warning badge, no action | -| `⌘K` button | — | Opens [8] Global Switcher | -| Settings gear | — | Future: workspace settings | - -### Implementation note - -This becomes a **SvelteKit layout** at `src/routes/workspace/+layout.svelte`. It loads workspace state once and passes it to child routes via context or shared store. - -### Data requirements - -| API call | When | -| -------------------- | --------------------------------- | -| `inspectWorkspace()` | On layout mount | -| `listWorktrees()` | On layout mount + after mutations | - ---- - -## [3] First Worktree Setup (guided panel) - -**When shown:** User opens a workspace that has zero managed (non-root) worktrees. - -**Purpose:** Guide the user to create their first managed worktree before doing any work. This enforces the "never work directly on root" principle. - -### Layout - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Context header (from App Shell) │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ ┌───────────────────────────────────────────────────────┐ │ -│ │ │ │ -│ │ Step 1 of 2: Pick a starting point │ │ -│ │ │ │ -│ │ Root is cloned and ready. Before making changes, │ │ -│ │ create a managed worktree from an existing ref. │ │ -│ │ │ │ -│ │ Source ref: [▼ main ] │ │ -│ │ New branch: [feature/_____________________ ] │ │ -│ │ Worktree path: /worktrees/feature-xxx │ │ -│ │ (auto-generated from branch name) │ │ -│ │ │ │ -│ │ ┌──────────────────────────────────────────────┐ │ │ -│ │ │ Commit graph (last ~30 commits) │ │ │ -│ │ │ * abc1234 (main) Initial commit │ │ │ -│ │ │ * def5678 Add README │ │ │ -│ │ │ ... │ │ │ -│ │ └──────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ [ Create managed worktree → ] │ │ -│ │ │ │ -│ └───────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────┘ -``` - -### Behavior - -- Loads refs and short graph on mount -- Ref selector shows branches first, then tags -- Branch name input auto-slugifies into worktree folder name as preview -- On create: calls `createManagedWorktree()` → transitions to [4] Main Workspace -- Cannot be skipped — the only way forward is creating a worktree - -### Data requirements - -| API call | When | -| -------------------------- | ------------------ | -| `listRefs()` | On mount | -| `getCommitGraph(limit=30)` | On mount | -| `createManagedWorktree()` | On "Create" submit | - -### Transitions - -| Action | Target | -| ---------------- | ----------------------- | -| Worktree created | → [4] Main Workspace | -| Error | Stay, show error inline | - ---- - -## [4] Main Workspace (primary workflow) - -**When shown:** After a workspace is open and at least one managed worktree exists. - -**Purpose:** This is where the user spends 95% of their time. Four-pane IDE-style layout for the core Git workflow: see status, stage files, review diffs, commit. - -### Layout - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Context header (from App Shell) │ -├────────────┬────────────────────────┬───────────────────────┤ -│ │ │ │ -│ Worktree │ [Status] [History] │ Diff inspector │ -│ sidebar │ │ │ -│ │ Unstaged changes │ file.ts │ -│ ● feat/a │ M src/app.ts │ ┌─────────────────┐ │ -│ feat/b │ A src/new.ts │ │ @@ -10,6 +10,8 │ │ -│ fix/c │ D src/old.ts │ │ - old line │ │ -│ │ │ │ + new line │ │ -│ ──────── │ [ Stage selected ] │ │ context line │ │ -│ Branches │ │ │ + added line │ │ -│ main │ Staged changes │ └─────────────────┘ │ -│ develop │ M src/app.ts │ │ -│ │ │ ─────────────────────│ -│ ──────── │ [ Unstage selected ] │ Commit composer │ -│ [+ New │ │ │ -│ worktree] │ │ [commit message____] │ -│ │ │ [ Commit to feat/a ] │ -│ │ │ │ -├────────────┴────────────────────────┴───────────────────────┤ -│ status bar: 3 unstaged · 1 staged · feat/a · abc1234 │ -└─────────────────────────────────────────────────────────────┘ -``` - -### Left sidebar: Worktree + branch rail - -| Element | Behavior | -| ------------------------ | ------------------------------------------------------------ | -| Managed worktrees list | Click to switch active worktree; active has bullet indicator | -| External worktrees | Shown below managed, visually dimmed | -| Branch list | Read-only list of local branches, dimmed if no worktree | -| "+ New worktree" button | Opens [6] Create Worktree modal | -| Context menu on worktree | Prune option → opens [7] Prune dialog | - -### Center pane: Status / History tabs - -**Status tab (default):** - -| Element | Behavior | -| ------------------- | ----------------------------------------------- | -| Unstaged files list | Grouped file list with status badges (M/A/D/R) | -| Staged files list | Same grouping, below unstaged | -| Stage button | Stages selected files (or all if none selected) | -| Unstage button | Unstages selected files | -| File click | Selects file → loads diff in right pane | - -**History tab:** - -- Switches center pane to [5] History & Graph view -- Same left sidebar and right pane remain - -### Right pane: Diff inspector + Commit composer - -| Element | Behavior | -| -------------------- | --------------------------------------------------- | -| File path header | Shows path of currently selected file | -| Diff view | Unified diff with syntax highlighting, hunk headers | -| Large file fallback | "File too large to display" message with byte count | -| Binary file fallback | "Binary file" badge, no diff rendered | -| Commit message input | Textarea, required before commit | -| Commit button | Commits staged changes to active worktree's branch | -| Branch label on btn | "Commit to feat/a" — always shows target branch | - -### Status bar (bottom strip) - -Shows at a glance: unstaged count, staged count, active branch, HEAD short hash. - -### Data requirements - -| API call | When | -| ----------------- | ------------------------------------- | -| `listWorktrees()` | On mount, after worktree create/prune | -| `getStatus()` | On mount, after stage/unstage/commit | -| `getDiff()` | On file selection | -| `stageFiles()` | On "Stage" action | -| `unstageFiles()` | On "Unstage" action | -| `commit()` | On "Commit" action | -| `listRefs()` | On mount, for branch sidebar | - -### Transitions - -| Action | Target | -| ---------------------------- | ------------------------------- | -| Click worktree in sidebar | Reload status for that worktree | -| Click "+ New worktree" | → [6] Create Worktree modal | -| Right-click worktree → Prune | → [7] Prune dialog | -| Click "History" tab | → [5] History & Graph tab | -| Press ⌘K | → [8] Global Switcher | -| Click project name in header | → [1] Project Picker | - ---- - -## [5] History & Graph (tab pane) - -**When shown:** User clicks "History" tab in the center pane of [4] Main Workspace. - -**Purpose:** Browse commit history with branch topology visualization. Select commits to inspect their diffs in the right pane. - -### Layout - -``` -┌────────────────────────────────────────────────────┐ -│ [Status] [History] │ -├──────────┬─────────────────────────────────────────┤ -│ Graph │ Commit list │ -│ lanes │ │ -│ │ │ abc1234 feat: add login (feat/a) │ -│ │\ │ def5678 fix: typo (main) │ -│ │ │ │ ghi9012 chore: deps │ -│ │ │ │ jkl3456 feat: signup │ -│ │/ │ mno7890 initial commit (tag: v0.1) │ -│ │ │ │ -│ │ [Load more ↓] │ -└──────────┴─────────────────────────────────────────┘ -``` - -### Behavior - -| Element | Behavior | -| ------------------ | ---------------------------------------------------------- | -| Graph lanes column | SVG-rendered branch topology (colored per branch) | -| Commit row | Hash, subject, author, relative date, ref decorations | -| Click commit | Loads commit diff summary in right pane (file list + diff) | -| Ref badges | Branch names (green), tags (yellow), HEAD (red) | -| Load more | Fetches next page of history | -| Scroll sync | Graph lanes and commit list scroll in lockstep | - -### Data requirements - -| API call | When | -| --------------------------- | ------------------- | -| `getCommitGraph(limit=120)` | On tab open | -| `getCommitDetails(hash)` | On commit row click | - -### Implementation note — graph rendering - -Phase 1 (MVP): ASCII graph from `git log --graph` displayed in monospace, as currently implemented. Functional, not pretty. - -Phase 2 (post-MVP): Structured commit data with precomputed lane positions, rendered as SVG columns. This requires a backend change to return parsed commit objects with parent relationships and lane assignments instead of raw text. - ---- - -## [6] Create Worktree (modal) - -**When shown:** User clicks "+ New worktree" in the sidebar of [4] Main Workspace. - -**Purpose:** Create a new managed worktree from a branch, tag, or commit ref. Same form as [3] First Worktree Setup but as a modal overlay — the user is already working, so keep them in context. - -### Layout - -``` -┌─────────────────────────────────────────────────┐ -│ Create managed worktree [✕] │ -├─────────────────────────────────────────────────┤ -│ │ -│ Source ref: [▼ main ] │ -│ New branch: [feature/_______________ ] │ -│ │ -│ Path preview: │ -│ /worktrees/feature-xxx │ -│ │ -│ [ Cancel ] [ Create worktree → ] │ -│ │ -└─────────────────────────────────────────────────┘ -``` - -### Behavior - -- Ref selector: branches first, then tags, searchable -- Branch name validates: no spaces, no special chars, no duplicates -- Path preview updates live from slugified branch name -- On create: `createManagedWorktree()` → closes modal → refreshes worktree list → auto-switches to new worktree - -### Data requirements - -| API call | When | -| ------------------------- | ------------- | -| `listRefs()` | On modal open | -| `createManagedWorktree()` | On submit | - ---- - -## [7] Prune Worktree (confirmation dialog) - -**When shown:** User right-clicks a managed worktree and selects "Remove" or "Prune." - -**Purpose:** Confirm destructive worktree removal. This deletes the worktree folder and cleans up Git metadata. - -### Layout - -``` -┌─────────────────────────────────────────────────┐ -│ Remove worktree [✕] │ -├─────────────────────────────────────────────────┤ -│ │ -│ ⚠ This will delete the worktree directory │ -│ and remove the Git worktree reference. │ -│ │ -│ Worktree: feature/login-ui │ -│ Path: /Users/.../worktrees/feature-login │ -│ Branch: feature/login-ui │ -│ │ -│ □ Also delete the branch │ -│ │ -│ [ Cancel ] [ Remove worktree ] │ -│ │ -└─────────────────────────────────────────────────┘ -``` - -### Behavior - -- Shows worktree details for confirmation -- Optional checkbox to also delete the local branch -- If worktree has uncommitted changes, show additional warning: "This worktree has uncommitted changes that will be lost." -- On confirm: `pruneWorktree()` → closes dialog → refreshes worktree list → switches to next available worktree - -### Data requirements - -| API call | When | -| ------------------------ | ------------------------------------------------- | -| `pruneWorktree()` | On confirm | -| `getStatus()` (optional) | On dialog open, to warn about uncommitted changes | - ---- - -## [8] Global Context Switcher (command palette) - -**When shown:** User presses `⌘K` (macOS) / `Ctrl+K` (Windows/Linux) from any workspace screen. - -**Purpose:** Keyboard-first rapid navigation. Search and switch between worktrees, branches, and run quick actions without mouse. - -### Layout - -``` -┌─────────────────────────────────────────────────┐ -│ [🔍 Search worktrees, branches, actions... ] │ -├─────────────────────────────────────────────────┤ -│ │ -│ MANAGED WORKTREES │ -│ ● feat/login-ui │ -│ fix/header-crash │ -│ chore/deps-update │ -│ │ -│ BRANCHES │ -│ main │ -│ develop │ -│ │ -│ ACTIONS │ -│ + New managed worktree │ -│ + New branch + worktree │ -│ Open project picker │ -│ │ -└─────────────────────────────────────────────────┘ -``` - -### Behavior - -| Element | Behavior | -| --------------- | ------------------------------------------------- | -| Search input | Fuzzy filter across all sections | -| Worktree row | Enter → switch active worktree | -| Branch row | Enter → offer to create worktree from this branch | -| Action row | Enter → execute (open modal, navigate, etc.) | -| Arrow keys | Navigate rows | -| Escape | Close palette | -| Active worktree | Marked with bullet, sorted first | - -### Data requirements - -| API call | When | -| ----------------- | ------- | -| `listWorktrees()` | On open | -| `listRefs()` | On open | - ---- - -## Data flow summary - -### Shared workspace state - -All workspace screens share a common state store loaded by the App Shell layout: - -``` -WorkspaceContext { - workspacePath: string - rootPath: string - worktreesPath: string - worktrees: WorktreeInfo[] // refreshed after mutations - activeWorktreePath: string | null // user selection - refs: RefInfo[] // refreshed after mutations -} -``` - -This is loaded once by the `/workspace` layout and exposed to all child components via Svelte context. Mutations (create worktree, prune, commit, etc.) trigger targeted refreshes. - -### Backend commands needed for full MVP - -| Command | Status | Used by screens | -| ----------------------------- | --------- | ------------------- | -| `git_info` | ✅ Exists | [1] | -| `create_sproutgit_workspace` | ✅ Exists | [1] | -| `inspect_sproutgit_workspace` | ✅ Exists | [1] [2] | -| `list_worktrees` | ✅ Exists | [2] [3] [4] [8] | -| `list_refs` | ✅ Exists | [3] [4] [5] [6] [8] | -| `get_commit_graph` | ✅ Exists | [3] [5] | -| `create_managed_worktree` | ✅ Exists | [3] [6] | -| `get_status` | ❌ Needed | [4] [7] | -| `stage_files` | ❌ Needed | [4] | -| `unstage_files` | ❌ Needed | [4] | -| `commit` | ❌ Needed | [4] | -| `get_diff` | ❌ Needed | [4] [5] | -| `prune_worktree` | ❌ Needed | [7] | -| `get_commit_details` | ❌ Needed | [5] | -| `fetch` | ❌ P1 | [4] (toolbar) | -| `push` | ❌ P1 | [4] (toolbar) | - ---- - -## Route structure - -``` -src/routes/ -├── +layout.svelte ← global CSS import only -├── +layout.ts ← ssr = false -├── +page.svelte ← [1] Project Picker -└── workspace/ - ├── +layout.svelte ← [2] App Shell (context header, shared state) - ├── +page.svelte ← [3] or [4] conditional on worktree count - └── (no other child routes needed for MVP — tabs/modals are components) -``` - -Modals and overlays ([6] [7] [8]) are Svelte components mounted inside the workspace layout, not separate routes. - ---- - -## Implementation order - -| Phase | What | Screens affected | -| ----- | --------------------------------------------------- | ---------------- | -| 1 | Workspace layout with context header | [2] | -| 2 | Conditional first-worktree vs main-workspace view | [3] [4] | -| 3 | Backend: `get_status`, `stage`, `unstage`, `commit` | [4] | -| 4 | Status pane + staging workflow | [4] | -| 5 | Backend: `get_diff` | [4] [5] | -| 6 | Diff inspector pane | [4] | -| 7 | Commit composer | [4] | -| 8 | History tab with graph | [5] | -| 9 | Create worktree modal | [6] | -| 10 | Backend: `prune_worktree` | [7] | -| 11 | Prune worktree dialog | [7] | -| 12 | Global context switcher | [8] | -| 13 | P1: fetch/push toolbar actions | [4] | - ---- - -## Library plan - -| Need | Choice | Why | -| --------------------- | -------------------------- | ------------------------------------ | -| Syntax highlighting | Shiki (fine-grained) | Tree-sitter quality, lazy-loadable | -| Diff rendering | Custom from parsed hunks | Keep control, avoid heavy deps early | -| Graph lanes (phase 1) | ASCII from git log | Already implemented, ship fast | -| Graph lanes (phase 2) | SVG lanes from parsed data | Better UX, do after MVP core works | -| Virtual scrolling | Svelte virtual list | File lists and history can be long | -| Git operations | CLI via Tauri commands | Already established pattern | - ---- - -## Post-MVP screens (not in this plan) - -These are documented in requirements.md but excluded from MVP implementation: - -- Merge conflict editor -- Rebase/cherry-pick visual workflow -- AI commit message generation -- Issue tracker integration panels -- MCP control surface -- Workspace settings/preferences screen diff --git a/docs/docs-platform-plan.md b/docs/docs-platform-plan.md deleted file mode 100644 index 2278acc..0000000 --- a/docs/docs-platform-plan.md +++ /dev/null @@ -1,243 +0,0 @@ -# SproutGit Documentation Platform Plan - -## Objective - -Adopt a modern documentation platform that is: - -- Hosted on the SproutGit website -- Updated directly from this codebase -- Easy for maintainers and AI agents to contribute to -- Fast, searchable, and simple to navigate -- Future-proof for versioning and API/reference growth - -## Decision Summary - -Primary recommendation: Astro Starlight. - -Why this is the best fit for SproutGit now: - -- The repository already contains an Astro website at `website/` -- Team can keep a single frontend ecosystem (Astro + Tailwind) -- Docs content can live in-repo and be updated via normal PR flow -- Starlight ships strong docs UX primitives (sidebar, breadcrumbs, structured nav) -- Supports modern search integrations and static hosting workflows - -## Scope - -In scope: - -- Documentation architecture and content organization -- Site navigation model and information architecture -- Search strategy (initial + scalable options) -- llms.txt strategy and generation approach -- Contribution workflow for humans and AI agents -- CI/CD and quality gates for docs - -Out of scope for this plan: - -- Immediate implementation details -- Final visual theming decisions -- Full migration of all existing docs in one step - -## Proposed Information Architecture - -Top-level doc sections: - -1. Getting Started -2. Core Concepts (worktree-first model) -3. User Guides (task-based workflows) -4. Reference (commands, settings, schemas) -5. Security and Cross-Platform Notes -6. Troubleshooting -7. Contributor Docs -8. Release Notes / Changelog - -Content format strategy: - -- Use Markdown/MDX for most pages -- Keep docs source in this repo to preserve version control and review history -- Treat README as a project entry point, but keep canonical deep docs in the docs site - -## Search Strategy - -Phase 1 (initial launch): - -- Use built-in/local static search option suitable for static docs -- Optimize titles, headings, and page descriptions for discoverability - -Phase 2 (scale-up): - -- Evaluate Algolia DocSearch for larger content sets and advanced ranking -- Keep search provider abstracted so migration is low-risk - -Acceptance expectations for search: - -- Search finds pages by feature name, command name, and key terms -- Result snippets are meaningful and not just page titles -- Keyboard-friendly behavior and fast response on desktop/mobile - -## llms.txt Strategy - -Goal: - -- Publish `llms.txt` from the docs website so LLM tools can discover key documentation resources and project context. - -Plan: - -1. Define a stable `llms.txt` schema for this project (project description + high-value links). -2. Generate `llms.txt` from docs metadata where practical to avoid manual drift. -3. Publish at the website root path (`/llms.txt`). -4. Add CI validation to ensure the file is generated/updated when docs structure changes. -5. Document ownership and update rules for `llms.txt` in contributor docs. - -Suggested `llms.txt` content categories: - -- Project identity and short description -- Primary docs index and getting-started links -- Security and architecture references -- Contribution workflow references -- Release notes location - -## Contribution Model (Human + AI) - -Goals: - -- Make docs updates as easy as code changes -- Ensure AI-generated edits are reviewable and safe -- Prevent stale docs during rapid feature development - -Workflow policy: - -1. Docs updates are first-class PR content, not an afterthought. -2. Feature PR template includes a docs impact checklist. -3. Any user-visible behavior change requires at least one docs touchpoint (new page or updated section). -4. AI agents can propose docs edits, but all changes remain PR-reviewed. -5. Keep pages modular and task-oriented to reduce merge conflicts. - -Authoring guidelines: - -- Prefer concise, task-first headings -- Include platform-specific notes where behavior differs (macOS/Linux/Windows) -- Include troubleshooting and failure-mode notes close to relevant guides -- Use consistent terminology: repository, workspace, worktree, branch - -## Migration Plan - -### Phase 0: Preparation - -- Audit current docs in `docs/`, README, and website pages -- Map existing content to target IA sections -- Identify duplicate or conflicting sources of truth - -Exit criteria: - -- Content inventory complete -- IA map approved - -### Phase 1: Foundation - -- Initialize docs section on website with core navigation skeleton -- Move or mirror a small pilot set of high-value pages -- Stand up baseline search - -Exit criteria: - -- Docs section live on website -- Navigation and search operational - -### Phase 2: Content Migration - -- Migrate remaining core docs in prioritized order: - 1. Getting started - 2. Worktree workflows - 3. Security/cross-platform guidance - 4. Contributor and troubleshooting content -- Add redirects or clear mapping from old locations where needed - -Exit criteria: - -- Core documentation topics available in new docs section -- No major dead links - -### Phase 3: llms.txt + Quality Gates - -- Publish generated `llms.txt` -- Add CI checks for docs build, link integrity, and metadata validation -- Add docs contribution checklist to PR workflow - -Exit criteria: - -- `llms.txt` available and validated -- Docs quality checks running in CI - -### Phase 4: Polish and Scale - -- Improve information scent (labels, summaries, cross-links) -- Tune search relevance and analytics -- Add versioning strategy if/when release cadence requires it - -Exit criteria: - -- Positive contributor and reader feedback -- Reduced doc discovery friction - -## CI/CD and Quality Gates (Planned) - -Planned checks for docs pull requests: - -1. Docs build must pass -2. Internal links must resolve -3. Broken anchors should fail the check -4. `llms.txt` validation should pass when docs navigation changes -5. Optional spelling/style lint (non-blocking initially) - -Release/deploy model: - -- Docs deploy with website pipeline from main branch -- Preview builds for PRs to review docs before merge - -## Ownership and Operating Model - -Proposed ownership: - -- Maintainers own final editorial and structural decisions -- Contributors (including AI agents) can submit improvements -- Security-sensitive docs require explicit maintainer review - -Maintenance cadence: - -- Continuous updates with feature PRs -- Recurring docs quality sweep (dead links, stale screenshots, drift checks) - -## Risks and Mitigations - -Risk: docs drift from product behavior. -Mitigation: require docs impact review in feature PR template and CI checks. - -Risk: navigation grows confusing as docs scale. -Mitigation: enforce IA limits and section ownership; run periodic nav refactors. - -Risk: search quality degrades with content growth. -Mitigation: monitor search analytics and tune ranking/content structure. - -Risk: `llms.txt` becomes stale. -Mitigation: generate from metadata and validate in CI. - -## Success Metrics - -1. Time-to-first-answer for common tasks decreases (qualitative + support feedback). -2. Docs PR frequency increases without quality regressions. -3. Search success improves (fewer repeated navigation clicks before target page). -4. Fewer support questions for documented workflows. -5. `llms.txt` remains current across releases. - -## Open Decisions - -1. Final search provider at scale (stay static/local vs Algolia). -2. Versioning trigger: at what release threshold to enable versioned docs. -3. Screenshot/media standards and update ownership. -4. Whether to keep all architecture docs in one location or split between `/docs` and website docs content. - -## Next Step (Planning Only) - -Create a short architecture decision record (ADR) confirming Astro Starlight as the selected platform and linking this plan as the implementation guide. diff --git a/docs/e2e-test-process.md b/docs/e2e-test-process.md deleted file mode 100644 index 9b32980..0000000 --- a/docs/e2e-test-process.md +++ /dev/null @@ -1,77 +0,0 @@ -# E2E Test Process - -This document defines the default E2E workflow for SproutGit. - -## Defaults - -- Playwright runs in headless mode by default via `e2e/playwright.config.ts`. -- E2E uses `@srsholmes/tauri-playwright` in `tauri` mode. -- Test workers are pinned to `1` for deterministic stateful desktop flows. -- `pnpm run test:e2e` runs Playwright directly against the built app. -- Playwright global setup performs one prebuild (`pnpm run test:e2e:build`) before tests unless explicitly skipped. -- `test:e2e:build` uses `tauri build --config src-tauri/tauri.e2e.conf.json --no-bundle --features e2e-testing` so tests use a release app binary without slow packaging/signing steps and only opt into Playwright permissions during E2E builds. - -To skip the one-time prebuild for faster local iteration: - -- `SPROUTGIT_E2E_SKIP_BUILD=1 pnpm run test:e2e` - -## Per-Test Isolation - -The `tauriPage` fixture owns per-test reset before the app launches: - -1. Reset isolated config DB path for the run. -2. Reset workspace test directories. -3. Start a fresh Tauri app process for the test. - -This ordering is required on Windows. Resetting the config DB or workspace directories after the app is already running can race startup reads, file watchers, and terminal child processes, producing `database is locked`, `EBUSY`, and `beforeEach` timeout flakes. - -Specs should assume a fresh app on first interaction and should not perform their own reset in `beforeEach` unless a test explicitly needs an in-app navigation step within the same process. - -## Browser Dependency Setup - -Playwright browser dependencies are installed via: - -- `pnpm run setup:playwright` - -This command is run automatically from `prepare`: - -- `pnpm run prepare` => `husky && pnpm run setup:playwright` - -Platform behavior: - -- Linux: installs Chromium with system deps (`playwright install --with-deps chromium`). -- macOS/Windows: installs Chromium (`playwright install chromium`). - -To skip local auto-setup: - -- Set `SPROUTGIT_SKIP_PLAYWRIGHT_SETUP=1`. - -## Pre-Commit Gate - -`.husky/pre-commit` includes these checks in order: - -1. `pnpm run cleanup:rust-targets:delete` -2. Rust unit tests -3. `pnpm run test` -4. lint -5. type check -6. full E2E run - -## Common Commands - -```bash -# Standard e2e run (isolated process per test) -pnpm run test:e2e - -# Tauri headed mode -pnpm run test:e2e --headed - -# Build + e2e -pnpm run test:e2e:full - -# Canary suites -pnpm run test:e2e:canary - -# Screenshot suite -pnpm run test:e2e:screenshots -``` diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 52e03e0..0000000 --- a/docs/index.md +++ /dev/null @@ -1,51 +0,0 @@ -# SproutGit Docs Index - -This file is the maintained entry point for the `docs/` folder. - -## Maintenance Rule - -Update this index whenever a document in `docs/` is added, renamed, removed, or substantially repurposed. - -## Agent Workflow - -On a new task: - -1. Read this file first. -2. Identify which linked docs are relevant to the request. -3. Read those docs before making design or implementation decisions. - -## Document Catalog - -### Product And Workflow - -- [requirements.md](requirements.md) — MVP scope, core UX principles, prescribed workspace layout, and default worktree-first product expectations. -- [branch-worktree-policy.md](branch-worktree-policy.md) — Branch/worktree binding rules, managed vs persistent branch policy, merge flow, and cleanup semantics. -- [worktree-hooks.md](worktree-hooks.md) — Proposed worktree lifecycle hook model, trigger semantics, scope, and constraints. -- [design-review-and-screen-plan.md](design-review-and-screen-plan.md) — Screen inventory, route flow, and UI architecture for the MVP. - -### Architecture And Platform - -- [architecture.md](architecture.md) — Backend git/system command architecture, validator pipeline, composability analysis, and extension guidance. -- [docs-platform-plan.md](docs-platform-plan.md) — Documentation-site strategy, information architecture, search plan, and contribution model for long-form docs. - -### Security - -- [security-audit.md](security-audit.md) — Security review of git/system interactions, risks found, and backend hardening decisions. - -### Testing, QA, And Benchmarks - -- [benchmark-repository-strategy.md](benchmark-repository-strategy.md) — Strategy for sample repositories used in screenshots, demos, regression validation, and performance testing. -- [tauri-playwright-adapter-cheatsheet.md](tauri-playwright-adapter-cheatsheet.md) — API signatures, adapter-specific gotchas, and runtime port/socket guidance for `@srsholmes/tauri-playwright`. -- [e2e-test-process.md](e2e-test-process.md) — Default E2E runtime behavior, per-test isolation flow, Playwright dependency setup, and pre-commit test gates. - -## Quick Relevance Guide - -- Worktree lifecycle, branch deletion, merge cleanup, or branch ownership questions: read [branch-worktree-policy.md](branch-worktree-policy.md). -- Core product behavior or MVP fit questions: read [requirements.md](requirements.md). -- Backend git/system execution changes: read [architecture.md](architecture.md) and [security-audit.md](security-audit.md). -- Hook behavior or hook UI changes: read [worktree-hooks.md](worktree-hooks.md). -- Screen structure, navigation, or UI flow changes: read [design-review-and-screen-plan.md](design-review-and-screen-plan.md). -- Docs-site or documentation-process work: read [docs-platform-plan.md](docs-platform-plan.md). -- Benchmark/demo/test repository work: read [benchmark-repository-strategy.md](benchmark-repository-strategy.md). -- Tauri E2E harness behavior, adapter APIs, or test fixture issues: read [tauri-playwright-adapter-cheatsheet.md](tauri-playwright-adapter-cheatsheet.md). -- E2E reset behavior, headless defaults, test command usage, or local browser setup flow: read [e2e-test-process.md](e2e-test-process.md). diff --git a/docs/requirements.md b/docs/requirements.md deleted file mode 100644 index 26bfc3b..0000000 --- a/docs/requirements.md +++ /dev/null @@ -1,186 +0,0 @@ -# SproutGit MVP Requirements - -## Product Goal - -Build a fast, open-source, cross-platform Git desktop app that treats worktrees as the default way to work. - -## Core UX Principle - -At all times, the app must make these three contexts obvious and ordered: - -1. Active Repository -2. Active Worktree -3. Active Branch - -This is a cascade where branch belongs to worktree, and worktree belongs to repository. - -## SproutGit Workspace Layout (Prescribed) - -SproutGit enforces a project workspace structure above Git root/worktrees: - -- Project workspace root: -- Main checkout (Git primary worktree, protected internal checkout): /root/ -- Managed worktrees container: /worktrees/ -- Individual managed worktree path: /worktrees/ -- SproutGit project metadata: /.sproutgit/ - - SQLite database: /.sproutgit/state.db - - Project marker/metadata file: /.sproutgit/project.json - -App-level config storage (global, per user): - -- SQLite config database in user profile directory (cross-platform app config location) -- Stores recent workspaces and app settings - -Rules: - -1. New managed worktrees must be created under /worktrees/. -2. The primary Git checkout must live at /root/. -3. The primary checkout at /root/ is a protected internal checkout and should not be the default location for day-to-day feature work. -4. Long-lived branches such as `main`, `master`, `develop`, and `release/*` should use dedicated worktrees for normal editing when the product exposes that workflow. -5. SproutGit labels each worktree as: - - Managed: path under /worktrees/ - - External: path outside the managed container -6. Default action for branch creation is "Create branch + worktree". -7. Worktree switcher prioritizes managed worktrees first. -8. Deleting a managed worktree prunes both Git metadata and its folder. -9. Presence of /.sproutgit/project.json identifies the filesystem as a SproutGit project. -10. SQLite state.db stores workspace-local state, not Git object data. -11. Global app settings and recent-workspace state must be stored in the user-profile config SQLite database. - -## MVP Scope - -1. Repository onboarding - - Open existing repository - - Clone repository -2. Worktree-first operations - - List worktrees - - Create worktree from branch - - Switch active worktree - - Prune worktree -3. Basic Git workflow - - Status - - Stage and unstage - - Commit -4. Context clarity UI - - Persistent context header: Repo > Worktree > Branch - - Color-coded context chips - - Global quick switcher - -## Solid MVP Feature List (v0.1) - -### P0: Must ship - -1. Repository workspace bootstrap - - Open local repository - - Clone repository into a managed workspace - - Show recent repositories -2. Persistent context clarity - - Always-visible Repo > Worktree > Branch cascade - - Explicit active indicators and detached HEAD state -3. Worktree-first core flow - - List worktrees with Managed vs External labels - - Create managed worktree from branch - - Switch active worktree quickly - - Prune/remove worktree safely - - Keep the protected `root/` checkout distinct from normal day-to-day worktrees -4. Branch plus worktree workflow - - Default action: create branch + create managed worktree - - Branch list and create from selected base -5. Status and commit workflow - - Working tree status grouped as staged and unstaged - - Stage and unstage file-level changes - - Commit from current worktree -6. Commit history and graph (essential) - - Commit log with branch/tag decorations - - Graph lane view showing branch topology - - Selection sync between log row and graph row -7. Guardrails and errors - - Clear errors for invalid repo/worktree operations - - Confirm destructive worktree removal actions - -### P1: Should ship if stable - -1. Fetch and pull for current worktree -2. Push current branch with upstream setup -3. Keyboard-first global context switcher -4. Repo-scoped worktree lifecycle hooks (local-only, persisted in workspace SQLite) - -### P2: Candidate (post-MVP) - -1. Hook execution history UI (per-hook run logs and status) -2. Hook parallel execution groups with timeout and blocking policy controls -3. Shell-aware script editor highlighting (bash/zsh/pwsh) in workspace settings - -### Excluded from v0.1 - -1. Merge conflict editor -2. Rebase/cherry-pick UI -3. Stash UI -4. AI commit naming -5. External issue tracker integrations - -## MVP Done Criteria - -1. A user can complete the standard feature-branch flow without terminal fallback: - - Open repo -> create branch+worktree -> make and stage changes -> commit -> push -2. The UI always shows unambiguous current Repo > Worktree > Branch context. -3. Managed workspace convention (/root + /worktrees) is enforced by default and visible in creation flows. -4. Core workflows pass smoke tests on macOS, Windows, and Linux. -5. Commit history graph is available in MVP and reflects branch topology for recent history. - -## Out of Scope (MVP) - -1. Merge conflict editor -2. Rebase/cherry-pick visual workflows -3. AI commit naming (defer to v0.2 BYOK) -4. External issue tracker integrations for naming automation (defer to post-MVP) - -## Post-MVP Integrations (Planned) - -1. Issue tracker driven worktree and branch naming templates - - Linear - - Azure DevOps Work Items - - GitHub Issues - - GitLab Issues - - Jira -2. Optional branch/worktree name suggestions from selected issue context. -3. Smart defaults such as - and branch/worktree pair creation. -4. Integration model should be provider-agnostic and pluggable so each provider can be added incrementally. - -## Post-MVP Agent Control (Planned) - -1. MCP control surface for agent orchestration across SproutGit projects. -2. Agent-safe operations only (create/switch/prune worktree, branch creation, status reads, commit drafting). -3. Explicit permission and confirmation gates for destructive operations. -4. Project-scoped capability model backed by .sproutgit metadata and SQLite state. -5. Full audit trail in project state for agent-triggered actions. - -## Post-MVP Local Automation (Planned) - -1. Repository-scoped lifecycle hooks for worktree create/remove events. -2. Hooks stored in `/.sproutgit/state.db`, never committed to Git by default. -3. Hook scripts run with OS-specific shells: - - Linux: bash - - macOS: zsh - - Windows: PowerShell Core (`pwsh`) -4. Hook definitions and runs managed with an ORM-backed SQLite model. -5. Hook dependency model supports multiple dependencies (AND semantics) and tree/DAG execution. -6. Hooks can be marked critical (required) or non-critical. -7. Force remove bypasses only failing non-critical hooks; critical failures still block. -8. `after_*` hook failures are warning-only by default. -9. Hook editor uses Monaco with shell-aware syntax highlighting. - -## Non-Functional Requirements - -1. Fast startup and low memory footprint -2. Equal support priority: macOS, Windows, Linux -3. Clear, actionable error states for Git failures -4. No hidden state changes; all branch/worktree changes are explicit in UI -5. SQLite project state must be resilient to app restarts and safe to rebuild from Git + filesystem state - -## Acceptance Criteria (Design Phase) - -1. Primary screen includes a persistent, unambiguous Repo > Worktree > Branch cascade. -2. Worktree creation flow defaults to managed path under /worktrees/. -3. Worktree list visually distinguishes Managed vs External. -4. Navigation makes worktrees first-class, not buried under advanced menus. diff --git a/docs/security-audit.md b/docs/security-audit.md deleted file mode 100644 index 519cd91..0000000 --- a/docs/security-audit.md +++ /dev/null @@ -1,82 +0,0 @@ -# Security Audit: Git and System Interactions - -Revision: Initial draft -Scope: `src-tauri/src` git/system process execution paths (`git/operations.rs`, `workspace.rs`, `git/diff.rs`, `editor.rs`, `git/helpers.rs`) - -## Summary - -The backend now uses a centralized, registered command policy for git and system operations. - -- Git actions are registered with `GitAction`. -- System actions are registered with `SystemAction`. -- Untrusted inputs are validated before command execution. -- Backend git commands run non-interactively (`GIT_TERMINAL_PROMPT=0`). -- Command lookup is cross-platform (`which` on Unix-like systems, `where` on Windows). -- Security-focused unit tests cover validation and action registration invariants. - -## Findings and Remediation - -### 1. Option-smuggling risk in untrusted git arguments (Resolved) - -Risk: -Untrusted refs/keys/URLs could begin with `-` and be interpreted as options by git. - -Remediation: - -- Added `validate_non_option_value` and `validate_git_config_key`. -- Added `validate_repo_url`. -- Updated command paths to validate before execution. -- Added `--` boundaries for git config key/value operations. - -### 2. No command registration for security-focused unit testing (Resolved) - -Risk: -No explicit allowlist/registry for git/system action types, making audit/test coverage weaker. - -Remediation: - -- Added `GitAction` registry with explicit action labels. -- Added `SystemAction` registry with explicit action labels. -- Routed command execution through helper builders/executors that require an action enum. -- Added tests ensuring unique registry labels. - -### 3. Platform-specific command detection (Resolved) - -Risk: -Using `which` only can fail on Windows. - -Remediation: - -- Added cross-platform command lookup helper using `where` on Windows. - -### 4. PATH separator portability issue (Resolved) - -Risk: -Hardcoded `:` separator in PATH augmentation is not cross-platform. - -Remediation: - -- Replaced PATH manipulation with `split_paths`/`join_paths`. -- Added OS-specific preferred path entries with fallback behavior. - -## Validation and Testing - -Framework: Rust built-in unit tests (`cargo test`). - -Current security-focused unit tests: - -- Registry uniqueness for `GitAction`. -- Registry uniqueness for `SystemAction`. -- Rejection of option-injection prefix for untrusted values. -- Validation of git config key format. -- Validation of repository URL constraints. - -CI requirement: - -- `cargo test` runs in the Rust matrix job on Linux, macOS, and Windows. - -## Residual Risk Notes - -- `open_in_editor` executes a user-configured editor command by design. This is user-authorized local execution, but remains a privileged operation. -- Repository URL trust still depends on git transport security and user intent; input validation prevents command-flag injection, not malicious remote content. -- Additional defense opportunities include stricter ref syntax validation for specific operations and optional allowlist-based protocol policy for clone URLs. diff --git a/docs/tauri-playwright-adapter-cheatsheet.md b/docs/tauri-playwright-adapter-cheatsheet.md deleted file mode 100644 index 6c384e6..0000000 --- a/docs/tauri-playwright-adapter-cheatsheet.md +++ /dev/null @@ -1,204 +0,0 @@ -# Tauri Playwright Adapter Cheatsheet - -This document is a practical reference for using `@srsholmes/tauri-playwright` in this repo. - -Sources consulted: - -- Installed package README: `node_modules/@srsholmes/tauri-playwright/README.md` -- Installed type definitions: `node_modules/@srsholmes/tauri-playwright/dist/index.d.ts` - -## What This Adapter Is - -`@srsholmes/tauri-playwright` provides a Playwright-like API for real Tauri webviews through the Rust plugin bridge (`tauri-plugin-playwright`). - -It supports three modes: - -- `browser`: Chromium + mocked Tauri IPC. -- `tauri`: real app + plugin bridge (cross-platform). -- `cdp`: WebView2 CDP (Windows). - -For SproutGit E2E, we primarily use `tauri` mode behavior with `PluginClient` + `TauriPage`. - -## Quick Wiring Checklist - -1. Rust plugin enabled only for E2E feature: - -- Cargo feature `e2e-testing = ["dep:tauri-plugin-playwright"]` -- Tauri builder plugin registration under `#[cfg(feature = "e2e-testing")]` -- E2E builds use `src-tauri/tauri.e2e.conf.json` so only E2E runs opt into the extra inline Playwright capability - -2. Capabilities: - -- Keep the default app config pinned to `"default"` capability only. -- Add Playwright permissions only from the E2E config overlay, not from the default capability file. - -3. Runtime connectivity: - -- `PluginClient(socketPath, tcpPort)` must match backend plugin config exactly. -- Keep socket path / TCP port consistent between webServer process and test worker process. - -## API Gotchas (Important) - -### 1. `TauriLocator.waitFor` takes a number, not an options object - -Correct: - -```ts -await tauriPage.getByTestId('btn-import').waitFor(15000); -``` - -Incorrect: - -```ts -await tauriPage.getByTestId('btn-import').waitFor({ timeout: 15000 }); -``` - -Why: adapter expects `waitFor(timeout?: number)`. Passing an object causes runtime command deserialization errors. - -### 2. `TauriPage` is not a native Playwright `Page` - -Do not assume Playwright `page.on(...)` event APIs are available on `TauriPage`. - -Example of unsupported pattern: - -```ts -tauriPage.on('console', ...) -``` - -Use adapter-supported methods (`locator`, `getBy*`, `waitForFunction`, `allTextContents`, screenshots, etc.) and explicit UI/state assertions. - -### 3. Prefer adapter-native wait primitives - -Supported and reliable: - -- `tauriPage.waitForSelector(selector, timeout?)` -- `tauriPage.waitForFunction(script, timeout?)` -- `locator.waitFor(timeout?)` - -When startup synchronization is flaky, prefer waiting on stable UI test IDs over immediate `evaluate` navigation. - -### 4. Command timeout behavior - -Adapter commands fail with messages like: - -- `TauriPage command 'eval' failed: timeout (30s)` - -Use short test timeouts and deterministic readiness checks to fail fast. - -## Commonly Used Calls In This Repo - -### Page interactions - -```ts -await tauriPage.click('[data-testid="btn-import"]'); -await tauriPage.fill('[data-testid="import-repo-path"]', repoPath); -await tauriPage.getByTestId('import-submit').click(); -``` - -### Read toasts - -```ts -const errors = await tauriPage.allTextContents( - '[data-testid="toast-item"][data-toast-type="error"] [data-testid="toast-message"]' -); -``` - -### Wait for app readiness - -```ts -await tauriPage.getByTestId('btn-import').waitFor(15000); -``` - -### Evaluate script - -```ts -await tauriPage.evaluate(`(() => { - if (window.location.pathname !== '/') { - window.location.assign('/'); - return; - } - - window.location.reload(); -})()`); -``` - -Use this pattern for route reset in `tauri` mode. Do not use `page.goto()` as a substitute for webview navigation. - -## Startup/Port Guidance For Parallel Runs - -To allow multiple parallel test runs: - -- Allocate dynamic dev server and plugin ports per run. -- Use a unique plugin socket path per run. -- Ensure these values are computed once and shared across all Playwright processes in that run. - -If each process computes independently, worker and webServer can drift and fail with socket mismatch/ENOENT. - -## Suggested SproutGit Testing Defaults - -- Keep test-level timeout modest (around 45s). -- Keep webServer startup timeout modest (around 90s). -- Keep plugin connect timeout modest (around 30s). -- Fail fast on startup error toasts, and attach them to test artifacts. -- Reset disk state in the fixture before launching Tauri for each test. -- For per-test isolation, delete the E2E workspace dir and config DB before app startup; do not delete them from a spec-level `beforeEach` after the app has already opened them. -- Keep the app lifecycle model consistent. If the fixture launches a fresh Tauri process per test, specs should not layer an in-app reset path on top of that. - -## Debug Checklist - -If a test fails before first interaction: - -1. Confirm plugin started and printed socket path. -2. Confirm fixture socket path matches backend socket path exactly. -3. Confirm locator waits use numeric timeout signatures. -4. Confirm no unsupported Playwright Page APIs are used on `TauriPage`. -5. Confirm startup toasts are captured with test IDs and attached on failure. - -## Known Failures (SproutGit) - -These are real failures we have already hit in this repo and what they usually mean. - -1. `Could not connect to Playwright plugin within 30000ms: Error: connect ENOENT /tmp/...sock` - -- Meaning: fixture and backend are using different socket paths. -- Fix: compute socket path once per run and propagate through environment to both webServer and worker process. - -2. `TauriPage command 'wait_for_selector' failed: invalid command: invalid type: map, expected u64` - -- Meaning: `locator.waitFor` was called with a Playwright options object. -- Fix: call `locator.waitFor(15000)` with numeric timeout. - -3. `TypeError: appSession.tauriPage.on is not a function` - -- Meaning: `TauriPage` is not a native Playwright `Page` event emitter. -- Fix: remove `page.on(...)` usage and rely on adapter-supported APIs. - -4. `TauriPage command 'eval' failed: timeout (30s)` - -- Meaning: startup navigation/evaluation happened before bridge/page readiness or got stuck. -- Fix: use deterministic UI readiness checks (stable test IDs) and avoid fragile early eval loops. - -5. `Failed to load recent workspaces: Config database migration failed ... Safety level may not be changed inside a transaction` - -- Meaning: migration SQL included PRAGMA statements executed in migration transaction. -- Fix: keep PRAGMAs in connection-open code (`db.rs`) and remove PRAGMAs from migration SQL files. - -6. `page.goto: Target page, context or browser has been closed` - -- Meaning: `page.goto()` was called while using adapter `tauri` mode, so navigation went through the browser-side adapter instead of the Tauri webview. -- Fix: never use `page.goto()` for Tauri startup/reset. Use stable UI waits, UI-driven navigation, or in-webview `window.location.assign('/')` / `window.location.reload()` via `evaluate()`. - -7. `Failed to load config ... database is locked` during E2E state reset - - - Meaning: test cleanup deleted or mutated the config DB while the app had already started and still had it open, or multiple processes shared the same config DB. - - Fix: scope `SPROUTGIT_CONFIG_DB_PATH` per run, keep workers at `1`, and reset the config DB before launching the next test's app process. - -8. Full suite flakes when using hard reload in every `beforeEach` - -- Meaning: forcing `window.location.assign('/')` / `window.location.reload()` before each spec can be less stable than UI-driven navigation in `tauri` mode when tests share one long-lived app process. -- Fix: use a persistent app process, reset disk state between tests, and use an `ensureHome()` helper that clicks back to the project list and waits for stable home-screen test IDs. Keep hard reloads as a targeted debugging tool, not the suite default. - -9. `beforeEach` times out before the first assertion on Windows - -- Meaning: the suite is spending its timeout budget deleting files or waiting on teardown after the app already launched. -- Fix: move reset into the fixture so config/workspace cleanup happens before `TauriProcessManager.start()`. diff --git a/docs/worktree-hooks.md b/docs/worktree-hooks.md deleted file mode 100644 index 439b789..0000000 --- a/docs/worktree-hooks.md +++ /dev/null @@ -1,298 +0,0 @@ -# Worktree Lifecycle Hooks (Proposed) - -Revision: Initial draft -Status: Proposed -Owner: SproutGit core - -## Goal - -Allow each SproutGit project to define local automation hooks that run on worktree lifecycle events. - -Examples: - -- Create config files -- Install dependencies -- Start/stop Docker services -- Run database migrations -- Create/remove local web server config (IIS/Apache/Nginx) - -These hooks are user-local and must not be version-controlled. - -## Scope - -### In Scope - -- Persist hook definitions in workspace SQLite (`/.sproutgit/state.db`) -- Bind hooks to a repository workspace (shared across all worktrees in that workspace) -- Support lifecycle triggers for worktree create/remove -- Allow parallel execution for independent hooks -- Provide per-OS script language selection: - - Linux: `bash` - - macOS: `zsh` - - Windows: `pwsh` (PowerShell Core) -- Provide syntax-highlighted hook editor in-app - -### Out of Scope (Initial) - -- Version-controlling hook definitions -- Global hooks across multiple repositories -- Distributed execution / remote runners -- Privileged escalation workflows - -## Trigger Model - -Initial trigger set: - -- `before_worktree_create` -- `after_worktree_create` -- `before_worktree_remove` -- `after_worktree_remove` -- `before_worktree_switch` -- `after_worktree_switch` -- `manual` - -Execution semantics: - -- `before_*` hooks can block the operation on failure (policy-controlled) -- `after_*` hooks run after Git operation completes -- `after_*` failures do not roll back Git operations, but are surfaced clearly -- `manual` hooks run only when explicitly invoked from a worktree row - -## SQLite Strategy (Decided) - -Use two SQLite databases, both managed through ORM repositories and migrations: - -1. User-profile config DB (global app state) - -- Stores app-level settings like recent workspaces -- Suggested location: - - macOS: `~/Library/Application Support/SproutGit/config.db` - - Linux: `$XDG_CONFIG_HOME/SproutGit/config.db` (fallback `~/.config/SproutGit/config.db`) - - Windows: `%APPDATA%/SproutGit/config.db` - -2. Workspace DB (repo-scoped state) - -- Existing location: `/.sproutgit/state.db` -- Stores hook definitions, dependencies, and run history for that workspace - -Decision: ORM is not limited to hooks. New and existing SQLite-backed features should move to ORM-backed repositories over time. - -## Data Model (SQLite via ORM) - -Requirement: introduce an ORM layer for this feature instead of raw SQL ad-hoc access. - -Recommended path: - -- Use `sea-orm` + `sea-query` for cross-platform SQLite support and ergonomic migrations - -Why: - -- Better schema evolution than hand-maintained SQL strings -- Typed entities for safer command payload handling -- Easier testing/mocking for hook CRUD and execution logs - -### User-profile tables - -`recent_workspaces` - -- `workspace_path` TEXT PRIMARY KEY -- `last_opened_at` INTEGER NOT NULL - -`app_settings` - -- `key` TEXT PRIMARY KEY -- `value` TEXT NOT NULL - -### Workspace tables - -`hook_definitions` - -- `id` TEXT PRIMARY KEY -- `name` TEXT NOT NULL -- `scope` TEXT NOT NULL (`worktree` | `workspace`) -- `trigger` TEXT NOT NULL -- `shell` TEXT NOT NULL (`bash` | `zsh` | `pwsh`) -- `script` TEXT NOT NULL -- `enabled` INTEGER NOT NULL DEFAULT 1 -- `critical` INTEGER NOT NULL DEFAULT 0 -- `timeout_seconds` INTEGER NOT NULL DEFAULT 600 -- `created_at` INTEGER NOT NULL -- `updated_at` INTEGER NOT NULL - -`hook_dependencies` - -- `hook_id` TEXT NOT NULL -- `depends_on_hook_id` TEXT NOT NULL -- PRIMARY KEY (`hook_id`, `depends_on_hook_id`) - -`hook_dependency_closure` (optional cache table) - -- `hook_id` TEXT NOT NULL -- `depends_on_hook_id` TEXT NOT NULL -- `depth` INTEGER NOT NULL -- PRIMARY KEY (`hook_id`, `depends_on_hook_id`) - -`hook_runs` - -- `id` TEXT PRIMARY KEY -- `hook_id` TEXT NOT NULL -- `trigger` TEXT NOT NULL -- `worktree_path` TEXT NOT NULL -- `status` TEXT NOT NULL (`success` | `failed` | `skipped` | `timed_out`) -- `started_at` INTEGER NOT NULL -- `finished_at` INTEGER NULL -- `exit_code` INTEGER NULL -- `stdout_snippet` TEXT NULL -- `stderr_snippet` TEXT NULL -- `error_message` TEXT NULL - -Indexes: - -- `idx_hook_definitions_trigger_enabled` -- `idx_hook_dependencies_depends_on` -- `idx_hook_runs_hook_started_at` -- `idx_hook_runs_worktree_started_at` - -Notes: - -- `workspace_path` is not required in `hook_definitions` because this DB is already workspace-local. -- Dependencies support multi-dependency AND semantics: a hook can run only after all listed dependencies succeed (or are skipped as allowed by policy). -- Use recursive CTE queries for dependency traversal/cycle detection. No non-portable SQLite extensions are required for the base DAG model. - -## Execution Model - -### Runtime shell selection - -Use host OS to decide supported shell. Hook contents are written to a temporary script file, and SproutGit executes that file with the selected shell: - -- Linux: execute with `bash ` -- macOS: execute with `zsh ` -- Windows: execute with `pwsh -NoLogo -NoProfile -NonInteractive -File ` - -This is file-based execution, not inline `-c`/`-Command` execution, so hook authors should not rely on inline-shell quoting behavior. Relative paths should be evaluated based on the hook process working directory, not the temporary script file location. - -If shell executable is unavailable, fail with explicit remediation guidance. - -### Parallelism and Dependency Tree - -Hooks run concurrently by default once dependency requirements are satisfied. - -Rules: - -- A hook is runnable only when all dependencies in `hook_dependencies` are completed successfully (AND semantics) -- For non-critical dependencies that fail, downstream hooks are still allowed to run -- Reject invalid dependency graphs (self-cycle or graph cycle) -- Preserve deterministic ordering among currently-runnable hooks by `name ASC` - -Suggested behavior for `before_*` triggers: - -- Run all groups -- If any critical hook fails, operation is rejected - -Suggested behavior for `after_*` triggers: - -- Run hooks and collect results -- Always warning-only by default -- Never mutate Git state to attempt rollback - -### Environment passed to hooks - -Provide minimal, explicit environment variables: - -- `SPROUTGIT_WORKSPACE_PATH` -- `SPROUTGIT_WORKSPACE_NAME` -- `SPROUTGIT_ROOT_PATH` -- `SPROUTGIT_WORKTREES_PATH` -- `SPROUTGIT_WORKTREE_PATH` -- `SPROUTGIT_WORKTREE_NAME` -- `SPROUTGIT_WORKTREE_BRANCH` -- `SPROUTGIT_WORKTREE_HEAD` -- `SPROUTGIT_WORKTREE_HEAD_SHORT` -- `SPROUTGIT_WORKTREE_DETACHED` -- `SPROUTGIT_TRIGGER` -- `SPROUTGIT_TRIGGER_PHASE` -- `SPROUTGIT_TRIGGER_ACTION` -- `SPROUTGIT_SOURCE_REF` (set for worktree create triggers when available) -- `SPROUTGIT_HOOK_ID` -- `SPROUTGIT_HOOK_NAME` -- `SPROUTGIT_HOOK_SCOPE` -- `SPROUTGIT_HOOK_SHELL` -- `SPROUTGIT_HOOK_CRITICAL` -- `SPROUTGIT_HOOK_TIMEOUT_SECONDS` -- `SPROUTGIT_OS` - -Do not pass secrets by default. - -## UI and Editor - -Add a `Hooks` management surface in the workspace UI: - -- List hooks by trigger -- Enable/disable toggles -- Critical toggle -- Timeout input -- Dependency editor (select one or more hook dependencies) -- Run-now action from a worktree row for enabled hooks -- Last run status and logs - -Editor requirements: - -- Use Monaco editor with syntax highlighting based on shell (`shell` for bash/zsh, `powershell` for pwsh) -- Show shell-specific script template snippets for quick start - -## Security and Safety - -This feature executes user-defined scripts locally and is high risk by design. - -Guardrails: - -- Clear warning: hooks execute arbitrary code -- Explicit per-hook confirmation for first run -- Timeout enforcement with process kill -- Output truncation in logs -- No implicit elevation (no admin/sudo prompts managed by SproutGit) -- Strict trigger payload validation (paths, trigger enum) - -Non-goals: - -- Sandboxing scripts in initial implementation - -## Failure Policy (Decided) - -- `before_*`: critical hooks gate operation; non-critical hooks do not gate -- `after_*`: warning-only by default (non-blocking) -- Force remove policy: bypass only failing non-critical hooks; critical failures still block -- timeout default: 10 minutes -- max captured output per stream: 64 KB - -## Observability - -- Persist run records in `hook_runs` -- Surface current run progress in UI -- Keep last N runs per hook (e.g. 200), with periodic pruning - -## Remaining Open Questions - -1. Should users be allowed to cancel long-running hooks from the UI? -2. Should global machine-wide hooks ever be supported, or should hooks remain workspace-only? - -## Rollout Plan - -### Phase 1: Persistence + CRUD - -- Add ORM and migrations for both config DB and workspace DB -- Move existing app-level state (recent workspaces/settings) to user-profile config DB -- Add hook CRUD APIs (list/create/update/delete/toggle) -- Add Monaco-powered hooks editor - -### Phase 2: Execution Engine - -- Implement trigger orchestration for create/remove -- Add timeout, logging, and status reporting -- Add test-run action in UI - -### Phase 3: Reliability - -- Add retry policy (optional, opt-in) -- Add richer log viewer and filtering -- Add diagnostics export for failed hook runs diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts deleted file mode 100644 index 578750b..0000000 --- a/e2e/fixtures.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { test as base } from '@playwright/test'; -import { - PluginClient, - TauriPage, - TauriProcessManager, - tauriExpect as expect, -} from '@srsholmes/tauri-playwright'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { resetConfigDb, resetTestDirs } from './helpers/fixtures'; - -const MCP_SOCKET = - process.env.SPROUTGIT_PLAYWRIGHT_SOCKET_PATH ?? join(tmpdir(), 'sproutgit-playwright.sock'); -const TCP_PORT = Number.parseInt(process.env.SPROUTGIT_PLAYWRIGHT_TCP_PORT ?? '6274', 10) || 6274; -const TAURI_COMMAND = process.env.SPROUTGIT_E2E_TAURI_COMMAND; -const TAURI_CWD = process.env.SPROUTGIT_E2E_TAURI_CWD; -const IS_WINDOWS = process.platform === 'win32'; - -function parseCommandSpec(spec: string): { command: string; args: string[] } { - const tokens: string[] = []; - const tokenPattern = /[^\s"']+|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g; - - for (const match of spec.matchAll(tokenPattern)) { - const raw = match[0]; - if (!raw) continue; - - const isDoubleQuoted = raw.startsWith('"') && raw.endsWith('"'); - const isSingleQuoted = raw.startsWith("'") && raw.endsWith("'"); - const unwrapped = isDoubleQuoted || isSingleQuoted ? raw.slice(1, -1) : raw; - const value = unwrapped.replace(/\\([\\"'])/g, '$1'); - tokens.push(value); - } - - if (tokens.length === 0) { - throw new Error('SPROUTGIT_E2E_TAURI_COMMAND is set but could not be parsed.'); - } - - return { - command: tokens[0], - args: tokens.slice(1), - }; -} - -type Fixtures = { - mode: 'tauri'; - _resetE2EState: void; - tauriPage: TauriPage; -}; - -export const test = base.extend({ - mode: ['tauri', { option: true }], - _resetE2EState: [ - async ({}, use) => { - // Reset disk state before the app launches so startup code never races - // the config DB deletion or workspace directory cleanup. - resetConfigDb(); - resetTestDirs(); - await use(); - }, - { auto: true }, - ], - tauriPage: async ({ mode, _resetE2EState }, use) => { - if (mode !== 'tauri') { - throw new Error(`Unsupported E2E mode: ${mode}`); - } - - void _resetE2EState; - - let processManager: TauriProcessManager | null = null; - let client: PluginClient | null = null; - let tauriPage: TauriPage | null = null; - - try { - if (TAURI_COMMAND) { - const { command, args } = parseCommandSpec(TAURI_COMMAND); - processManager = new TauriProcessManager({ - command, - args, - cwd: TAURI_CWD, - socketPath: IS_WINDOWS ? undefined : MCP_SOCKET, - tcpPort: TCP_PORT, - startTimeout: 120, - }); - - const connection = await processManager.start(); - client = connection.tcpPort - ? new PluginClient(undefined, connection.tcpPort) - : new PluginClient(connection.socketPath ?? MCP_SOCKET, undefined); - } else { - if (!IS_WINDOWS) { - const waitManager = new TauriProcessManager({ socketPath: MCP_SOCKET }); - await waitManager.waitForSocket(30_000); - } - - client = IS_WINDOWS - ? new PluginClient(undefined, TCP_PORT) - : new PluginClient(MCP_SOCKET, undefined); - } - - await client.connect(); - const ping = await client.send({ type: 'ping' }); - if (!ping.ok) { - throw new Error('Plugin ping failed'); - } - - tauriPage = new TauriPage(client); - await use(tauriPage); - } finally { - // Kill all PTY terminal sessions and stop the file watcher before - // disconnecting. On Windows, PowerShell processes hold directory handles - // on their CWD (worktree paths). If these processes are still alive when - // the next test's resetTestDirs() runs, rmSync will fail with EBUSY. - // - // This must happen regardless of whether processManager is set (i.e. it - // applies to both the spawned-process mode and the shared pre-built app - // mode used in CI). In shared-app mode processManager is null so the - // taskkill block below is skipped, making this the only cleanup path. - if (tauriPage) { - try { - await tauriPage.evaluate(` - (async () => { - const invoke = window.__TAURI_INTERNALS__?.invoke; - if (typeof invoke !== 'function') { - return; - } - - try { - await invoke('close_all_terminals'); - } catch { - // Ignore teardown errors for best-effort cleanup. - } - })() - `); - await tauriPage.evaluate(` - (async () => { - const invoke = window.__TAURI_INTERNALS__?.invoke; - if (typeof invoke !== 'function') { - return; - } - - try { - await invoke('stop_watching_worktrees'); - } catch { - // Ignore teardown errors for best-effort cleanup. - } - })() - `); - // Give the OS a moment to release file handles after PTY termination. - await new Promise((resolve) => setTimeout(resolve, 500)); - } catch { - // Ignore teardown errors — the app may already be in a bad state. - } - } - - client?.disconnect(); - - // On Windows, processManager.stop() calls TerminateProcess() on the Tauri - // parent process only. Child processes spawned by the Tauri app (e.g. - // PowerShell hook terminals) are NOT in the same Windows Job Object and - // are NOT killed — they become orphaned with their CWD still pointing at - // worktree directories, causing EBUSY on rmSync in the next test's reset. - // - // Fix: use `taskkill /F /T /PID` to kill the entire process tree before - // calling stop(), so all child processes release their directory handles. - if (IS_WINDOWS && processManager) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const pid = (processManager as any).process?.pid as number | undefined; - if (pid) { - try { - const { execSync } = await import('node:child_process'); - execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore' }); - } catch { - // Process may already have exited — ignore - } - } - } - - processManager?.stop(); - // Short grace period for the OS to fully release file handles after the - // process tree is terminated. taskkill /F /T is synchronous so 500ms is - // sufficient; the previous 2s was compensating for orphaned children that - // are now killed above. - await new Promise((resolve) => setTimeout(resolve, 500)); - } - }, -}); - -export { expect }; diff --git a/e2e/global.setup.mjs b/e2e/global.setup.mjs deleted file mode 100644 index 2848d40..0000000 --- a/e2e/global.setup.mjs +++ /dev/null @@ -1,65 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { resolve } from 'node:path'; - -function binaryName() { - return process.platform === 'win32' ? 'SproutGit.exe' : 'SproutGit'; -} - -function resolveBuiltBinaryPath() { - const name = binaryName(); - - const candidates = [ - process.env.SPROUTGIT_E2E_TAURI_COMMAND, - process.env.CARGO_TARGET_DIR ? resolve(process.env.CARGO_TARGET_DIR, 'release', name) : null, - resolve(process.cwd(), 'src-tauri', 'target', 'release', name), - process.platform === 'darwin' - ? resolve(homedir(), 'Library', 'Caches', 'SproutGit', 'cargo-target', 'release', name) - : null, - process.platform === 'linux' - ? resolve(homedir(), '.cache', 'sproutgit', 'cargo-target', 'release', name) - : null, - ].filter(Boolean); - - for (const candidate of candidates) { - if (existsSync(candidate)) { - return candidate; - } - } - - return null; -} - -export default async function globalSetup() { - if (process.env.SPROUTGIT_E2E_SKIP_BUILD === '1') { - console.warn('[e2e] Skipping tauri build (SPROUTGIT_E2E_SKIP_BUILD=1).'); - const existingBinary = resolveBuiltBinaryPath(); - if (!existingBinary) { - throw new Error( - 'SPROUTGIT_E2E_SKIP_BUILD=1 was set, but no built SproutGit binary was found. Run `pnpm run test:e2e:build` first.' - ); - } - process.env.SPROUTGIT_E2E_TAURI_COMMAND = existingBinary; - process.env.SPROUTGIT_E2E_TAURI_CWD = process.cwd(); - console.warn(`[e2e] Using existing built app: ${existingBinary}`); - return; - } - - console.warn('[e2e] Running one-time e2e build before Playwright tests...'); - execFileSync('pnpm', ['run', 'test:e2e:build'], { - stdio: 'inherit', - env: process.env, - }); - - const builtBinary = resolveBuiltBinaryPath(); - if (!builtBinary) { - throw new Error( - 'Build completed but no SproutGit release binary could be located for E2E launch.' - ); - } - - process.env.SPROUTGIT_E2E_TAURI_COMMAND = builtBinary; - process.env.SPROUTGIT_E2E_TAURI_CWD = process.cwd(); - console.warn(`[e2e] Using built app: ${builtBinary}`); -} diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..3ed95a2 --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,120 @@ +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { execSync } from 'child_process'; + +/** 15-second baseline for element assertions (matches waitforTimeout in wdio.conf.ts). */ +export const E2E_TIMEOUT_MS = 15_000; + +/** + * Navigate to a hash route inside the already-loaded Electron page. + * Manipulates location directly (same as the old Playwright helper) and waits + * one tick for TanStack Router to process the hashchange. + */ +export async function gotoHash(hash: string): Promise { + await browser.execute((h: string) => { + window.location.hash = h; + }, hash); + await browser.pause(200); +} + +/** Navigate back to the home route. */ +export async function goHome(): Promise { + await gotoHash('/'); +} + +/** Bootstrap a minimal git repo with one commit and return its path. */ +export function createTestRepo(name = 'repo'): string { + const dir = mkdtempSync(join(tmpdir(), `sg-e2e-${name}-`)); + execSync('git init', { cwd: dir }); + execSync('git config user.email "test@example.com"', { cwd: dir }); + execSync('git config user.name "Test User"', { cwd: dir }); + execSync('echo "# test" > README.md', { cwd: dir }); + execSync('git add .', { cwd: dir }); + execSync('git commit -m "init"', { cwd: dir }); + return dir; +} + +/** Remove a temporary repo directory. */ +export function cleanupRepo(dir: string): void { + rmSync(dir, { recursive: true, force: true }); +} + +/** + * Close a workspace's SQLite DB in the main process and navigate home. + * Call this before cleanupRepo() on Windows to release the file lock on + * .sproutgit/state.db before attempting to delete the directory. + */ +export async function closeAndCleanup(workspacePath: string): Promise { + // browser.execute() does NOT await Promises — use executeAsync so the + // WebDriver call blocks until the IPC round-trip actually completes and + // the SQLite connection is closed before we attempt to delete on Windows. + await browser.executeAsync( + (p: string, done: (err?: string) => void) => { + (window as unknown as { api: { closeWorkspace: (path: string) => Promise } }) + .api.closeWorkspace(p) + .then(() => done(), (e: unknown) => done(String(e))); + }, + workspacePath + ); + await goHome(); + cleanupRepo(workspacePath); +} + +// ── Toast helpers ───────────────────────────────────────────────────────────── + +/** Wait for a toast with the given variant to appear. */ +export async function waitForToast(variant: 'success' | 'error' | 'info'): Promise { + await expect( + $(`[data-testid="toast"][data-toast-variant="${variant}"]`) + ).toBeDisplayed({ message: `Expected a "${variant}" toast to appear` }); +} + +/** + * Install a MutationObserver in the renderer that records every error toast + * text that appears during the test. Call the returned function at the end of + * the test to assert no unexpected error toasts were shown. + * + * Usage: + * const assertNoErrors = monitorErrors(); + * // ... test body ... + * await assertNoErrors(); + */ +export function monitorErrors(): () => Promise { + void browser.execute(() => { + // Use a namespaced key so monitors from different tests don't clash. + (window as unknown as Record)['__sgErrorToasts'] = []; + const observer = new MutationObserver(mutations => { + for (const mutation of mutations) { + for (const node of Array.from(mutation.addedNodes)) { + if (node instanceof HTMLElement) { + const toasts = [ + ...(node.matches('[data-toast-variant="error"]') ? [node] : []), + ...Array.from(node.querySelectorAll('[data-toast-variant="error"]')), + ]; + for (const t of toasts) { + ((window as unknown as Record)['__sgErrorToasts'] as string[]) + .push(t.textContent?.trim() ?? ''); + } + } + } + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + (window as unknown as Record)['__sgErrorObserver'] = observer; + }); + + return async () => { + const errors = await browser.execute(() => { + const obs = (window as unknown as Record)['__sgErrorObserver'] as MutationObserver | undefined; + obs?.disconnect(); + return ((window as unknown as Record)['__sgErrorToasts'] as string[] | undefined) ?? []; + }) as string[]; + + if (errors.length > 0) { + throw new Error( + `Unexpected error toast(s) appeared:\n ${errors.map(m => `• ${m}`).join('\n ')}` + ); + } + }; +} diff --git a/e2e/helpers/benchmark-repos.ts b/e2e/helpers/benchmark-repos.ts deleted file mode 100644 index 09c831d..0000000 --- a/e2e/helpers/benchmark-repos.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { join } from 'node:path'; - -import { - CANARIES_DIR, - checkout, - cloneCanaryRepo, - commitAll, - createAnnotatedTag, - createTestRepo, - mergeNoFastForward, - runGit, - writeRepoFile, -} from './fixtures'; - -export const HERO_SNAPSHOT_TAG = 'v1.0.0'; - -export function createHeroMediaRepo() { - const repoPath = createTestRepo('axiom', { - files: { - 'src/index.ts': 'export { createApp } from "./app";\n', - 'src/app.ts': 'export function createApp() { return { version: "0.1.0" }; }\n', - 'src/auth/index.ts': 'export { authenticate } from "./jwt";\n', - 'src/auth/jwt.ts': 'export function authenticate(token: string) { return !!token; }\n', - 'src/api/client.ts': 'export const BASE_URL = "https://api.axiom.dev";\n', - 'src/ui/dashboard.tsx': 'export function Dashboard() { return
Dashboard
; }\n', - 'src/ui/components/Button.tsx': - 'export function Button({ label }: { label: string }) { return ; }\n', - 'src/utils/helpers.ts': 'export const noop = () => {};\n', - 'src/config.ts': 'export const config = { env: "development", debug: true };\n', - 'docs/README.md': '# Axiom\n\nA modern TypeScript application framework.\n', - 'docs/api.md': '# API Reference\n\nSee source for details.\n', - 'tests/smoke.test.ts': 'describe("smoke", () => { it("works", () => {}); });\n', - '.github/workflows/ci.yml': - 'name: CI\non: [push, pull_request]\njobs:\n test:\n runs-on: ubuntu-latest\n steps: []\n', - }, - extraCommits: 1, - }); - - // ── feature/auth ──────────────────────────────────────────────────────────── - checkout(repoPath, 'feature/auth', true); - writeRepoFile( - repoPath, - 'src/auth/jwt.ts', - 'export function authenticate(token: string) {\n' + - ' if (!token) throw new Error("Missing token");\n' + - ' return { valid: true, sub: "user:1" };\n' + - '}\n' - ); - commitAll(repoPath, 'feat(auth): add JWT validation with error handling', 2); - writeRepoFile( - repoPath, - 'src/auth/refresh.ts', - 'export function refreshToken(token: string) { return token; }\n' - ); - commitAll(repoPath, 'feat(auth): add refresh token support', 3); - writeRepoFile( - repoPath, - 'tests/auth.test.ts', - 'describe("auth", () => { it("validates JWT", () => {}); });\n' - ); - commitAll(repoPath, 'test(auth): add JWT unit tests', 4); - checkout(repoPath, 'main'); - mergeNoFastForward(repoPath, 'feature/auth', 'Merge feature/auth: JWT validation', 20); - - // ── feature/dashboard ─────────────────────────────────────────────────────── - checkout(repoPath, 'feature/dashboard', true); - writeRepoFile( - repoPath, - 'src/ui/dashboard.tsx', - 'export function Dashboard() {\n' + - ' return (\n' + - '
\n' + - '

Dashboard

\n' + - ' \n' + - '
\n' + - ' );\n' + - '}\n' - ); - commitAll(repoPath, 'feat(ui): redesign dashboard with metrics panel', 5); - writeRepoFile( - repoPath, - 'src/ui/components/MetricsPanel.tsx', - 'export function MetricsPanel() { return
; }\n' - ); - commitAll(repoPath, 'feat(ui): add MetricsPanel component', 6); - writeRepoFile( - repoPath, - 'src/ui/theme.ts', - 'export const theme = { primary: "#1a8a5c", surface: "#fff" };\n' - ); - commitAll(repoPath, 'feat(ui): add design token configuration', 7); - writeRepoFile( - repoPath, - 'tests/dashboard.test.tsx', - 'describe("Dashboard", () => { it("renders", () => {}); });\n' - ); - commitAll(repoPath, 'test(ui): add dashboard snapshot tests', 8); - checkout(repoPath, 'main'); - mergeNoFastForward(repoPath, 'feature/dashboard', 'Merge feature/dashboard: redesign', 21); - - // ── hotfix/token-expiry ───────────────────────────────────────────────────── - checkout(repoPath, 'hotfix/token-expiry', true); - writeRepoFile( - repoPath, - 'src/auth/jwt.ts', - 'export function authenticate(token: string) {\n' + - ' if (!token) throw new Error("Missing token");\n' + - ' if (isExpired(token)) throw new Error("Token expired");\n' + - ' return { valid: true, sub: "user:1" };\n' + - '}\n' + - 'export function isExpired(_token: string) { return false; }\n' - ); - commitAll(repoPath, 'fix: handle expired JWT tokens gracefully', 9); - writeRepoFile( - repoPath, - 'tests/auth.test.ts', - 'describe("auth", () => {\n' + - ' it("validates JWT", () => {});\n' + - ' it("rejects expired tokens", () => {});\n' + - '});\n' - ); - commitAll(repoPath, 'test: add token expiry regression test', 10); - checkout(repoPath, 'main'); - mergeNoFastForward( - repoPath, - 'hotfix/token-expiry', - 'Merge hotfix/token-expiry: expired JWT patch', - 22 - ); - - // ── release/1.0 tag ───────────────────────────────────────────────────────── - checkout(repoPath, 'release/1.0', true); - writeRepoFile( - repoPath, - 'src/index.ts', - 'export { createApp } from "./app";\nexport const VERSION = "1.0.0";\n' - ); - commitAll(repoPath, 'chore: bump version to 1.0.0', 33); - createAnnotatedTag(repoPath, HERO_SNAPSHOT_TAG, 'Release 1.0.0 — stable auth and dashboard', 34); - checkout(repoPath, 'main'); - - // ── feature/notifications ─────────────────────────────────────────────────── - checkout(repoPath, 'feature/notifications', true); - writeRepoFile( - repoPath, - 'src/notifications/index.ts', - 'export function notify(msg: string) { console.log(msg); }\n' - ); - commitAll(repoPath, 'feat: add push notification service', 14); - writeRepoFile( - repoPath, - 'src/notifications/preferences.ts', - 'export const defaultPrefs = { email: true, push: true };\n' - ); - commitAll(repoPath, 'feat: add notification preference model', 15); - writeRepoFile( - repoPath, - 'tests/notifications.test.ts', - 'describe("notifications", () => { it("sends", () => {}); });\n' - ); - commitAll(repoPath, 'test: add notification unit tests', 16); - checkout(repoPath, 'main'); - mergeNoFastForward(repoPath, 'feature/notifications', 'Merge feature/notifications', 23); - - // ── chore/deps ────────────────────────────────────────────────────────────── - checkout(repoPath, 'chore/deps', true); - writeRepoFile(repoPath, 'package.json', '{ "name": "axiom", "version": "1.1.0-next" }\n'); - commitAll(repoPath, 'chore(deps): upgrade TypeScript to 5.4', 17); - writeRepoFile( - repoPath, - 'vite.config.ts', - 'import { defineConfig } from "vite";\nexport default defineConfig({});\n' - ); - commitAll(repoPath, 'chore(deps): upgrade Vite to 5.3', 18); - checkout(repoPath, 'main'); - mergeNoFastForward(repoPath, 'chore/deps', 'Merge chore/deps: dependency upgrades', 24); - - // ── post-merge main commits ────────────────────────────────────────────────── - writeRepoFile( - repoPath, - 'docs/CHANGELOG.md', - '# Changelog\n\n## v1.1.0\n- Push notifications\n- Dependency upgrades\n' - ); - commitAll(repoPath, 'docs: update CHANGELOG for v1.1.0', 35); - writeRepoFile( - repoPath, - 'src/config.ts', - 'export const config = { env: "production", debug: false };\n' - ); - commitAll(repoPath, 'chore: switch default env to production', 36); - createAnnotatedTag(repoPath, 'v1.1.0', 'Release 1.1.0 — notifications and dep upgrades', 37); - - // ── Open feature branches (not merged) ────────────────────────────────────── - - checkout(repoPath, 'feature/api-v2', true); - writeRepoFile( - repoPath, - 'src/api/v2/client.ts', - 'export const API_V2 = "https://api.axiom.dev/v2";\n' - ); - commitAll(repoPath, 'feat(api): scaffold v2 client module', 11); - writeRepoFile( - repoPath, - 'src/api/v2/schema.ts', - 'export type ApiResponse = { data: T; meta: object };\n' - ); - commitAll(repoPath, 'feat(api): add v2 response schema types', 12); - writeRepoFile( - repoPath, - 'src/api/v2/batch.ts', - 'export function batch(requests: unknown[]) { return requests; }\n' - ); - commitAll(repoPath, 'feat(api): add request batching support', 13); - checkout(repoPath, 'main'); - - checkout(repoPath, 'feature/dark-mode', true); - writeRepoFile( - repoPath, - 'src/ui/theme.ts', - 'export const theme = { primary: "#1a8a5c", surface: "#fff" };\n' + - 'export const darkTheme = { primary: "#74c7a4", surface: "#1e1e2e" };\n' - ); - commitAll(repoPath, 'feat: implement system-level dark mode', 19); - writeRepoFile( - repoPath, - 'src/ui/useTheme.ts', - 'export function useTheme() {\n return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";\n}\n' - ); - commitAll(repoPath, 'feat: add useTheme hook for automatic dark mode', 26); - writeRepoFile( - repoPath, - 'tests/theme.test.ts', - 'describe("theme", () => { it("detects dark mode", () => {}); });\n' - ); - commitAll(repoPath, 'test: add dark mode detection unit tests', 27); - checkout(repoPath, 'main'); - - checkout(repoPath, 'feat/settings', true); - writeRepoFile( - repoPath, - 'src/ui/settings/index.tsx', - 'export function SettingsPage() { return
Settings
; }\n' - ); - commitAll(repoPath, 'feat: add user settings page skeleton', 28); - writeRepoFile( - repoPath, - 'src/ui/settings/storage.ts', - 'export const saveSettings = (s: object) => localStorage.setItem("settings", JSON.stringify(s));\n' - ); - commitAll(repoPath, 'feat: persist settings to localStorage', 29); - writeRepoFile( - repoPath, - 'tests/settings.test.ts', - 'describe("settings", () => { it("persists", () => {}); });\n' - ); - commitAll(repoPath, 'test: add settings integration tests', 30); - checkout(repoPath, 'main'); - - checkout(repoPath, 'docs/api-reference', true); - writeRepoFile( - repoPath, - 'docs/api.md', - '# API Reference\n\n## createApp()\n\nCreates an Axiom application instance.\n\n```ts\nimport { createApp } from "axiom";\nconst app = createApp();\n```\n' - ); - commitAll(repoPath, 'docs: write comprehensive API reference guide', 31); - writeRepoFile( - repoPath, - 'docs/examples/quickstart.ts', - 'import { createApp } from "axiom";\nconst app = createApp();\napp.listen(3000);\n' - ); - commitAll(repoPath, 'docs: add quickstart code examples', 32); - checkout(repoPath, 'main'); - - return repoPath; -} - -export function createGraphStressRepo() { - const repoPath = createTestRepo('stress-graph', { extraCommits: 2 }); - - for (const [offset, branch] of ['feature/a', 'feature/b', 'feature/c'].entries()) { - checkout(repoPath, branch, true); - writeRepoFile(repoPath, `${branch.replace('/', '-')}.txt`, `${branch}\n`); - commitAll(repoPath, `Advance ${branch}`, 30 + offset); - checkout(repoPath, 'main'); - } - - mergeNoFastForward(repoPath, 'feature/a', 'Merge feature/a', 40); - mergeNoFastForward(repoPath, 'feature/b', 'Merge feature/b', 41); - mergeNoFastForward(repoPath, 'feature/c', 'Merge feature/c', 42); - createAnnotatedTag(repoPath, 'stress-graph-v1', 'Graph stress snapshot', 43); - - return repoPath; -} - -export function createNamingEdgeCaseRepo() { - return createTestRepo('stress-naming', { - branches: [ - 'feature/with-dashes', - 'feature/with_underscores', - 'release/2026-q2', - 'bugfix/context.menu', - ], - files: { - 'src/names.txt': 'edge cases\n', - }, - }); -} - -export function createScaleStressRepo() { - const repoPath = createTestRepo('stress-scale'); - for (let index = 0; index < 120; index += 1) { - writeRepoFile( - repoPath, - `fixtures/scale/file-${String(index).padStart(3, '0')}.txt`, - `row ${index}\n` - ); - } - commitAll(repoPath, 'Add scale fixture set', 50); - return repoPath; -} - -export const CANARY_REPOS = [ - { - name: 'pnpm', - remoteUrl: 'https://github.com/pnpm/pnpm.git', - ref: process.env.CANARY_REF_PNPM || null, - }, - { - name: 'svelte-kit', - remoteUrl: 'https://github.com/sveltejs/kit.git', - ref: process.env.CANARY_REF_SVELTE_KIT || null, - }, -]; - -export function materializeCanaryRepo(definition: { - name: string; - remoteUrl: string; - ref: string | null; -}) { - return cloneCanaryRepo(join(CANARIES_DIR, definition.name), definition.remoteUrl, definition.ref); -} - -export function createAllGeneratedBenchmarks() { - return { - hero: createHeroMediaRepo(), - graph: createGraphStressRepo(), - naming: createNamingEdgeCaseRepo(), - scale: createScaleStressRepo(), - }; -} diff --git a/e2e/helpers/fixtures.ts b/e2e/helpers/fixtures.ts deleted file mode 100644 index ba3db69..0000000 --- a/e2e/helpers/fixtures.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { appendFileSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { Faker, en } from '@faker-js/faker'; - -const HERE = dirname(fileURLToPath(import.meta.url)); - -export const ROOT = resolve(HERE, '../../'); -export const TEST_DIR = process.env.SPROUTGIT_E2E_TEST_DIR ?? join(ROOT, 'tmp', 'test'); -export const REPOS_DIR = join(TEST_DIR, 'repos'); -export const CANARIES_DIR = join(TEST_DIR, 'canaries'); -export const CONFIG_DB_PATH = process.env.SPROUTGIT_CONFIG_DB_PATH; - -const BASE_GIT_ENV = { - GIT_AUTHOR_NAME: 'SproutGit Test', - GIT_AUTHOR_EMAIL: 'test@sproutgit.test', - GIT_COMMITTER_NAME: 'SproutGit Test', - GIT_COMMITTER_EMAIL: 'test@sproutgit.test', - GIT_TERMINAL_PROMPT: '0', -}; - -// Shared Faker instance — seeded per call to produce deterministic but -// organic-looking commit timestamps spread over the past ~3 months. -const _faker = new Faker({ locale: [en] }); - -function commitDate(sequence: number): string { - const SPREAD_DAYS = 90; - const MAX_SEQ = 42; - - // Linear interpolation: seq 0 = oldest (~90 days ago), higher = more recent. - const daysAgo = SPREAD_DAYS * (1 - sequence / MAX_SEQ); - - // Seed per-sequence so dates are reproducible across test runs. - _faker.seed(sequence * 8_675_309); - const refDate = new Date(Date.now() - daysAgo * 86_400_000); - // Small jitter: pick a moment within ±6 hours of the reference point. - const d = _faker.date.between({ - from: new Date(refDate.getTime() - 6 * 3_600_000), - to: new Date(refDate.getTime() + 6 * 3_600_000), - }); - // Clamp to typical working hours so commits look human. - d.setHours(_faker.number.int({ min: 9, max: 18 }), _faker.number.int({ min: 0, max: 59 }), 0, 0); - return d.toISOString(); -} - -// Produce a realistic developer name and email for a given sequence number. -// Seeded separately from commitDate so they're independent. -function authorInfo(sequence: number): { name: string; email: string } { - _faker.seed(sequence * 3_141_592 + 1); - const firstName = _faker.person.firstName(); - const lastName = _faker.person.lastName(); - const name = `${firstName} ${lastName}`; - const email = _faker.internet.email({ firstName, lastName }).toLowerCase(); - return { name, email }; -} - -function gitEnv(sequence: number) { - const date = commitDate(sequence); - const { name, email } = authorInfo(sequence); - return { - GIT_AUTHOR_NAME: name, - GIT_AUTHOR_EMAIL: email, - GIT_COMMITTER_NAME: name, - GIT_COMMITTER_EMAIL: email, - GIT_TERMINAL_PROMPT: '0', - GIT_AUTHOR_DATE: date, - GIT_COMMITTER_DATE: date, - }; -} - -export function runGit(cwd: string, args: string[], extraEnv: Record = {}) { - const env = { ...process.env }; - delete env.GIT_DIR; - delete env.GIT_WORK_TREE; - delete env.GIT_INDEX_FILE; - delete env.GIT_COMMON_DIR; - - return execFileSync('git', args, { - cwd, - env: { ...env, ...BASE_GIT_ENV, ...extraEnv }, - stdio: 'pipe', - }) - .toString() - .trim(); -} - -export function cleanupTestDirs() { - if (!existsSync(TEST_DIR)) { - return; - } - - // On Windows the notify watcher and terminal child processes can keep a - // worktree directory handle alive briefly after teardown begins. Keep trying - // with a bounded linear backoff so CI can absorb transient release latency. - const maxAttempts = process.platform === 'win32' ? 45 : 1; - const baseRetryDelayMs = 250; - const maxRetryDelayMs = 1_500; - let lastRetryableError: unknown = null; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - rmSync(TEST_DIR, { recursive: true, force: true }); - return; - } catch (err: unknown) { - if (!existsSync(TEST_DIR)) { - return; - } - const isLastAttempt = attempt === maxAttempts; - const code = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : ''; - const isRetryable = code === 'EBUSY' || code === 'EPERM' || code === 'ENOTEMPTY'; - if (!isRetryable) throw err; - lastRetryableError = err; - if (isLastAttempt) break; - // Synchronous busy-wait: sleep between retries. - const retryDelayMs = Math.min(baseRetryDelayMs * attempt, maxRetryDelayMs); - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelayMs); - } - } - - // If a handle remains stuck after retries on Windows, quarantine the current - // directory so the next test can still start with a fresh TEST_DIR. This is - // preferable to failing the whole suite on transient file-lock lag. - if (process.platform === 'win32' && existsSync(TEST_DIR)) { - const quarantinePath = `${TEST_DIR}.stale-${Date.now()}`; - try { - renameSync(TEST_DIR, quarantinePath); - return; - } catch { - // Fall through to the original failure for debugging if quarantine fails. - } - } - - if (lastRetryableError) { - throw lastRetryableError; - } -} - -export function setupTestDirs() { - mkdirSync(REPOS_DIR, { recursive: true }); - mkdirSync(CANARIES_DIR, { recursive: true }); -} - -export function resetTestDirs() { - cleanupTestDirs(); - setupTestDirs(); -} - -export function resetConfigDb() { - if (CONFIG_DB_PATH) { - const targets = [CONFIG_DB_PATH, `${CONFIG_DB_PATH}-wal`, `${CONFIG_DB_PATH}-shm`]; - const maxAttempts = process.platform === 'win32' ? 20 : 1; - const retryDelayMs = 100; - - for (const target of targets) { - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - rmSync(target, { force: true }); - break; - } catch (err: unknown) { - const isLastAttempt = attempt === maxAttempts; - const code = - err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : ''; - const isRetryable = code === 'EBUSY' || code === 'EPERM'; - if (isLastAttempt || !isRetryable) throw err; - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelayMs); - } - } - } - } -} - -export function querySqlite(dbPath: string, sql: string): string[][] { - const output = execFileSync('sqlite3', ['-separator', '\t', dbPath, sql], { - encoding: 'utf8', - stdio: 'pipe', - }).trim(); - if (!output) return []; - // Strip trailing \r from each row before splitting by \t to handle Windows - // CRLF output from the sqlite3 CLI (\n splits leave \r on intermediate rows). - return output.split('\n').map(row => row.replace(/\r$/, '').split('\t')); -} - -export function executeSqlite(dbPath: string, sql: string) { - execFileSync('sqlite3', [dbPath, sql], { - encoding: 'utf8', - stdio: 'pipe', - }); -} - -export function commitAll(repoPath: string, message: string, sequence: number) { - runGit(repoPath, ['add', '--all']); - runGit(repoPath, ['commit', '-m', message], gitEnv(sequence)); -} - -export function checkout(repoPath: string, ref: string, create = false) { - runGit(repoPath, create ? ['checkout', '-b', ref] : ['checkout', ref]); -} - -export function mergeNoFastForward( - repoPath: string, - ref: string, - message: string, - sequence: number -) { - runGit(repoPath, ['merge', '--no-ff', ref, '-m', message], gitEnv(sequence)); -} - -export function createAnnotatedTag( - repoPath: string, - tag: string, - message: string, - sequence: number -) { - runGit(repoPath, ['tag', '-a', tag, '-m', message], gitEnv(sequence)); -} - -export function writeRepoFile(repoPath: string, relativePath: string, content: string) { - const absolutePath = join(repoPath, relativePath); - mkdirSync(dirname(absolutePath), { recursive: true }); - writeFileSync(absolutePath, content); - return absolutePath; -} - -export function appendRepoFile(repoPath: string, relativePath: string, line: string) { - appendFileSync(join(repoPath, relativePath), `${line}\n`); -} - -interface CreateTestRepoOptions { - extraCommits?: number; - branches?: string[]; - files?: Record; -} - -export function createTestRepo(name: string, opts: CreateTestRepoOptions = {}) { - const { extraCommits = 0, branches = [], files = {} } = opts; - - const repoPath = join(REPOS_DIR, name); - mkdirSync(repoPath, { recursive: true }); - - runGit(repoPath, ['init', '-b', 'main']); - runGit(repoPath, ['config', 'user.email', 'test@sproutgit.test']); - runGit(repoPath, ['config', 'user.name', 'SproutGit Test']); - - writeRepoFile(repoPath, 'README.md', `# ${name}\n\nGenerated by SproutGit E2E tests.\n`); - for (const [relativePath, content] of Object.entries(files)) { - writeRepoFile(repoPath, relativePath, content); - } - commitAll(repoPath, 'Initial commit', 0); - - for (let index = 1; index <= extraCommits; index += 1) { - writeRepoFile(repoPath, `docs/note-${index}.md`, `note ${index}\n`); - commitAll(repoPath, `Add note ${index}`, index); - } - - for (const branch of branches) { - runGit(repoPath, ['branch', branch]); - } - - return repoPath; -} - -export function cloneCanaryRepo(targetPath: string, remoteUrl: string, ref: string | null = null) { - rmSync(targetPath, { recursive: true, force: true }); - runGit(TEST_DIR, ['clone', '--depth', '1', remoteUrl, targetPath]); - if (ref) { - runGit(targetPath, ['fetch', '--depth', '1', 'origin', ref]); - runGit(targetPath, ['checkout', ref]); - } - return targetPath; -} diff --git a/e2e/helpers/screenshots.ts b/e2e/helpers/screenshots.ts index be58386..9cf57ee 100644 --- a/e2e/helpers/screenshots.ts +++ b/e2e/helpers/screenshots.ts @@ -1,70 +1,101 @@ +/** + * Screenshot capture utilities for the WDIO/Electron E2E test suite. + * + * Ported from old/e2e/helpers/screenshots.ts (Tauri/Playwright). + * Adapted to use WebdriverIO browser globals (`browser`, `$`). + * + * Usage: + * import { captureNamedScreenshot, captureScreenshotVariants } + * from '../helpers/screenshots.js'; + * + * Screenshots are written to: + * - $SCREENSHOT_TARGET (or $PLAYWRIGHT_SCREENSHOT_TARGET for backward compat) + * - e2e/test-results/screenshots/ (default) + */ + import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, isAbsolute, join, resolve } from 'node:path'; -import type { TestInfo } from '@playwright/test'; -import type { BrowserPageAdapter, TauriPage } from '@srsholmes/tauri-playwright'; +// --------------------------------------------------------------------------- +// Platform detection +// --------------------------------------------------------------------------- -import { ROOT } from './fixtures'; +const PLATFORM_FOLDER = + process.platform === 'darwin' ? 'mac' : + process.platform === 'win32' ? 'windows' : + 'linux'; -function resolveTargetDir() { - const target = process.env.PLAYWRIGHT_SCREENSHOT_TARGET; - if (!target) { - return join(ROOT, 'test-results', 'screenshots'); - } - return isAbsolute(target) ? target : resolve(ROOT, target); +// --------------------------------------------------------------------------- +// Resolve output directory +// --------------------------------------------------------------------------- + +function resolveTargetDir(): string { + const env = + process.env['SCREENSHOT_TARGET'] ?? + process.env['PLAYWRIGHT_SCREENSHOT_TARGET']; + const base = env + ? (isAbsolute(env) ? env : resolve(process.cwd(), env)) + : join(resolve(__dirname, '..'), 'test-results', 'screenshots'); + // Nest under a platform subfolder so mac/windows/linux shots are separate. + return join(base, PLATFORM_FOLDER); } -function slug(value: string) { +function slug(value: string): string { return value .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } -// ── Window sizing ───────────────────────────────────────────────────────────── +// --------------------------------------------------------------------------- +// Theme forcing +// --------------------------------------------------------------------------- -/** - * Resize the Tauri window to a compact size suitable for marketing screenshots. - * Calls the `set_window_size` Tauri command (compiled in with the `e2e-testing` - * feature) so the resize is synchronous and fully awaited before returning. - */ -export async function resizeWindowForScreenshot( - tauriPage: TauriPage | BrowserPageAdapter, - width = 960, - height = 620 -) { - try { - await tauriPage.evaluate( - `window.__TAURI_INTERNALS__.invoke('set_window_size', { width: ${width}, height: ${height} })` - ); - // Brief pause so the OS has time to finish compositing the resized window - // before we capture a screenshot. - await new Promise(r => setTimeout(r, 300)); - } catch { - // Non-fatal: proceed with the current window size - } -} +// Light mode: Catppuccin Latte palette (matches styles.css :root defaults) +const LIGHT_CSS_VARS = [ + '--sg-bg:#f5f5f5', + '--sg-surface:#ffffff', + '--sg-surface-raised:#eaeaef', + '--sg-border:#d4d4dc', + '--sg-border-subtle:#e0e0e8', + '--sg-text:#1e1e2e', + '--sg-text-dim:#555568', + '--sg-text-faint:#8888a0', + '--sg-primary:#036837', + '--sg-primary-hover:#19ac5c', + '--sg-danger:#c4314b', + '--sg-warning:#9a6700', + '--sg-accent:#6ac74c', + '--sg-avatar-bg:#dff0c4', + '--sg-avatar-text:#036837', + '--sg-input-bg:#ffffff', + '--sg-input-border:#d4d4dc', + '--sg-input-focus:#036837', +].join(';'); + +// Dark mode: Catppuccin Mocha palette (matches styles.css dark media query) +const DARK_CSS_VARS = [ + '--sg-bg:#1e1e2e', + '--sg-surface:#262637', + '--sg-surface-raised:#2e2e42', + '--sg-border:#3a3a52', + '--sg-border-subtle:#32324a', + '--sg-text:#cdd6f4', + '--sg-text-dim:#8b8fad', + '--sg-text-faint:#6c7086', + '--sg-primary:#19ac5c', + '--sg-primary-hover:#6ac74c', + '--sg-danger:#f38ba8', + '--sg-warning:#f9e2af', + '--sg-accent:#92ce36', + '--sg-avatar-bg:#2b3b35', + '--sg-avatar-text:#dff0c4', + '--sg-input-bg:#1a1a2a', + '--sg-input-border:#3a3a52', + '--sg-input-focus:#19ac5c', +].join(';'); -// ── Theme forcing ──────────────────────────────────────────────────────────── - -const LIGHT_CSS_VARS = - '--sg-bg:#f5f5f5;--sg-surface:#ffffff;--sg-surface-raised:#eaeaef;' + - '--sg-border:#d4d4dc;--sg-border-subtle:#e0e0e8;--sg-text:#1e1e2e;' + - '--sg-text-dim:#555568;--sg-text-faint:#8888a0;--sg-primary:#036837;' + - '--sg-primary-hover:#19ac5c;--sg-danger:#c4314b;--sg-warning:#9a6700;' + - '--sg-accent:#6ac74c;--sg-avatar-bg:#dff0c4;--sg-avatar-text:#036837;' + - '--sg-input-bg:#ffffff;--sg-input-border:#d4d4dc;--sg-input-focus:#036837'; - -const DARK_CSS_VARS = - '--sg-bg:#1e1e2e;--sg-surface:#262637;--sg-surface-raised:#2e2e42;' + - '--sg-border:#3a3a52;--sg-border-subtle:#32324a;--sg-text:#cdd6f4;' + - '--sg-text-dim:#8b8fad;--sg-text-faint:#6c7086;--sg-primary:#19ac5c;' + - '--sg-primary-hover:#6ac74c;--sg-danger:#f38ba8;--sg-warning:#f9e2af;' + - '--sg-accent:#92ce36;--sg-avatar-bg:#2b3b35;--sg-avatar-text:#dff0c4;' + - '--sg-input-bg:#1a1a2a;--sg-input-border:#3a3a52;--sg-input-focus:#19ac5c'; - -// Catppuccin Latte (light) and Catppuccin Mocha (dark) xterm canvas themes. -// Must match the palette used in TerminalPanel.svelte. +// xterm.js canvas themes — Catppuccin Latte (light) and Mocha (dark) const LIGHT_XTERM_THEME = { background: '#eff1f5', foreground: '#4c4f69', @@ -113,130 +144,196 @@ const DARK_XTERM_THEME = { brightWhite: '#a6adc8', }; -async function forceTheme(tauriPage: TauriPage | BrowserPageAdapter, theme: 'light' | 'dark') { - const termBg = theme === 'dark' ? DARK_XTERM_THEME.background : LIGHT_XTERM_THEME.background; +async function forceTheme(theme: 'light' | 'dark'): Promise { const cssVars = theme === 'dark' ? DARK_CSS_VARS : LIGHT_CSS_VARS; - // Include a rule that forces the terminal wrapper background to match the - // selected screenshot theme so the wrapper stays in sync with the xterm canvas. - const css = JSON.stringify( - `:root{${cssVars}} [data-sg-terminal]{background-color:${termBg}!important}` - ); - await tauriPage.evaluate( - `(() => { - let el = document.getElementById('sg-forced-theme'); - if (!el) { - el = document.createElement('style'); - el.id = 'sg-forced-theme'; - document.head.appendChild(el); - } - el.textContent = ${css}; - })()` + const termBg = theme === 'dark' ? '#1e1e2e' : '#eff1f5'; + const styleContent = `:root{${cssVars}} [data-sg-terminal]{background-color:${termBg}!important}`; + + // Inject (or update) a forced-theme - %sveltekit.head% - - -
- -
Launching SproutGit
-
-
%sveltekit.body%
- - - diff --git a/src/lib/components/Autocomplete.svelte b/src/lib/components/Autocomplete.svelte deleted file mode 100644 index 9360d68..0000000 --- a/src/lib/components/Autocomplete.svelte +++ /dev/null @@ -1,144 +0,0 @@ - - -
- - - {#if open && filtered.length > 0} -
- {#each filtered as item, i} - - {/each} -
- {/if} -
diff --git a/src/lib/components/Checkbox.svelte b/src/lib/components/Checkbox.svelte deleted file mode 100644 index 8568294..0000000 --- a/src/lib/components/Checkbox.svelte +++ /dev/null @@ -1,56 +0,0 @@ - - - diff --git a/src/lib/components/CommitGraph.svelte b/src/lib/components/CommitGraph.svelte deleted file mode 100644 index af028c8..0000000 --- a/src/lib/components/CommitGraph.svelte +++ /dev/null @@ -1,830 +0,0 @@ - - -{#if laneData.rows.length === 0} -
-
-
- -
-
-

No commits yet

-

- Make your first commit to see history here. -

-
-
-
-{:else} -
- - {#if searchOpen} -
- - - {#if searchQuery.trim()} - - {matchingIndices.length > 0 - ? `${activeMatchIdx + 1}/${matchingIndices.length}` - : 'No matches'} - - - - {/if} - -
- {/if} - - -
-
- -
- - - {#each laneData.rows as row} - {#each row.parentPositions as parent} - {#if row.lane === parent.lane} - - {:else} - {@const x1 = laneX(row.lane)} - {@const x2 = laneX(parent.lane)} - {@const dy = Math.max(ROW_H, parent.y - row.y)} - {@const c = Math.max(ROW_H * 0.6, Math.min(dy * 0.3, ROW_H * 3))} - - {/if} - {/each} - - {#if row.offGraphParentCount > 0} - {@const x = laneX(row.lane)} - {@const x2 = x + COL_W * 0.75} - {@const y2 = Math.min(svgHeight - 2, row.y + ROW_H * 0.9)} - - {/if} - {/each} - - - {#each laneData.rows as row, i} - {#if row.worktreeBranch} - - - 0 && !matchSet.has(i) - ? 'var(--sg-text-faint)' - : laneColor(row.lane)} - opacity={matchSet.size > 0 && !matchSet.has(i) ? 0.3 : 1} - rx="1" - /> - - {:else} - 0 && !matchSet.has(i) - ? 'var(--sg-text-faint)' - : laneColor(row.lane)} - opacity={matchSet.size > 0 && !matchSet.has(i) ? 0.3 : 1} - /> - {/if} - {/each} - -
- - -
- {#each laneData.rows as row, i} - {@const isMatch = matchSet.has(i)} - {@const isActive = matchingIndices[activeMatchIdx] === i} - {@const dimmed = matchSet.size > 0 && !isMatch} - {@const isSelected = selectedSet.has(i)} - {@const isWtRow = !!row.worktreeBranch} -
handleCommitClick(i, e)} - onkeydown={e => handleCommitKeydown(i, e)} - oncontextmenu={e => commitContextMenu(row, e)} - > - -
- {#each row.refs as ref} - - {/each} - {row.subject} -
- - {#if row.offGraphParentCount > 0} - - ↘ +{row.offGraphParentCount} - - {/if} - - - {row.shortHash} - - - - - - -
- {/each} - - {#if loadingmore} -
- Loading older commits… -
- {:else if hasmore} -
- Scroll down to load older commits -
- {/if} -
-
-
-
-{/if} - - -{#if contextMenu} - (contextMenu = null)} - /> -{/if} diff --git a/src/lib/components/ConfirmDialog.svelte b/src/lib/components/ConfirmDialog.svelte deleted file mode 100644 index feccb0d..0000000 --- a/src/lib/components/ConfirmDialog.svelte +++ /dev/null @@ -1,64 +0,0 @@ - - - -
{ - if (e.target === e.currentTarget) oncancel(); - }} -> - -
e.stopPropagation()} - > -

{title}

-

{message}

-
- - -
-
-
diff --git a/src/lib/components/ContextMenu.svelte b/src/lib/components/ContextMenu.svelte deleted file mode 100644 index ccd81d2..0000000 --- a/src/lib/components/ContextMenu.svelte +++ /dev/null @@ -1,108 +0,0 @@ - - - -
e.preventDefault()} -> - {#each items as item} - {#if 'separator' in item && item.separator} -
- {:else} - - {/if} - {/each} -
diff --git a/src/lib/components/DiffViewer.svelte b/src/lib/components/DiffViewer.svelte deleted file mode 100644 index 00d2cb4..0000000 --- a/src/lib/components/DiffViewer.svelte +++ /dev/null @@ -1,377 +0,0 @@ - - -{#snippet diffPane()} -
- {#if loading} -
- -
- {:else if files && !selectedFile} -
- Select a file to view its diff -
- {:else if parsedDiff.length === 0} -
- No diff content (file may be binary or empty) -
- {:else} - - - {#each parsedDiff as line, idx} - {@const hl = highlightedLines.get(idx)} - {#if line.type === 'header'} - - - - - {:else if line.type === 'hunk'} - - - - - {:else if line.type === 'add'} - - - - {#if hl} - - {:else} - - {/if} - - {:else if line.type === 'del'} - - - - {#if hl} - - {:else} - - {/if} - - {:else if line.type === 'context'} - - - - {#if hl} - - {:else} - - {/if} - - {/if} - {/each} - -
{line.content}
···{line.content}
{line.newNum}+{@html hl}+{line.content}
{line.oldNum}-{@html hl}-{line.content}
{line.oldNum}{line.newNum} {@html hl} - {line.content}
- {/if} -
-{/snippet} - -{#if files} - -
- -
-
-

- Changes in - {commitLabel} - · {files.length} file{files.length !== 1 ? 's' : ''} -

- {#if commits.length === 1} -

- {commits[0].authorName} <{commits[0].authorEmail}> · {commits[0].authorDate} - {#if commits[0].subject} - — {commits[0].subject} - {/if} -

- {/if} -
- -
- -
- -
-
- {#each files as file} - - {/each} -
-
- - - {@render diffPane()} -
-
-{:else} - - {@render diffPane()} -{/if} diff --git a/src/lib/components/MonacoEditor.svelte b/src/lib/components/MonacoEditor.svelte deleted file mode 100644 index f0b3931..0000000 --- a/src/lib/components/MonacoEditor.svelte +++ /dev/null @@ -1,123 +0,0 @@ - - -
diff --git a/src/lib/components/ResizableSidebar.svelte b/src/lib/components/ResizableSidebar.svelte deleted file mode 100644 index 5aee1f0..0000000 --- a/src/lib/components/ResizableSidebar.svelte +++ /dev/null @@ -1,144 +0,0 @@ - - -
- {@render children()} - - - - - -
diff --git a/src/lib/components/Select.svelte b/src/lib/components/Select.svelte deleted file mode 100644 index d24b44b..0000000 --- a/src/lib/components/Select.svelte +++ /dev/null @@ -1,42 +0,0 @@ - - -
- - - - -
diff --git a/src/lib/components/Spinner.svelte b/src/lib/components/Spinner.svelte deleted file mode 100644 index 0121002..0000000 --- a/src/lib/components/Spinner.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - -
- - - - - {#if label} - {label} - {/if} -
diff --git a/src/lib/components/StagingPanel.svelte b/src/lib/components/StagingPanel.svelte deleted file mode 100644 index 501c138..0000000 --- a/src/lib/components/StagingPanel.svelte +++ /dev/null @@ -1,771 +0,0 @@ - - -
- -
- -
-

- Changes -

- {#if branch} - {branch} - {/if} - {#if !loading} - · {statusFiles.length === 0 - ? 'Clean' - : `${statusFiles.length} file${statusFiles.length !== 1 ? 's' : ''}`} - {/if} -
-
- - -
-
- - {#if loading} -
- -

Loading changes…

-
- {:else} -
- -
-
- -
-
-

- Staged ({stagedFiles.length}) -

- {#if stagedFiles.length > 0} - - {/if} -
- {#if stagedFiles.length === 0} -

No staged changes

- {:else} - {#each stagedFiles as file} -
- - -
- {/each} - {/if} -
- - -
-
-

- Changes ({unstagedFiles.length}) -

- {#if unstagedFiles.length > 0} - - {/if} -
- {#if unstagedFiles.length === 0} -

No unstaged changes

- {:else} - {#each unstagedFiles as file} -
- - -
- {/each} - {/if} -
-
- - - -
- - {#if commitError} -

- {commitError} -

- {/if} - -

- Ctrl+Enter to commit -

-
-
- - -
- {#if diffLoading} -
- -

Loading diff…

-
- {:else if diffFile && diffContent} -
- - {diffStaged ? 'STAGED' : 'UNSTAGED'} - - - {diffFile} - -
-
- - - {#each parsedDiff as line, idx} - {@const hl = highlightedLines.get(idx)} - {#if line.type === 'header'} - - - - - {:else if line.type === 'hunk'} - - - - - {:else if line.type === 'add'} - - - - {#if hl} - - {:else} - - {/if} - - {:else if line.type === 'del'} - - - - {#if hl} - - {:else} - - {/if} - - {:else if line.type === 'context'} - - - - {#if hl} - - {:else} - - {/if} - - {/if} - {/each} - -
{line.content}
···{line.content}
{line.newNum}+{@html hl}+{line.content}
{line.oldNum}-{@html hl}-{line.content}
{line.oldNum}{line.newNum} {@html hl} - {line.content}
-
- {:else if diffFile && !diffContent} -
-

- No diff content (file may be binary or empty) -

-
- {:else} -
-

Select a file to view changes

-
- {/if} -
-
- {/if} -
diff --git a/src/lib/components/TerminalContainer.svelte b/src/lib/components/TerminalContainer.svelte deleted file mode 100644 index d859232..0000000 --- a/src/lib/components/TerminalContainer.svelte +++ /dev/null @@ -1,846 +0,0 @@ - - -
- -
- -
- {#each sessions as session (session.id)} - {@const isActive = activeId === session.id} - {@const isDragTarget = dragToId === session.id} - -
onTabPointerDown(e, session.id)} - onpointermove={onTabPointerMove} - onpointerup={onTabPointerUp} - oncontextmenu={e => openCtxMenu(e, session.id)} - > - {#if isActive} - - {/if} - - {#if isDragTarget && dragToSide === 'before'} - - {/if} - - - {#if renamingId === session.id} - { - if (e.key === 'Enter') commitRename(); - if (e.key === 'Escape') cancelRename(); - e.stopPropagation(); - }} - onclick={e => e.stopPropagation()} - class="min-w-0 w-[100px] rounded bg-[var(--sg-input-bg)] px-2 py-1 text-[11px] font-medium text-[var(--sg-text)] outline outline-1 outline-[var(--sg-primary)] focus:outline-[var(--sg-primary)]" - /> - {:else} - - - {/if} - - - - - - {#if isDragTarget && dragToSide === 'after'} - - {/if} -
- {/each} -
- - -
- - - - - - {#if showAddMenu} -
- {#each availableShells as shell} - - {/each} -
- {/if} -
- - -
- - -
- - - -
-
- - - - {#if sessions.length === 0} -
-

No terminal sessions

-

- Click + to start one -

-
- {:else} -
- {#each sessions as session (session.id)} - -
(activeId = session.id)} - class:border-l={layout === 'split' && sessions.indexOf(session) > 0} - class:border-[var(--sg-border)]={layout === 'split' && sessions.indexOf(session) > 0} - class:border={layout === 'grid'} - class:border-[var(--sg-border-subtle)]={layout === 'grid'} - > - { - const index = sessions.findIndex(item => item.id === session.id); - if (index < 0) return; - if (sessions[index]?.ptyId === ptyId) return; - sessions = sessions.map(item => - item.id === session.id ? { ...item, ptyId } : item - ); - }} - onReady={() => { - if (activeId !== session.id && pendingReadyFocusId !== session.id) { - return; - } - tick().then(() => { - panelInstances[session.id]?.refit(); - panelInstances[session.id]?.focus(); - if (pendingReadyFocusId === session.id) { - pendingReadyFocusId = null; - } - }); - }} - onAutoClosed={() => { - void closeSession(session.id); - }} - /> -
- {/each} -
- {/if} - - {#if interactionLocked} -
- {lockReason} -
- {/if} -
- - -{#if ctxMenu} - (ctxMenu = null)} - /> -{/if} diff --git a/src/lib/components/TerminalDock.svelte b/src/lib/components/TerminalDock.svelte deleted file mode 100644 index d2f1380..0000000 --- a/src/lib/components/TerminalDock.svelte +++ /dev/null @@ -1,55 +0,0 @@ - - -
- {#each workspaceSnapshots as workspaceState (workspaceState.workspacePath)} -
- {#each workspaceState.initializedPaths as wtPath (wtPath)} -
- -
- {/each} -
- {/each} -
diff --git a/src/lib/components/TerminalPanel.svelte b/src/lib/components/TerminalPanel.svelte deleted file mode 100644 index 2f0afa5..0000000 --- a/src/lib/components/TerminalPanel.svelte +++ /dev/null @@ -1,406 +0,0 @@ - - - - -
- {#if error} -
-
-

Failed to start terminal

-

{error}

-
-
- {:else} - -
- - {#if locked} -
-
- Hook running - input locked -
- {/if} - - {#if closed} -
- Process exited — close the tab to dismiss -
- {/if} - {/if} -
diff --git a/src/lib/components/ToastContainer.svelte b/src/lib/components/ToastContainer.svelte deleted file mode 100644 index 0cb3a45..0000000 --- a/src/lib/components/ToastContainer.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - -
- {#each toasts() as t (t.id)} -
- - - - -
-

- {t.message} -

- {#if t.action} - - {/if} -
- -
- {/each} -
diff --git a/src/lib/components/UpdateBadge.svelte b/src/lib/components/UpdateBadge.svelte deleted file mode 100644 index ac6ed00..0000000 --- a/src/lib/components/UpdateBadge.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - -{#if updateState.available} - - - v{updateState.available.version} - -{/if} diff --git a/src/lib/components/WindowControls.svelte b/src/lib/components/WindowControls.svelte deleted file mode 100644 index e79788a..0000000 --- a/src/lib/components/WindowControls.svelte +++ /dev/null @@ -1,88 +0,0 @@ - - -{#if isWindows} - -{/if} - - diff --git a/src/lib/components/WorkspaceHooksModal.svelte b/src/lib/components/WorkspaceHooksModal.svelte deleted file mode 100644 index 948c0bd..0000000 --- a/src/lib/components/WorkspaceHooksModal.svelte +++ /dev/null @@ -1,1360 +0,0 @@ - - -{#if open} - - -
- - - -
- - -
e.stopPropagation()} - class="flex h-[min(78vh,760px)] w-[min(900px,96vw)] flex-col overflow-hidden rounded-xl border border-[var(--sg-border)] bg-[var(--sg-surface)] shadow-xl" - transition:scale={{ duration: 220, start: 0.97 }} - data-testid="hooks-modal" - > -
- - - - -
-

Workspace Hooks

-

- Lifecycle hooks run automatically when worktrees are created, switched, or deleted. -

-
-
- - -
-
- -
- {#if loading} -
- - Loading hooks… -
- {:else if hooks.length === 0} -
-
-

No hooks defined

-

- Create a hook to automate setup, cleanup, or branch workflow tasks. -

-
-
- {:else} -
- {#each hookSections as section} -
-
-
-

- {section.label} -

-

- Hooks are nested under the dependencies they wait for. -

-
- - {section.rows.length} hook{section.rows.length === 1 ? '' : 's'} - -
- -
- {#each section.rows as row} -
0 ? 10 : 0)}px;`} - transition:fade={{ duration: 140 }} - data-testid="hook-list-row" - > - {#if row.depth > 0} - - - - {/if} -
-
-
-

- {row.hook.name} -

-
-

- {executionTargetLabel(row.hook)} • {row.hook.timeoutSeconds}s • {row - .hook.shell} -

-
- - {row.hook.scope} - - {#if row.hook.critical} - Critical - {/if} - {#if row.hook.switchOncePerSession} - First switch only - {/if} - {#if row.hook.trigger === 'before_worktree_switch' || row.hook.trigger === 'after_worktree_switch'} - {#if row.hook.switchRunOnCreate} - On create - {/if} - {#if row.hook.switchRunOnDelete} - On delete - {/if} - {/if} - {#if row.hook.keepOpenOnCompletion} - Keep run dialog open - {/if} - {#if row.hook.dependencyIds.length > 0} - - Depends on {getDependencyNames(row.hook.dependencyIds)} - - {/if} -
-
- -
- - - -
-
-
- {/each} -
-
- {/each} -
- {/if} -
-
-
-{/if} - -{#if editorOpen} - - -
- - - -
(editorOpen = false)} - > - - -
e.stopPropagation()} - class="flex h-[min(86vh,860px)] w-[min(980px,96vw)] flex-col overflow-hidden rounded-xl border border-[var(--sg-border)] bg-[var(--sg-surface)] shadow-2xl" - transition:scale={{ duration: 220, start: 0.97 }} - > -
-
-

- {editingHookId ? 'Edit hook' : 'New hook'} -

-

- Choose when this hook runs and which shell executes it. -

-
- -
- -
-
-
-
-

- Hook Identity -

-

- Give this automation a clear, action-oriented name. -

-
- -
- -
-
- -
-
-

- When And Where -

-

- Choose the lifecycle event and the target context for this hook. -

-
- -
- - - -
- -
-

Run summary

-

- This hook runs on {normalizeTriggerLabel(form.trigger)} - and targets - {selectedRunAgainstOption?.label}. -

-
-
- -
-
-

- Behavior -

-

- Control lifecycle gating and terminal visibility after script completion. -

-
- -
-
{ - const input = (e.currentTarget as HTMLElement).querySelector( - 'input[type="checkbox"]' - ); - if (input) (input as HTMLInputElement).click(); - }} - onkeydown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - const input = (e.currentTarget as HTMLElement).querySelector( - 'input[type="checkbox"]' - ); - if (input) (input as HTMLInputElement).click(); - } - }} - > -
- { - form = { ...form, enabled: next }; - }} - > - - Enabled - Disable to keep this hook configured without running it. - - -
-
- -
{ - const input = (e.currentTarget as HTMLElement).querySelector( - 'input[type="checkbox"]' - ); - if (input) (input as HTMLInputElement).click(); - }} - onkeydown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - const input = (e.currentTarget as HTMLElement).querySelector( - 'input[type="checkbox"]' - ); - if (input) (input as HTMLInputElement).click(); - } - }} - > -
- { - form = { ...form, critical: next }; - }} - > - - Critical - If this fails in a before_* trigger, the worktree action is blocked. - - -
-
- -
{ - const input = (e.currentTarget as HTMLElement).querySelector( - 'input[type="checkbox"]' - ); - if (input) (input as HTMLInputElement).click(); - }} - onkeydown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - const input = (e.currentTarget as HTMLElement).querySelector( - 'input[type="checkbox"]' - ); - if (input) (input as HTMLInputElement).click(); - } - }} - > -
- { - form = { ...form, keepOpenOnCompletion: next }; - }} - > - - Keep run dialog open - Keep the launched terminal tab open after the script exits. When off, - hook-run terminal tabs auto-close on exit. - - -
-
- - {#if form.trigger === 'before_worktree_switch' || form.trigger === 'after_worktree_switch'} -
{ - const input = (e.currentTarget as HTMLElement).querySelector( - 'input[type="checkbox"]' - ); - if (input) (input as HTMLInputElement).click(); - }} - onkeydown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - const input = (e.currentTarget as HTMLElement).querySelector( - 'input[type="checkbox"]' - ); - if (input) (input as HTMLInputElement).click(); - } - }} - > -
- { - form = { ...form, switchOncePerSession: next }; - }} - > - - First switch each session - Run this switch hook only the first time each worktree is selected in - the current app session. - - -
-
- -
{ - const input = (e.currentTarget as HTMLElement).querySelector( - 'input[type="checkbox"]' - ); - if (input) (input as HTMLInputElement).click(); - }} - onkeydown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - const input = (e.currentTarget as HTMLElement).querySelector( - 'input[type="checkbox"]' - ); - if (input) (input as HTMLInputElement).click(); - } - }} - > -
- { - form = { ...form, switchRunOnCreate: next }; - }} - > - - Run on create auto-switch - Run during auto-activation after creating a new worktree. - - -
-
- -
{ - const input = (e.currentTarget as HTMLElement).querySelector( - 'input[type="checkbox"]' - ); - if (input) (input as HTMLInputElement).click(); - }} - onkeydown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - const input = (e.currentTarget as HTMLElement).querySelector( - 'input[type="checkbox"]' - ); - if (input) (input as HTMLInputElement).click(); - } - }} - > -
- { - form = { ...form, switchRunOnDelete: next }; - }} - > - - Run on delete auto-switch - Run before deleting the active worktree when SproutGit auto-switches away. - - -
-
- {/if} -
-
- -
-
-

- Dependencies -

-

- Use dependencies to build a run order for hooks on the same trigger. -

-
-

Depends on

- {#if dependencyCandidates.length === 0} -
- No hooks available for this trigger yet. -
- {:else} -
-
- {#each dependencyCandidates as candidate} - {@const isChecked = form.dependencyIds.includes(candidate.id)} - toggleDependency(candidate.id, next)} - > - {candidate.name} - - {/each} -
-
- {/if} -

- This hook runs only after all selected dependencies complete. -

-
- -
-
-

- Script -

-

- Write the command sequence here. Runtime variables stay available below for - reference. -

-
- { - form = { ...form, script: next }; - }} - /> -
-
-
-

- Available runtime variables -

-

- These are injected into every hook process so scripts can adapt to the current - workspace, worktree, trigger, and hook metadata. -

-
- - {form.shell === 'pwsh' || form.shell === 'powershell' ? '$env:NAME' : '$NAME'} - -
- -
- {#each hookVariableGroups as group} -
-

- {group.title} -

-
- {#each group.items as item} -
-

{item.name}

-

- {item.description} -

-
- {/each} -
-
- {/each} -
-
-
-
-
- -
- - -
-
-
-{/if} diff --git a/src/lib/path-utils.ts b/src/lib/path-utils.ts deleted file mode 100644 index 3d8fe75..0000000 --- a/src/lib/path-utils.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Cross-platform filesystem path helpers for the SproutGit frontend. - * - * These helpers are the **single source of truth** for path normalisation and - * comparison in the webview. They intentionally mirror the behaviour of the - * Rust-side `path_to_frontend()` and `strip_win_prefix()` helpers in - * `src-tauri/src/git/helpers.rs` so that paths flowing across the bridge - * compare correctly on every supported platform (macOS, Linux, Windows). - * - * Always import from `$lib/path-utils` instead of: - * • calling `.toLowerCase()` on a path, - * • inlining `.replace(/\\/g, '/')` separator conversions, - * • or doing strict `===` comparisons on filesystem paths. - * - * Why this matters: - * • Linux is case-sensitive — lowercasing a real path produces a path that - * does not exist (e.g. `/home/runner/work/SproutGit/SproutGit/...` → - * `/home/runner/work/sproutgit/sproutgit/...`). Round-tripping such a path - * to the backend then fails with "Repository path does not exist". - * • Windows path canonicalisation may emit mixed-case drive letters or the - * `\\?\` extended-length prefix; the backend strips the prefix before - * returning paths, but case-only differences can still occur. - * • macOS APFS and Windows NTFS are case-insensitive **by default** (but not - * always); equality must therefore be case-insensitive on those OSes. - * - * Storage rule: never mutate the case of a path before storing it. Keep the - * exact string returned by the backend, and only lowercase at the moment of - * comparison via `pathsEqual()` / `pathKey()`. - */ - -const _userAgent = - typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string' - ? navigator.userAgent - : ''; - -export const IS_WINDOWS = /windows/i.test(_userAgent); -export const IS_MACOS = /mac os x|macintosh/i.test(_userAgent) && !IS_WINDOWS; - -/** - * Default filesystem case-sensitivity for the current OS. - * - * Linux / *BSD: case-sensitive (most filesystems). - * macOS APFS: case-insensitive by default (case-sensitive APFS exists but is - * rare; treating it as case-insensitive matches user expectations - * and avoids breaking common workflows). - * Windows NTFS: case-insensitive by default. - */ -export const PATH_CASE_INSENSITIVE = IS_WINDOWS || IS_MACOS; - -/** - * Convert all backslash separators to forward slashes. - * - * The Rust backend already returns forward-slash paths via `path_to_frontend()`, - * but values coming from the OS-native file watcher, terminal `cwd`, or - * synthesised fallback paths in the frontend may still contain backslashes on - * Windows. Normalising here keeps a single canonical separator across the UI. - */ -export function normalizePathSeparators(path: string): string { - return path.replace(/\\/g, '/'); -} - -/** - * Strip a single trailing path separator (after separator normalisation). - * - * Use this before comparing path prefixes so `/foo` and `/foo/` are equivalent. - */ -export function stripTrailingSeparator(path: string): string { - const norm = normalizePathSeparators(path); - if (norm.length > 1 && norm.endsWith('/')) { - return norm.slice(0, -1); - } - return norm; -} - -/** - * Canonical comparison key for a filesystem path. - * - * • Always normalises separators to `/`. - * • Lowercases on case-insensitive platforms (Windows, macOS) only. - * - * Use this whenever you need to use a path as a `Map` / object key, deduplicate - * a list of paths, or compute a `Set` of paths. Never persist the result back - * into a field that is later used for I/O — keep the original-case path for - * that. - */ -export function pathKey(path: string): string { - const norm = normalizePathSeparators(path); - return PATH_CASE_INSENSITIVE ? norm.toLowerCase() : norm; -} - -/** - * Case- and separator-aware filesystem path equality. - * - * Returns `true` when both arguments are nullish, `false` if exactly one is. - */ -export function pathsEqual(a: string | null | undefined, b: string | null | undefined): boolean { - if (a == null || b == null) return a == b; - return pathKey(a) === pathKey(b); -} - -/** - * Returns `true` when `child` is `parent` or is a path nested under `parent`. - * - * Honours the platform's filesystem case-sensitivity rules. The check is - * separator-aware: `/foo/bar` is *not* a prefix of `/foo/barbaz`. - */ -export function pathStartsWith(parent: string, child: string): boolean { - const p = stripTrailingSeparator(parent); - const c = stripTrailingSeparator(child); - if (pathsEqual(p, c)) return true; - const pKey = pathKey(p); - const cKey = pathKey(c); - return cKey.startsWith(`${pKey}/`); -} - -/** - * Find a path within a list using filesystem-aware equality. - * - * Returns the **original** entry from `paths` (preserving its case), not the - * lowercased comparison form. Useful for round-tripping a user-supplied or - * persisted path back to the canonical on-disk path returned by the backend. - */ -export function findPath(items: T[], getPath: (item: T) => string, target: string): T | null { - const targetKey = pathKey(target); - return items.find(item => pathKey(getPath(item)) === targetKey) ?? null; -} diff --git a/src/lib/paths.svelte.ts b/src/lib/paths.svelte.ts deleted file mode 100644 index 77356f3..0000000 --- a/src/lib/paths.svelte.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Reactive path utilities for user-facing path labels. - * - * On macOS and Linux, `tildify()` replaces the home-directory prefix of an - * absolute path with `~`. On Windows the path is returned unchanged. - */ -import { homeDir } from '@tauri-apps/api/path'; - -const IS_WINDOWS = typeof navigator !== 'undefined' && /windows/i.test(navigator.userAgent); - -let _home = $state(''); - -if (typeof window !== 'undefined' && !IS_WINDOWS) { - homeDir() - .then(h => { - // Normalise: ensure no trailing separator - _home = h.endsWith('/') ? h.slice(0, -1) : h; - }) - .catch(() => { - // Non-fatal: leave _home empty so tildify falls back to the raw path - }); -} - -/** - * Returns a display-friendly version of `path` by replacing the user's home - * directory prefix with `~` (macOS / Linux only). - * - * Examples (macOS, home = "/Users/alice"): - * "/Users/alice" → "~" - * "/Users/alice/Projects/foo" → "~/Projects/foo" - * "/tmp/other" → "/tmp/other" - */ -export function tildify(path: string): string { - if (!_home || !path) return path; - if (path === _home) return '~'; - if (path.startsWith(`${_home}/`)) return `~${path.slice(_home.length)}`; - return path; -} diff --git a/src/lib/ref-utils.ts b/src/lib/ref-utils.ts deleted file mode 100644 index 694d722..0000000 --- a/src/lib/ref-utils.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { RefInfo } from '$lib/sproutgit'; - -/** - * Comparator for sorting refs in the "create worktree" source-ref list. - * The detected default remote branch (e.g. `origin/main`) sorts first, - * followed by upstream/*, origin/*, other remotes, local branches, tags. - */ -export function makeCompareRefsForCreate( - defaultRemoteBranch?: string -): (a: RefInfo, b: RefInfo) => number { - return function compareRefsForCreate(a: RefInfo, b: RefInfo): number { - const rank = (ref: RefInfo): number => { - if (defaultRemoteBranch && ref.name === defaultRemoteBranch) return 0; - if (ref.kind === 'remote' && ref.name.startsWith('upstream/')) return 1; - if (ref.kind === 'remote' && ref.name.startsWith('origin/')) return 2; - if (ref.kind === 'remote') return 3; - if (ref.kind === 'branch') return 4; - return 5; - }; - const rankDiff = rank(a) - rank(b); - if (rankDiff !== 0) return rankDiff; - return a.name.localeCompare(b.name); - }; -} - -/** - * Pick the best default source ref from a list of refs. - * Returns the default remote branch if present, otherwise the highest-ranked - * remote ref, otherwise the first ref, otherwise `'HEAD'`. - */ -export function preferredSourceRef(refList: RefInfo[], defaultRemoteBranch?: string): string { - if (defaultRemoteBranch) { - const defaultRef = refList.find(r => r.name === defaultRemoteBranch); - if (defaultRef) return defaultRef.name; - } - const sorted = [...refList].sort(makeCompareRefsForCreate(defaultRemoteBranch)); - const preferred = sorted.find(ref => ref.kind === 'remote') ?? sorted[0]; - return preferred?.name ?? 'HEAD'; -} diff --git a/src/lib/sproutgit.ts b/src/lib/sproutgit.ts deleted file mode 100644 index 1fbdcf4..0000000 --- a/src/lib/sproutgit.ts +++ /dev/null @@ -1,672 +0,0 @@ -import { invoke } from '@tauri-apps/api/core'; -import { listen, type UnlistenFn } from '@tauri-apps/api/event'; - -export type GitInfo = { - installed: boolean; - version?: string | null; -}; - -export type WorktreeInfo = { - path: string; - head?: string | null; - branch?: string | null; - detached: boolean; -}; - -export type WorktreeListResult = { - repoPath: string; - worktrees: WorktreeInfo[]; -}; - -export type WorkspaceInitResult = { - workspacePath: string; - rootPath: string; - worktreesPath: string; - metadataPath: string; - stateDbPath: string; - cloned: boolean; -}; - -export type WorkspaceStatus = { - workspacePath: string; - rootPath: string; - worktreesPath: string; - metadataPath: string; - stateDbPath: string; - isSproutgitProject: boolean; - rootExists: boolean; - worktreesExists: boolean; - metadataExists: boolean; - stateDbExists: boolean; -}; - -export type RecentWorkspace = { - workspacePath: string; - lastOpenedAt: number; -}; - -export type RefInfo = { - name: string; - fullName: string; - kind: 'branch' | 'remote' | 'tag'; - target: string; -}; - -export type RefsResult = { - repoPath: string; - refs: RefInfo[]; - /** Short name of the default remote branch (e.g. `origin/main`), if discoverable. */ - defaultRemoteBranch?: string; -}; - -export type CommitEntry = { - hash: string; - shortHash: string; - parents: string[]; - authorName: string; - authorEmail: string; - authorDate: string; - subject: string; - refs: string[]; -}; - -export type CommitGraphResult = { - repoPath: string; - commits: CommitEntry[]; -}; - -export type CreateWorktreeResult = { - worktreePath: string; - branch: string; - fromRef: string; -}; - -export type WorktreeProvenance = { - worktreePath: string; - branch: string; - sourceRef: string; - initiatingWorktreePath?: string | null; - rootRepoPath: string; - createdAt: number; - updatedAt: number; -}; - -export type NestedRepoSyncRule = { - repoRelativePath: string; - enabled: boolean; - createdAt: number; - updatedAt: number; -}; - -export type NestedRepoSyncRuleInput = { - repoRelativePath: string; - enabled: boolean; -}; - -export type WorkspaceHookTrigger = - | 'before_worktree_create' - | 'after_worktree_create' - | 'before_worktree_remove' - | 'after_worktree_remove' - | 'before_worktree_switch' - | 'after_worktree_switch' - | 'manual'; - -export type WorkspaceHookScope = 'worktree' | 'workspace'; - -export type HookExecutionTarget = 'workspace' | 'trigger_worktree' | 'initiating_worktree'; - -export type HookExecutionMode = 'terminal_tab'; - -export type WorkspaceHookShell = 'bash' | 'zsh' | 'pwsh' | 'powershell'; - -export type WorkspaceHook = { - id: string; - name: string; - scope: WorkspaceHookScope; - trigger: WorkspaceHookTrigger; - executionTarget: HookExecutionTarget; - executionMode: HookExecutionMode; - shell: WorkspaceHookShell; - script: string; - enabled: boolean; - critical: boolean; - switchOncePerSession: boolean; - switchRunOnCreate: boolean; - switchRunOnDelete: boolean; - keepOpenOnCompletion: boolean; - timeoutSeconds: number; - createdAt: number; - updatedAt: number; - dependencyIds: string[]; -}; - -export type HookUpsertInput = { - name: string; - scope: WorkspaceHookScope; - trigger: WorkspaceHookTrigger; - executionTarget: HookExecutionTarget; - shell: WorkspaceHookShell; - script: string; - enabled: boolean; - critical: boolean; - switchOncePerSession: boolean; - switchRunOnCreate: boolean; - switchRunOnDelete: boolean; - keepOpenOnCompletion: boolean; - timeoutSeconds: number; - dependencyIds: string[]; -}; - -export type WorktreeSwitchHookSource = 'manual' | 'create' | 'delete' | 'load'; - -export type CheckoutResult = { - worktreePath: string; - previousBranch: string | null; - newBranch: string; - stashed: boolean; -}; - -export type PushBranchResult = { - worktreePath: string; - branch: string; - upstream: string | null; - published: boolean; -}; - -export type WorktreePushStatus = { - worktreePath: string; - branch: string | null; - upstream: string | null; - remotes: string[]; - suggestedRemote: string | null; - detached: boolean; -}; - -export type DiffFileEntry = { - path: string; - status: string; - oldPath: string | null; -}; - -export type DiffFilesResult = { - commit: string; - base: string | null; - files: DiffFileEntry[]; -}; - -export type DiffContentResult = { - commit: string; - base: string | null; - filePath: string | null; - diff: string; -}; - -export type HookProgressEvent = { - trigger: string; - hookId: string; - hookName: string; - keepOpenOnCompletion?: boolean; - phase: 'start' | 'end' | 'skipped'; - status: string; - stdoutSnippet?: string | null; - stderrSnippet?: string | null; - errorMessage?: string | null; -}; - -export type HookTerminalLaunchEvent = { - trigger: string; - hookId: string; - hookName: string; - shell: WorkspaceHookShell; - cwd: string; - command: string; - envVars: Record; - keepOpenOnCompletion: boolean; -}; - -export type DeviceCodeResponse = { - deviceCode: string; - userCode: string; - verificationUri: string; - expiresIn: number; - interval: number; -}; - -export type GitHubPollResult = { - status: 'pending' | 'complete' | 'expired' | 'error'; - username?: string | null; - error?: string | null; -}; - -export type GitHubAuthStatus = { - authenticated: boolean; - username?: string | null; - provider: string; -}; - -export type GitHubAuthStorageMigration = { - migrated: boolean; - storageBackend: 'keychain' | 'file' | 'none'; - hadLegacyFileToken: boolean; - error?: string | null; -}; - -export type GitHubRepo = { - fullName: string; - cloneUrl: string; - private: boolean; - description?: string | null; -}; - -export type GitHubEmailSuggestion = { - label: string; - email: string; - kind: string; - primary: boolean; - verified: boolean; -}; - -export type EditorInfo = { - id: string; - name: string; - command: string; - installed: boolean; -}; - -export type GitToolInfo = { - id: string; - name: string; - command: string; - installed: boolean; - supportsDiff: boolean; - supportsMerge: boolean; -}; - -export const getGitInfo = () => invoke('git_info'); - -export const createWorkspace = (workspacePath: string, repoUrl?: string | null) => - invoke('create_sproutgit_workspace', { - workspacePath, - repoUrl: repoUrl?.trim() ? repoUrl : null, - }); - -export const importGitRepoWorkspace = (workspacePath: string, sourceRepoPath: string) => - invoke('import_git_repo_workspace', { - workspacePath, - sourceRepoPath, - }); - -export type ImportRepoMode = 'inPlace' | 'move' | 'copy'; - -export const importGitRepoWorkspaceWithMode = ( - sourceRepoPath: string, - mode: ImportRepoMode, - workspacePath?: string | null -) => - invoke('import_git_repo_workspace_with_mode', { - sourceRepoPath, - mode, - workspacePath: workspacePath?.trim() ? workspacePath : null, - }); - -export const inspectWorkspace = (workspacePath: string) => - invoke('inspect_sproutgit_workspace', { workspacePath }); - -export const listRecentWorkspaces = () => invoke('list_recent_workspaces'); - -export const touchRecentWorkspace = (workspacePath: string) => - invoke('touch_recent_workspace', { workspacePath }); - -export const removeRecentWorkspace = (workspacePath: string) => - invoke('remove_recent_workspace', { workspacePath }); - -export const getAppSetting = (key: string) => invoke('get_app_setting', { key }); - -export const setAppSetting = (key: string, value?: string | null) => - invoke('set_app_setting', { - key, - value: value?.trim() ? value : null, - }); - -export const listWorktrees = (repoPath: string) => - invoke('list_worktrees', { repoPath }); - -export const listRefs = (repoPath: string) => invoke('list_refs', { repoPath }); - -export const getCommitGraph = (repoPath: string, limit?: number | null, skip?: number | null) => - invoke('get_commit_graph', { - repoPath, - limit: limit ?? null, - skip: skip ?? null, - }); - -export const countCommits = (repoPath: string) => invoke('count_commits', { repoPath }); - -export const createManagedWorktree = ( - rootRepoPath: string, - managedWorktreesPath: string, - fromRef: string, - newBranch: string, - initiatingWorktreePath?: string | null -) => - invoke('create_managed_worktree', { - rootRepoPath, - managedWorktreesPath, - fromRef, - newBranch, - initiatingWorktreePath: initiatingWorktreePath?.trim() ? initiatingWorktreePath : null, - }); - -export const deleteManagedWorktree = ( - rootRepoPath: string, - worktreePath: string, - deleteBranch = true, - initiatingWorktreePath?: string | null -) => - invoke('delete_managed_worktree', { - rootRepoPath, - worktreePath, - deleteBranch, - initiatingWorktreePath: initiatingWorktreePath?.trim() ? initiatingWorktreePath : null, - }); - -export const checkoutWorktree = (worktreePath: string, targetRef: string, autoStash = true) => - invoke('checkout_worktree', { - worktreePath, - targetRef, - autoStash, - }); - -export const resetWorktreeBranch = ( - worktreePath: string, - targetRef: string, - mode: 'soft' | 'mixed' | 'hard' -) => - invoke('reset_worktree_branch', { - worktreePath, - targetRef, - mode, - }); - -export const getWorktreePushStatus = (worktreePath: string) => - invoke('get_worktree_push_status', { worktreePath }); - -export const fetchWorktree = (worktreePath: string) => - invoke('fetch_worktree', { worktreePath }); - -export const pullWorktree = (worktreePath: string) => - invoke('pull_worktree', { worktreePath }); - -export const pushWorktreeBranch = (worktreePath: string, publishRemote?: string | null) => - invoke('push_worktree_branch', { - worktreePath, - publishRemote: publishRemote?.trim() ? publishRemote : null, - }); - -export const listWorkspaceHooks = (workspacePath: string, trigger?: WorkspaceHookTrigger) => - invoke('list_workspace_hooks', { - workspacePath, - trigger: trigger ?? null, - }); - -export const createWorkspaceHook = (workspacePath: string, input: HookUpsertInput) => - invoke('create_workspace_hook', { workspacePath, input }); - -export const updateWorkspaceHook = ( - workspacePath: string, - hookId: string, - input: HookUpsertInput -) => - invoke('update_workspace_hook', { - workspacePath, - hookId, - input, - }); - -export const deleteWorkspaceHook = (workspacePath: string, hookId: string) => - invoke('delete_workspace_hook', { workspacePath, hookId }); - -export const toggleWorkspaceHook = (workspacePath: string, hookId: string, enabled: boolean) => - invoke('toggle_workspace_hook', { workspacePath, hookId, enabled }); - -export const getAvailableHookShells = () => - invoke('get_available_hook_shells'); - -export const runWorkspaceHook = ( - workspacePath: string, - hookId: string, - worktreePath: string, - initiatingWorktreePath?: string | null -) => - invoke('run_workspace_hook', { - workspacePath, - hookId, - worktreePath, - initiatingWorktreePath: initiatingWorktreePath?.trim() ? initiatingWorktreePath : null, - }); - -export const runWorktreeSwitchHooks = ( - workspacePath: string, - targetWorktreePath: string, - initiatingWorktreePath?: string | null, - source: WorktreeSwitchHookSource = 'manual' -) => - invoke('run_worktree_switch_hooks', { - workspacePath, - targetWorktreePath, - initiatingWorktreePath: initiatingWorktreePath?.trim() ? initiatingWorktreePath : null, - source, - }); - -export const listWorktreeProvenance = (workspacePath: string) => - invoke('list_worktree_provenance', { workspacePath }); - -export const getWorktreeProvenance = (workspacePath: string, worktreePath: string) => - invoke('get_worktree_provenance', { - workspacePath, - worktreePath, - }); - -export const listNestedRepoSyncRules = (workspacePath: string) => - invoke('list_nested_repo_sync_rules', { workspacePath }); - -export const upsertNestedRepoSyncRule = (workspacePath: string, input: NestedRepoSyncRuleInput) => - invoke('upsert_nested_repo_sync_rule', { - workspacePath, - input, - }); - -export const deleteNestedRepoSyncRule = (workspacePath: string, repoRelativePath: string) => - invoke('delete_nested_repo_sync_rule', { - workspacePath, - repoRelativePath, - }); - -export const openInEditor = (worktreePath: string) => - invoke('open_in_editor', { worktreePath }); - -export const getDiffFiles = (repoPath: string, commit: string, base?: string | null) => - invoke('get_diff_files', { - repoPath, - commit, - base: base ?? null, - }); - -export const getDiffContent = ( - repoPath: string, - commit: string, - base?: string | null, - filePath?: string | null -) => - invoke('get_diff_content', { - repoPath, - commit, - base: base ?? null, - filePath: filePath ?? null, - }); - -export const onCloneProgress = (callback: (message: string) => void): Promise => - listen('clone-progress', event => callback(event.payload)); - -export const onImportProgress = (callback: (message: string) => void): Promise => - listen('import-progress', event => callback(event.payload)); - -export const onGitOpProgress = (callback: (line: string) => void): Promise => - listen('git-op-progress', event => callback(event.payload)); - -export const onHookProgress = ( - callback: (payload: HookProgressEvent) => void -): Promise => - listen('hook-progress', event => callback(event.payload)); - -export const onHookTerminalLaunch = ( - callback: (payload: HookTerminalLaunchEvent) => void -): Promise => - listen('hook-terminal-launch', event => callback(event.payload)); - -export const githubDeviceFlowStart = () => invoke('github_device_flow_start'); - -export const githubDeviceFlowPoll = (deviceCode: string) => - invoke('github_device_flow_poll', { deviceCode }); - -export const migrateGithubAuthStorage = () => - invoke('migrate_github_auth_storage'); - -export const getGithubAuthStatus = () => invoke('get_github_auth_status'); - -export const githubLogout = () => invoke('github_logout'); - -export const listGithubRepos = () => invoke('list_github_repos'); - -export const listGithubEmailSuggestions = () => - invoke('list_github_email_suggestions'); - -export const getHomeDir = () => invoke('get_home_dir'); - -export const detectEditors = () => invoke('detect_editors'); - -export const detectGitTools = () => invoke('detect_git_tools'); - -export const getGitConfig = (key: string) => invoke('get_git_config', { key }); - -export const setGitConfig = (key: string, value: string) => - invoke('set_git_config', { key, value }); - -export type StatusFileEntry = { - path: string; - origPath?: string; - indexStatus: string; - workTreeStatus: string; -}; - -export type WorktreeStatusResult = { - worktreePath: string; - files: StatusFileEntry[]; -}; - -export type CommitResult = { - hash: string; - shortHash: string; - subject: string; -}; - -export type WorkingDiffResult = { - worktreePath: string; - filePath: string | null; - staged: boolean; - diff: string; -}; - -export const getWorktreeStatus = (worktreePath: string) => - invoke('get_worktree_status', { worktreePath }); - -export const stageFiles = (worktreePath: string, paths: string[]) => - invoke('stage_files', { worktreePath, paths }); - -export const unstageFiles = (worktreePath: string, paths: string[]) => - invoke('unstage_files', { worktreePath, paths }); - -export const createCommit = (worktreePath: string, message: string) => - invoke('create_commit', { worktreePath, message }); - -export const getWorkingDiff = (worktreePath: string, staged: boolean, filePath?: string) => - invoke('get_working_diff', { - worktreePath, - staged, - filePath: filePath || null, - }); - -// ── File Watcher ── - -export const startWatchingWorktrees = (paths: string[], rootPath?: string | null) => - invoke('start_watching_worktrees', { paths, rootPath: rootPath ?? null }); - -export const stopWatchingWorktrees = () => invoke('stop_watching_worktrees'); - -export const onWorktreeChanged = (callback: (worktreePath: string) => void): Promise => - listen('worktree-changed', event => callback(event.payload)); - -export const onGitRefsChanged = (callback: () => void): Promise => - listen('git-refs-changed', () => callback()); - -// ── Terminal ── - -export const listAvailableShells = () => invoke('list_available_shells'); - -export const spawnTerminal = ( - shell: string, - cwd: string, - cols: number, - rows: number, - command?: string | null, - hookId?: string | null, - envVars?: Record | null -) => - invoke('spawn_terminal', { - shell, - cwd, - cols, - rows, - command: command ?? null, - hookId: hookId ?? null, - envVars: envVars ?? null, - }); - -/** - * Returns the epoch-ms timestamp at which the auto-close terminal session for - * the given hook exited, or `null` if no such session has completed yet. - * - * Provides a deterministic backend-state synchronisation point so callers do - * not need to observe the (PTY exit → IPC event → reactive update → DOM - * removal) chain that drives terminal-tab disappearance. - */ -export const isHookTerminalClosed = (hookId: string) => - invoke('is_hook_terminal_closed', { hookId }); - -export const terminalInput = (ptyId: string, data: string) => - invoke('terminal_input', { ptyId, data }); - -export const terminalResize = (ptyId: string, cols: number, rows: number) => - invoke('terminal_resize', { ptyId, cols, rows }); - -export const closeTerminal = (ptyId: string) => invoke('close_terminal', { ptyId }); - -export const closeAllTerminals = () => invoke('close_all_terminals'); - -export const closeTerminalsForPath = (path: string) => - invoke('close_terminals_for_path', { path }); - -export const onTerminalOutput = ( - ptyId: string, - callback: (data: string) => void -): Promise => - listen(`terminal-output-${ptyId}`, event => callback(event.payload)); - -export const onTerminalClosed = (ptyId: string, callback: () => void): Promise => - listen(`terminal-closed-${ptyId}`, () => callback()); - -// ── Build info ── - -/** Returns true when the app was compiled with the `e2e-testing` Cargo feature. */ -export const isE2eBuild = () => invoke('is_e2e_build'); diff --git a/src/lib/terminal-session-cache.svelte.ts b/src/lib/terminal-session-cache.svelte.ts deleted file mode 100644 index 7eb141e..0000000 --- a/src/lib/terminal-session-cache.svelte.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { pathKey } from '$lib/path-utils'; - -type CachedSession = { - id: string; - shell: string; - label: string; - initialCommand?: string; - envVars?: Record; - autoCloseOnExit?: boolean; - hookId?: string; - ptyId?: string; -}; - -type CachedContainerState = { - sessions: CachedSession[]; - activeId: string | null; - layout: 'tabs' | 'split' | 'grid'; -}; - -const EMPTY_STATE: CachedContainerState = { - sessions: [], - activeId: null, - layout: 'tabs', -}; - -// Plain Map — no reactivity needed; this is "remember between mounts" storage only. -// DO NOT use $state here: setTerminalContainerCache both reads and writes this value, -// and any $effect that calls it would create an infinite reactive loop. -const cacheByCwd = new Map(); - -function cloneState(state: CachedContainerState): CachedContainerState { - return { - sessions: state.sessions.map(session => ({ - ...session, - envVars: session.envVars ? { ...session.envVars } : undefined, - })), - activeId: state.activeId, - layout: state.layout, - }; -} - -export function getTerminalContainerCache(cwd: string): CachedContainerState { - return cloneState(cacheByCwd.get(pathKey(cwd)) ?? EMPTY_STATE); -} - -export function setTerminalContainerCache(cwd: string, nextState: CachedContainerState) { - cacheByCwd.set(pathKey(cwd), cloneState(nextState)); -} - -export function clearTerminalContainerCache(cwd: string) { - cacheByCwd.delete(pathKey(cwd)); -} diff --git a/src/lib/toast.svelte.ts b/src/lib/toast.svelte.ts deleted file mode 100644 index f03c84e..0000000 --- a/src/lib/toast.svelte.ts +++ /dev/null @@ -1,56 +0,0 @@ -export type ToastType = 'info' | 'success' | 'error' | 'warning'; - -export type ToastAction = { label: string; onClick: () => void }; - -export type Toast = { - id: number; - type: ToastType; - message: string; - removing?: boolean; - action?: ToastAction; -}; - -let nextId = 0; -let toasts = $state([]); - -export function getToasts(): Toast[] { - return toasts; -} - -export function addToast( - type: ToastType, - message: string, - durationMs = 4000, - action?: ToastAction -) { - const id = nextId++; - toasts = [...toasts, { id, type, message, action }]; - - if (durationMs > 0) { - setTimeout(() => removeToast(id), durationMs); - } - - return id; -} - -export function removeToast(id: number) { - const idx = toasts.findIndex(t => t.id === id); - if (idx === -1) return; - - // Mark as removing for exit animation - toasts = toasts.map(t => (t.id === id ? { ...t, removing: true } : t)); - setTimeout(() => { - toasts = toasts.filter(t => t.id !== id); - }, 200); -} - -export const toast = { - info: (msg: string, duration?: number, action?: ToastAction) => - addToast('info', msg, duration, action), - success: (msg: string, duration?: number, action?: ToastAction) => - addToast('success', msg, duration, action), - error: (msg: string, duration?: number, action?: ToastAction) => - addToast('error', msg, duration, action), - warning: (msg: string, duration?: number, action?: ToastAction) => - addToast('warning', msg, duration, action), -}; diff --git a/src/lib/update.svelte.ts b/src/lib/update.svelte.ts deleted file mode 100644 index 78981d4..0000000 --- a/src/lib/update.svelte.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { Update } from '@tauri-apps/plugin-updater'; - -let available = $state(null); -let checked = $state(false); - -type GitHubRelease = { - tag_name?: string; - name?: string | null; - body?: string | null; - draft?: boolean; - prerelease?: boolean; -}; - -type ParsedSemver = { - major: number; - minor: number; - patch: number; - prerelease: string[]; -}; - -const RELEASES_API_URL = - 'https://api.github.com/repos/InterestingSoftware/SproutGit/releases?per_page=100'; - -function stripVersionPrefix(version: string): string { - return version.trim().replace(/^v/i, ''); -} - -function parseSemver(version: string): ParsedSemver | null { - const cleaned = stripVersionPrefix(version).split('+')[0] ?? ''; - const [core, prereleasePart] = cleaned.split('-', 2); - const parts = (core ?? '').split('.'); - if (parts.length !== 3) return null; - - const major = Number.parseInt(parts[0] ?? '', 10); - const minor = Number.parseInt(parts[1] ?? '', 10); - const patch = Number.parseInt(parts[2] ?? '', 10); - if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) { - return null; - } - - return { - major, - minor, - patch, - prerelease: (prereleasePart ?? '').split('.').filter(Boolean), - }; -} - -function compareSemver(a: ParsedSemver, b: ParsedSemver): number { - if (a.major !== b.major) return a.major - b.major; - if (a.minor !== b.minor) return a.minor - b.minor; - if (a.patch !== b.patch) return a.patch - b.patch; - - const aPre = a.prerelease; - const bPre = b.prerelease; - - // Stable versions sort after pre-release versions with the same core. - if (aPre.length === 0 && bPre.length === 0) return 0; - if (aPre.length === 0) return 1; - if (bPre.length === 0) return -1; - - const maxLen = Math.max(aPre.length, bPre.length); - for (let i = 0; i < maxLen; i += 1) { - const left = aPre[i]; - const right = bPre[i]; - if (left === undefined) return -1; - if (right === undefined) return 1; - if (left === right) continue; - - const leftNum = Number.parseInt(left, 10); - const rightNum = Number.parseInt(right, 10); - const leftIsNum = /^\d+$/.test(left); - const rightIsNum = /^\d+$/.test(right); - - if (leftIsNum && rightIsNum) return leftNum - rightNum; - if (leftIsNum) return -1; - if (rightIsNum) return 1; - return left.localeCompare(right); - } - - return 0; -} - -function sectionHeader(release: GitHubRelease, version: string): string { - const title = release.name?.trim(); - return title ? `v${version} - ${title}` : `v${version}`; -} - -export async function loadReleaseNotesBetween( - currentVersion: string, - targetVersion: string -): Promise { - const currentParsed = parseSemver(currentVersion); - const targetParsed = parseSemver(targetVersion); - if (!currentParsed || !targetParsed) return null; - if (compareSemver(targetParsed, currentParsed) <= 0) return null; - - const response = await fetch(RELEASES_API_URL, { - headers: { - Accept: 'application/vnd.github+json', - }, - }); - - if (!response.ok) { - throw new Error(`GitHub release fetch failed (${response.status})`); - } - - const raw = (await response.json()) as unknown; - if (!Array.isArray(raw)) return null; - - const sections: string[] = []; - for (const item of raw) { - if (!item || typeof item !== 'object') continue; - - const release = item as GitHubRelease; - if (release.draft) continue; - - const tag = typeof release.tag_name === 'string' ? release.tag_name : null; - if (!tag) continue; - - const version = stripVersionPrefix(tag); - const parsedVersion = parseSemver(version); - if (!parsedVersion) continue; - - const afterCurrent = compareSemver(parsedVersion, currentParsed) > 0; - const atOrBeforeTarget = compareSemver(parsedVersion, targetParsed) <= 0; - if (!afterCurrent || !atOrBeforeTarget) continue; - - const body = typeof release.body === 'string' ? release.body.trim() : ''; - const header = sectionHeader(release, version); - sections.push(`${header}\n${body || 'No release notes provided.'}`); - } - - return sections.length > 0 ? sections.join('\n\n') : null; -} - -export const updateState = { - get available() { - return available; - }, - get checked() { - return checked; - }, - set(update: Update | null) { - available = update; - checked = true; - }, -}; diff --git a/src/lib/validation.ts b/src/lib/validation.ts deleted file mode 100644 index 15981c1..0000000 --- a/src/lib/validation.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Validate a git branch name according to `git check-ref-format` rules. - * Returns null if valid, or an error message string if invalid. - * - * Rules: https://git-scm.com/docs/git-check-ref-format - */ -export function validateBranchName(name: string): string | null { - const trimmed = name.trim(); - - if (!trimmed) { - return 'Branch name is required.'; - } - - if (trimmed.startsWith('-')) { - return 'Cannot start with a hyphen.'; - } - - if (trimmed.startsWith('.') || trimmed.includes('/.')) { - return "Cannot start with a dot or contain '/.'."; - } - - if (trimmed.endsWith('.')) { - return 'Cannot end with a dot.'; - } - - if (trimmed.endsWith('/')) { - return 'Cannot end with a slash.'; - } - - if (trimmed.includes('..')) { - return "Cannot contain '..'."; - } - - if (trimmed.includes('@{')) { - return "Cannot contain '@{'."; - } - - if (trimmed === '@') { - return "Cannot be the single character '@'."; - } - - if (trimmed.endsWith('.lock')) { - return "Cannot end with '.lock'."; - } - - if (trimmed.includes('\\')) { - return 'Cannot contain backslash.'; - } - - // ASCII control characters (0x00-0x1F, 0x7F), space, tilde, caret, colon - // eslint-disable-next-line no-control-regex - if (/[\x00-\x1f\x7f ~^:]/.test(trimmed)) { - return 'Cannot contain spaces or special characters (~, ^, :, control chars).'; - } - - // eslint-disable-next-line no-useless-escape - if (/[?*\[]/.test(trimmed)) { - return 'Cannot contain glob characters (?, *, [).'; - } - - if (trimmed.includes('//')) { - return 'Cannot contain consecutive slashes.'; - } - - return null; -} - -/** - * Validate a source ref field (branch, tag, or commit hash). - */ -export function validateSourceRef(ref: string): string | null { - const trimmed = ref.trim(); - if (!trimmed) { - return 'Source ref is required.'; - } - return null; -} - -/** - * Validate a commit message. - * Returns null if valid, or an error message string if invalid. - */ -export function validateCommitMessage(message: string): string | null { - const trimmed = message.trim(); - - if (!trimmed) { - return 'Commit message is required.'; - } - - // eslint-disable-next-line no-control-regex - if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(trimmed)) { - return 'Commit message contains unsupported control characters.'; - } - - if (trimmed.length > 10_000) { - return 'Commit message is too long (max 10,000 characters).'; - } - - return null; -} diff --git a/src/lib/workspace-terminals.svelte.ts b/src/lib/workspace-terminals.svelte.ts deleted file mode 100644 index 7207f38..0000000 --- a/src/lib/workspace-terminals.svelte.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { normalizePathSeparators, pathsEqual } from '$lib/path-utils'; - -export type WorkspaceTab = 'history' | 'changes' | 'terminal'; - -export type HookTerminalLaunchRequest = { - id: string; - cwd: string; - shell: string; - label: string; - command: string; - envVars?: Record; - keepOpenOnCompletion: boolean; - hookId: string; -}; - -export type WorkspaceTerminalSnapshot = { - activeTab: WorkspaceTab; - activeTerminalPath: string | null; - initializedPaths: string[]; - launchRequests: HookTerminalLaunchRequest[]; -}; - -const DEFAULT_SNAPSHOT: WorkspaceTerminalSnapshot = { - activeTab: 'history', - activeTerminalPath: null, - initializedPaths: [], - launchRequests: [], -}; - -let availableShells = $state([]); -let defaultShell = $state(''); -let activeWorkspacePath = $state(null); -let snapshots = $state>({}); - -function normalizeWorkspacePath(path: string): string { - return normalizePathSeparators(path); -} - -function cloneSnapshot(snapshot: WorkspaceTerminalSnapshot): WorkspaceTerminalSnapshot { - return { - activeTab: snapshot.activeTab, - activeTerminalPath: snapshot.activeTerminalPath, - initializedPaths: [...snapshot.initializedPaths], - launchRequests: snapshot.launchRequests.map(request => ({ - ...request, - envVars: { ...(request.envVars ?? {}) }, - })), - }; -} - -function getOrCreateSnapshot(workspacePath: string): WorkspaceTerminalSnapshot { - const key = normalizeWorkspacePath(workspacePath); - const existing = snapshots[key]; - if (existing) return existing; - - const next = cloneSnapshot(DEFAULT_SNAPSHOT); - snapshots = { ...snapshots, [key]: next }; - return next; -} - -function updateSnapshot( - workspacePath: string, - updater: (current: WorkspaceTerminalSnapshot) => WorkspaceTerminalSnapshot -) { - const key = normalizeWorkspacePath(workspacePath); - const current = getOrCreateSnapshot(key); - const next = cloneSnapshot(updater(cloneSnapshot(current))); - snapshots = { ...snapshots, [key]: next }; -} - -export function setTerminalShellOptions(shells: string[], shell: string) { - availableShells = [...shells]; - defaultShell = shell; -} - -export function getTerminalShellOptions() { - return { - availableShells, - defaultShell, - }; -} - -export function setActiveWorkspacePath(workspacePath: string | null) { - activeWorkspacePath = workspacePath ? normalizeWorkspacePath(workspacePath) : null; -} - -export function getActiveWorkspacePath() { - return activeWorkspacePath; -} - -export function getWorkspaceTerminalSnapshot( - workspacePath: string -): WorkspaceTerminalSnapshot { - return cloneSnapshot(getOrCreateSnapshot(workspacePath)); -} - -export function getWorkspaceTerminalSnapshots(): Array< - WorkspaceTerminalSnapshot & { workspacePath: string } -> { - return Object.entries(snapshots).map(([workspacePath, snapshot]) => ({ - workspacePath, - ...cloneSnapshot(snapshot), - })); -} - -export function setWorkspaceTerminalSnapshot( - workspacePath: string, - snapshot: WorkspaceTerminalSnapshot -) { - const key = normalizeWorkspacePath(workspacePath); - snapshots = { - ...snapshots, - [key]: cloneSnapshot(snapshot), - }; -} - -export function pruneWorkspaceTerminalSnapshot( - workspacePath: string, - validPaths: string[] -) { - const normalizedValidPaths = validPaths.map(path => normalizePathSeparators(path)); - updateSnapshot(workspacePath, current => { - const initializedPaths = current.initializedPaths.filter(path => - normalizedValidPaths.some(validPath => pathsEqual(path, validPath)) - ); - const launchRequests = current.launchRequests.filter(request => - normalizedValidPaths.some(validPath => pathsEqual(request.cwd, validPath)) - ); - const activeTerminalPath = - current.activeTerminalPath && - normalizedValidPaths.some(validPath => pathsEqual(current.activeTerminalPath, validPath)) - ? current.activeTerminalPath - : initializedPaths[0] ?? normalizedValidPaths[0] ?? null; - - return { - activeTab: current.activeTab, - activeTerminalPath, - initializedPaths, - launchRequests, - }; - }); -} - -export function clearWorkspaceTerminalStateForPath(workspacePath: string, path: string) { - const normalizedPath = normalizePathSeparators(path); - updateSnapshot(workspacePath, current => { - const initializedPaths = current.initializedPaths.filter( - existing => !pathsEqual(existing, normalizedPath) - ); - const launchRequests = current.launchRequests.filter( - request => !pathsEqual(request.cwd, normalizedPath) - ); - const activeTerminalPath = - current.activeTerminalPath && pathsEqual(current.activeTerminalPath, normalizedPath) - ? initializedPaths[0] ?? null - : current.activeTerminalPath; - - return { - activeTab: current.activeTab, - activeTerminalPath, - initializedPaths, - launchRequests, - }; - }); -} \ No newline at end of file diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte deleted file mode 100644 index 9669736..0000000 --- a/src/routes/+error.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - -
- Redirecting... -
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte deleted file mode 100644 index 59cf0e7..0000000 --- a/src/routes/+layout.svelte +++ /dev/null @@ -1,196 +0,0 @@ - - -{@render children()} -{#if isRouteNavigating} -
-
- -
-
-{/if} - diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts deleted file mode 100644 index 9d24899..0000000 --- a/src/routes/+layout.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Tauri doesn't have a Node.js server to do proper SSR -// so we use adapter-static with a fallback to index.html to put the site in SPA mode -// See: https://svelte.dev/docs/kit/single-page-apps -// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info -export const ssr = false; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte deleted file mode 100644 index 677dff2..0000000 --- a/src/routes/+page.svelte +++ /dev/null @@ -1,1272 +0,0 @@ - - - - -{#if !gitChecked} -
- -
-{:else if !git.installed} -
-
🌱
-

Git is not installed

-

- SproutGit requires Git to manage repositories and worktrees. Please install Git and relaunch - the app. -

- -
-{:else} -
- -
- - - SproutGit - -
- - - -
-
- -
- - -
-
-

- Start -

-
-
- - - -
- {#if appVersion} -
-

- SproutGit {import.meta.env.DEV ? appVersion : `v${appVersion}`} -

-
- {/if} -
-
- - -
-
-

- Recent projects -

-
- -
- {#if knownProjects.length === 0} -
-
-
- -
-
-

No projects yet

-

- Clone a repo or open a folder to get started. -

-
-
-
- {:else} -
- {#each knownProjects as project} -
- - - -
- {/each} -
- {/if} -
-
-
- - - {#if showCloneModal} -
{ - if (e.target === e.currentTarget) void closeCloneModal(); - }} - onkeydown={event => { - if ( - (event.key === 'Enter' || event.key === ' ') && - event.target === event.currentTarget - ) { - event.preventDefault(); - void closeCloneModal(); - } - }} - > -
trapModalFocus(event, cloneDialog)} - class="flex w-[480px] flex-col rounded-lg border border-[var(--sg-border)] bg-[var(--sg-surface)] shadow-2xl" - > - -
- - - - -
-

- Clone Repository -

-

- Pull a remote into a fresh SproutGit workspace -

-
- -
- - -
-
- - {#if githubRepos.length > 0} - - {:else} - - {/if} -
- -
- - -
- -
- -
- - -
- {#if workspacePath} -

{workspacePath}

- {/if} -
- - {#if creating && cloneProgress.length > 0} -
- {#if clonePercent !== null} -
-
-
-
- {clonePercent}% -
- {/if} -
- {#each cloneProgress as line} -

{line}

- {/each} -
-
- {/if} - - {#if error} -

- {error} -

- {/if} - -
- - -
-
-
-
- {/if} - - - {#if showImportModal} -
{ - if (e.target === e.currentTarget) void closeImportModal(); - }} - onkeydown={event => { - if ( - (event.key === 'Enter' || event.key === ' ') && - event.target === event.currentTarget - ) { - event.preventDefault(); - void closeImportModal(); - } - }} - > -
trapModalFocus(event, importDialog)} - class="flex w-[520px] flex-col rounded-lg border border-[var(--sg-border)] bg-[var(--sg-surface)] shadow-2xl" - > - -
- - - - -
-

- Import Git Repo -

-

- Wrap an existing local repo as a SproutGit workspace -

-
- -
- - -
-
- -
- - -
-
- -
-

- How should it be imported? -

-
- - - -
-
- - {#if importMode === 'move' || importMode === 'copy'} -
- - -
- - -
- {#if importWorkspacePath} -

- {importWorkspacePath} -

- {/if} -
- {/if} - - {#if error} -

{error}

- {/if} - - {#if importing && importProgressMsg} -

{importProgressMsg}

- {/if} - -
- - -
-
-
-
- {/if} -
-{/if} diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte deleted file mode 100644 index b7becb8..0000000 --- a/src/routes/settings/+page.svelte +++ /dev/null @@ -1,921 +0,0 @@ - - -
-
- -
- Settings -
- - -
-
- -
-
-
- - - - -
-

Settings

-

- Configure SproutGit, integrations, and your default tools. -

-
-
- -
-
- -

Git Provider

-
- {#if githubAuth === null} -
- Checking connection... -
- {:else if githubAuth.authenticated} -
-

{githubAuth.username}

- -
- {:else if deviceCode} -
-

- Enter this code on - -

-
- {deviceCode.userCode} - -
-
- - {authPolling ? 'Waiting for authorization…' : 'Opening GitHub…'} -
-
- {:else} - - {/if} -
- -
-
-
-
- -

Git Settings

-
-

- These update your global Git configuration. -

-
- - {#if toolsLoading} -
- Detecting editors and tools... -
- {:else} -
-
-
-
- -
-

- Author Identity -

-

- {currentGitName || '(not set)'} · {currentGitEmail || '(not set)'} -

-
-
- -
- {#if editingAuthor} -
- - -
- {#if githubAuth?.authenticated && githubAuth.username}{/if} -
- {#if githubAuth?.authenticated} -
- {#if githubEmailsLoading}Loading GitHub emails...{:else}{#each githubEmailSuggestions as suggestion}{/each}{/if} -
- {/if} -
- {/if} -
- -
-
-
- -
-

Editor

- {#if editorDisplay}

- {editorDisplay.name} -

{:else}

(not set)

{/if} -
-
- -
- {#if editingEditor} -
-
- {#each installedEditors as editor}{/each} -
-
- -
- {#if unavailableEditors.length > 0}

- Not found: {unavailableEditors.map(e => e.name).join(', ')} -

{/if} -
- {/if} -
- -
-
-
- -
-

Diff Tool

- {#if diffToolDisplay}

- {diffToolDisplay.name} -

{:else}

(not set)

{/if} -
-
- -
- {#if editingDiffTool} -
-
- {#each installedDiffTools as tool}{/each} -
-
- -
-
- {/if} -
- -
-
-
- -
-

Merge Tool

- {#if mergeToolDisplay}

- {mergeToolDisplay.name} -

{:else}

(not set)

{/if} -
-
- -
- {#if editingMergeTool} -
-
- {#each installedMergeTools as tool}{/each} -
-
- -
-
- {/if} -
- -
-
- -

Git Installation

-
- {#if gitInfo === null}

- Checking... -

{:else if gitInfo.installed}

- {gitInfo.version} -

{:else}

Git not found

{/if} -
-
- {/if} -
- -
-
-
- -

Terminal Shell

-
-

- Default shell used in SproutGit's terminal panel. -

- {#if shellsLoading} -
- Detecting shells... -
- {:else if availableShells.length === 0} -

No supported shells detected.

- {:else} -
- {#each availableShells as shell}{/each} -
- {/if} -
- -
-
- -

About

-
-
- SproutGit{#if appVersion !== null}{import.meta.env.DEV - ? appVersion - : `v${appVersion}`}{:else}{/if} -
-
- {#if !updaterEnabled} -

- Updater is disabled in development builds. -

- {:else if updateState.available} -

- Update v{updateState.available.version} available -

- {#if releaseNotesLoading} -
- Loading release notes... -
- {:else if releaseNotes} -
-
{releaseNotes}
-
- {/if} - - {:else} -

- {#if updateState.checked}Up to date{:else}Check for the latest version{/if} -

- - {/if} -
-
-
-
-
-
-
diff --git a/src/routes/workspace/+page.svelte b/src/routes/workspace/+page.svelte deleted file mode 100644 index 2267e05..0000000 --- a/src/routes/workspace/+page.svelte +++ /dev/null @@ -1,3552 +0,0 @@ - - -{#snippet fileStatusIcon(status: string)} - {#if status === 'A' || status === '?'} - - - - - {:else if status === 'D'} - - - - - {:else if status === 'R'} - - - - - {:else if status === 'U'} - - - - - {:else} - - - - - {/if} -{/snippet} - -
- -
- -
- {workspace?.workspacePath.split('/').pop() ?? '...'} - - - - {selectedWorktree?.branch ?? (selectedWorktree?.detached ? 'detached' : '—')} - -
- - - -
-
- - {#if loading} -
- -

Loading workspace…

-
- {:else} - {#if error} -
- {error} -
- {/if} - -
- - - - - - -
- - {#if workspace} -
- - - -
- {/if} - - {#if activeTab === 'changes' && selectedWorktree && !activeIsRoot} - -
- -
- {#if statusLoading} -
- -

Loading changes…

-
- {:else} -
- -
-
-
-

- Changes ({unstagedFiles.length}) -

- {#if unstagedFiles.length > 0} - - {/if} -
- {#if unstagedFiles.length === 0} -

- No unstaged changes -

- {:else} - {#each unstagedFiles as file} - - -
handleFileMouseDown(e, `unstaged:${file.path}`)} - onmouseenter={() => handleFileMouseEnter(`unstaged:${file.path}`)} - onclick={e => handleFileClick(e, `unstaged:${file.path}`)} - title={file.path} - > -
- {@render fileStatusIcon(file.workTreeStatus)} - {file.path} -
- -
- {/each} - {/if} -
-
- - - -
-
-
- - -
-
-
-

- Staged ({stagedFiles.length}) -

- {#if stagedFiles.length > 0} - - {/if} -
- {#if stagedFiles.length === 0} -

- No staged changes -

- {:else} - {#each stagedFiles as file} - - -
handleFileMouseDown(e, `staged:${file.path}`)} - onmouseenter={() => handleFileMouseEnter(`staged:${file.path}`)} - onclick={e => handleFileClick(e, `staged:${file.path}`)} - title={file.path} - > -
- {@render fileStatusIcon(file.indexStatus)} - {file.path} -
- -
- {/each} - {/if} -
-
-
- - -
- - - {#if committing && gitOpLog.length > 0} -
- {#each gitOpLog as line} -

- {line} -

- {/each} -
- {/if} -

- Ctrl+Enter to commit · Enter for new line -

-
- {/if} -
- - -
- {#if hasMultiSelection} -
-

- {selectedFilePaths.size} files selected -

-

- Use "Stage selected" or "Unstage selected" to act on all selected files at once. -

-
- {:else if stagingDiffFile} - -
- - {stagingDiffStaged ? 'STAGED' : 'UNSTAGED'} - - {#if stagingDiffOrigPath} - {stagingDiffOrigPath} - - {stagingDiffFile} - {:else} - {stagingDiffFile} - {/if} -
- - - {:else} -
-

Select a file to view its diff

-
- {/if} -
-
- {:else if activeTab === 'history'} - -
-
-
-

- Commit graph -

- {#if selectedCommits.length > 0} - - {selectedCommits.length === 1 - ? '1 commit selected' - : `${selectedCommits.length} commits selected`} - - {/if} -
- - {#if graphHasMore && totalCommitCount !== null} - Showing {(graph?.commits.length ?? 0).toLocaleString()} of {totalCommitCount.toLocaleString()} - commits - {:else if graphHasMore} - {(graph?.commits.length ?? 0).toLocaleString()}+ commits - {:else} - {(graph?.commits.length ?? 0).toLocaleString()} commits - {/if} - -
- -
- {#if loading} -
- -

Loading commit history…

-
- {:else} - - {/if} -
-
- - - {#if selectedCommits.length > 0} -
- -
- {/if} - {:else} - {#if activeTab === 'terminal' && !defaultShell} -
-

No shell detected on this system

-
- {:else if activeTab === 'terminal' && !activeTerminalPath} -
-

- Select a worktree or workspace terminal target -

-
- {:else if activeTab !== 'terminal'} -
-

Select a worktree to view changes

-
- {/if} - {/if} - - - {#each [...terminalInitializedPaths] as wtPath (wtPath)} -
- -
- {/each} - -
-
- {/if} -
- -{#if confirmDialog} - (confirmDialog = null)} - /> -{/if} - - { - hooksModalOpen = false; - }} -/> - -{#if publishModalOpen} - - -
{ - if (syncingAction !== 'push') { - publishModalOpen = false; - } - }} - style="animation: sg-fade-in 0.15s ease-out" - > - - -
e.stopPropagation()} - class="w-full max-w-md rounded-xl border border-[var(--sg-border)] bg-[var(--sg-surface)] shadow-2xl" - style="animation: sg-slide-up 0.2s ease-out" - > -
-

Publish branch

-

- Branch {publishBranch || 'current'} has no upstream yet. Choose where to publish. -

-
- -
-
-

- Remote -

- (formTouched.branch = true)} - data-testid="input-new-branch" - class="w-full rounded border bg-[var(--sg-input-bg)] px-2 py-1.5 text-xs text-[var(--sg-text)] placeholder-[var(--sg-text-faint)] outline-none {formTouched.branch && - branchError - ? 'border-[var(--sg-danger)] focus:border-[var(--sg-danger)]' - : 'border-[var(--sg-input-border)] focus:border-[var(--sg-input-focus)]'}" - placeholder={createBranchType === 'managed' ? 'feature/my-task' : 'main'} - /> - {#if formTouched.branch && branchError} -

{branchError}

- {/if} -
- -
- - -
- -
-
-{/if} - -{#if worktreeContextMenu} - (worktreeContextMenu = null)} - /> -{/if} diff --git a/static/favicon.png b/static/favicon.png deleted file mode 100644 index 3b0d125..0000000 Binary files a/static/favicon.png and /dev/null differ diff --git a/static/svelte.svg b/static/svelte.svg deleted file mode 100644 index c5e0848..0000000 --- a/static/svelte.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/tauri.svg b/static/tauri.svg deleted file mode 100644 index 31b62c9..0000000 --- a/static/tauri.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/static/vite.svg b/static/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/static/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/svelte.config.js b/svelte.config.js deleted file mode 100644 index 9093d92..0000000 --- a/svelte.config.js +++ /dev/null @@ -1,18 +0,0 @@ -// Tauri doesn't have a Node.js server to do proper SSR -// so we use adapter-static with a fallback to index.html to put the site in SPA mode -// See: https://svelte.dev/docs/kit/single-page-apps -// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info -import adapter from '@sveltejs/adapter-static'; -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; - -/** @type {import('@sveltejs/kit').Config} */ -const config = { - preprocess: vitePreprocess(), - kit: { - adapter: adapter({ - fallback: 'index.html', - }), - }, -}; - -export default config; diff --git a/tests/path-utils.test.ts b/tests/path-utils.test.ts deleted file mode 100644 index 85846a3..0000000 --- a/tests/path-utils.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; - -const ORIGINAL_NAVIGATOR = globalThis.navigator; - -function setUserAgent(userAgent: string) { - Object.defineProperty(globalThis, 'navigator', { - value: { userAgent }, - configurable: true, - }); -} - -async function loadFresh() { - vi.resetModules(); - return await import('$lib/path-utils'); -} - -afterEach(() => { - Object.defineProperty(globalThis, 'navigator', { - value: ORIGINAL_NAVIGATOR, - configurable: true, - }); -}); - -describe('path-utils', () => { - describe('normalizePathSeparators', () => { - it('converts backslashes to forward slashes', async () => { - setUserAgent('Mozilla/5.0 (Windows NT 10.0)'); - const { normalizePathSeparators } = await loadFresh(); - expect(normalizePathSeparators('C:\\Users\\foo\\bar')).toBe('C:/Users/foo/bar'); - }); - - it('leaves forward-slash paths unchanged', async () => { - setUserAgent('Mozilla/5.0 (Linux)'); - const { normalizePathSeparators } = await loadFresh(); - expect(normalizePathSeparators('/home/runner/work/SproutGit/SproutGit')).toBe( - '/home/runner/work/SproutGit/SproutGit' - ); - }); - }); - - describe('stripTrailingSeparator', () => { - it('removes a single trailing separator', async () => { - setUserAgent('Mozilla/5.0 (Linux)'); - const { stripTrailingSeparator } = await loadFresh(); - expect(stripTrailingSeparator('/foo/bar/')).toBe('/foo/bar'); - expect(stripTrailingSeparator('C:\\foo\\')).toBe('C:/foo'); - }); - - it('leaves paths without trailing separators unchanged and handles edge cases', async () => { - setUserAgent('Mozilla/5.0 (Linux)'); - const { stripTrailingSeparator } = await loadFresh(); - expect(stripTrailingSeparator('/foo/bar')).toBe('/foo/bar'); - expect(stripTrailingSeparator('C:\\foo\\bar')).toBe('C:/foo/bar'); - expect(stripTrailingSeparator('')).toBe(''); - expect(stripTrailingSeparator('//')).toBe('/'); - }); - - it('preserves the root separator', async () => { - setUserAgent('Mozilla/5.0 (Linux)'); - const { stripTrailingSeparator } = await loadFresh(); - expect(stripTrailingSeparator('/')).toBe('/'); - }); - }); - - describe('pathKey', () => { - it('lowercases on Windows', async () => { - setUserAgent('Mozilla/5.0 (Windows NT 10.0)'); - const { pathKey } = await loadFresh(); - expect(pathKey('C:\\Users\\Foo')).toBe('c:/users/foo'); - }); - - it('lowercases on macOS', async () => { - setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'); - const { pathKey } = await loadFresh(); - expect(pathKey('/Users/Alice/Project')).toBe('/users/alice/project'); - }); - - it('preserves case on Linux', async () => { - setUserAgent('Mozilla/5.0 (X11; Linux x86_64)'); - const { pathKey } = await loadFresh(); - expect(pathKey('/home/runner/work/SproutGit/SproutGit')).toBe( - '/home/runner/work/SproutGit/SproutGit' - ); - expect(pathKey('\\home\\runner\\work\\SproutGit\\SproutGit')).toBe( - '/home/runner/work/SproutGit/SproutGit' - ); - }); - }); - - describe('pathsEqual', () => { - it('returns true for case-only differences on Windows', async () => { - setUserAgent('Mozilla/5.0 (Windows NT 10.0)'); - const { pathsEqual } = await loadFresh(); - expect(pathsEqual('C:\\Users\\Foo', 'c:/users/foo')).toBe(true); - }); - - it('returns false for case-only differences on Linux', async () => { - setUserAgent('Mozilla/5.0 (X11; Linux x86_64)'); - const { pathsEqual } = await loadFresh(); - expect(pathsEqual('/home/Runner/work', '/home/runner/work')).toBe(false); - }); - - it('returns true for identical paths on every platform', async () => { - setUserAgent('Mozilla/5.0 (X11; Linux x86_64)'); - const { pathsEqual } = await loadFresh(); - expect(pathsEqual('/a/b/c', '/a/b/c')).toBe(true); - }); - - it('treats both nullish as equal and one nullish as unequal', async () => { - setUserAgent('Mozilla/5.0 (Linux)'); - const { pathsEqual } = await loadFresh(); - expect(pathsEqual(null, null)).toBe(true); - expect(pathsEqual(undefined, undefined)).toBe(true); - expect(pathsEqual(null, undefined)).toBe(true); - expect(pathsEqual(undefined, null)).toBe(true); - expect(pathsEqual(null, '/foo')).toBe(false); - expect(pathsEqual('/foo', undefined)).toBe(false); - }); - }); - - describe('pathStartsWith', () => { - it('matches a directory prefix on Windows (case-insensitive)', async () => { - setUserAgent('Mozilla/5.0 (Windows NT 10.0)'); - const { pathStartsWith } = await loadFresh(); - expect(pathStartsWith('C:\\Users\\foo', 'c:/users/FOO/projects/x')).toBe(true); - }); - - it('does not match prefixes that are not directory boundaries', async () => { - setUserAgent('Mozilla/5.0 (Linux)'); - const { pathStartsWith } = await loadFresh(); - expect(pathStartsWith('/foo/bar', '/foo/barbaz')).toBe(false); - }); - - it('matches when parent has a trailing separator', async () => { - setUserAgent('Mozilla/5.0 (Linux)'); - const { pathStartsWith } = await loadFresh(); - expect(pathStartsWith('/foo/', '/foo/bar')).toBe(true); - }); - - it('matches the path itself', async () => { - setUserAgent('Mozilla/5.0 (Linux)'); - const { pathStartsWith } = await loadFresh(); - expect(pathStartsWith('/foo/bar', '/foo/bar')).toBe(true); - expect(pathStartsWith('/foo/bar/', '/foo/bar')).toBe(true); - }); - - it('preserves Linux case-sensitivity', async () => { - setUserAgent('Mozilla/5.0 (X11; Linux x86_64)'); - const { pathStartsWith } = await loadFresh(); - expect(pathStartsWith('/home/Runner', '/home/runner/work')).toBe(false); - }); - }); - - describe('findPath', () => { - it('returns the original-case entry when looking up by lowercased path on Windows', async () => { - setUserAgent('Mozilla/5.0 (Windows NT 10.0)'); - const { findPath } = await loadFresh(); - const items = [{ path: 'C:\\Users\\Alice\\repo' }, { path: 'C:\\Users\\Alice\\other' }]; - const match = findPath(items, item => item.path, 'c:/users/alice/repo'); - expect(match).not.toBeNull(); - expect(match?.path).toBe('C:\\Users\\Alice\\repo'); - }); - - it('preserves case on Linux and returns null when case differs', async () => { - setUserAgent('Mozilla/5.0 (X11; Linux x86_64)'); - const { findPath } = await loadFresh(); - const items = [{ path: '/home/runner/work/SproutGit/SproutGit' }]; - expect( - findPath(items, item => item.path, '/home/runner/work/sproutgit/sproutgit') - ).toBeNull(); - expect( - findPath(items, item => item.path, '/home/runner/work/SproutGit/SproutGit')?.path - ).toBe('/home/runner/work/SproutGit/SproutGit'); - }); - }); -}); diff --git a/tests/paths.test.ts b/tests/paths.test.ts deleted file mode 100644 index 1c2b8c6..0000000 --- a/tests/paths.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// ── Mock Tauri path API before importing the module ────────────────────────── -vi.mock('@tauri-apps/api/path', () => ({ - homeDir: vi.fn().mockResolvedValue('/home/alice'), -})); - -// ── Dynamic import so the mock is in place when the module initialises ─────── -const { tildify } = await import('$lib/paths.svelte'); - -// Wait for the homeDir() promise to resolve and _home to be set -await new Promise(resolve => setTimeout(resolve, 0)); - -// ───────────────────────────────────────────────────────────────────────────── - -describe('tildify', () => { - it('replaces home directory prefix with ~', () => { - expect(tildify('/home/alice/Projects/foo')).toBe('~/Projects/foo'); - }); - - it('returns exactly ~ when path equals the home directory', () => { - expect(tildify('/home/alice')).toBe('~'); - }); - - it('does not modify paths outside the home directory', () => { - expect(tildify('/tmp/other')).toBe('/tmp/other'); - expect(tildify('/var/log/syslog')).toBe('/var/log/syslog'); - }); - - it('does not double-tildify an already-tildified path', () => { - // ~/foo is not an absolute path starting with /home/alice, so untouched - expect(tildify('~/Projects/foo')).toBe('~/Projects/foo'); - }); - - it('returns an empty string unchanged', () => { - expect(tildify('')).toBe(''); - }); - - it('does not match partial home prefix without a separator', () => { - // /home/alicebob is a different user — should not be tildified - expect(tildify('/home/alicebob/stuff')).toBe('/home/alicebob/stuff'); - }); -}); diff --git a/tests/update-release-notes.test.ts b/tests/update-release-notes.test.ts deleted file mode 100644 index ce362a0..0000000 --- a/tests/update-release-notes.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -const ORIGINAL_FETCH = globalThis.fetch; - -afterEach(() => { - if (ORIGINAL_FETCH) { - globalThis.fetch = ORIGINAL_FETCH; - } else { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete (globalThis as { fetch?: typeof fetch }).fetch; - } - vi.restoreAllMocks(); -}); - -describe('loadReleaseNotesBetween', () => { - it('includes the target version and excludes the current version', async () => { - const mockedReleases = [ - { tag_name: 'v0.4.0', body: 'target release notes', draft: false }, - { tag_name: 'v0.3.0', body: 'intermediate release notes', draft: false }, - { tag_name: 'v0.2.0', body: 'current release notes', draft: false }, - { tag_name: 'v0.1.0', body: 'older release notes', draft: false }, - ]; - - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => mockedReleases, - }); - vi.stubGlobal('fetch', fetchMock); - - const { loadReleaseNotesBetween } = await import('$lib/update.svelte'); - const notes = await loadReleaseNotesBetween('0.2.0', '0.4.0'); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(notes).toContain('v0.4.0'); - expect(notes).toContain('target release notes'); - expect(notes).toContain('v0.3.0'); - expect(notes).toContain('intermediate release notes'); - expect(notes).not.toContain('v0.2.0'); - expect(notes).not.toContain('current release notes'); - expect(notes).not.toContain('v0.1.0'); - }); - - it('returns null when current and target versions are the same', async () => { - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - - const { loadReleaseNotesBetween } = await import('$lib/update.svelte'); - const notes = await loadReleaseNotesBetween('0.4.0', '0.4.0'); - - expect(notes).toBeNull(); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/worktree-create-sort.test.ts b/tests/worktree-create-sort.test.ts deleted file mode 100644 index 8c6ca4c..0000000 --- a/tests/worktree-create-sort.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import type { RefInfo } from '$lib/sproutgit'; -import { makeCompareRefsForCreate, preferredSourceRef } from '$lib/ref-utils'; - -function ref(name: string, kind: RefInfo['kind'] = 'remote'): RefInfo { - return { name, fullName: name, kind, target: '' }; -} - -describe('compareRefsForCreate', () => { - it('ranks default remote branch first when specified', () => { - const refs = [ref('origin/alpha'), ref('origin/main'), ref('origin/zebra')]; - const sorted = [...refs].sort(makeCompareRefsForCreate('origin/main')); - expect(sorted[0].name).toBe('origin/main'); - }); - - it('ranks upstream/* before origin/* when no default is set', () => { - const refs = [ref('origin/main'), ref('upstream/main')]; - const sorted = [...refs].sort(makeCompareRefsForCreate()); - expect(sorted[0].name).toBe('upstream/main'); - }); - - it('ranks origin/* before other remotes', () => { - const refs = [ref('fork/feature'), ref('origin/main')]; - const sorted = [...refs].sort(makeCompareRefsForCreate()); - expect(sorted[0].name).toBe('origin/main'); - }); - - it('ranks remote branches before local branches', () => { - const refs = [ref('feature', 'branch'), ref('origin/main')]; - const sorted = [...refs].sort(makeCompareRefsForCreate()); - expect(sorted[0].kind).toBe('remote'); - }); - - it('sorts alphabetically within the same rank', () => { - const refs = [ref('origin/zebra'), ref('origin/alpha'), ref('origin/main')]; - const sorted = [...refs].sort(makeCompareRefsForCreate()); - expect(sorted.map(r => r.name)).toEqual(['origin/alpha', 'origin/main', 'origin/zebra']); - }); -}); - -describe('preferredSourceRef', () => { - it('returns defaultRemoteBranch when it is in the list', () => { - const refs = [ref('origin/alpha'), ref('origin/main'), ref('origin/zebra')]; - expect(preferredSourceRef(refs, 'origin/main')).toBe('origin/main'); - }); - - it('falls back to highest-ranked remote when default is not in list', () => { - const refs = [ref('origin/alpha'), ref('origin/zebra')]; - expect(preferredSourceRef(refs, 'origin/main')).toBe('origin/alpha'); - }); - - it('falls back to upstream/* over origin/* when no default', () => { - const refs = [ref('origin/main'), ref('upstream/main')]; - expect(preferredSourceRef(refs)).toBe('upstream/main'); - }); - - it('returns HEAD when ref list is empty', () => { - expect(preferredSourceRef([])).toBe('HEAD'); - }); -}); diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index c054b8f..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "./.svelte-kit/tsconfig.json", - "compilerOptions": { - "allowJs": true, - "checkJs": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "sourceMap": true, - "strict": true, - "moduleResolution": "bundler" - }, - "exclude": ["website/**/*"] - // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias - // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files - // - // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes - // from the referenced tsconfig.json - TypeScript does not merge them in -} diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..d746e8d --- /dev/null +++ b/turbo.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://turbo.build/schema.json", + "ui": "tui", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "dev": { + "persistent": true, + "cache": false + }, + "test": { + "dependsOn": ["^build"], + "outputs": ["coverage/**"] + }, + "lint": { + "dependsOn": ["^build"] + }, + "typecheck": { + "dependsOn": ["^build"] + } + } +} diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index b28f6f1..0000000 --- a/vite.config.js +++ /dev/null @@ -1,49 +0,0 @@ -import { defineConfig } from 'vite'; -import { sveltekit } from '@sveltejs/kit/vite'; -import tailwindcss from '@tailwindcss/vite'; - -const host = process.env.TAURI_DEV_HOST; - -/** - * @param {string} name - * @param {number} fallback - */ -function parsePortEnv(name, fallback) { - const raw = process.env[name]; - if (!raw || !raw.trim()) return fallback; - const parsed = Number.parseInt(raw, 10); - if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) { - throw new Error(`Invalid ${name}: ${raw}`); - } - return parsed; -} - -const port = parsePortEnv('SPROUTGIT_E2E_DEV_PORT', 1420); -const hmrPort = parsePortEnv('SPROUTGIT_E2E_HMR_PORT', 1421); - -// https://vite.dev/config/ -export default defineConfig(async () => ({ - plugins: [sveltekit(), tailwindcss()], - - // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` - // - // 1. prevent Vite from obscuring rust errors - clearScreen: false, - // 2. tauri expects a fixed port, fail if that port is not available - server: { - port, - strictPort: true, - host: host || false, - hmr: host - ? { - protocol: 'ws', - host, - port: hmrPort, - } - : undefined, - watch: { - // 3. tell Vite to ignore watching `src-tauri` - ignored: ['**/src-tauri/**'], - }, - }, -})); diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index c747eaf..0000000 --- a/vitest.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig } from 'vitest/config'; -import { svelte } from '@sveltejs/vite-plugin-svelte'; - -export default defineConfig({ - plugins: [svelte()], - resolve: { - alias: { - $lib: '/src/lib', - }, - }, - test: { - globals: true, - environment: 'jsdom', - include: ['tests/**/*.test.ts'], - }, -}); diff --git a/website/.astro/content.d.ts b/website/.astro/content.d.ts new file mode 100644 index 0000000..d9eaab4 --- /dev/null +++ b/website/.astro/content.d.ts @@ -0,0 +1,154 @@ +declare module 'astro:content' { + export interface RenderResult { + Content: import('astro/runtime/server/index.js').AstroComponentFactory; + headings: import('astro').MarkdownHeading[]; + remarkPluginFrontmatter: Record; + } + interface Render { + '.md': Promise; + } + + export interface RenderedContent { + html: string; + metadata?: { + imagePaths: Array; + [key: string]: unknown; + }; + } + + type Flatten = T extends { [K: string]: infer U } ? U : never; + + export type CollectionKey = keyof DataEntryMap; + export type CollectionEntry = Flatten; + + type AllValuesOf = T extends any ? T[keyof T] : never; + + export type ReferenceDataEntry< + C extends CollectionKey, + E extends keyof DataEntryMap[C] = string, + > = { + collection: C; + id: E; + }; + + export type ReferenceLiveEntry = { + collection: C; + id: string; + }; + + export function getCollection>( + collection: C, + filter?: (entry: CollectionEntry) => entry is E, + ): Promise; + export function getCollection( + collection: C, + filter?: (entry: CollectionEntry) => unknown, + ): Promise[]>; + + export function getLiveCollection( + collection: C, + filter?: LiveLoaderCollectionFilterType, + ): Promise< + import('astro').LiveDataCollectionResult, LiveLoaderErrorType> + >; + + export function getEntry< + C extends keyof DataEntryMap, + E extends keyof DataEntryMap[C] | (string & {}), + >( + entry: ReferenceDataEntry, + ): E extends keyof DataEntryMap[C] + ? Promise + : Promise | undefined>; + export function getEntry< + C extends keyof DataEntryMap, + E extends keyof DataEntryMap[C] | (string & {}), + >( + collection: C, + id: E, + ): E extends keyof DataEntryMap[C] + ? string extends keyof DataEntryMap[C] + ? Promise | undefined + : Promise + : Promise | undefined>; + export function getLiveEntry( + collection: C, + filter: string | LiveLoaderEntryFilterType, + ): Promise, LiveLoaderErrorType>>; + + /** Resolve an array of entry references from the same collection */ + export function getEntries( + entries: ReferenceDataEntry[], + ): Promise[]>; + + export function render( + entry: DataEntryMap[C][string], + ): Promise; + + export function reference< + C extends + | keyof DataEntryMap + // Allow generic `string` to avoid excessive type errors in the config + // if `dev` is not running to update as you edit. + // Invalid collection names will be caught at build time. + | (string & {}), + >( + collection: C, + ): import('astro/zod').ZodPipe< + import('astro/zod').ZodString, + import('astro/zod').ZodTransform< + C extends keyof DataEntryMap + ? { + collection: C; + id: string; + } + : never, + string + > + >; + + type ReturnTypeOrOriginal = T extends (...args: any[]) => infer R ? R : T; + type InferEntrySchema = import('astro/zod').infer< + ReturnTypeOrOriginal['schema']> + >; + type ExtractLoaderConfig = T extends { loader: infer L } ? L : never; + type InferLoaderSchema< + C extends keyof DataEntryMap, + L = ExtractLoaderConfig, + > = L extends { schema: import('astro/zod').ZodSchema } + ? import('astro/zod').infer + : any; + + type DataEntryMap = { + + }; + + type ExtractLoaderTypes = T extends import('astro/loaders').LiveLoader< + infer TData, + infer TEntryFilter, + infer TCollectionFilter, + infer TError + > + ? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError } + : { data: never; entryFilter: never; collectionFilter: never; error: never }; + type ExtractEntryFilterType = ExtractLoaderTypes['entryFilter']; + type ExtractCollectionFilterType = ExtractLoaderTypes['collectionFilter']; + type ExtractErrorType = ExtractLoaderTypes['error']; + + type LiveLoaderDataType = + LiveContentConfig['collections'][C]['schema'] extends undefined + ? ExtractDataType + : import('astro/zod').infer< + Exclude + >; + type LiveLoaderEntryFilterType = + ExtractEntryFilterType; + type LiveLoaderCollectionFilterType = + ExtractCollectionFilterType; + type LiveLoaderErrorType = ExtractErrorType< + LiveContentConfig['collections'][C]['loader'] + >; + + export type ContentConfig = never; + export type LiveContentConfig = never; +} diff --git a/website/.astro/settings.json b/website/.astro/settings.json new file mode 100644 index 0000000..edca518 --- /dev/null +++ b/website/.astro/settings.json @@ -0,0 +1,5 @@ +{ + "_variables": { + "lastUpdateCheck": 1778711178085 + } +} \ No newline at end of file diff --git a/website/.astro/types.d.ts b/website/.astro/types.d.ts new file mode 100644 index 0000000..f964fe0 --- /dev/null +++ b/website/.astro/types.d.ts @@ -0,0 +1 @@ +/// diff --git a/website/package.json b/website/package.json index 6ee268d..61c782c 100644 --- a/website/package.json +++ b/website/package.json @@ -13,7 +13,8 @@ "sharp": "^0.34.5" }, "devDependencies": { - "@tailwindcss/vite": "^4.2.4", - "tailwindcss": "^4.2.4" + "@tailwindcss/vite": "4.2.4", + "tailwindcss": "4.2.4", + "vite": "^7.3.2" } -} +} \ No newline at end of file diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml deleted file mode 100644 index 1b993b0..0000000 --- a/website/pnpm-lock.yaml +++ /dev/null @@ -1,3371 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@fontsource/space-grotesk': - specifier: ^5.2.10 - version: 5.2.10 - astro: - specifier: ^6.1.10 - version: 6.1.10(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.2)(typescript@5.9.3) - sharp: - specifier: ^0.34.5 - version: 0.34.5 - devDependencies: - '@tailwindcss/vite': - specifier: ^4.2.4 - version: 4.2.4(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)) - tailwindcss: - specifier: ^4.2.4 - version: 4.2.4 - -packages: - - '@astrojs/compiler@3.0.1': - resolution: {integrity: sha512-z97oYbdebO5aoWzuJ/8q5hLK232+17KcLZ7cJ8BCWk6+qNzVxn/gftC0KzMBUTD8WAaBkPpNSQK6PXLnNrZ0CA==} - - '@astrojs/internal-helpers@0.9.0': - resolution: {integrity: sha512-GdYkzR26re8izmyYlBqf4z2s7zNngmWLFuxw0UKiPNqHraZGS6GKWIwSHgS22RDlu2ePFJ8bzmpBcUszut/SDg==} - - '@astrojs/markdown-remark@7.1.1': - resolution: {integrity: sha512-C6e9BnLGlbdv6bV8MYGeHpHxsUHrCrB4OuRLqi5LI7oiBVcBcqfUN06zpwFQdHgV48QCCrMmLpyqBr7VqC+swA==} - - '@astrojs/prism@4.0.1': - resolution: {integrity: sha512-nksZQVjlferuWzhPsBpQ1JE5XuKAf1id1/9Hj4a9KG4+ofrlzxUUwX4YGQF/SuDiuiGKEnzopGOt38F3AnVWsQ==} - engines: {node: '>=22.12.0'} - - '@astrojs/telemetry@3.3.1': - resolution: {integrity: sha512-7fcIxXS9J4ls5tr8b3ww9rbAIz2+HrhNJYZdkAhhB4za/I5IZ/60g+Bs8q7zwG0tOIZfNB4JWhVJ1Qkl/OrNCw==} - engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - - '@capsizecss/unpack@4.0.0': - resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} - engines: {node: '>=18'} - - '@clack/core@1.2.0': - resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==} - - '@clack/prompts@1.2.0': - resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==} - - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@fontsource/space-grotesk@5.2.10': - resolution: {integrity: sha512-XNXEbT74OIITPqw2H6HXwPDp85fy43uxfBwFR5PU+9sLnjuLj12KlhVM9nZVN6q6dlKjkuN8JisW/OBxwxgUew==} - - '@img/colour@1.1.0': - resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@oslojs/encoding@1.1.0': - resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} - - '@rollup/pluginutils@5.3.0': - resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - - '@rollup/rollup-android-arm-eabi@4.60.2': - resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.60.2': - resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.60.2': - resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.60.2': - resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.60.2': - resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.60.2': - resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.60.2': - resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.60.2': - resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.60.2': - resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.60.2': - resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.60.2': - resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.60.2': - resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.60.2': - resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.60.2': - resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.60.2': - resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.60.2': - resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.60.2': - resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.60.2': - resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.60.2': - resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openbsd-x64@4.60.2': - resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.60.2': - resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.60.2': - resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.60.2': - resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.60.2': - resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.60.2': - resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} - cpu: [x64] - os: [win32] - - '@shikijs/core@4.0.2': - resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} - engines: {node: '>=20'} - - '@shikijs/engine-javascript@4.0.2': - resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==} - engines: {node: '>=20'} - - '@shikijs/engine-oniguruma@4.0.2': - resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==} - engines: {node: '>=20'} - - '@shikijs/langs@4.0.2': - resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==} - engines: {node: '>=20'} - - '@shikijs/primitive@4.0.2': - resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==} - engines: {node: '>=20'} - - '@shikijs/themes@4.0.2': - resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==} - engines: {node: '>=20'} - - '@shikijs/types@4.0.2': - resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==} - engines: {node: '>=20'} - - '@shikijs/vscode-textmate@10.0.2': - resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} - - '@tailwindcss/node@4.2.4': - resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} - - '@tailwindcss/oxide-android-arm64@4.2.4': - resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.2.4': - resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.2.4': - resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==} - engines: {node: '>= 20'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-freebsd-x64@4.2.4': - resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==} - engines: {node: '>= 20'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': - resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==} - engines: {node: '>= 20'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': - resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@tailwindcss/oxide-linux-arm64-musl@4.2.4': - resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@tailwindcss/oxide-linux-x64-gnu@4.2.4': - resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@tailwindcss/oxide-linux-x64-musl@4.2.4': - resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@tailwindcss/oxide-wasm32-wasi@4.2.4': - resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - - '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': - resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.2.4': - resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==} - engines: {node: '>= 20'} - cpu: [x64] - os: [win32] - - '@tailwindcss/oxide@4.2.4': - resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==} - engines: {node: '>= 20'} - - '@tailwindcss/vite@4.2.4': - resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==} - peerDependencies: - vite: ^5.2.0 || ^6 || ^7 || ^8 - - '@types/debug@4.1.13': - resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/hast@3.0.4': - resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - - '@types/mdast@4.0.4': - resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} - - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - - '@types/nlcst@2.0.3': - resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} - - '@types/unist@3.0.3': - resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - - '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - - array-iterate@2.0.1: - resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} - - astro@6.1.10: - resolution: {integrity: sha512-jQAIki6c862oxRr7OXXC+h3n4wg1EpmKgCH3vv1FtXM9VFmD2iTjlaxrfb0I6eQCwtUjSBxfJBFBDSXHu7Wing==} - engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} - hasBin: true - - axobject-query@4.1.0: - resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} - engines: {node: '>= 0.4'} - - bail@2.0.2: - resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} - - boolbase@1.0.0: - resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - - ccount@2.0.1: - resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - - character-entities-html4@2.1.0: - resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} - - character-entities-legacy@3.0.0: - resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} - - character-entities@2.0.2: - resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} - - chokidar@5.0.0: - resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} - engines: {node: '>= 20.19.0'} - - ci-info@4.4.0: - resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} - engines: {node: '>=8'} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - comma-separated-tokens@2.0.3: - resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} - - commander@11.1.0: - resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} - engines: {node: '>=16'} - - common-ancestor-path@2.0.0: - resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} - engines: {node: '>= 18'} - - cookie-es@1.2.3: - resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} - - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} - engines: {node: '>=18'} - - crossws@0.3.5: - resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} - - css-select@5.2.2: - resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} - - css-tree@2.2.1: - resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} - - css-tree@3.2.1: - resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - - css-what@6.2.2: - resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} - engines: {node: '>= 6'} - - csso@5.0.5: - resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decode-named-character-reference@1.3.0: - resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} - - defu@6.1.7: - resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} - - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - - destr@2.0.5: - resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - devalue@5.7.1: - resolution: {integrity: sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==} - - devlop@1.1.0: - resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - - diff@8.0.4: - resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} - engines: {node: '>=0.3.1'} - - dlv@1.1.3: - resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - - dom-serializer@2.0.0: - resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} - - domelementtype@2.3.0: - resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - - domhandler@5.0.3: - resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} - engines: {node: '>= 4'} - - domutils@3.2.2: - resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - - dset@3.1.4: - resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} - engines: {node: '>=4'} - - enhanced-resolve@5.21.0: - resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} - engines: {node: '>=10.13.0'} - - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} - engines: {node: '>=0.12'} - - entities@6.0.1: - resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} - engines: {node: '>=0.12'} - - es-module-lexer@2.1.0: - resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} - engines: {node: '>=18'} - hasBin: true - - escape-string-regexp@5.0.0: - resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} - engines: {node: '>=12'} - - estree-walker@2.0.2: - resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - - eventemitter3@5.0.4: - resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - - extend@3.0.2: - resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - - fast-string-truncated-width@1.2.1: - resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==} - - fast-string-width@1.1.0: - resolution: {integrity: sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==} - - fast-wrap-ansi@0.1.6: - resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - flattie@1.1.1: - resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} - engines: {node: '>=8'} - - fontace@0.4.1: - resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} - - fontkitten@1.0.3: - resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} - engines: {node: '>=20'} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - github-slugger@2.0.0: - resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - h3@1.15.11: - resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} - - hast-util-from-html@2.0.3: - resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} - - hast-util-from-parse5@8.0.3: - resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} - - hast-util-is-element@3.0.0: - resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} - - hast-util-parse-selector@4.0.0: - resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} - - hast-util-raw@9.1.0: - resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} - - hast-util-to-html@9.0.5: - resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} - - hast-util-to-parse5@8.0.1: - resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} - - hast-util-to-text@4.0.2: - resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} - - hast-util-whitespace@3.0.0: - resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} - - hastscript@9.0.1: - resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} - - html-escaper@3.0.3: - resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} - - html-void-elements@3.0.0: - resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} - - http-cache-semantics@4.2.0: - resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} - - iron-webcrypto@1.2.1: - resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} - - is-docker@3.0.0: - resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - hasBin: true - - is-docker@4.0.0: - resolution: {integrity: sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==} - engines: {node: '>=20'} - hasBin: true - - is-inside-container@1.0.0: - resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} - engines: {node: '>=14.16'} - hasBin: true - - is-plain-obj@4.1.0: - resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} - engines: {node: '>=12'} - - is-wsl@3.1.1: - resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} - engines: {node: '>=16'} - - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} - hasBin: true - - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} - engines: {node: '>= 12.0.0'} - - longest-streak@3.1.0: - resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} - - lru-cache@11.3.5: - resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} - engines: {node: 20 || >=22} - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - magicast@0.5.2: - resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} - - markdown-table@3.0.4: - resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - - mdast-util-definitions@6.0.0: - resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} - - mdast-util-find-and-replace@3.0.2: - resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} - - mdast-util-from-markdown@2.0.3: - resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} - - mdast-util-gfm-autolink-literal@2.0.1: - resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} - - mdast-util-gfm-footnote@2.1.0: - resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} - - mdast-util-gfm-strikethrough@2.0.0: - resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} - - mdast-util-gfm-table@2.0.0: - resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} - - mdast-util-gfm-task-list-item@2.0.0: - resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} - - mdast-util-gfm@3.1.0: - resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} - - mdast-util-phrasing@4.1.0: - resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} - - mdast-util-to-hast@13.2.1: - resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} - - mdast-util-to-markdown@2.1.2: - resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} - - mdast-util-to-string@4.0.0: - resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} - - mdn-data@2.0.28: - resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} - - mdn-data@2.27.1: - resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - - micromark-core-commonmark@2.0.3: - resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} - - micromark-extension-gfm-autolink-literal@2.1.0: - resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} - - micromark-extension-gfm-footnote@2.1.0: - resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} - - micromark-extension-gfm-strikethrough@2.1.0: - resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} - - micromark-extension-gfm-table@2.1.1: - resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} - - micromark-extension-gfm-tagfilter@2.0.0: - resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} - - micromark-extension-gfm-task-list-item@2.1.0: - resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} - - micromark-extension-gfm@3.0.0: - resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} - - micromark-factory-destination@2.0.1: - resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} - - micromark-factory-label@2.0.1: - resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} - - micromark-factory-space@2.0.1: - resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} - - micromark-factory-title@2.0.1: - resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} - - micromark-factory-whitespace@2.0.1: - resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} - - micromark-util-character@2.1.1: - resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} - - micromark-util-chunked@2.0.1: - resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} - - micromark-util-classify-character@2.0.1: - resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} - - micromark-util-combine-extensions@2.0.1: - resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} - - micromark-util-decode-numeric-character-reference@2.0.2: - resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} - - micromark-util-decode-string@2.0.1: - resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} - - micromark-util-encode@2.0.1: - resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} - - micromark-util-html-tag-name@2.0.1: - resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} - - micromark-util-normalize-identifier@2.0.1: - resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} - - micromark-util-resolve-all@2.0.1: - resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} - - micromark-util-sanitize-uri@2.0.1: - resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} - - micromark-util-subtokenize@2.1.0: - resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} - - micromark-util-symbol@2.0.1: - resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} - - micromark-util-types@2.0.2: - resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} - - micromark@4.0.2: - resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - - mrmime@2.0.1: - resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} - engines: {node: '>=10'} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - neotraverse@0.6.18: - resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} - engines: {node: '>= 10'} - - nlcst-to-string@4.0.0: - resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} - - node-fetch-native@1.6.7: - resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} - - node-mock-http@1.0.4: - resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - nth-check@2.1.1: - resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - - obug@2.1.1: - resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - - ofetch@1.5.1: - resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} - - ohash@2.0.11: - resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} - - oniguruma-parser@0.12.2: - resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} - - oniguruma-to-es@4.3.6: - resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} - - p-limit@7.3.0: - resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} - engines: {node: '>=20'} - - p-queue@9.2.0: - resolution: {integrity: sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g==} - engines: {node: '>=20'} - - p-timeout@7.0.1: - resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} - engines: {node: '>=20'} - - package-manager-detector@1.6.0: - resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} - - parse-latin@7.0.0: - resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} - - parse5@7.3.0: - resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - - piccolore@0.1.3: - resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - postcss@8.5.12: - resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} - engines: {node: ^10 || ^12 || >=14} - - prismjs@1.30.0: - resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} - engines: {node: '>=6'} - - property-information@7.1.0: - resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} - - radix3@1.1.2: - resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} - - readdirp@5.0.0: - resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} - engines: {node: '>= 20.19.0'} - - regex-recursion@6.0.2: - resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} - - regex-utilities@2.3.0: - resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} - - regex@6.1.0: - resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} - - rehype-parse@9.0.1: - resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} - - rehype-raw@7.0.0: - resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} - - rehype-stringify@10.0.1: - resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} - - rehype@13.0.2: - resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==} - - remark-gfm@4.0.1: - resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} - - remark-parse@11.0.0: - resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} - - remark-rehype@11.1.2: - resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} - - remark-smartypants@3.0.2: - resolution: {integrity: sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==} - engines: {node: '>=16.0.0'} - - remark-stringify@11.0.0: - resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} - - retext-latin@4.0.0: - resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} - - retext-smartypants@6.2.0: - resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} - - retext-stringify@4.0.0: - resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==} - - retext@9.0.0: - resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} - - rollup@4.60.2: - resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - sax@1.6.0: - resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} - engines: {node: '>=11.0.0'} - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - - shiki@4.0.2: - resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} - engines: {node: '>=20'} - - sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - - smol-toml@1.6.1: - resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} - engines: {node: '>= 18'} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - space-separated-tokens@2.0.2: - resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - - stringify-entities@4.0.4: - resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} - - svgo@4.0.1: - resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} - engines: {node: '>=16'} - hasBin: true - - tailwindcss@4.2.4: - resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} - - tapable@2.3.3: - resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} - engines: {node: '>=6'} - - tiny-inflate@1.0.3: - resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} - - tinyclip@0.1.12: - resolution: {integrity: sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA==} - engines: {node: ^16.14.0 || >= 17.3.0} - - tinyexec@1.1.1: - resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} - engines: {node: '>=18'} - - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} - engines: {node: '>=12.0.0'} - - trim-lines@3.0.1: - resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} - - trough@2.2.0: - resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - - tsconfck@3.1.6: - resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} - engines: {node: ^18 || >=20} - hasBin: true - peerDependencies: - typescript: ^5.0.0 - peerDependenciesMeta: - typescript: - optional: true - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - ufo@1.6.3: - resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} - - ultrahtml@1.6.0: - resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} - - uncrypto@0.1.3: - resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} - - unified@11.0.5: - resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} - - unifont@0.7.4: - resolution: {integrity: sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==} - - unist-util-find-after@5.0.0: - resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} - - unist-util-is@6.0.1: - resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} - - unist-util-modify-children@4.0.0: - resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} - - unist-util-position@5.0.0: - resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} - - unist-util-remove-position@5.0.0: - resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} - - unist-util-stringify-position@4.0.0: - resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} - - unist-util-visit-children@3.0.0: - resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} - - unist-util-visit-parents@6.0.2: - resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} - - unist-util-visit@5.1.0: - resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} - - unstorage@1.17.5: - resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} - peerDependencies: - '@azure/app-configuration': ^1.8.0 - '@azure/cosmos': ^4.2.0 - '@azure/data-tables': ^13.3.0 - '@azure/identity': ^4.6.0 - '@azure/keyvault-secrets': ^4.9.0 - '@azure/storage-blob': ^12.26.0 - '@capacitor/preferences': ^6 || ^7 || ^8 - '@deno/kv': '>=0.9.0' - '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 - '@planetscale/database': ^1.19.0 - '@upstash/redis': ^1.34.3 - '@vercel/blob': '>=0.27.1' - '@vercel/functions': ^2.2.12 || ^3.0.0 - '@vercel/kv': ^1 || ^2 || ^3 - aws4fetch: ^1.0.20 - db0: '>=0.2.1' - idb-keyval: ^6.2.1 - ioredis: ^5.4.2 - uploadthing: ^7.4.4 - peerDependenciesMeta: - '@azure/app-configuration': - optional: true - '@azure/cosmos': - optional: true - '@azure/data-tables': - optional: true - '@azure/identity': - optional: true - '@azure/keyvault-secrets': - optional: true - '@azure/storage-blob': - optional: true - '@capacitor/preferences': - optional: true - '@deno/kv': - optional: true - '@netlify/blobs': - optional: true - '@planetscale/database': - optional: true - '@upstash/redis': - optional: true - '@vercel/blob': - optional: true - '@vercel/functions': - optional: true - '@vercel/kv': - optional: true - aws4fetch: - optional: true - db0: - optional: true - idb-keyval: - optional: true - ioredis: - optional: true - uploadthing: - optional: true - - vfile-location@5.0.3: - resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} - - vfile-message@4.0.3: - resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} - - vfile@6.0.3: - resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - - vite@7.3.2: - resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitefu@1.1.3: - resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} - peerDependencies: - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - vite: - optional: true - - web-namespaces@2.0.1: - resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} - - which-pm-runs@1.1.0: - resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} - engines: {node: '>=4'} - - xxhash-wasm@1.1.0: - resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} - - yargs-parser@22.0.0: - resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} - engines: {node: ^20.19.0 || ^22.12.0 || >=23} - - yocto-queue@1.2.2: - resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} - engines: {node: '>=12.20'} - - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - - zwitch@2.0.4: - resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} - -snapshots: - - '@astrojs/compiler@3.0.1': {} - - '@astrojs/internal-helpers@0.9.0': - dependencies: - picomatch: 4.0.4 - - '@astrojs/markdown-remark@7.1.1': - dependencies: - '@astrojs/internal-helpers': 0.9.0 - '@astrojs/prism': 4.0.1 - github-slugger: 2.0.0 - hast-util-from-html: 2.0.3 - hast-util-to-text: 4.0.2 - js-yaml: 4.1.1 - mdast-util-definitions: 6.0.0 - rehype-raw: 7.0.0 - rehype-stringify: 10.0.1 - remark-gfm: 4.0.1 - remark-parse: 11.0.0 - remark-rehype: 11.1.2 - remark-smartypants: 3.0.2 - retext-smartypants: 6.2.0 - shiki: 4.0.2 - smol-toml: 1.6.1 - unified: 11.0.5 - unist-util-remove-position: 5.0.0 - unist-util-visit: 5.1.0 - unist-util-visit-parents: 6.0.2 - vfile: 6.0.3 - transitivePeerDependencies: - - supports-color - - '@astrojs/prism@4.0.1': - dependencies: - prismjs: 1.30.0 - - '@astrojs/telemetry@3.3.1': - dependencies: - ci-info: 4.4.0 - dlv: 1.1.3 - dset: 3.1.4 - is-docker: 4.0.0 - is-wsl: 3.1.1 - which-pm-runs: 1.1.0 - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/parser@7.29.2': - dependencies: - '@babel/types': 7.29.0 - - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@capsizecss/unpack@4.0.0': - dependencies: - fontkitten: 1.0.3 - - '@clack/core@1.2.0': - dependencies: - fast-wrap-ansi: 0.1.6 - sisteransi: 1.0.5 - - '@clack/prompts@1.2.0': - dependencies: - '@clack/core': 1.2.0 - fast-string-width: 1.1.0 - fast-wrap-ansi: 0.1.6 - sisteransi: 1.0.5 - - '@emnapi/runtime@1.10.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@esbuild/aix-ppc64@0.27.7': - optional: true - - '@esbuild/android-arm64@0.27.7': - optional: true - - '@esbuild/android-arm@0.27.7': - optional: true - - '@esbuild/android-x64@0.27.7': - optional: true - - '@esbuild/darwin-arm64@0.27.7': - optional: true - - '@esbuild/darwin-x64@0.27.7': - optional: true - - '@esbuild/freebsd-arm64@0.27.7': - optional: true - - '@esbuild/freebsd-x64@0.27.7': - optional: true - - '@esbuild/linux-arm64@0.27.7': - optional: true - - '@esbuild/linux-arm@0.27.7': - optional: true - - '@esbuild/linux-ia32@0.27.7': - optional: true - - '@esbuild/linux-loong64@0.27.7': - optional: true - - '@esbuild/linux-mips64el@0.27.7': - optional: true - - '@esbuild/linux-ppc64@0.27.7': - optional: true - - '@esbuild/linux-riscv64@0.27.7': - optional: true - - '@esbuild/linux-s390x@0.27.7': - optional: true - - '@esbuild/linux-x64@0.27.7': - optional: true - - '@esbuild/netbsd-arm64@0.27.7': - optional: true - - '@esbuild/netbsd-x64@0.27.7': - optional: true - - '@esbuild/openbsd-arm64@0.27.7': - optional: true - - '@esbuild/openbsd-x64@0.27.7': - optional: true - - '@esbuild/openharmony-arm64@0.27.7': - optional: true - - '@esbuild/sunos-x64@0.27.7': - optional: true - - '@esbuild/win32-arm64@0.27.7': - optional: true - - '@esbuild/win32-ia32@0.27.7': - optional: true - - '@esbuild/win32-x64@0.27.7': - optional: true - - '@fontsource/space-grotesk@5.2.10': {} - - '@img/colour@1.1.0': {} - - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.10.0 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@oslojs/encoding@1.1.0': {} - - '@rollup/pluginutils@5.3.0(rollup@4.60.2)': - dependencies: - '@types/estree': 1.0.8 - estree-walker: 2.0.2 - picomatch: 4.0.4 - optionalDependencies: - rollup: 4.60.2 - - '@rollup/rollup-android-arm-eabi@4.60.2': - optional: true - - '@rollup/rollup-android-arm64@4.60.2': - optional: true - - '@rollup/rollup-darwin-arm64@4.60.2': - optional: true - - '@rollup/rollup-darwin-x64@4.60.2': - optional: true - - '@rollup/rollup-freebsd-arm64@4.60.2': - optional: true - - '@rollup/rollup-freebsd-x64@4.60.2': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.60.2': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.60.2': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.60.2': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.60.2': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.60.2': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.60.2': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.60.2': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.60.2': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.60.2': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.60.2': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.60.2': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.60.2': - optional: true - - '@rollup/rollup-linux-x64-musl@4.60.2': - optional: true - - '@rollup/rollup-openbsd-x64@4.60.2': - optional: true - - '@rollup/rollup-openharmony-arm64@4.60.2': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.60.2': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.60.2': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.60.2': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.60.2': - optional: true - - '@shikijs/core@4.0.2': - dependencies: - '@shikijs/primitive': 4.0.2 - '@shikijs/types': 4.0.2 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - hast-util-to-html: 9.0.5 - - '@shikijs/engine-javascript@4.0.2': - dependencies: - '@shikijs/types': 4.0.2 - '@shikijs/vscode-textmate': 10.0.2 - oniguruma-to-es: 4.3.6 - - '@shikijs/engine-oniguruma@4.0.2': - dependencies: - '@shikijs/types': 4.0.2 - '@shikijs/vscode-textmate': 10.0.2 - - '@shikijs/langs@4.0.2': - dependencies: - '@shikijs/types': 4.0.2 - - '@shikijs/primitive@4.0.2': - dependencies: - '@shikijs/types': 4.0.2 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - - '@shikijs/themes@4.0.2': - dependencies: - '@shikijs/types': 4.0.2 - - '@shikijs/types@4.0.2': - dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - - '@shikijs/vscode-textmate@10.0.2': {} - - '@tailwindcss/node@4.2.4': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.21.0 - jiti: 2.6.1 - lightningcss: 1.32.0 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.2.4 - - '@tailwindcss/oxide-android-arm64@4.2.4': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.2.4': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.2.4': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.2.4': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.2.4': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.2.4': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.2.4': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.2.4': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.2.4': - optional: true - - '@tailwindcss/oxide@4.2.4': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.4 - '@tailwindcss/oxide-darwin-arm64': 4.2.4 - '@tailwindcss/oxide-darwin-x64': 4.2.4 - '@tailwindcss/oxide-freebsd-x64': 4.2.4 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.4 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.4 - '@tailwindcss/oxide-linux-x64-musl': 4.2.4 - '@tailwindcss/oxide-wasm32-wasi': 4.2.4 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - - '@tailwindcss/vite@4.2.4(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))': - dependencies: - '@tailwindcss/node': 4.2.4 - '@tailwindcss/oxide': 4.2.4 - tailwindcss: 4.2.4 - vite: 7.3.2(jiti@2.6.1)(lightningcss@1.32.0) - - '@types/debug@4.1.13': - dependencies: - '@types/ms': 2.1.0 - - '@types/estree@1.0.8': {} - - '@types/hast@3.0.4': - dependencies: - '@types/unist': 3.0.3 - - '@types/mdast@4.0.4': - dependencies: - '@types/unist': 3.0.3 - - '@types/ms@2.1.0': {} - - '@types/nlcst@2.0.3': - dependencies: - '@types/unist': 3.0.3 - - '@types/unist@3.0.3': {} - - '@ungap/structured-clone@1.3.0': {} - - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.2 - - argparse@2.0.1: {} - - aria-query@5.3.2: {} - - array-iterate@2.0.1: {} - - astro@6.1.10(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.2)(typescript@5.9.3): - dependencies: - '@astrojs/compiler': 3.0.1 - '@astrojs/internal-helpers': 0.9.0 - '@astrojs/markdown-remark': 7.1.1 - '@astrojs/telemetry': 3.3.1 - '@capsizecss/unpack': 4.0.0 - '@clack/prompts': 1.2.0 - '@oslojs/encoding': 1.1.0 - '@rollup/pluginutils': 5.3.0(rollup@4.60.2) - aria-query: 5.3.2 - axobject-query: 4.1.0 - ci-info: 4.4.0 - clsx: 2.1.1 - common-ancestor-path: 2.0.0 - cookie: 1.1.1 - devalue: 5.7.1 - diff: 8.0.4 - dset: 3.1.4 - es-module-lexer: 2.1.0 - esbuild: 0.27.7 - flattie: 1.1.1 - fontace: 0.4.1 - github-slugger: 2.0.0 - html-escaper: 3.0.3 - http-cache-semantics: 4.2.0 - js-yaml: 4.1.1 - magic-string: 0.30.21 - magicast: 0.5.2 - mrmime: 2.0.1 - neotraverse: 0.6.18 - obug: 2.1.1 - p-limit: 7.3.0 - p-queue: 9.2.0 - package-manager-detector: 1.6.0 - piccolore: 0.1.3 - picomatch: 4.0.4 - rehype: 13.0.2 - semver: 7.7.4 - shiki: 4.0.2 - smol-toml: 1.6.1 - svgo: 4.0.1 - tinyclip: 0.1.12 - tinyexec: 1.1.1 - tinyglobby: 0.2.16 - tsconfck: 3.1.6(typescript@5.9.3) - ultrahtml: 1.6.0 - unifont: 0.7.4 - unist-util-visit: 5.1.0 - unstorage: 1.17.5 - vfile: 6.0.3 - vite: 7.3.2(jiti@2.6.1)(lightningcss@1.32.0) - vitefu: 1.1.3(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)) - xxhash-wasm: 1.1.0 - yargs-parser: 22.0.0 - zod: 4.3.6 - optionalDependencies: - sharp: 0.34.5 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@netlify/blobs' - - '@planetscale/database' - - '@types/node' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - db0 - - idb-keyval - - ioredis - - jiti - - less - - lightningcss - - rollup - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - typescript - - uploadthing - - yaml - - axobject-query@4.1.0: {} - - bail@2.0.2: {} - - boolbase@1.0.0: {} - - ccount@2.0.1: {} - - character-entities-html4@2.1.0: {} - - character-entities-legacy@3.0.0: {} - - character-entities@2.0.2: {} - - chokidar@5.0.0: - dependencies: - readdirp: 5.0.0 - - ci-info@4.4.0: {} - - clsx@2.1.1: {} - - comma-separated-tokens@2.0.3: {} - - commander@11.1.0: {} - - common-ancestor-path@2.0.0: {} - - cookie-es@1.2.3: {} - - cookie@1.1.1: {} - - crossws@0.3.5: - dependencies: - uncrypto: 0.1.3 - - css-select@5.2.2: - dependencies: - boolbase: 1.0.0 - css-what: 6.2.2 - domhandler: 5.0.3 - domutils: 3.2.2 - nth-check: 2.1.1 - - css-tree@2.2.1: - dependencies: - mdn-data: 2.0.28 - source-map-js: 1.2.1 - - css-tree@3.2.1: - dependencies: - mdn-data: 2.27.1 - source-map-js: 1.2.1 - - css-what@6.2.2: {} - - csso@5.0.5: - dependencies: - css-tree: 2.2.1 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decode-named-character-reference@1.3.0: - dependencies: - character-entities: 2.0.2 - - defu@6.1.7: {} - - dequal@2.0.3: {} - - destr@2.0.5: {} - - detect-libc@2.1.2: {} - - devalue@5.7.1: {} - - devlop@1.1.0: - dependencies: - dequal: 2.0.3 - - diff@8.0.4: {} - - dlv@1.1.3: {} - - dom-serializer@2.0.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - entities: 4.5.0 - - domelementtype@2.3.0: {} - - domhandler@5.0.3: - dependencies: - domelementtype: 2.3.0 - - domutils@3.2.2: - dependencies: - dom-serializer: 2.0.0 - domelementtype: 2.3.0 - domhandler: 5.0.3 - - dset@3.1.4: {} - - enhanced-resolve@5.21.0: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.3 - - entities@4.5.0: {} - - entities@6.0.1: {} - - es-module-lexer@2.1.0: {} - - esbuild@0.27.7: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 - - escape-string-regexp@5.0.0: {} - - estree-walker@2.0.2: {} - - eventemitter3@5.0.4: {} - - extend@3.0.2: {} - - fast-string-truncated-width@1.2.1: {} - - fast-string-width@1.1.0: - dependencies: - fast-string-truncated-width: 1.2.1 - - fast-wrap-ansi@0.1.6: - dependencies: - fast-string-width: 1.1.0 - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - flattie@1.1.1: {} - - fontace@0.4.1: - dependencies: - fontkitten: 1.0.3 - - fontkitten@1.0.3: - dependencies: - tiny-inflate: 1.0.3 - - fsevents@2.3.3: - optional: true - - github-slugger@2.0.0: {} - - graceful-fs@4.2.11: {} - - h3@1.15.11: - dependencies: - cookie-es: 1.2.3 - crossws: 0.3.5 - defu: 6.1.7 - destr: 2.0.5 - iron-webcrypto: 1.2.1 - node-mock-http: 1.0.4 - radix3: 1.1.2 - ufo: 1.6.3 - uncrypto: 0.1.3 - - hast-util-from-html@2.0.3: - dependencies: - '@types/hast': 3.0.4 - devlop: 1.1.0 - hast-util-from-parse5: 8.0.3 - parse5: 7.3.0 - vfile: 6.0.3 - vfile-message: 4.0.3 - - hast-util-from-parse5@8.0.3: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - devlop: 1.1.0 - hastscript: 9.0.1 - property-information: 7.1.0 - vfile: 6.0.3 - vfile-location: 5.0.3 - web-namespaces: 2.0.1 - - hast-util-is-element@3.0.0: - dependencies: - '@types/hast': 3.0.4 - - hast-util-parse-selector@4.0.0: - dependencies: - '@types/hast': 3.0.4 - - hast-util-raw@9.1.0: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - '@ungap/structured-clone': 1.3.0 - hast-util-from-parse5: 8.0.3 - hast-util-to-parse5: 8.0.1 - html-void-elements: 3.0.0 - mdast-util-to-hast: 13.2.1 - parse5: 7.3.0 - unist-util-position: 5.0.0 - unist-util-visit: 5.1.0 - vfile: 6.0.3 - web-namespaces: 2.0.1 - zwitch: 2.0.4 - - hast-util-to-html@9.0.5: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - ccount: 2.0.1 - comma-separated-tokens: 2.0.3 - hast-util-whitespace: 3.0.0 - html-void-elements: 3.0.0 - mdast-util-to-hast: 13.2.1 - property-information: 7.1.0 - space-separated-tokens: 2.0.2 - stringify-entities: 4.0.4 - zwitch: 2.0.4 - - hast-util-to-parse5@8.0.1: - dependencies: - '@types/hast': 3.0.4 - comma-separated-tokens: 2.0.3 - devlop: 1.1.0 - property-information: 7.1.0 - space-separated-tokens: 2.0.2 - web-namespaces: 2.0.1 - zwitch: 2.0.4 - - hast-util-to-text@4.0.2: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - hast-util-is-element: 3.0.0 - unist-util-find-after: 5.0.0 - - hast-util-whitespace@3.0.0: - dependencies: - '@types/hast': 3.0.4 - - hastscript@9.0.1: - dependencies: - '@types/hast': 3.0.4 - comma-separated-tokens: 2.0.3 - hast-util-parse-selector: 4.0.0 - property-information: 7.1.0 - space-separated-tokens: 2.0.2 - - html-escaper@3.0.3: {} - - html-void-elements@3.0.0: {} - - http-cache-semantics@4.2.0: {} - - iron-webcrypto@1.2.1: {} - - is-docker@3.0.0: {} - - is-docker@4.0.0: {} - - is-inside-container@1.0.0: - dependencies: - is-docker: 3.0.0 - - is-plain-obj@4.1.0: {} - - is-wsl@3.1.1: - dependencies: - is-inside-container: 1.0.0 - - jiti@2.6.1: {} - - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - lightningcss-android-arm64@1.32.0: - optional: true - - lightningcss-darwin-arm64@1.32.0: - optional: true - - lightningcss-darwin-x64@1.32.0: - optional: true - - lightningcss-freebsd-x64@1.32.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.32.0: - optional: true - - lightningcss-linux-arm64-gnu@1.32.0: - optional: true - - lightningcss-linux-arm64-musl@1.32.0: - optional: true - - lightningcss-linux-x64-gnu@1.32.0: - optional: true - - lightningcss-linux-x64-musl@1.32.0: - optional: true - - lightningcss-win32-arm64-msvc@1.32.0: - optional: true - - lightningcss-win32-x64-msvc@1.32.0: - optional: true - - lightningcss@1.32.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 - - longest-streak@3.1.0: {} - - lru-cache@11.3.5: {} - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - magicast@0.5.2: - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - source-map-js: 1.2.1 - - markdown-table@3.0.4: {} - - mdast-util-definitions@6.0.0: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - unist-util-visit: 5.1.0 - - mdast-util-find-and-replace@3.0.2: - dependencies: - '@types/mdast': 4.0.4 - escape-string-regexp: 5.0.0 - unist-util-is: 6.0.1 - unist-util-visit-parents: 6.0.2 - - mdast-util-from-markdown@2.0.3: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - decode-named-character-reference: 1.3.0 - devlop: 1.1.0 - mdast-util-to-string: 4.0.0 - micromark: 4.0.2 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-decode-string: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - unist-util-stringify-position: 4.0.0 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-autolink-literal@2.0.1: - dependencies: - '@types/mdast': 4.0.4 - ccount: 2.0.1 - devlop: 1.1.0 - mdast-util-find-and-replace: 3.0.2 - micromark-util-character: 2.1.1 - - mdast-util-gfm-footnote@2.1.0: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - micromark-util-normalize-identifier: 2.0.1 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-strikethrough@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-table@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-task-list-item@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm@3.1.0: - dependencies: - mdast-util-from-markdown: 2.0.3 - mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0 - mdast-util-gfm-strikethrough: 2.0.0 - mdast-util-gfm-table: 2.0.0 - mdast-util-gfm-task-list-item: 2.0.0 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-phrasing@4.1.0: - dependencies: - '@types/mdast': 4.0.4 - unist-util-is: 6.0.1 - - mdast-util-to-hast@13.2.1: - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - '@ungap/structured-clone': 1.3.0 - devlop: 1.1.0 - micromark-util-sanitize-uri: 2.0.1 - trim-lines: 3.0.1 - unist-util-position: 5.0.0 - unist-util-visit: 5.1.0 - vfile: 6.0.3 - - mdast-util-to-markdown@2.1.2: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - longest-streak: 3.1.0 - mdast-util-phrasing: 4.1.0 - mdast-util-to-string: 4.0.0 - micromark-util-classify-character: 2.0.1 - micromark-util-decode-string: 2.0.1 - unist-util-visit: 5.1.0 - zwitch: 2.0.4 - - mdast-util-to-string@4.0.0: - dependencies: - '@types/mdast': 4.0.4 - - mdn-data@2.0.28: {} - - mdn-data@2.27.1: {} - - micromark-core-commonmark@2.0.3: - dependencies: - decode-named-character-reference: 1.3.0 - devlop: 1.1.0 - micromark-factory-destination: 2.0.1 - micromark-factory-label: 2.0.1 - micromark-factory-space: 2.0.1 - micromark-factory-title: 2.0.1 - micromark-factory-whitespace: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.1 - micromark-util-classify-character: 2.0.1 - micromark-util-html-tag-name: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-subtokenize: 2.1.0 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-autolink-literal@2.1.0: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-footnote@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-strikethrough@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-util-chunked: 2.0.1 - micromark-util-classify-character: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-table@2.1.1: - dependencies: - devlop: 1.1.0 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-tagfilter@2.0.0: - dependencies: - micromark-util-types: 2.0.2 - - micromark-extension-gfm-task-list-item@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm@3.0.0: - dependencies: - micromark-extension-gfm-autolink-literal: 2.1.0 - micromark-extension-gfm-footnote: 2.1.0 - micromark-extension-gfm-strikethrough: 2.1.0 - micromark-extension-gfm-table: 2.1.1 - micromark-extension-gfm-tagfilter: 2.0.0 - micromark-extension-gfm-task-list-item: 2.1.0 - micromark-util-combine-extensions: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-destination@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-label@2.0.1: - dependencies: - devlop: 1.1.0 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-space@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-types: 2.0.2 - - micromark-factory-title@2.0.1: - dependencies: - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-whitespace@2.0.1: - dependencies: - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-character@2.1.1: - dependencies: - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-chunked@2.0.1: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-classify-character@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-combine-extensions@2.0.1: - dependencies: - micromark-util-chunked: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-decode-numeric-character-reference@2.0.2: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-decode-string@2.0.1: - dependencies: - decode-named-character-reference: 1.3.0 - micromark-util-character: 2.1.1 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-symbol: 2.0.1 - - micromark-util-encode@2.0.1: {} - - micromark-util-html-tag-name@2.0.1: {} - - micromark-util-normalize-identifier@2.0.1: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-resolve-all@2.0.1: - dependencies: - micromark-util-types: 2.0.2 - - micromark-util-sanitize-uri@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-encode: 2.0.1 - micromark-util-symbol: 2.0.1 - - micromark-util-subtokenize@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-util-chunked: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-symbol@2.0.1: {} - - micromark-util-types@2.0.2: {} - - micromark@4.0.2: - dependencies: - '@types/debug': 4.1.13 - debug: 4.4.3 - decode-named-character-reference: 1.3.0 - devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.1 - micromark-util-combine-extensions: 2.0.1 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-encode: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-subtokenize: 2.1.0 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - transitivePeerDependencies: - - supports-color - - mrmime@2.0.1: {} - - ms@2.1.3: {} - - nanoid@3.3.11: {} - - neotraverse@0.6.18: {} - - nlcst-to-string@4.0.0: - dependencies: - '@types/nlcst': 2.0.3 - - node-fetch-native@1.6.7: {} - - node-mock-http@1.0.4: {} - - normalize-path@3.0.0: {} - - nth-check@2.1.1: - dependencies: - boolbase: 1.0.0 - - obug@2.1.1: {} - - ofetch@1.5.1: - dependencies: - destr: 2.0.5 - node-fetch-native: 1.6.7 - ufo: 1.6.3 - - ohash@2.0.11: {} - - oniguruma-parser@0.12.2: {} - - oniguruma-to-es@4.3.6: - dependencies: - oniguruma-parser: 0.12.2 - regex: 6.1.0 - regex-recursion: 6.0.2 - - p-limit@7.3.0: - dependencies: - yocto-queue: 1.2.2 - - p-queue@9.2.0: - dependencies: - eventemitter3: 5.0.4 - p-timeout: 7.0.1 - - p-timeout@7.0.1: {} - - package-manager-detector@1.6.0: {} - - parse-latin@7.0.0: - dependencies: - '@types/nlcst': 2.0.3 - '@types/unist': 3.0.3 - nlcst-to-string: 4.0.0 - unist-util-modify-children: 4.0.0 - unist-util-visit-children: 3.0.0 - vfile: 6.0.3 - - parse5@7.3.0: - dependencies: - entities: 6.0.1 - - piccolore@0.1.3: {} - - picocolors@1.1.1: {} - - picomatch@2.3.2: {} - - picomatch@4.0.4: {} - - postcss@8.5.12: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prismjs@1.30.0: {} - - property-information@7.1.0: {} - - radix3@1.1.2: {} - - readdirp@5.0.0: {} - - regex-recursion@6.0.2: - dependencies: - regex-utilities: 2.3.0 - - regex-utilities@2.3.0: {} - - regex@6.1.0: - dependencies: - regex-utilities: 2.3.0 - - rehype-parse@9.0.1: - dependencies: - '@types/hast': 3.0.4 - hast-util-from-html: 2.0.3 - unified: 11.0.5 - - rehype-raw@7.0.0: - dependencies: - '@types/hast': 3.0.4 - hast-util-raw: 9.1.0 - vfile: 6.0.3 - - rehype-stringify@10.0.1: - dependencies: - '@types/hast': 3.0.4 - hast-util-to-html: 9.0.5 - unified: 11.0.5 - - rehype@13.0.2: - dependencies: - '@types/hast': 3.0.4 - rehype-parse: 9.0.1 - rehype-stringify: 10.0.1 - unified: 11.0.5 - - remark-gfm@4.0.1: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0 - micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0 - remark-stringify: 11.0.0 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - - remark-parse@11.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 - micromark-util-types: 2.0.2 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - - remark-rehype@11.1.2: - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - mdast-util-to-hast: 13.2.1 - unified: 11.0.5 - vfile: 6.0.3 - - remark-smartypants@3.0.2: - dependencies: - retext: 9.0.0 - retext-smartypants: 6.2.0 - unified: 11.0.5 - unist-util-visit: 5.1.0 - - remark-stringify@11.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-to-markdown: 2.1.2 - unified: 11.0.5 - - retext-latin@4.0.0: - dependencies: - '@types/nlcst': 2.0.3 - parse-latin: 7.0.0 - unified: 11.0.5 - - retext-smartypants@6.2.0: - dependencies: - '@types/nlcst': 2.0.3 - nlcst-to-string: 4.0.0 - unist-util-visit: 5.1.0 - - retext-stringify@4.0.0: - dependencies: - '@types/nlcst': 2.0.3 - nlcst-to-string: 4.0.0 - unified: 11.0.5 - - retext@9.0.0: - dependencies: - '@types/nlcst': 2.0.3 - retext-latin: 4.0.0 - retext-stringify: 4.0.0 - unified: 11.0.5 - - rollup@4.60.2: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.2 - '@rollup/rollup-android-arm64': 4.60.2 - '@rollup/rollup-darwin-arm64': 4.60.2 - '@rollup/rollup-darwin-x64': 4.60.2 - '@rollup/rollup-freebsd-arm64': 4.60.2 - '@rollup/rollup-freebsd-x64': 4.60.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 - '@rollup/rollup-linux-arm-musleabihf': 4.60.2 - '@rollup/rollup-linux-arm64-gnu': 4.60.2 - '@rollup/rollup-linux-arm64-musl': 4.60.2 - '@rollup/rollup-linux-loong64-gnu': 4.60.2 - '@rollup/rollup-linux-loong64-musl': 4.60.2 - '@rollup/rollup-linux-ppc64-gnu': 4.60.2 - '@rollup/rollup-linux-ppc64-musl': 4.60.2 - '@rollup/rollup-linux-riscv64-gnu': 4.60.2 - '@rollup/rollup-linux-riscv64-musl': 4.60.2 - '@rollup/rollup-linux-s390x-gnu': 4.60.2 - '@rollup/rollup-linux-x64-gnu': 4.60.2 - '@rollup/rollup-linux-x64-musl': 4.60.2 - '@rollup/rollup-openbsd-x64': 4.60.2 - '@rollup/rollup-openharmony-arm64': 4.60.2 - '@rollup/rollup-win32-arm64-msvc': 4.60.2 - '@rollup/rollup-win32-ia32-msvc': 4.60.2 - '@rollup/rollup-win32-x64-gnu': 4.60.2 - '@rollup/rollup-win32-x64-msvc': 4.60.2 - fsevents: 2.3.3 - - sax@1.6.0: {} - - semver@7.7.4: {} - - sharp@0.34.5: - dependencies: - '@img/colour': 1.1.0 - detect-libc: 2.1.2 - semver: 7.7.4 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - - shiki@4.0.2: - dependencies: - '@shikijs/core': 4.0.2 - '@shikijs/engine-javascript': 4.0.2 - '@shikijs/engine-oniguruma': 4.0.2 - '@shikijs/langs': 4.0.2 - '@shikijs/themes': 4.0.2 - '@shikijs/types': 4.0.2 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - - sisteransi@1.0.5: {} - - smol-toml@1.6.1: {} - - source-map-js@1.2.1: {} - - space-separated-tokens@2.0.2: {} - - stringify-entities@4.0.4: - dependencies: - character-entities-html4: 2.1.0 - character-entities-legacy: 3.0.0 - - svgo@4.0.1: - dependencies: - commander: 11.1.0 - css-select: 5.2.2 - css-tree: 3.2.1 - css-what: 6.2.2 - csso: 5.0.5 - picocolors: 1.1.1 - sax: 1.6.0 - - tailwindcss@4.2.4: {} - - tapable@2.3.3: {} - - tiny-inflate@1.0.3: {} - - tinyclip@0.1.12: {} - - tinyexec@1.1.1: {} - - tinyglobby@0.2.16: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - trim-lines@3.0.1: {} - - trough@2.2.0: {} - - tsconfck@3.1.6(typescript@5.9.3): - optionalDependencies: - typescript: 5.9.3 - - tslib@2.8.1: - optional: true - - typescript@5.9.3: - optional: true - - ufo@1.6.3: {} - - ultrahtml@1.6.0: {} - - uncrypto@0.1.3: {} - - unified@11.0.5: - dependencies: - '@types/unist': 3.0.3 - bail: 2.0.2 - devlop: 1.1.0 - extend: 3.0.2 - is-plain-obj: 4.1.0 - trough: 2.2.0 - vfile: 6.0.3 - - unifont@0.7.4: - dependencies: - css-tree: 3.2.1 - ofetch: 1.5.1 - ohash: 2.0.11 - - unist-util-find-after@5.0.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - - unist-util-is@6.0.1: - dependencies: - '@types/unist': 3.0.3 - - unist-util-modify-children@4.0.0: - dependencies: - '@types/unist': 3.0.3 - array-iterate: 2.0.1 - - unist-util-position@5.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-remove-position@5.0.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-visit: 5.1.0 - - unist-util-stringify-position@4.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-visit-children@3.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-visit-parents@6.0.2: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - - unist-util-visit@5.1.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - unist-util-visit-parents: 6.0.2 - - unstorage@1.17.5: - dependencies: - anymatch: 3.1.3 - chokidar: 5.0.0 - destr: 2.0.5 - h3: 1.15.11 - lru-cache: 11.3.5 - node-fetch-native: 1.6.7 - ofetch: 1.5.1 - ufo: 1.6.3 - - vfile-location@5.0.3: - dependencies: - '@types/unist': 3.0.3 - vfile: 6.0.3 - - vfile-message@4.0.3: - dependencies: - '@types/unist': 3.0.3 - unist-util-stringify-position: 4.0.0 - - vfile@6.0.3: - dependencies: - '@types/unist': 3.0.3 - vfile-message: 4.0.3 - - vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0): - dependencies: - esbuild: 0.27.7 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.12 - rollup: 4.60.2 - tinyglobby: 0.2.16 - optionalDependencies: - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.32.0 - - vitefu@1.1.3(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)): - optionalDependencies: - vite: 7.3.2(jiti@2.6.1)(lightningcss@1.32.0) - - web-namespaces@2.0.1: {} - - which-pm-runs@1.1.0: {} - - xxhash-wasm@1.1.0: {} - - yargs-parser@22.0.0: {} - - yocto-queue@1.2.2: {} - - zod@4.3.6: {} - - zwitch@2.0.4: {} diff --git a/website/public/screenshots/context-menu/commit-history-dark.png b/website/public/screenshots/context-menu/commit-history-dark.png deleted file mode 100644 index 8c3d44a..0000000 Binary files a/website/public/screenshots/context-menu/commit-history-dark.png and /dev/null differ diff --git a/website/public/screenshots/context-menu/commit-history-light.png b/website/public/screenshots/context-menu/commit-history-light.png deleted file mode 100644 index 17500a3..0000000 Binary files a/website/public/screenshots/context-menu/commit-history-light.png and /dev/null differ diff --git a/website/public/screenshots/diff/changes-panel-dark.png b/website/public/screenshots/diff/changes-panel-dark.png deleted file mode 100644 index 7ad16df..0000000 Binary files a/website/public/screenshots/diff/changes-panel-dark.png and /dev/null differ diff --git a/website/public/screenshots/diff/changes-panel-light.png b/website/public/screenshots/diff/changes-panel-light.png deleted file mode 100644 index 9e2b0b1..0000000 Binary files a/website/public/screenshots/diff/changes-panel-light.png and /dev/null differ diff --git a/website/public/screenshots/settings/preferences-dark.png b/website/public/screenshots/settings/preferences-dark.png deleted file mode 100644 index 158f0b2..0000000 Binary files a/website/public/screenshots/settings/preferences-dark.png and /dev/null differ diff --git a/website/public/screenshots/settings/preferences-light.png b/website/public/screenshots/settings/preferences-light.png deleted file mode 100644 index e2c2a51..0000000 Binary files a/website/public/screenshots/settings/preferences-light.png and /dev/null differ diff --git a/website/public/screenshots/terminal/grid-view-dark.png b/website/public/screenshots/terminal/grid-view-dark.png deleted file mode 100644 index c31f684..0000000 Binary files a/website/public/screenshots/terminal/grid-view-dark.png and /dev/null differ diff --git a/website/public/screenshots/terminal/grid-view-light.png b/website/public/screenshots/terminal/grid-view-light.png deleted file mode 100644 index 3a82c98..0000000 Binary files a/website/public/screenshots/terminal/grid-view-light.png and /dev/null differ diff --git a/website/public/screenshots/workspace/commit-graph-dark.png b/website/public/screenshots/workspace/commit-graph-dark.png deleted file mode 100644 index 56db483..0000000 Binary files a/website/public/screenshots/workspace/commit-graph-dark.png and /dev/null differ diff --git a/website/public/screenshots/workspace/commit-graph-light.png b/website/public/screenshots/workspace/commit-graph-light.png deleted file mode 100644 index df04908..0000000 Binary files a/website/public/screenshots/workspace/commit-graph-light.png and /dev/null differ diff --git a/website/src/assets/screenshots/context-menu/commit-history-dark.png b/website/src/assets/screenshots/context-menu/commit-history-dark.png deleted file mode 100644 index 7ae0be9..0000000 Binary files a/website/src/assets/screenshots/context-menu/commit-history-dark.png and /dev/null differ diff --git a/website/src/assets/screenshots/context-menu/commit-history-light.png b/website/src/assets/screenshots/context-menu/commit-history-light.png deleted file mode 100644 index b1b8902..0000000 Binary files a/website/src/assets/screenshots/context-menu/commit-history-light.png and /dev/null differ diff --git a/website/src/assets/screenshots/diff/changes-panel-dark.png b/website/src/assets/screenshots/diff/changes-panel-dark.png deleted file mode 100644 index fb21a2a..0000000 Binary files a/website/src/assets/screenshots/diff/changes-panel-dark.png and /dev/null differ diff --git a/website/src/assets/screenshots/diff/changes-panel-light.png b/website/src/assets/screenshots/diff/changes-panel-light.png deleted file mode 100644 index 58701b4..0000000 Binary files a/website/src/assets/screenshots/diff/changes-panel-light.png and /dev/null differ diff --git a/website/src/assets/screenshots/hooks/management-dark.png b/website/src/assets/screenshots/hooks/management-dark.png deleted file mode 100644 index 93aad5e..0000000 Binary files a/website/src/assets/screenshots/hooks/management-dark.png and /dev/null differ diff --git a/website/src/assets/screenshots/hooks/management-light.png b/website/src/assets/screenshots/hooks/management-light.png deleted file mode 100644 index 0bf0755..0000000 Binary files a/website/src/assets/screenshots/hooks/management-light.png and /dev/null differ diff --git a/website/src/assets/screenshots/linux/context-menu/commit-history-dark.png b/website/src/assets/screenshots/linux/context-menu/commit-history-dark.png new file mode 100644 index 0000000..e76cff6 Binary files /dev/null and b/website/src/assets/screenshots/linux/context-menu/commit-history-dark.png differ diff --git a/website/src/assets/screenshots/linux/context-menu/commit-history-light.png b/website/src/assets/screenshots/linux/context-menu/commit-history-light.png new file mode 100644 index 0000000..f6d7a10 Binary files /dev/null and b/website/src/assets/screenshots/linux/context-menu/commit-history-light.png differ diff --git a/website/src/assets/screenshots/linux/diff/changes-panel-dark.png b/website/src/assets/screenshots/linux/diff/changes-panel-dark.png new file mode 100644 index 0000000..6fa0741 Binary files /dev/null and b/website/src/assets/screenshots/linux/diff/changes-panel-dark.png differ diff --git a/website/src/assets/screenshots/linux/diff/changes-panel-light.png b/website/src/assets/screenshots/linux/diff/changes-panel-light.png new file mode 100644 index 0000000..5c95798 Binary files /dev/null and b/website/src/assets/screenshots/linux/diff/changes-panel-light.png differ diff --git a/website/src/assets/screenshots/linux/hooks/management-dark.png b/website/src/assets/screenshots/linux/hooks/management-dark.png new file mode 100644 index 0000000..88e87ae Binary files /dev/null and b/website/src/assets/screenshots/linux/hooks/management-dark.png differ diff --git a/website/src/assets/screenshots/linux/hooks/management-light.png b/website/src/assets/screenshots/linux/hooks/management-light.png new file mode 100644 index 0000000..2b4f5d4 Binary files /dev/null and b/website/src/assets/screenshots/linux/hooks/management-light.png differ diff --git a/website/src/assets/screenshots/linux/settings/preferences-dark.png b/website/src/assets/screenshots/linux/settings/preferences-dark.png new file mode 100644 index 0000000..1edff90 Binary files /dev/null and b/website/src/assets/screenshots/linux/settings/preferences-dark.png differ diff --git a/website/src/assets/screenshots/linux/settings/preferences-light.png b/website/src/assets/screenshots/linux/settings/preferences-light.png new file mode 100644 index 0000000..3852cb7 Binary files /dev/null and b/website/src/assets/screenshots/linux/settings/preferences-light.png differ diff --git a/website/src/assets/screenshots/linux/terminal/grid-view-dark.png b/website/src/assets/screenshots/linux/terminal/grid-view-dark.png new file mode 100644 index 0000000..c7bfa26 Binary files /dev/null and b/website/src/assets/screenshots/linux/terminal/grid-view-dark.png differ diff --git a/website/src/assets/screenshots/linux/terminal/grid-view-light.png b/website/src/assets/screenshots/linux/terminal/grid-view-light.png new file mode 100644 index 0000000..a5da4bb Binary files /dev/null and b/website/src/assets/screenshots/linux/terminal/grid-view-light.png differ diff --git a/website/src/assets/screenshots/linux/workspace/commit-graph-dark.png b/website/src/assets/screenshots/linux/workspace/commit-graph-dark.png new file mode 100644 index 0000000..2d735e9 Binary files /dev/null and b/website/src/assets/screenshots/linux/workspace/commit-graph-dark.png differ diff --git a/website/src/assets/screenshots/linux/workspace/commit-graph-light.png b/website/src/assets/screenshots/linux/workspace/commit-graph-light.png new file mode 100644 index 0000000..c4146ff Binary files /dev/null and b/website/src/assets/screenshots/linux/workspace/commit-graph-light.png differ diff --git a/website/src/assets/screenshots/mac/context-menu/commit-history-dark.png b/website/src/assets/screenshots/mac/context-menu/commit-history-dark.png new file mode 100644 index 0000000..e76cff6 Binary files /dev/null and b/website/src/assets/screenshots/mac/context-menu/commit-history-dark.png differ diff --git a/website/src/assets/screenshots/mac/context-menu/commit-history-light.png b/website/src/assets/screenshots/mac/context-menu/commit-history-light.png new file mode 100644 index 0000000..f6d7a10 Binary files /dev/null and b/website/src/assets/screenshots/mac/context-menu/commit-history-light.png differ diff --git a/website/src/assets/screenshots/mac/diff/changes-panel-dark.png b/website/src/assets/screenshots/mac/diff/changes-panel-dark.png new file mode 100644 index 0000000..6fa0741 Binary files /dev/null and b/website/src/assets/screenshots/mac/diff/changes-panel-dark.png differ diff --git a/website/src/assets/screenshots/mac/diff/changes-panel-light.png b/website/src/assets/screenshots/mac/diff/changes-panel-light.png new file mode 100644 index 0000000..5c95798 Binary files /dev/null and b/website/src/assets/screenshots/mac/diff/changes-panel-light.png differ diff --git a/website/src/assets/screenshots/mac/hooks/management-dark.png b/website/src/assets/screenshots/mac/hooks/management-dark.png new file mode 100644 index 0000000..88e87ae Binary files /dev/null and b/website/src/assets/screenshots/mac/hooks/management-dark.png differ diff --git a/website/src/assets/screenshots/mac/hooks/management-light.png b/website/src/assets/screenshots/mac/hooks/management-light.png new file mode 100644 index 0000000..2b4f5d4 Binary files /dev/null and b/website/src/assets/screenshots/mac/hooks/management-light.png differ diff --git a/website/src/assets/screenshots/mac/settings/preferences-dark.png b/website/src/assets/screenshots/mac/settings/preferences-dark.png new file mode 100644 index 0000000..1edff90 Binary files /dev/null and b/website/src/assets/screenshots/mac/settings/preferences-dark.png differ diff --git a/website/src/assets/screenshots/mac/settings/preferences-light.png b/website/src/assets/screenshots/mac/settings/preferences-light.png new file mode 100644 index 0000000..3852cb7 Binary files /dev/null and b/website/src/assets/screenshots/mac/settings/preferences-light.png differ diff --git a/website/src/assets/screenshots/mac/terminal/grid-view-dark.png b/website/src/assets/screenshots/mac/terminal/grid-view-dark.png new file mode 100644 index 0000000..c7bfa26 Binary files /dev/null and b/website/src/assets/screenshots/mac/terminal/grid-view-dark.png differ diff --git a/website/src/assets/screenshots/mac/terminal/grid-view-light.png b/website/src/assets/screenshots/mac/terminal/grid-view-light.png new file mode 100644 index 0000000..a5da4bb Binary files /dev/null and b/website/src/assets/screenshots/mac/terminal/grid-view-light.png differ diff --git a/website/src/assets/screenshots/mac/workspace/commit-graph-dark.png b/website/src/assets/screenshots/mac/workspace/commit-graph-dark.png new file mode 100644 index 0000000..2d735e9 Binary files /dev/null and b/website/src/assets/screenshots/mac/workspace/commit-graph-dark.png differ diff --git a/website/src/assets/screenshots/mac/workspace/commit-graph-light.png b/website/src/assets/screenshots/mac/workspace/commit-graph-light.png new file mode 100644 index 0000000..c4146ff Binary files /dev/null and b/website/src/assets/screenshots/mac/workspace/commit-graph-light.png differ diff --git a/website/src/assets/screenshots/settings/preferences-dark.png b/website/src/assets/screenshots/settings/preferences-dark.png deleted file mode 100644 index e096dd9..0000000 Binary files a/website/src/assets/screenshots/settings/preferences-dark.png and /dev/null differ diff --git a/website/src/assets/screenshots/settings/preferences-light.png b/website/src/assets/screenshots/settings/preferences-light.png deleted file mode 100644 index 3fde11a..0000000 Binary files a/website/src/assets/screenshots/settings/preferences-light.png and /dev/null differ diff --git a/website/src/assets/screenshots/terminal/grid-view-dark.png b/website/src/assets/screenshots/terminal/grid-view-dark.png deleted file mode 100644 index 8d03f03..0000000 Binary files a/website/src/assets/screenshots/terminal/grid-view-dark.png and /dev/null differ diff --git a/website/src/assets/screenshots/terminal/grid-view-light.png b/website/src/assets/screenshots/terminal/grid-view-light.png deleted file mode 100644 index c57f204..0000000 Binary files a/website/src/assets/screenshots/terminal/grid-view-light.png and /dev/null differ diff --git a/website/src/assets/screenshots/windows/context-menu/commit-history-dark.png b/website/src/assets/screenshots/windows/context-menu/commit-history-dark.png new file mode 100644 index 0000000..e76cff6 Binary files /dev/null and b/website/src/assets/screenshots/windows/context-menu/commit-history-dark.png differ diff --git a/website/src/assets/screenshots/windows/context-menu/commit-history-light.png b/website/src/assets/screenshots/windows/context-menu/commit-history-light.png new file mode 100644 index 0000000..f6d7a10 Binary files /dev/null and b/website/src/assets/screenshots/windows/context-menu/commit-history-light.png differ diff --git a/website/src/assets/screenshots/windows/diff/changes-panel-dark.png b/website/src/assets/screenshots/windows/diff/changes-panel-dark.png new file mode 100644 index 0000000..6fa0741 Binary files /dev/null and b/website/src/assets/screenshots/windows/diff/changes-panel-dark.png differ diff --git a/website/src/assets/screenshots/windows/diff/changes-panel-light.png b/website/src/assets/screenshots/windows/diff/changes-panel-light.png new file mode 100644 index 0000000..5c95798 Binary files /dev/null and b/website/src/assets/screenshots/windows/diff/changes-panel-light.png differ diff --git a/website/src/assets/screenshots/windows/hooks/management-dark.png b/website/src/assets/screenshots/windows/hooks/management-dark.png new file mode 100644 index 0000000..88e87ae Binary files /dev/null and b/website/src/assets/screenshots/windows/hooks/management-dark.png differ diff --git a/website/src/assets/screenshots/windows/hooks/management-light.png b/website/src/assets/screenshots/windows/hooks/management-light.png new file mode 100644 index 0000000..2b4f5d4 Binary files /dev/null and b/website/src/assets/screenshots/windows/hooks/management-light.png differ diff --git a/website/src/assets/screenshots/windows/settings/preferences-dark.png b/website/src/assets/screenshots/windows/settings/preferences-dark.png new file mode 100644 index 0000000..1edff90 Binary files /dev/null and b/website/src/assets/screenshots/windows/settings/preferences-dark.png differ diff --git a/website/src/assets/screenshots/windows/settings/preferences-light.png b/website/src/assets/screenshots/windows/settings/preferences-light.png new file mode 100644 index 0000000..3852cb7 Binary files /dev/null and b/website/src/assets/screenshots/windows/settings/preferences-light.png differ diff --git a/website/src/assets/screenshots/windows/terminal/grid-view-dark.png b/website/src/assets/screenshots/windows/terminal/grid-view-dark.png new file mode 100644 index 0000000..c7bfa26 Binary files /dev/null and b/website/src/assets/screenshots/windows/terminal/grid-view-dark.png differ diff --git a/website/src/assets/screenshots/windows/terminal/grid-view-light.png b/website/src/assets/screenshots/windows/terminal/grid-view-light.png new file mode 100644 index 0000000..a5da4bb Binary files /dev/null and b/website/src/assets/screenshots/windows/terminal/grid-view-light.png differ diff --git a/website/src/assets/screenshots/windows/workspace/commit-graph-dark.png b/website/src/assets/screenshots/windows/workspace/commit-graph-dark.png new file mode 100644 index 0000000..2d735e9 Binary files /dev/null and b/website/src/assets/screenshots/windows/workspace/commit-graph-dark.png differ diff --git a/website/src/assets/screenshots/windows/workspace/commit-graph-light.png b/website/src/assets/screenshots/windows/workspace/commit-graph-light.png new file mode 100644 index 0000000..c4146ff Binary files /dev/null and b/website/src/assets/screenshots/windows/workspace/commit-graph-light.png differ diff --git a/website/src/assets/screenshots/workspace/commit-graph-dark.png b/website/src/assets/screenshots/workspace/commit-graph-dark.png deleted file mode 100644 index 8f29917..0000000 Binary files a/website/src/assets/screenshots/workspace/commit-graph-dark.png and /dev/null differ diff --git a/website/src/assets/screenshots/workspace/commit-graph-light.png b/website/src/assets/screenshots/workspace/commit-graph-light.png deleted file mode 100644 index 0c0b940..0000000 Binary files a/website/src/assets/screenshots/workspace/commit-graph-light.png and /dev/null differ diff --git a/website/src/components/Features.astro b/website/src/components/Features.astro index ba50eda..e4bc3e1 100644 --- a/website/src/components/Features.astro +++ b/website/src/components/Features.astro @@ -45,7 +45,7 @@ const features = [ { icon: ``, title: 'Lightweight native app', - desc: 'Built with Tauri v2 — a Rust backend and native webview. Small bundle, fast startup, low memory. No Electron, no extra runtime.', + desc: 'Built with Electron — a battle-tested Chromium + Node.js runtime. Ships as a signed native app on macOS, Windows, and Linux with auto-update built in.', tag: 'Platform', }, ]; @@ -127,8 +127,8 @@ const features = [
Open source forever
-
Rust + Tauri v2
-
No Electron
+
Electron + React 19
+
macOS · Windows · Linux
diff --git a/website/src/components/NavHero.astro b/website/src/components/NavHero.astro index 087f859..8b65706 100644 --- a/website/src/components/NavHero.astro +++ b/website/src/components/NavHero.astro @@ -301,7 +301,7 @@ const RELEASES_PAGE = `${GITHUB}/releases/latest`; const assetMatchers = { macos: (/** @type {{ name: string }} */ asset) => asset.name.endsWith('.dmg') && /aarch64|arm64/i.test(asset.name), - windows: (/** @type {{ name: string }} */ asset) => asset.name.endsWith('.msi'), + windows: (/** @type {{ name: string }} */ asset) => asset.name.endsWith('.exe'), linux: (/** @type {{ name: string }} */ asset) => asset.name.endsWith('.AppImage'), }; diff --git a/website/src/components/ScreenshotSlider.astro b/website/src/components/ScreenshotSlider.astro index 3a1e131..212ca67 100644 --- a/website/src/components/ScreenshotSlider.astro +++ b/website/src/components/ScreenshotSlider.astro @@ -2,44 +2,65 @@ import { getImage } from 'astro:assets'; import type { ImageMetadata } from 'astro'; -import commitGraphLight from '../assets/screenshots/workspace/commit-graph-light.png'; -import commitGraphDark from '../assets/screenshots/workspace/commit-graph-dark.png'; -import changesPanelLight from '../assets/screenshots/diff/changes-panel-light.png'; -import changesPanelDark from '../assets/screenshots/diff/changes-panel-dark.png'; -import contextMenuLight from '../assets/screenshots/context-menu/commit-history-light.png'; -import contextMenuDark from '../assets/screenshots/context-menu/commit-history-dark.png'; -import settingsLight from '../assets/screenshots/settings/preferences-light.png'; -import settingsDark from '../assets/screenshots/settings/preferences-dark.png'; -import hooksLight from '../assets/screenshots/hooks/management-light.png'; -import hooksDark from '../assets/screenshots/hooks/management-dark.png'; -import terminalLight from '../assets/screenshots/terminal/grid-view-light.png'; -import terminalDark from '../assets/screenshots/terminal/grid-view-dark.png'; - -// Build optimised AVIF + WebP sources for a light/dark pair in parallel. -async function optimise(l: ImageMetadata, d: ImageMetadata) { +// Eagerly import every PNG under screenshots/ — keyed by relative path. +// Structure: screenshots/{mac|windows|linux}/{category}/{name}-{light|dark}.png +const allShots = import.meta.glob<{ default: ImageMetadata }>( + '../assets/screenshots/**/*.png', + { eager: true }, +); + +function getShot(platform: string, category: string, file: string): ImageMetadata | undefined { + return allShots[`../assets/screenshots/${platform}/${category}/${file}.png`]?.default; +} + +// Build optimised AVIF + WebP + original src strings for a light/dark pair. +async function optimisePair(light: ImageMetadata, dark: ImageMetadata) { const [la, lw, lp, da, dw, dp] = await Promise.all([ - getImage({ src: l, format: 'avif' }), - getImage({ src: l, format: 'webp' }), - getImage({ src: l }), - getImage({ src: d, format: 'avif' }), - getImage({ src: d, format: 'webp' }), - getImage({ src: d }), + getImage({ src: light, format: 'avif' }), + getImage({ src: light, format: 'webp' }), + getImage({ src: light }), + getImage({ src: dark, format: 'avif' }), + getImage({ src: dark, format: 'webp' }), + getImage({ src: dark }), ]); - return { la, lw, lp, da, dw, dp }; + return { la: la.src, lw: lw.src, lp: lp.src, da: da.src, dw: dw.src, dp: dp.src }; } -const rawSlides = [ - { id: 'commit-graph', label: 'Commit graph', caption: 'Lane-based history with worktree markers, ref badges, and right-click actions.', l: commitGraphLight, d: commitGraphDark }, - { id: 'changes-panel', label: 'Changes & diff', caption: 'Staged/unstaged files alongside a syntax-highlighted unified diff.', l: changesPanelLight, d: changesPanelDark }, - { id: 'context-menu', label: 'Context menu', caption: 'Right-click any commit to copy hashes, checkout, reset, or open a worktree.', l: contextMenuLight, d: contextMenuDark }, - { id: 'settings', label: 'Settings', caption: 'Git identity, editor, shell, merge tool, and GitHub auth — all in one place.', l: settingsLight, d: settingsDark }, - { id: 'hooks', label: 'Workspace hooks', caption: 'Lifecycle hooks with dependency ordering — run scripts when worktrees are created, switched, or deleted.', l: hooksLight, d: hooksDark }, - { id: 'terminal', label: 'Integrated terminal', caption: 'Multiple shell sessions per worktree in tabs, split, or grid layout.', l: terminalLight, d: terminalDark }, -]; - -// Run all 5 image pairs concurrently. -const optimised = await Promise.all(rawSlides.map(s => optimise(s.l, s.d))); -const slides = rawSlides.map((s, i) => ({ ...s, ...optimised[i] })); +// Slide definitions — platform-agnostic. +const SLIDE_DEFS = [ + { id: 'commit-graph', category: 'workspace', base: 'commit-graph', label: 'Commit graph', caption: 'Lane-based history with worktree markers, ref badges, and right-click actions.' }, + { id: 'changes-panel', category: 'diff', base: 'changes-panel', label: 'Changes & diff', caption: 'Staged/unstaged files alongside a syntax-highlighted unified diff.' }, + { id: 'context-menu', category: 'context-menu', base: 'commit-history', label: 'Context menu', caption: 'Right-click any commit to copy hashes, checkout, reset, or open a worktree.' }, + { id: 'settings', category: 'settings', base: 'preferences', label: 'Settings', caption: 'Git identity, editor, shell, merge tool, and GitHub auth — all in one place.' }, + { id: 'hooks', category: 'hooks', base: 'management', label: 'Workspace hooks', caption: 'Lifecycle hooks with dependency ordering — run scripts when worktrees are created, switched, or deleted.' }, + { id: 'terminal', category: 'terminal', base: 'grid-view', label: 'Integrated terminal',caption: 'Multiple shell sessions per worktree in tabs, split, or grid layout.' }, +] as const; + +const PLATFORMS = ['mac', 'windows', 'linux'] as const; + +// Resolve and optimise all platform × slide combinations. +const slides = await Promise.all(SLIDE_DEFS.map(async (def) => { + // mac is the required canonical source; other platforms fall back to mac. + const macLight = getShot('mac', def.category, `${def.base}-light`)!; + const macDark = getShot('mac', def.category, `${def.base}-dark`)!; + + const platformSrcs: Record>> = {}; + await Promise.all(PLATFORMS.map(async (p) => { + const light = getShot(p, def.category, `${def.base}-light`) ?? macLight; + const dark = getShot(p, def.category, `${def.base}-dark`) ?? macDark; + platformSrcs[p] = await optimisePair(light, dark); + })); + + return { + ...def, + // SSR default: mac (most visitors; JS swaps for Windows/Linux) + mac: platformSrcs['mac']!, + // JSON blob stored as data attribute so client JS can swap sources + platforms: JSON.stringify(platformSrcs), + width: macLight.width, + height: macLight.height, + }; +})); ---
@@ -79,21 +100,22 @@ const slides = rawSlides.map((s, i) => ({ ...s, ...optimised[i] })); data-index={i} data-active={i === 0 ? 'true' : 'false'} data-caption={slide.caption} + data-platforms={slide.platforms} aria-hidden={i !== 0} id={`slide-${slide.id}`} role="tabpanel" > - - - - - + + + + + {slide.label} ({ ...s, ...optimised[i] })); .sg-caption[data-fading] { opacity: 0; } + +