a very good jj gui
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Sprint 3: add integration tests for core flows (5 suites)

+2224 -13
+1
apps/desktop/package.json
··· 60 60 "@vitejs/plugin-react": "^5.1.2", 61 61 "agentation": "^1.1.0", 62 62 "babel-plugin-react-compiler": "^1.0.0", 63 + "jsdom": "^28.0.0", 63 64 "react-grab": "^0.0.98", 64 65 "tailwindcss": "^4.1.18", 65 66 "typescript": "^5.6.3",
+326
apps/desktop/src/__tests__/data-loading.test.ts
··· 1 + /** 2 + * Suite 4: Data loading 3 + * 4 + * Tests batching, caching, prefetch, and collection deduplication 5 + * for the data loading pipeline. 6 + */ 7 + 8 + import { beforeEach, afterEach, describe, expect, test, vi } from "vitest"; 9 + import { Effect } from "effect"; 10 + import { resetIdCounter } from "./fixtures"; 11 + import { createBatchLoader } from "@/lib/batch-loader"; 12 + 13 + // ── Hoisted mocks ─────────────────────────────────────────────────────────── 14 + const mocks = vi.hoisted(() => ({ 15 + listen: vi.fn(), 16 + watchRepository: vi.fn(), 17 + unwatchRepository: vi.fn(), 18 + getRevisionDiff: vi.fn(), 19 + getRevisionChanges: vi.fn(), 20 + getRevisions: vi.fn(), 21 + })); 22 + 23 + vi.mock("@tauri-apps/api/event", () => ({ 24 + listen: mocks.listen, 25 + })); 26 + 27 + vi.mock("@/components/ui/sonner", () => ({ 28 + toast: { success: vi.fn(), error: vi.fn() }, 29 + })); 30 + 31 + vi.mock("@/tauri-commands", () => ({ 32 + generateChangeIds: vi.fn().mockResolvedValue([]), 33 + getCommitRecency: vi.fn().mockResolvedValue({}), 34 + getRepositories: vi.fn().mockResolvedValue([]), 35 + getRevisionChanges: mocks.getRevisionChanges, 36 + getRevisionDiff: mocks.getRevisionDiff, 37 + getRevisions: mocks.getRevisions, 38 + jjAbandon: vi.fn(), 39 + jjDescribe: vi.fn(), 40 + jjEdit: vi.fn(), 41 + jjGitFetch: vi.fn(), 42 + jjGitPush: vi.fn(), 43 + jjNew: vi.fn(), 44 + jjRebase: vi.fn(), 45 + jjSquash: vi.fn(), 46 + removeRepository: vi.fn(), 47 + undoOperation: vi.fn(), 48 + unwatchRepository: mocks.unwatchRepository, 49 + upsertRepository: vi.fn(), 50 + watchRepository: mocks.watchRepository, 51 + })); 52 + 53 + import { 54 + getRevisionsCollection, 55 + getRevisionChangesCollection, 56 + getRevisionDiffCollection, 57 + queryClient, 58 + } from "@/db"; 59 + 60 + // ============================================================================ 61 + // BatchLoader tests (batching, caching, prefetch) 62 + // ============================================================================ 63 + 64 + describe("Data loading - BatchLoader", () => { 65 + beforeEach(() => { 66 + vi.useFakeTimers(); 67 + resetIdCounter(); 68 + }); 69 + 70 + afterEach(() => { 71 + vi.useRealTimers(); 72 + }); 73 + 74 + test("debounces multiple queue calls into single flush", async () => { 75 + const fetchedBatches: string[][] = []; 76 + const loader = createBatchLoader({ 77 + debounceMs: 50, 78 + maxBatchSize: 100, 79 + fetchBatch: (ids) => { 80 + fetchedBatches.push([...ids]); 81 + return Effect.succeed(ids.map((id) => ({ id }))); 82 + }, 83 + syncToCollection: () => {}, 84 + isLoaded: () => false, 85 + }); 86 + 87 + loader.queue("a"); 88 + loader.queue("b"); 89 + loader.queue("c"); 90 + 91 + // Nothing fetched yet (debounce pending) 92 + expect(fetchedBatches).toHaveLength(0); 93 + 94 + vi.advanceTimersByTime(60); 95 + await vi.runAllTimersAsync(); 96 + 97 + expect(fetchedBatches).toHaveLength(1); 98 + expect(fetchedBatches[0]).toEqual(["a", "b", "c"]); 99 + }); 100 + 101 + test("deduplicates IDs within a single queue batch", async () => { 102 + const fetchedBatches: string[][] = []; 103 + const loader = createBatchLoader({ 104 + debounceMs: 10, 105 + maxBatchSize: 100, 106 + fetchBatch: (ids) => { 107 + fetchedBatches.push([...ids]); 108 + return Effect.succeed(ids.map((id) => ({ id }))); 109 + }, 110 + syncToCollection: () => {}, 111 + isLoaded: () => false, 112 + }); 113 + 114 + loader.queue("x"); 115 + loader.queue("x"); 116 + loader.queue("x"); 117 + loader.queue("y"); 118 + 119 + expect(loader.pendingCount()).toBe(2); // x and y, deduplicated 120 + 121 + await loader.flushPromise(); 122 + 123 + expect(fetchedBatches).toHaveLength(1); 124 + expect(fetchedBatches[0]).toEqual(["x", "y"]); 125 + }); 126 + 127 + test("skips already-loaded IDs", () => { 128 + const loaded = new Set(["cached-1", "cached-2"]); 129 + const loader = createBatchLoader({ 130 + debounceMs: 10, 131 + maxBatchSize: 100, 132 + fetchBatch: (ids) => Effect.succeed(ids.map((id) => ({ id }))), 133 + syncToCollection: () => {}, 134 + isLoaded: (id) => loaded.has(id), 135 + }); 136 + 137 + loader.queue("cached-1"); // Should be skipped 138 + loader.queue("new-1"); // Should be queued 139 + loader.queue("cached-2"); // Should be skipped 140 + 141 + expect(loader.pendingCount()).toBe(1); 142 + }); 143 + 144 + test("chunks large batches according to maxBatchSize", async () => { 145 + const batchSizes: number[] = []; 146 + const loader = createBatchLoader({ 147 + debounceMs: 10, 148 + maxBatchSize: 5, 149 + fetchBatch: (ids) => { 150 + batchSizes.push(ids.length); 151 + return Effect.succeed(ids.map((id) => ({ id }))); 152 + }, 153 + syncToCollection: () => {}, 154 + isLoaded: () => false, 155 + }); 156 + 157 + // Queue 13 items → should result in chunks of 5, 5, 3 158 + loader.queueMany(Array.from({ length: 13 }, (_, i) => `item-${i}`)); 159 + await loader.flushPromise(); 160 + 161 + expect(batchSizes).toEqual([5, 5, 3]); 162 + }); 163 + 164 + test("syncs fetched items to collection callback", async () => { 165 + const synced: { id: string }[] = []; 166 + const loader = createBatchLoader({ 167 + debounceMs: 10, 168 + maxBatchSize: 100, 169 + fetchBatch: (ids) => Effect.succeed(ids.map((id) => ({ id }))), 170 + syncToCollection: (items) => synced.push(...items), 171 + isLoaded: () => false, 172 + }); 173 + 174 + loader.queueMany(["p", "q", "r"]); 175 + await loader.flushPromise(); 176 + 177 + expect(synced).toEqual([{ id: "p" }, { id: "q" }, { id: "r" }]); 178 + }); 179 + 180 + test("requeues failed IDs for retry", async () => { 181 + let callCount = 0; 182 + const loader = createBatchLoader({ 183 + debounceMs: 10, 184 + maxBatchSize: 100, 185 + fetchBatch: (ids) => { 186 + callCount++; 187 + if (callCount === 1) { 188 + return Effect.fail(new Error("Network error")); 189 + } 190 + return Effect.succeed(ids.map((id) => ({ id }))); 191 + }, 192 + syncToCollection: () => {}, 193 + isLoaded: () => false, 194 + }); 195 + 196 + loader.queue("retry-id"); 197 + await loader.flushPromise(); 198 + 199 + // First attempt fails → ID should be back in pending 200 + expect(loader.pendingCount()).toBe(1); 201 + 202 + // Second attempt succeeds 203 + await loader.flushPromise(); 204 + expect(loader.pendingCount()).toBe(0); 205 + }); 206 + 207 + test("queueMany skips already-loaded and queues the rest", () => { 208 + const loaded = new Set(["exist-1"]); 209 + const loader = createBatchLoader({ 210 + debounceMs: 10, 211 + maxBatchSize: 100, 212 + fetchBatch: (ids) => Effect.succeed(ids.map((id) => ({ id }))), 213 + syncToCollection: () => {}, 214 + isLoaded: (id) => loaded.has(id), 215 + }); 216 + 217 + loader.queueMany(["exist-1", "new-1", "new-2", "exist-1"]); 218 + 219 + expect(loader.pendingCount()).toBe(2); // new-1 and new-2 220 + }); 221 + 222 + test("has() checks isLoaded callback", () => { 223 + const loaded = new Set(["loaded-1"]); 224 + const loader = createBatchLoader({ 225 + debounceMs: 10, 226 + maxBatchSize: 100, 227 + fetchBatch: (ids) => Effect.succeed(ids.map((id) => ({ id }))), 228 + syncToCollection: () => {}, 229 + isLoaded: (id) => loaded.has(id), 230 + }); 231 + 232 + expect(loader.has("loaded-1")).toBe(true); 233 + expect(loader.has("not-loaded")).toBe(false); 234 + }); 235 + }); 236 + 237 + // ============================================================================ 238 + // Collection caching and prefetch tests 239 + // ============================================================================ 240 + 241 + describe("Data loading - Collection caching", () => { 242 + beforeEach(() => { 243 + vi.clearAllMocks(); 244 + resetIdCounter(); 245 + queryClient.clear(); 246 + mocks.listen.mockResolvedValue(vi.fn()); 247 + }); 248 + 249 + test("getRevisionsCollection returns same instance for same repoPath+preset", () => { 250 + const preset = `cache-test-${Date.now()}`; 251 + const col1 = getRevisionsCollection("/tmp/cache-repo", preset); 252 + const col2 = getRevisionsCollection("/tmp/cache-repo", preset); 253 + 254 + expect(col1).toBe(col2); 255 + }); 256 + 257 + test("getRevisionsCollection returns different instances for different presets", () => { 258 + const ts = Date.now(); 259 + const col1 = getRevisionsCollection("/tmp/preset-repo", `preset-a-${ts}`); 260 + const col2 = getRevisionsCollection("/tmp/preset-repo", `preset-b-${ts}`); 261 + 262 + expect(col1).not.toBe(col2); 263 + }); 264 + 265 + test("getRevisionsCollection returns different instances for different repos", () => { 266 + const preset = `repo-diff-${Date.now()}`; 267 + const col1 = getRevisionsCollection("/tmp/repo-x", preset); 268 + const col2 = getRevisionsCollection("/tmp/repo-y", preset); 269 + 270 + expect(col1).not.toBe(col2); 271 + }); 272 + 273 + test("getRevisionChangesCollection returns same instance for same repoPath+changeId", () => { 274 + const col1 = getRevisionChangesCollection("/tmp/changes-repo", "change-a"); 275 + const col2 = getRevisionChangesCollection("/tmp/changes-repo", "change-a"); 276 + 277 + expect(col1).toBe(col2); 278 + }); 279 + 280 + test("getRevisionDiffCollection returns same instance for same repoPath+changeId", () => { 281 + const col1 = getRevisionDiffCollection("/tmp/diff-repo", "change-a"); 282 + const col2 = getRevisionDiffCollection("/tmp/diff-repo", "change-a"); 283 + 284 + expect(col1).toBe(col2); 285 + }); 286 + }); 287 + 288 + // ============================================================================ 289 + // Prefetch tests 290 + // ============================================================================ 291 + 292 + describe("Data loading - Prefetch", () => { 293 + beforeEach(() => { 294 + vi.clearAllMocks(); 295 + resetIdCounter(); 296 + queryClient.clear(); 297 + mocks.listen.mockResolvedValue(vi.fn()); 298 + mocks.getRevisionDiff.mockResolvedValue("--- a/file\n+++ b/file"); 299 + mocks.getRevisionChanges.mockResolvedValue([]); 300 + }); 301 + 302 + test("prefetchRevisionDiffs creates collections for each changeId", async () => { 303 + const { prefetchRevisionDiffs } = await import("@/db"); 304 + const changeIds = ["change-1", "change-2", "change-3"]; 305 + 306 + prefetchRevisionDiffs("/tmp/prefetch-repo", changeIds); 307 + 308 + // Each changeId should have a collection created 309 + for (const id of changeIds) { 310 + const col = getRevisionDiffCollection("/tmp/prefetch-repo", id); 311 + expect(col).toBeDefined(); 312 + } 313 + }); 314 + 315 + test("prefetchRevisionChanges creates collections for each changeId", async () => { 316 + const { prefetchRevisionChanges } = await import("@/db"); 317 + const changeIds = ["change-a", "change-b"]; 318 + 319 + prefetchRevisionChanges("/tmp/prefetch-changes", changeIds); 320 + 321 + for (const id of changeIds) { 322 + const col = getRevisionChangesCollection("/tmp/prefetch-changes", id); 323 + expect(col).toBeDefined(); 324 + } 325 + }); 326 + });
+262
apps/desktop/src/__tests__/fixtures/index.ts
··· 1 + /** 2 + * Shared test fixtures and helpers for integration tests. 3 + * 4 + * Provides deterministic mock data factories, mock collections, 5 + * and waitFor utilities. 6 + */ 7 + 8 + import { vi } from "vitest"; 9 + import type { Revision, Repository, ChangedFile, BookmarkInfo } from "@/schemas"; 10 + 11 + // ============================================================================ 12 + // Deterministic ID generation (no randomness for reproducible tests) 13 + // ============================================================================ 14 + 15 + let idCounter = 0; 16 + 17 + /** Reset the counter between tests for deterministic runs */ 18 + export function resetIdCounter(): void { 19 + idCounter = 0; 20 + } 21 + 22 + /** Generate a deterministic change ID (12 chars, k-z range like jj) */ 23 + export function deterministicChangeId(): string { 24 + const chars = "klmnopqrstuvwxyz"; 25 + const num = idCounter++; 26 + let result = ""; 27 + let remaining = num; 28 + for (let i = 0; i < 12; i++) { 29 + result = chars[remaining % chars.length] + result; 30 + remaining = Math.floor(remaining / chars.length); 31 + } 32 + return result; 33 + } 34 + 35 + /** Generate a deterministic commit ID (hex-like) */ 36 + export function deterministicCommitId(): string { 37 + return `commit${String(idCounter++).padStart(10, "0")}`; 38 + } 39 + 40 + // ============================================================================ 41 + // Mock data factories 42 + // ============================================================================ 43 + 44 + export function createMockBookmark( 45 + name: string, 46 + overrides?: Partial<Omit<BookmarkInfo, "name">>, 47 + ): BookmarkInfo { 48 + return { 49 + name, 50 + is_tracked: overrides?.is_tracked ?? true, 51 + remote: overrides?.remote ?? "origin", 52 + is_ahead: overrides?.is_ahead ?? false, 53 + is_behind: overrides?.is_behind ?? false, 54 + is_conflicted: overrides?.is_conflicted ?? false, 55 + }; 56 + } 57 + 58 + export function createMockRevision(overrides?: Partial<Revision>): Revision { 59 + const changeId = overrides?.change_id ?? deterministicChangeId(); 60 + const commitId = overrides?.commit_id ?? deterministicCommitId(); 61 + return { 62 + commit_id: commitId, 63 + change_id: changeId, 64 + change_id_short: overrides?.change_id_short ?? changeId.slice(0, 4), 65 + parent_edges: overrides?.parent_edges ?? [], 66 + children_ids: overrides?.children_ids ?? [], 67 + description: overrides?.description ?? "", 68 + author: overrides?.author ?? "test@example.com", 69 + timestamp: overrides?.timestamp ?? "2026-01-01T00:00:00Z", 70 + is_working_copy: overrides?.is_working_copy ?? false, 71 + is_immutable: overrides?.is_immutable ?? false, 72 + is_mine: overrides?.is_mine ?? true, 73 + is_trunk: overrides?.is_trunk ?? false, 74 + is_divergent: overrides?.is_divergent ?? false, 75 + divergent_index: overrides?.divergent_index ?? null, 76 + has_conflict: overrides?.has_conflict ?? false, 77 + bookmarks: overrides?.bookmarks ?? [], 78 + }; 79 + } 80 + 81 + export function createMockRepository(overrides?: Partial<Repository>): Repository { 82 + const id = overrides?.id ?? `repo-${idCounter++}`; 83 + return { 84 + id, 85 + path: overrides?.path ?? `/tmp/test-repos/${id}`, 86 + name: overrides?.name ?? `test-repo-${id}`, 87 + last_opened_at: overrides?.last_opened_at ?? 1700000000000, 88 + revset_preset: overrides?.revset_preset ?? null, 89 + }; 90 + } 91 + 92 + export function createMockChangedFile(overrides?: Partial<ChangedFile>): ChangedFile { 93 + return { 94 + path: overrides?.path ?? `src/file-${idCounter++}.ts`, 95 + status: overrides?.status ?? "modified", 96 + }; 97 + } 98 + 99 + // ============================================================================ 100 + // Deterministic revision graph builder 101 + // ============================================================================ 102 + 103 + /** Build a linear chain of revisions for testing */ 104 + export function buildRevisionChain(count: number): Revision[] { 105 + resetIdCounter(); 106 + const revisions: Revision[] = []; 107 + 108 + for (let i = 0; i < count; i++) { 109 + const rev = createMockRevision({ 110 + parent_edges: i > 0 ? [{ parent_id: revisions[i - 1].commit_id, edge_type: "direct" }] : [], 111 + is_working_copy: i === count - 1, 112 + is_immutable: i < count - 2, 113 + description: `Commit ${i}`, 114 + }); 115 + 116 + // Set children_ids on parent 117 + if (i > 0) { 118 + revisions[i - 1] = { 119 + ...revisions[i - 1], 120 + children_ids: [...revisions[i - 1].children_ids, rev.commit_id], 121 + }; 122 + } 123 + 124 + revisions.push(rev); 125 + } 126 + 127 + return revisions; 128 + } 129 + 130 + // ============================================================================ 131 + // Mock collection (replaces TanStack DB collection for unit testing) 132 + // ============================================================================ 133 + 134 + export interface MockCollection<T> { 135 + state: Map<string, T>; 136 + utils: { 137 + writeUpsert: (items: T[]) => void; 138 + writeDelete: (key: string) => void; 139 + }; 140 + } 141 + 142 + /** 143 + * Creates a lightweight mock collection that implements the same interface 144 + * as TanStack DB collections used by db.ts functions. 145 + * This avoids the complex async sync initialization of real collections. 146 + */ 147 + export function createMockCollectionForRepos(): MockCollection<Repository> { 148 + const state = new Map<string, Repository>(); 149 + return { 150 + state, 151 + utils: { 152 + writeUpsert: (items: Repository[]) => { 153 + for (const item of items) { 154 + state.set(item.id, item); 155 + } 156 + }, 157 + writeDelete: (key: string) => { 158 + state.delete(key); 159 + }, 160 + }, 161 + }; 162 + } 163 + 164 + /** Key function matching db.ts getRevisionKey */ 165 + function revisionKey(r: Revision): string { 166 + if (r.divergent_index != null) { 167 + return `${r.change_id}/${r.divergent_index}`; 168 + } 169 + return r.change_id; 170 + } 171 + 172 + export function createMockCollectionForRevisions(): MockCollection<Revision> { 173 + const state = new Map<string, Revision>(); 174 + return { 175 + state, 176 + utils: { 177 + writeUpsert: (items: Revision[]) => { 178 + for (const item of items) { 179 + state.set(revisionKey(item), item); 180 + } 181 + }, 182 + writeDelete: (key: string) => { 183 + state.delete(key); 184 + }, 185 + }, 186 + }; 187 + } 188 + 189 + // ============================================================================ 190 + // waitFor utility for timing-sensitive tests 191 + // ============================================================================ 192 + 193 + export interface WaitForOptions { 194 + timeout?: number; 195 + interval?: number; 196 + } 197 + 198 + /** 199 + * Poll a condition until it returns true or timeout is reached. 200 + * Works with both real and fake timers. 201 + */ 202 + export async function waitFor( 203 + condition: () => boolean | Promise<boolean>, 204 + options?: WaitForOptions, 205 + ): Promise<void> { 206 + const { timeout = 2000, interval = 10 } = options ?? {}; 207 + const start = Date.now(); 208 + 209 + while (Date.now() - start < timeout) { 210 + const result = await condition(); 211 + if (result) return; 212 + await new Promise((resolve) => setTimeout(resolve, interval)); 213 + } 214 + 215 + throw new Error(`waitFor timed out after ${timeout}ms`); 216 + } 217 + 218 + /** 219 + * Wait for a mock function to have been called a specified number of times. 220 + */ 221 + export async function waitForCalls( 222 + mockFn: ReturnType<typeof vi.fn>, 223 + count: number, 224 + options?: WaitForOptions, 225 + ): Promise<void> { 226 + await waitFor(() => mockFn.mock.calls.length >= count, options); 227 + } 228 + 229 + /** 230 + * Flush all pending microtasks and settled promises. 231 + */ 232 + export async function flushMicrotasks(): Promise<void> { 233 + await new Promise((resolve) => setTimeout(resolve, 0)); 234 + } 235 + 236 + // ============================================================================ 237 + // Standard Tauri command mock creator 238 + // ============================================================================ 239 + 240 + export function createTauriCommandMocks() { 241 + return { 242 + generateChangeIds: vi.fn().mockResolvedValue([]), 243 + getCommitRecency: vi.fn().mockResolvedValue({}), 244 + getRepositories: vi.fn().mockResolvedValue([]), 245 + getRevisionChanges: vi.fn().mockResolvedValue([]), 246 + getRevisionDiff: vi.fn().mockResolvedValue(""), 247 + getRevisions: vi.fn().mockResolvedValue([]), 248 + jjAbandon: vi.fn().mockResolvedValue({ operation_id: "op-1", change_id: null }), 249 + jjDescribe: vi.fn().mockResolvedValue({ operation_id: "op-1", change_id: null }), 250 + jjEdit: vi.fn().mockResolvedValue({ operation_id: "op-1", change_id: null }), 251 + jjGitFetch: vi.fn().mockResolvedValue({ operation_id: "op-1", change_id: null }), 252 + jjGitPush: vi.fn().mockResolvedValue({ operation_id: "op-1", change_id: null }), 253 + jjNew: vi.fn().mockResolvedValue({ operation_id: "op-1", change_id: null }), 254 + jjRebase: vi.fn().mockResolvedValue({ operation_id: "op-1", change_id: null }), 255 + jjSquash: vi.fn().mockResolvedValue({ operation_id: "op-1", change_id: null }), 256 + removeRepository: vi.fn().mockResolvedValue(undefined), 257 + undoOperation: vi.fn().mockResolvedValue(undefined), 258 + unwatchRepository: vi.fn().mockResolvedValue(undefined), 259 + upsertRepository: vi.fn().mockResolvedValue(undefined), 260 + watchRepository: vi.fn().mockResolvedValue(undefined), 261 + }; 262 + }
+400
apps/desktop/src/__tests__/keyboard-nav.test.ts
··· 1 + // @vitest-environment jsdom 2 + /** 3 + * Suite 5: Keyboard navigation 4 + * 5 + * Tests j/k/enter/escape flows, gg/G shortcuts, @/parent/child navigation. 6 + * Uses jsdom environment for window/document event simulation. 7 + */ 8 + 9 + import { beforeEach, afterEach, describe, expect, test, vi } from "vitest"; 10 + import { buildRevisionChain, resetIdCounter } from "./fixtures"; 11 + import type { Revision } from "@/schemas"; 12 + import { getRevisionKey } from "@/db.pure"; 13 + 14 + // ============================================================================ 15 + // Standalone keyboard handler (mirrors useKeyboardNavigation logic) 16 + // ============================================================================ 17 + 18 + /** 19 + * Creates a keyboard navigation handler identical to the useKeyboardNavigation 20 + * hook logic but without React dependencies for direct testing. 21 + */ 22 + function createKeyboardHandler(options: { 23 + getRevisions: () => Revision[]; 24 + getSelectedKey: () => string | null; 25 + onNavigate: (key: string) => void; 26 + scrollToChangeId?: (key: string, opts?: { align?: string; smooth?: boolean }) => void; 27 + }) { 28 + const { getRevisions, getSelectedKey, onNavigate, scrollToChangeId } = options; 29 + let sequenceBuffer = ""; 30 + let sequenceTimestamp = 0; 31 + const SEQUENCE_TIMEOUT = 500; 32 + 33 + function handleKeyDown(event: KeyboardEvent) { 34 + const activeElement = document.activeElement; 35 + if (activeElement?.tagName === "INPUT" || activeElement?.tagName === "TEXTAREA") { 36 + return; 37 + } 38 + 39 + const revisions = getRevisions(); 40 + const revisionKey = getSelectedKey(); 41 + 42 + let currentIndex = revisions.findIndex((r) => getRevisionKey(r) === revisionKey); 43 + if (currentIndex < 0) { 44 + currentIndex = revisions.findIndex((r) => r.is_working_copy); 45 + if (currentIndex < 0) currentIndex = 0; 46 + } 47 + const currentRevision = revisions[currentIndex] ?? null; 48 + 49 + // Handle "gg" sequence 50 + const now = Date.now(); 51 + if (now - sequenceTimestamp > SEQUENCE_TIMEOUT) { 52 + sequenceBuffer = ""; 53 + } 54 + sequenceBuffer += event.key; 55 + sequenceTimestamp = now; 56 + if (sequenceBuffer.length > 2) { 57 + sequenceBuffer = sequenceBuffer.slice(-2); 58 + } 59 + 60 + if (sequenceBuffer.endsWith("gg")) { 61 + const first = revisions[0]; 62 + if (first) { 63 + onNavigate(getRevisionKey(first)); 64 + scrollToChangeId?.(getRevisionKey(first), { align: "center", smooth: true }); 65 + } 66 + sequenceBuffer = ""; 67 + event.preventDefault(); 68 + return; 69 + } 70 + 71 + let targetKey: string | null = null; 72 + 73 + const isMinusKey = 74 + event.key === "-" || event.code === "Minus" || event.code === "NumpadSubtract"; 75 + const isPlusKey = 76 + event.key === "+" || 77 + event.key === "=" || 78 + event.code === "Equal" || 79 + event.code === "NumpadAdd"; 80 + 81 + switch (true) { 82 + case event.key === "j" || event.key === "ArrowDown": 83 + if (currentIndex >= 0 && currentIndex < revisions.length - 1) { 84 + targetKey = getRevisionKey(revisions[currentIndex + 1]); 85 + } 86 + event.preventDefault(); 87 + break; 88 + 89 + case event.key === "k" || event.key === "ArrowUp": 90 + if (currentIndex > 0) { 91 + targetKey = getRevisionKey(revisions[currentIndex - 1]); 92 + } 93 + event.preventDefault(); 94 + break; 95 + 96 + case isMinusKey: 97 + if (currentRevision) { 98 + const parentId = currentRevision.parent_edges[0]?.parent_id; 99 + if (parentId) { 100 + const parent = revisions.find((r) => r.commit_id === parentId); 101 + if (parent) targetKey = getRevisionKey(parent); 102 + } 103 + } 104 + event.preventDefault(); 105 + break; 106 + 107 + case isPlusKey: 108 + if (currentRevision) { 109 + const childId = currentRevision.children_ids[0]; 110 + if (childId) { 111 + const child = revisions.find((r) => r.commit_id === childId); 112 + if (child) targetKey = getRevisionKey(child); 113 + } 114 + } 115 + event.preventDefault(); 116 + break; 117 + 118 + case event.key === "@": { 119 + const wc = revisions.find((r) => r.is_working_copy); 120 + targetKey = wc ? getRevisionKey(wc) : null; 121 + event.preventDefault(); 122 + break; 123 + } 124 + 125 + case event.key === "G": { 126 + const last = revisions[revisions.length - 1]; 127 + targetKey = last ? getRevisionKey(last) : null; 128 + event.preventDefault(); 129 + break; 130 + } 131 + 132 + case event.key === "Escape": 133 + onNavigate(""); 134 + event.preventDefault(); 135 + return; // Early return (Escape navigates to "") 136 + } 137 + 138 + if (targetKey) { 139 + onNavigate(targetKey); 140 + scrollToChangeId?.(targetKey, { align: "auto", smooth: false }); 141 + } 142 + } 143 + 144 + window.addEventListener("keydown", handleKeyDown); 145 + return () => window.removeEventListener("keydown", handleKeyDown); 146 + } 147 + 148 + // ============================================================================ 149 + // Helper to dispatch keyboard events 150 + // ============================================================================ 151 + 152 + function pressKey(key: string, opts?: Partial<KeyboardEventInit>): void { 153 + window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...opts })); 154 + } 155 + 156 + // ============================================================================ 157 + // Tests 158 + // ============================================================================ 159 + 160 + describe("Keyboard navigation", () => { 161 + let revisions: Revision[]; 162 + let selectedKey: string | null; 163 + let onNavigateMock: ReturnType<typeof vi.fn>; 164 + let scrollToMock: ReturnType<typeof vi.fn>; 165 + let cleanup: () => void; 166 + 167 + beforeEach(() => { 168 + resetIdCounter(); 169 + revisions = buildRevisionChain(5); // 5 revisions, last is WC 170 + selectedKey = getRevisionKey(revisions[2]); // Start in the middle 171 + onNavigateMock = vi.fn((key: string) => { 172 + selectedKey = key; 173 + }); 174 + scrollToMock = vi.fn(); 175 + 176 + cleanup = createKeyboardHandler({ 177 + getRevisions: () => revisions, 178 + getSelectedKey: () => selectedKey, 179 + // biome-ignore lint/complexity/noBannedTypes: vi.fn() returns opaque mock type 180 + onNavigate: (...args: unknown[]) => (onNavigateMock as Function)(...args), 181 + // biome-ignore lint/complexity/noBannedTypes: vi.fn() returns opaque mock type 182 + scrollToChangeId: (...args: unknown[]) => (scrollToMock as Function)(...args), 183 + }); 184 + }); 185 + 186 + afterEach(() => { 187 + cleanup(); 188 + vi.clearAllMocks(); 189 + }); 190 + 191 + // ── j/k (next/prev) ───────────────────────────────────────────────────── 192 + 193 + test("j moves selection down by one", () => { 194 + const startKey = getRevisionKey(revisions[2]); 195 + selectedKey = startKey; 196 + 197 + pressKey("j"); 198 + 199 + expect(onNavigateMock).toHaveBeenCalledWith(getRevisionKey(revisions[3])); 200 + }); 201 + 202 + test("k moves selection up by one", () => { 203 + selectedKey = getRevisionKey(revisions[2]); 204 + 205 + pressKey("k"); 206 + 207 + expect(onNavigateMock).toHaveBeenCalledWith(getRevisionKey(revisions[1])); 208 + }); 209 + 210 + test("ArrowDown moves selection down", () => { 211 + selectedKey = getRevisionKey(revisions[1]); 212 + 213 + pressKey("ArrowDown"); 214 + 215 + expect(onNavigateMock).toHaveBeenCalledWith(getRevisionKey(revisions[2])); 216 + }); 217 + 218 + test("ArrowUp moves selection up", () => { 219 + selectedKey = getRevisionKey(revisions[3]); 220 + 221 + pressKey("ArrowUp"); 222 + 223 + expect(onNavigateMock).toHaveBeenCalledWith(getRevisionKey(revisions[2])); 224 + }); 225 + 226 + test("j at bottom does not navigate", () => { 227 + selectedKey = getRevisionKey(revisions[4]); // Last 228 + 229 + pressKey("j"); 230 + 231 + expect(onNavigateMock).not.toHaveBeenCalled(); 232 + }); 233 + 234 + test("k at top does not navigate", () => { 235 + selectedKey = getRevisionKey(revisions[0]); // First 236 + 237 + pressKey("k"); 238 + 239 + expect(onNavigateMock).not.toHaveBeenCalled(); 240 + }); 241 + 242 + // ── G (go to last) ────────────────────────────────────────────────────── 243 + 244 + test("G jumps to last revision", () => { 245 + selectedKey = getRevisionKey(revisions[0]); 246 + 247 + pressKey("G"); 248 + 249 + expect(onNavigateMock).toHaveBeenCalledWith(getRevisionKey(revisions[4])); 250 + }); 251 + 252 + // ── gg (go to first) ──────────────────────────────────────────────────── 253 + 254 + test("gg jumps to first revision", () => { 255 + selectedKey = getRevisionKey(revisions[4]); 256 + 257 + pressKey("g"); 258 + pressKey("g"); 259 + 260 + expect(onNavigateMock).toHaveBeenCalledWith(getRevisionKey(revisions[0])); 261 + }); 262 + 263 + // ── @ (go to working copy) ────────────────────────────────────────────── 264 + 265 + test("@ jumps to working copy revision", () => { 266 + selectedKey = getRevisionKey(revisions[0]); 267 + 268 + pressKey("@"); 269 + 270 + const wcRev = revisions.find((r) => r.is_working_copy); 271 + expect(wcRev).toBeDefined(); 272 + expect(onNavigateMock).toHaveBeenCalledWith(getRevisionKey(wcRev as Revision)); 273 + }); 274 + 275 + // ── Escape (clear selection) ───────────────────────────────────────────── 276 + 277 + test("Escape clears selection", () => { 278 + selectedKey = getRevisionKey(revisions[2]); 279 + 280 + pressKey("Escape"); 281 + 282 + expect(onNavigateMock).toHaveBeenCalledWith(""); 283 + }); 284 + 285 + // ── - (parent) and + (child) ───────────────────────────────────────────── 286 + 287 + test("- navigates to parent revision", () => { 288 + selectedKey = getRevisionKey(revisions[2]); 289 + 290 + pressKey("-"); 291 + 292 + // Revision 2's parent is revision 1 293 + expect(onNavigateMock).toHaveBeenCalledWith(getRevisionKey(revisions[1])); 294 + }); 295 + 296 + test("+ navigates to child revision", () => { 297 + selectedKey = getRevisionKey(revisions[1]); 298 + 299 + pressKey("+"); 300 + 301 + // Revision 1's child is revision 2 302 + expect(onNavigateMock).toHaveBeenCalledWith(getRevisionKey(revisions[2])); 303 + }); 304 + 305 + test("- at root does not navigate (no parent)", () => { 306 + selectedKey = getRevisionKey(revisions[0]); 307 + 308 + pressKey("-"); 309 + 310 + expect(onNavigateMock).not.toHaveBeenCalled(); 311 + }); 312 + 313 + test("+ at leaf does not navigate (no children)", () => { 314 + selectedKey = getRevisionKey(revisions[4]); // Last, WC 315 + 316 + pressKey("+"); 317 + 318 + // WC has no children (children_ids is []) 319 + expect(onNavigateMock).not.toHaveBeenCalled(); 320 + }); 321 + 322 + // ── Input focus blocking ──────────────────────────────────────────────── 323 + 324 + test("keyboard events are ignored when input is focused", () => { 325 + const input = document.createElement("input"); 326 + document.body.appendChild(input); 327 + input.focus(); 328 + 329 + pressKey("j"); 330 + pressKey("k"); 331 + pressKey("G"); 332 + 333 + expect(onNavigateMock).not.toHaveBeenCalled(); 334 + document.body.removeChild(input); 335 + }); 336 + 337 + test("keyboard events are ignored when textarea is focused", () => { 338 + const textarea = document.createElement("textarea"); 339 + document.body.appendChild(textarea); 340 + textarea.focus(); 341 + 342 + pressKey("j"); 343 + 344 + expect(onNavigateMock).not.toHaveBeenCalled(); 345 + document.body.removeChild(textarea); 346 + }); 347 + 348 + // ── Scroll behavior ───────────────────────────────────────────────────── 349 + 350 + test("j/k scrolls with align auto", () => { 351 + selectedKey = getRevisionKey(revisions[1]); 352 + 353 + pressKey("j"); 354 + 355 + expect(scrollToMock).toHaveBeenCalledWith(getRevisionKey(revisions[2]), { 356 + align: "auto", 357 + smooth: false, 358 + }); 359 + }); 360 + 361 + test("G scrolls with align auto", () => { 362 + pressKey("G"); 363 + 364 + expect(scrollToMock).toHaveBeenCalledWith( 365 + getRevisionKey(revisions[4]), 366 + expect.objectContaining({ align: "auto" }), 367 + ); 368 + }); 369 + 370 + // ── Sequential j presses walk through all revisions ───────────────────── 371 + 372 + test("pressing j repeatedly walks through all revisions", () => { 373 + selectedKey = getRevisionKey(revisions[0]); 374 + const visited: string[] = [selectedKey]; 375 + 376 + for (let i = 0; i < 4; i++) { 377 + pressKey("j"); 378 + visited.push(selectedKey); 379 + } 380 + 381 + expect(visited).toEqual(revisions.map((r) => getRevisionKey(r))); 382 + }); 383 + 384 + // ── Deterministic: no selection defaults to WC ────────────────────────── 385 + 386 + test("when no revision is selected, navigation starts from working copy", () => { 387 + selectedKey = null; 388 + 389 + pressKey("j"); // Should move down from WC (index 4), which is at end so no-op 390 + 391 + // WC is last, so j from there is a no-op, but the handler resolves to WC index 392 + // Let's test k which should move up from WC 393 + onNavigateMock.mockClear(); 394 + selectedKey = null; 395 + pressKey("k"); 396 + 397 + // From WC (index 4), k should go to index 3 398 + expect(onNavigateMock).toHaveBeenCalledWith(getRevisionKey(revisions[3])); 399 + }); 400 + });
+426
apps/desktop/src/__tests__/mutations.test.ts
··· 1 + /** 2 + * Suite 3: Mutations 3 + * 4 + * Tests new/edit/abandon with undo via operation_id through 5 + * mock collections with optimistic updates. 6 + */ 7 + 8 + import { beforeEach, describe, expect, test, vi } from "vitest"; 9 + import { 10 + createMockRevision, 11 + createMockCollectionForRevisions, 12 + resetIdCounter, 13 + flushMicrotasks, 14 + type MockCollection, 15 + } from "./fixtures"; 16 + import type { Revision } from "@/schemas"; 17 + 18 + // ── Hoisted mocks ─────────────────────────────────────────────────────────── 19 + const mocks = vi.hoisted(() => ({ 20 + listen: vi.fn(), 21 + watchRepository: vi.fn(), 22 + unwatchRepository: vi.fn(), 23 + jjNew: vi.fn(), 24 + jjEdit: vi.fn(), 25 + jjAbandon: vi.fn(), 26 + jjDescribe: vi.fn(), 27 + jjSquash: vi.fn(), 28 + undoOperation: vi.fn(), 29 + generateChangeIds: vi.fn(), 30 + toastSuccess: vi.fn(), 31 + toastError: vi.fn(), 32 + })); 33 + 34 + vi.mock("@tauri-apps/api/event", () => ({ 35 + listen: mocks.listen, 36 + })); 37 + 38 + vi.mock("@/components/ui/sonner", () => ({ 39 + toast: { 40 + success: mocks.toastSuccess, 41 + error: mocks.toastError, 42 + }, 43 + })); 44 + 45 + vi.mock("@/tauri-commands", () => ({ 46 + generateChangeIds: mocks.generateChangeIds, 47 + getCommitRecency: vi.fn().mockResolvedValue({}), 48 + getRepositories: vi.fn().mockResolvedValue([]), 49 + getRevisionChanges: vi.fn().mockResolvedValue([]), 50 + getRevisionDiff: vi.fn().mockResolvedValue(""), 51 + getRevisions: vi.fn().mockResolvedValue([]), 52 + jjAbandon: mocks.jjAbandon, 53 + jjDescribe: mocks.jjDescribe, 54 + jjEdit: mocks.jjEdit, 55 + jjGitFetch: vi.fn(), 56 + jjGitPush: vi.fn(), 57 + jjNew: mocks.jjNew, 58 + jjRebase: vi.fn(), 59 + jjSquash: mocks.jjSquash, 60 + removeRepository: vi.fn(), 61 + undoOperation: mocks.undoOperation, 62 + unwatchRepository: mocks.unwatchRepository, 63 + upsertRepository: vi.fn(), 64 + watchRepository: mocks.watchRepository, 65 + })); 66 + 67 + import { 68 + editRevision, 69 + newRevision, 70 + abandonRevision, 71 + describeRevision, 72 + squashRevision, 73 + getRevisionKey, 74 + queryClient, 75 + } from "@/db"; 76 + 77 + const REPO_PATH = "/tmp/test-mutations"; 78 + 79 + // Type alias for mock collection compatibility with db.ts functions 80 + type AnyRevisionsCollection = Parameters<typeof editRevision>[0]; 81 + 82 + describe("Mutations", () => { 83 + let parentRev: Revision; 84 + let wcRev: Revision; 85 + let collection: MockCollection<Revision>; 86 + 87 + beforeEach(() => { 88 + vi.clearAllMocks(); 89 + resetIdCounter(); 90 + 91 + mocks.listen.mockResolvedValue(vi.fn()); 92 + mocks.jjEdit.mockResolvedValue({ operation_id: "op-edit-1", change_id: null }); 93 + mocks.jjNew.mockResolvedValue({ operation_id: "op-new-1", change_id: "newchangeid1" }); 94 + mocks.jjAbandon.mockResolvedValue({ operation_id: "op-abandon-1", change_id: null }); 95 + mocks.jjDescribe.mockResolvedValue({ operation_id: "op-describe-1", change_id: "wc-change" }); 96 + mocks.jjSquash.mockResolvedValue({ operation_id: "op-squash-1", change_id: null }); 97 + mocks.undoOperation.mockResolvedValue(undefined); 98 + mocks.generateChangeIds.mockResolvedValue([]); 99 + 100 + parentRev = createMockRevision({ 101 + commit_id: "parent-commit-001", 102 + change_id: "parentchange1", 103 + change_id_short: "pare", 104 + description: "parent commit", 105 + is_immutable: true, 106 + is_trunk: true, 107 + }); 108 + 109 + wcRev = createMockRevision({ 110 + commit_id: "wc-commit-001", 111 + change_id: "wcchangeid01", 112 + change_id_short: "wcch", 113 + description: "", 114 + is_working_copy: true, 115 + parent_edges: [{ parent_id: parentRev.commit_id, edge_type: "direct" }], 116 + }); 117 + 118 + collection = createMockCollectionForRevisions(); 119 + collection.utils.writeUpsert([parentRev, wcRev]); 120 + }); 121 + 122 + // ── editRevision ──────────────────────────────────────────────────────── 123 + 124 + test("editRevision moves working copy flag optimistically", () => { 125 + const targetRev = createMockRevision({ 126 + commit_id: "target-commit-001", 127 + change_id: "targetchangid", 128 + change_id_short: "targ", 129 + description: "target revision", 130 + }); 131 + collection.utils.writeUpsert([targetRev]); 132 + 133 + editRevision(collection as unknown as AnyRevisionsCollection, REPO_PATH, targetRev, wcRev); 134 + 135 + // Target should now be WC 136 + const updated = collection.state.get(getRevisionKey(targetRev)); 137 + expect(updated?.is_working_copy).toBe(true); 138 + 139 + // Old WC should no longer be WC 140 + const oldWc = collection.state.get(getRevisionKey(wcRev)); 141 + expect(oldWc?.is_working_copy).toBe(false); 142 + }); 143 + 144 + test("editRevision calls jjEdit backend", async () => { 145 + const targetRev = createMockRevision({ 146 + change_id_short: "edit-target", 147 + }); 148 + collection.utils.writeUpsert([targetRev]); 149 + 150 + editRevision(collection as unknown as AnyRevisionsCollection, REPO_PATH, targetRev, wcRev); 151 + await flushMicrotasks(); 152 + 153 + expect(mocks.jjEdit).toHaveBeenCalledWith(REPO_PATH, "edit-target"); 154 + }); 155 + 156 + test("editRevision reverts on backend failure", async () => { 157 + mocks.jjEdit.mockRejectedValueOnce(new Error("Edit failed")); 158 + 159 + const targetRev = createMockRevision({ 160 + commit_id: "fail-target-001", 161 + change_id: "failtargetid", 162 + change_id_short: "fail", 163 + }); 164 + collection.utils.writeUpsert([targetRev]); 165 + 166 + editRevision(collection as unknown as AnyRevisionsCollection, REPO_PATH, targetRev, wcRev); 167 + 168 + // Wait for the promise rejection to propagate 169 + await flushMicrotasks(); 170 + await flushMicrotasks(); 171 + 172 + // WC should be reverted back 173 + const revertedWc = collection.state.get(getRevisionKey(wcRev)); 174 + expect(revertedWc?.is_working_copy).toBe(true); 175 + 176 + const revertedTarget = collection.state.get(getRevisionKey(targetRev)); 177 + expect(revertedTarget?.is_working_copy).toBe(false); 178 + }); 179 + 180 + // ── newRevision ───────────────────────────────────────────────────────── 181 + 182 + test("newRevision calls jjNew with parent change IDs", async () => { 183 + newRevision( 184 + collection as unknown as AnyRevisionsCollection, 185 + REPO_PATH, 186 + [parentRev.change_id], 187 + parentRev, 188 + wcRev, 189 + ); 190 + await flushMicrotasks(); 191 + 192 + expect(mocks.jjNew).toHaveBeenCalledWith( 193 + REPO_PATH, 194 + [parentRev.change_id], 195 + undefined, // No pre-allocated ID when pool is empty 196 + ); 197 + }); 198 + 199 + test("newRevision with pre-allocated change ID uses pool", async () => { 200 + // Seed the change ID pool in query cache 201 + queryClient.setQueryData(["change-id-pool", REPO_PATH], { 202 + repoPath: REPO_PATH, 203 + ids: ["preallocated01"], 204 + }); 205 + 206 + newRevision( 207 + collection as unknown as AnyRevisionsCollection, 208 + REPO_PATH, 209 + [parentRev.change_id], 210 + parentRev, 211 + wcRev, 212 + ); 213 + await flushMicrotasks(); 214 + 215 + expect(mocks.jjNew).toHaveBeenCalledWith(REPO_PATH, [parentRev.change_id], "preallocated01"); 216 + }); 217 + 218 + test("newRevision creates optimistic revision when pool has IDs", () => { 219 + queryClient.setQueryData(["change-id-pool", REPO_PATH], { 220 + repoPath: REPO_PATH, 221 + ids: ["optimisticid1", "spare000002"], 222 + }); 223 + 224 + newRevision( 225 + collection as unknown as AnyRevisionsCollection, 226 + REPO_PATH, 227 + [parentRev.change_id], 228 + parentRev, 229 + wcRev, 230 + ); 231 + 232 + // The optimistic revision should exist in the collection 233 + const optimistic = collection.state.get("optimisticid1"); 234 + expect(optimistic).toBeDefined(); 235 + expect(optimistic?.is_working_copy).toBe(true); 236 + expect(optimistic?.parent_edges[0]?.parent_id).toBe(parentRev.commit_id); 237 + }); 238 + 239 + // ── abandonRevision ───────────────────────────────────────────────────── 240 + 241 + test("abandonRevision optimistically removes non-WC revision", () => { 242 + const nonWcRev = createMockRevision({ 243 + commit_id: "abandon-target-001", 244 + change_id: "abandontarget", 245 + change_id_short: "aban", 246 + description: "to be abandoned", 247 + }); 248 + collection.utils.writeUpsert([nonWcRev]); 249 + 250 + abandonRevision(collection as unknown as AnyRevisionsCollection, REPO_PATH, nonWcRev); 251 + 252 + expect(collection.state.get(getRevisionKey(nonWcRev))).toBeUndefined(); 253 + }); 254 + 255 + test("abandonRevision does NOT optimistically remove WC revision", () => { 256 + abandonRevision(collection as unknown as AnyRevisionsCollection, REPO_PATH, wcRev); 257 + 258 + // WC should still be in collection (backend handles the swap) 259 + expect(collection.state.get(getRevisionKey(wcRev))).toBeDefined(); 260 + }); 261 + 262 + test("abandonRevision calls jjAbandon backend", async () => { 263 + const rev = createMockRevision({ change_id_short: "aban-be" }); 264 + collection.utils.writeUpsert([rev]); 265 + 266 + abandonRevision(collection as unknown as AnyRevisionsCollection, REPO_PATH, rev); 267 + await flushMicrotasks(); 268 + 269 + expect(mocks.jjAbandon).toHaveBeenCalledWith(REPO_PATH, "aban-be"); 270 + }); 271 + 272 + test("abandonRevision shows toast with undo action on success", async () => { 273 + const rev = createMockRevision({ 274 + change_id_short: "undo-test", 275 + }); 276 + collection.utils.writeUpsert([rev]); 277 + 278 + abandonRevision(collection as unknown as AnyRevisionsCollection, REPO_PATH, rev); 279 + await flushMicrotasks(); 280 + await flushMicrotasks(); 281 + 282 + expect(mocks.toastSuccess).toHaveBeenCalledWith( 283 + expect.stringContaining("undo-test"), 284 + expect.objectContaining({ 285 + action: expect.objectContaining({ 286 + label: "Undo", 287 + onClick: expect.any(Function), 288 + }), 289 + }), 290 + ); 291 + }); 292 + 293 + test("abandonRevision undo calls undoOperation with operation_id", async () => { 294 + mocks.jjAbandon.mockResolvedValueOnce({ operation_id: "op-undo-test", change_id: null }); 295 + 296 + const rev = createMockRevision({ change_id_short: "undo-op" }); 297 + collection.utils.writeUpsert([rev]); 298 + 299 + abandonRevision(collection as unknown as AnyRevisionsCollection, REPO_PATH, rev); 300 + await flushMicrotasks(); 301 + await flushMicrotasks(); 302 + 303 + // Extract the undo onClick handler from the toast call 304 + const toastCall = mocks.toastSuccess.mock.calls[0]; 305 + const undoAction = toastCall?.[1]?.action; 306 + expect(undoAction).toBeDefined(); 307 + 308 + // Trigger undo 309 + undoAction.onClick(); 310 + await flushMicrotasks(); 311 + 312 + expect(mocks.undoOperation).toHaveBeenCalledWith(REPO_PATH, "op-undo-test"); 313 + }); 314 + 315 + test("abandonRevision reverts optimistic delete on backend failure", async () => { 316 + mocks.jjAbandon.mockRejectedValueOnce(new Error("Abandon failed")); 317 + 318 + const rev = createMockRevision({ 319 + commit_id: "reverted-001", 320 + change_id: "revertedaban1", 321 + change_id_short: "reve", 322 + }); 323 + collection.utils.writeUpsert([rev]); 324 + 325 + abandonRevision(collection as unknown as AnyRevisionsCollection, REPO_PATH, rev); 326 + await flushMicrotasks(); 327 + await flushMicrotasks(); 328 + 329 + // Should be restored 330 + expect(collection.state.get(getRevisionKey(rev))).toBeDefined(); 331 + }); 332 + 333 + // ── describeRevision ──────────────────────────────────────────────────── 334 + 335 + test("describeRevision updates description optimistically", () => { 336 + describeRevision( 337 + collection as unknown as AnyRevisionsCollection, 338 + REPO_PATH, 339 + wcRev, 340 + "new description", 341 + ); 342 + 343 + const updated = collection.state.get(getRevisionKey(wcRev)); 344 + expect(updated?.description).toBe("new description"); 345 + }); 346 + 347 + test("describeRevision calls jjDescribe backend", async () => { 348 + describeRevision( 349 + collection as unknown as AnyRevisionsCollection, 350 + REPO_PATH, 351 + wcRev, 352 + "backend desc", 353 + ); 354 + await flushMicrotasks(); 355 + 356 + expect(mocks.jjDescribe).toHaveBeenCalledWith(REPO_PATH, wcRev.change_id_short, "backend desc"); 357 + }); 358 + 359 + test("describeRevision reverts description on backend failure", async () => { 360 + mocks.jjDescribe.mockRejectedValueOnce(new Error("Describe failed")); 361 + 362 + const rev = createMockRevision({ 363 + commit_id: "desc-fail-001", 364 + change_id: "descfailid01", 365 + change_id_short: "desc", 366 + description: "original text", 367 + }); 368 + collection.utils.writeUpsert([rev]); 369 + 370 + describeRevision( 371 + collection as unknown as AnyRevisionsCollection, 372 + REPO_PATH, 373 + rev, 374 + "attempted change", 375 + ); 376 + 377 + // Optimistic update applied immediately 378 + expect(collection.state.get(getRevisionKey(rev))?.description).toBe("attempted change"); 379 + 380 + await flushMicrotasks(); 381 + await flushMicrotasks(); 382 + 383 + // Should revert 384 + expect(collection.state.get(getRevisionKey(rev))?.description).toBe("original text"); 385 + }); 386 + 387 + // ── squashRevision ────────────────────────────────────────────────────── 388 + 389 + test("squashRevision rejects immutable revisions", () => { 390 + squashRevision(collection as unknown as AnyRevisionsCollection, REPO_PATH, parentRev); 391 + 392 + expect(mocks.jjSquash).not.toHaveBeenCalled(); 393 + expect(mocks.toastError).toHaveBeenCalledWith( 394 + "Cannot squash immutable revision", 395 + expect.anything(), 396 + ); 397 + }); 398 + 399 + test("squashRevision rejects revisions with no parents", () => { 400 + const rootRev = createMockRevision({ 401 + parent_edges: [], 402 + is_immutable: false, 403 + }); 404 + collection.utils.writeUpsert([rootRev]); 405 + 406 + squashRevision(collection as unknown as AnyRevisionsCollection, REPO_PATH, rootRev); 407 + 408 + expect(mocks.jjSquash).not.toHaveBeenCalled(); 409 + expect(mocks.toastError).toHaveBeenCalledWith("Cannot squash root revision", expect.anything()); 410 + }); 411 + 412 + test("squashRevision optimistically removes non-WC revision", () => { 413 + const rev = createMockRevision({ 414 + commit_id: "squash-target-001", 415 + change_id: "squashtarget1", 416 + change_id_short: "squa", 417 + is_immutable: false, 418 + parent_edges: [{ parent_id: parentRev.commit_id, edge_type: "direct" }], 419 + }); 420 + collection.utils.writeUpsert([rev]); 421 + 422 + squashRevision(collection as unknown as AnyRevisionsCollection, REPO_PATH, rev); 423 + 424 + expect(collection.state.get(getRevisionKey(rev))).toBeUndefined(); 425 + }); 426 + });
+237
apps/desktop/src/__tests__/repo-management.test.ts
··· 1 + /** 2 + * Suite 1: Repository management 3 + * 4 + * Tests add, remove, list, and persistence of repositories through 5 + * the db.ts functions with mock collections for optimistic updates. 6 + */ 7 + 8 + import { beforeEach, describe, expect, test, vi } from "vitest"; 9 + import { 10 + createMockRepository, 11 + createMockCollectionForRepos, 12 + resetIdCounter, 13 + type MockCollection, 14 + } from "./fixtures"; 15 + import type { Repository } from "@/schemas"; 16 + 17 + // ── Hoisted mocks ─────────────────────────────────────────────────────────── 18 + const mocks = vi.hoisted(() => ({ 19 + getRepositories: vi.fn(), 20 + upsertRepository: vi.fn(), 21 + removeRepository: vi.fn(), 22 + listen: vi.fn(), 23 + watchRepository: vi.fn(), 24 + unwatchRepository: vi.fn(), 25 + })); 26 + 27 + vi.mock("@tauri-apps/api/event", () => ({ 28 + listen: mocks.listen, 29 + })); 30 + 31 + vi.mock("@/components/ui/sonner", () => ({ 32 + toast: { success: vi.fn(), error: vi.fn() }, 33 + })); 34 + 35 + vi.mock("@/tauri-commands", () => ({ 36 + generateChangeIds: vi.fn().mockResolvedValue([]), 37 + getCommitRecency: vi.fn().mockResolvedValue({}), 38 + getRepositories: mocks.getRepositories, 39 + getRevisionChanges: vi.fn().mockResolvedValue([]), 40 + getRevisionDiff: vi.fn().mockResolvedValue(""), 41 + getRevisions: vi.fn().mockResolvedValue([]), 42 + jjAbandon: vi.fn(), 43 + jjDescribe: vi.fn(), 44 + jjEdit: vi.fn(), 45 + jjGitFetch: vi.fn(), 46 + jjGitPush: vi.fn(), 47 + jjNew: vi.fn(), 48 + jjRebase: vi.fn(), 49 + jjSquash: vi.fn(), 50 + removeRepository: mocks.removeRepository, 51 + undoOperation: vi.fn(), 52 + unwatchRepository: mocks.unwatchRepository, 53 + upsertRepository: mocks.upsertRepository, 54 + watchRepository: mocks.watchRepository, 55 + })); 56 + 57 + import { 58 + addRepository, 59 + deleteRepository, 60 + updateRepository, 61 + ensureRepositories, 62 + queryClient, 63 + } from "@/db"; 64 + 65 + // Type alias to make the mock collection compatible with db.ts functions. 66 + // The mock collection duck-types the real TanStack DB collection. 67 + type AnyCollection = Parameters<typeof addRepository>[0]; 68 + 69 + describe("Repository management", () => { 70 + let collection: MockCollection<Repository>; 71 + 72 + beforeEach(() => { 73 + vi.clearAllMocks(); 74 + resetIdCounter(); 75 + mocks.getRepositories.mockResolvedValue([]); 76 + mocks.upsertRepository.mockResolvedValue(undefined); 77 + mocks.removeRepository.mockResolvedValue(undefined); 78 + mocks.listen.mockResolvedValue(vi.fn()); 79 + 80 + collection = createMockCollectionForRepos(); 81 + }); 82 + 83 + // ── Add ───────────────────────────────────────────────────────────────── 84 + 85 + test("addRepository inserts into collection optimistically", async () => { 86 + const repo = createMockRepository({ id: "add-1", name: "new-repo" }); 87 + 88 + await addRepository(collection as unknown as AnyCollection, repo); 89 + 90 + expect(collection.state.get("add-1")).toBeDefined(); 91 + expect(collection.state.get("add-1")?.name).toBe("new-repo"); 92 + }); 93 + 94 + test("addRepository calls upsertRepository backend", async () => { 95 + const repo = createMockRepository({ id: "add-2" }); 96 + 97 + await addRepository(collection as unknown as AnyCollection, repo); 98 + 99 + expect(mocks.upsertRepository).toHaveBeenCalledTimes(1); 100 + expect(mocks.upsertRepository).toHaveBeenCalledWith(repo); 101 + }); 102 + 103 + test("addRepository reverts optimistic insert on backend failure", async () => { 104 + mocks.upsertRepository.mockRejectedValueOnce(new Error("DB write failed")); 105 + const repo = createMockRepository({ id: "add-fail" }); 106 + 107 + await expect(addRepository(collection as unknown as AnyCollection, repo)).rejects.toThrow( 108 + "DB write failed", 109 + ); 110 + 111 + expect(collection.state.get("add-fail")).toBeUndefined(); 112 + }); 113 + 114 + // ── Remove ────────────────────────────────────────────────────────────── 115 + 116 + test("deleteRepository removes from collection optimistically", async () => { 117 + const repo = createMockRepository({ id: "del-1" }); 118 + collection.state.set("del-1", repo); 119 + 120 + await deleteRepository(collection as unknown as AnyCollection, "del-1"); 121 + 122 + expect(collection.state.get("del-1")).toBeUndefined(); 123 + }); 124 + 125 + test("deleteRepository calls removeRepository backend", async () => { 126 + const repo = createMockRepository({ id: "del-2" }); 127 + collection.state.set("del-2", repo); 128 + 129 + await deleteRepository(collection as unknown as AnyCollection, "del-2"); 130 + 131 + expect(mocks.removeRepository).toHaveBeenCalledWith("del-2"); 132 + }); 133 + 134 + test("deleteRepository reverts on backend failure", async () => { 135 + mocks.removeRepository.mockRejectedValueOnce(new Error("Delete failed")); 136 + const repo = createMockRepository({ id: "del-fail" }); 137 + collection.state.set("del-fail", repo); 138 + 139 + await expect( 140 + deleteRepository(collection as unknown as AnyCollection, "del-fail"), 141 + ).rejects.toThrow("Delete failed"); 142 + 143 + expect(collection.state.get("del-fail")).toBeDefined(); 144 + expect(collection.state.get("del-fail")?.id).toBe("del-fail"); 145 + }); 146 + 147 + // ── Update ────────────────────────────────────────────────────────────── 148 + 149 + test("updateRepository applies changes optimistically", async () => { 150 + const repo = createMockRepository({ id: "upd-1", name: "old-name" }); 151 + collection.state.set("upd-1", repo); 152 + 153 + const updated = { ...repo, name: "new-name" }; 154 + await updateRepository(collection as unknown as AnyCollection, updated); 155 + 156 + expect(collection.state.get("upd-1")?.name).toBe("new-name"); 157 + }); 158 + 159 + test("updateRepository reverts to previous state on failure", async () => { 160 + mocks.upsertRepository.mockRejectedValueOnce(new Error("Update failed")); 161 + const repo = createMockRepository({ id: "upd-fail", name: "original" }); 162 + collection.state.set("upd-fail", repo); 163 + 164 + const updated = { ...repo, name: "attempted-change" }; 165 + await expect(updateRepository(collection as unknown as AnyCollection, updated)).rejects.toThrow( 166 + "Update failed", 167 + ); 168 + 169 + expect(collection.state.get("upd-fail")?.name).toBe("original"); 170 + }); 171 + 172 + // ── List / ensureRepositories ──────────────────────────────────────────── 173 + 174 + test("ensureRepositories fetches from backend on first call", async () => { 175 + const repos = [createMockRepository({ id: "list-1" }), createMockRepository({ id: "list-2" })]; 176 + // Clear cache to ensure fresh fetch 177 + queryClient.removeQueries({ queryKey: ["repositories"] }); 178 + mocks.getRepositories.mockResolvedValueOnce(repos); 179 + 180 + const result = await ensureRepositories(); 181 + 182 + expect(result).toHaveLength(2); 183 + expect(mocks.getRepositories).toHaveBeenCalledTimes(1); 184 + }); 185 + 186 + test("ensureRepositories returns cached data on subsequent calls", async () => { 187 + const repos = [createMockRepository({ id: "cached-1" })]; 188 + queryClient.removeQueries({ queryKey: ["repositories"] }); 189 + mocks.getRepositories.mockResolvedValueOnce(repos); 190 + 191 + await ensureRepositories(); 192 + const result = await ensureRepositories(); 193 + 194 + expect(result).toHaveLength(1); 195 + // Only called once due to caching 196 + expect(mocks.getRepositories).toHaveBeenCalledTimes(1); 197 + }); 198 + 199 + // ── Persistence (round-trip) ──────────────────────────────────────────── 200 + 201 + test("add then remove leaves collection empty for that ID", async () => { 202 + const repo = createMockRepository({ id: "roundtrip-1" }); 203 + 204 + await addRepository(collection as unknown as AnyCollection, repo); 205 + expect(collection.state.get("roundtrip-1")).toBeDefined(); 206 + 207 + await deleteRepository(collection as unknown as AnyCollection, "roundtrip-1"); 208 + expect(collection.state.get("roundtrip-1")).toBeUndefined(); 209 + }); 210 + 211 + test("multiple repositories can coexist in collection", async () => { 212 + const repo1 = createMockRepository({ id: "multi-1", name: "alpha" }); 213 + const repo2 = createMockRepository({ id: "multi-2", name: "beta" }); 214 + const repo3 = createMockRepository({ id: "multi-3", name: "gamma" }); 215 + 216 + await addRepository(collection as unknown as AnyCollection, repo1); 217 + await addRepository(collection as unknown as AnyCollection, repo2); 218 + await addRepository(collection as unknown as AnyCollection, repo3); 219 + 220 + expect(collection.state.get("multi-1")?.name).toBe("alpha"); 221 + expect(collection.state.get("multi-2")?.name).toBe("beta"); 222 + expect(collection.state.get("multi-3")?.name).toBe("gamma"); 223 + }); 224 + 225 + test("add, update, then remove is a clean lifecycle", async () => { 226 + const repo = createMockRepository({ id: "lifecycle-1", name: "v1" }); 227 + 228 + await addRepository(collection as unknown as AnyCollection, repo); 229 + expect(collection.state.get("lifecycle-1")?.name).toBe("v1"); 230 + 231 + await updateRepository(collection as unknown as AnyCollection, { ...repo, name: "v2" }); 232 + expect(collection.state.get("lifecycle-1")?.name).toBe("v2"); 233 + 234 + await deleteRepository(collection as unknown as AnyCollection, "lifecycle-1"); 235 + expect(collection.state.get("lifecycle-1")).toBeUndefined(); 236 + }); 237 + });
+203
apps/desktop/src/__tests__/watcher-lifecycle.test.ts
··· 1 + /** 2 + * Suite 2: Watcher lifecycle 3 + * 4 + * Tests setup, events, cleanup on switch, and ref-counting of 5 + * repository file-system watchers. 6 + */ 7 + 8 + import { beforeEach, describe, expect, test, vi } from "vitest"; 9 + import { resetIdCounter } from "./fixtures"; 10 + 11 + // ── Hoisted mocks ─────────────────────────────────────────────────────────── 12 + const mocks = vi.hoisted(() => ({ 13 + listen: vi.fn(), 14 + unlisten: vi.fn(), 15 + watchRepository: vi.fn(), 16 + unwatchRepository: vi.fn(), 17 + invalidateQueries: vi.fn(), 18 + })); 19 + 20 + vi.mock("@tauri-apps/api/event", () => ({ 21 + listen: mocks.listen, 22 + })); 23 + 24 + vi.mock("@/components/ui/sonner", () => ({ 25 + toast: { success: vi.fn(), error: vi.fn() }, 26 + })); 27 + 28 + vi.mock("@/tauri-commands", () => ({ 29 + generateChangeIds: vi.fn().mockResolvedValue([]), 30 + getCommitRecency: vi.fn().mockResolvedValue({}), 31 + getRepositories: vi.fn().mockResolvedValue([]), 32 + getRevisionChanges: vi.fn().mockResolvedValue([]), 33 + getRevisionDiff: vi.fn().mockResolvedValue(""), 34 + getRevisions: vi.fn().mockResolvedValue([]), 35 + jjAbandon: vi.fn(), 36 + jjDescribe: vi.fn(), 37 + jjEdit: vi.fn(), 38 + jjGitFetch: vi.fn(), 39 + jjGitPush: vi.fn(), 40 + jjNew: vi.fn(), 41 + jjRebase: vi.fn(), 42 + jjSquash: vi.fn(), 43 + removeRepository: vi.fn(), 44 + undoOperation: vi.fn(), 45 + unwatchRepository: mocks.unwatchRepository, 46 + upsertRepository: vi.fn(), 47 + watchRepository: mocks.watchRepository, 48 + })); 49 + 50 + import { setupRepoWatcher, teardownRepoWatcher } from "@/db"; 51 + 52 + describe("Watcher lifecycle", () => { 53 + beforeEach(() => { 54 + vi.clearAllMocks(); 55 + resetIdCounter(); 56 + mocks.listen.mockResolvedValue(mocks.unlisten); 57 + mocks.watchRepository.mockResolvedValue(undefined); 58 + mocks.unwatchRepository.mockResolvedValue(undefined); 59 + }); 60 + 61 + // ── Setup ─────────────────────────────────────────────────────────────── 62 + 63 + test("setup calls watchRepository and listen once", async () => { 64 + const repoPath = "/tmp/watcher-setup"; 65 + 66 + await setupRepoWatcher(repoPath); 67 + 68 + expect(mocks.watchRepository).toHaveBeenCalledTimes(1); 69 + expect(mocks.watchRepository).toHaveBeenCalledWith(repoPath); 70 + expect(mocks.listen).toHaveBeenCalledTimes(1); 71 + expect(mocks.listen).toHaveBeenCalledWith("repo-changed", expect.any(Function)); 72 + }); 73 + 74 + test("setup is idempotent via ref-counting (no duplicate watchers)", async () => { 75 + const repoPath = "/tmp/watcher-idempotent"; 76 + 77 + await setupRepoWatcher(repoPath); 78 + await setupRepoWatcher(repoPath); 79 + await setupRepoWatcher(repoPath); 80 + 81 + expect(mocks.watchRepository).toHaveBeenCalledTimes(1); 82 + expect(mocks.listen).toHaveBeenCalledTimes(1); 83 + }); 84 + 85 + // ── Ref-counting teardown ─────────────────────────────────────────────── 86 + 87 + test("teardown decrements refCount without cleanup until zero", async () => { 88 + const repoPath = "/tmp/watcher-refcount"; 89 + 90 + // Setup twice (refCount = 2) 91 + await setupRepoWatcher(repoPath); 92 + await setupRepoWatcher(repoPath); 93 + 94 + // First teardown: refCount → 1 (no cleanup yet) 95 + await teardownRepoWatcher(repoPath); 96 + expect(mocks.unlisten).not.toHaveBeenCalled(); 97 + expect(mocks.unwatchRepository).not.toHaveBeenCalled(); 98 + 99 + // Second teardown: refCount → 0 (cleanup) 100 + await teardownRepoWatcher(repoPath); 101 + expect(mocks.unlisten).toHaveBeenCalledTimes(1); 102 + expect(mocks.unwatchRepository).toHaveBeenCalledTimes(1); 103 + expect(mocks.unwatchRepository).toHaveBeenCalledWith(repoPath); 104 + }); 105 + 106 + test("teardown is a no-op for unknown repositories", async () => { 107 + await teardownRepoWatcher("/tmp/watcher-unknown"); 108 + 109 + expect(mocks.unwatchRepository).not.toHaveBeenCalled(); 110 + expect(mocks.unlisten).not.toHaveBeenCalled(); 111 + }); 112 + 113 + // ── Cleanup on switch ─────────────────────────────────────────────────── 114 + 115 + test("switching repos tears down old watcher before setting up new", async () => { 116 + const events: string[] = []; 117 + 118 + mocks.watchRepository.mockImplementation(async (path: string) => { 119 + events.push(`watch:${path}`); 120 + }); 121 + mocks.unwatchRepository.mockImplementation(async (path: string) => { 122 + events.push(`unwatch:${path}`); 123 + }); 124 + 125 + // Setup repo A 126 + await setupRepoWatcher("/tmp/repo-a"); 127 + 128 + // Tear down repo A, setup repo B (simulates project switch) 129 + await teardownRepoWatcher("/tmp/repo-a"); 130 + await setupRepoWatcher("/tmp/repo-b"); 131 + 132 + expect(events).toEqual(["watch:/tmp/repo-a", "unwatch:/tmp/repo-a", "watch:/tmp/repo-b"]); 133 + }); 134 + 135 + test("rapid switches do not accumulate active watchers", async () => { 136 + const switchCount = 10; 137 + 138 + for (let i = 0; i < switchCount; i++) { 139 + const repoPath = `/tmp/rapid-switch-${i}`; 140 + await setupRepoWatcher(repoPath); 141 + await teardownRepoWatcher(repoPath); 142 + } 143 + 144 + // Each repo was watched and unwatched exactly once 145 + expect(mocks.watchRepository).toHaveBeenCalledTimes(switchCount); 146 + expect(mocks.unwatchRepository).toHaveBeenCalledTimes(switchCount); 147 + }); 148 + 149 + // ── Event callback ────────────────────────────────────────────────────── 150 + 151 + test("setup registers event listener for repo-changed events", async () => { 152 + await setupRepoWatcher("/tmp/watcher-events"); 153 + 154 + // The listen call should have registered a handler for "repo-changed" 155 + const listenCall = mocks.listen.mock.calls[0]; 156 + expect(listenCall[0]).toBe("repo-changed"); 157 + expect(typeof listenCall[1]).toBe("function"); 158 + }); 159 + 160 + test("teardown calls the unlisten function from setup", async () => { 161 + const customUnlisten = vi.fn(); 162 + mocks.listen.mockResolvedValueOnce(customUnlisten); 163 + 164 + await setupRepoWatcher("/tmp/watcher-unlisten"); 165 + await teardownRepoWatcher("/tmp/watcher-unlisten"); 166 + 167 + expect(customUnlisten).toHaveBeenCalledTimes(1); 168 + }); 169 + 170 + // ── Independent watchers ──────────────────────────────────────────────── 171 + 172 + test("different repos have independent watchers and ref-counts", async () => { 173 + await setupRepoWatcher("/tmp/independent-a"); 174 + await setupRepoWatcher("/tmp/independent-b"); 175 + 176 + // Tear down only A 177 + await teardownRepoWatcher("/tmp/independent-a"); 178 + 179 + // A should be unwatched, B still active 180 + expect(mocks.unwatchRepository).toHaveBeenCalledTimes(1); 181 + expect(mocks.unwatchRepository).toHaveBeenCalledWith("/tmp/independent-a"); 182 + 183 + // Tear down B 184 + await teardownRepoWatcher("/tmp/independent-b"); 185 + expect(mocks.unwatchRepository).toHaveBeenCalledTimes(2); 186 + expect(mocks.unwatchRepository).toHaveBeenCalledWith("/tmp/independent-b"); 187 + }); 188 + 189 + test("re-setup after full teardown creates fresh watcher", async () => { 190 + const repoPath = "/tmp/watcher-re-setup"; 191 + 192 + await setupRepoWatcher(repoPath); 193 + await teardownRepoWatcher(repoPath); 194 + 195 + // Should be fully torn down 196 + expect(mocks.unwatchRepository).toHaveBeenCalledTimes(1); 197 + 198 + // Re-setup should create a new watcher 199 + await setupRepoWatcher(repoPath); 200 + expect(mocks.watchRepository).toHaveBeenCalledTimes(2); 201 + expect(mocks.listen).toHaveBeenCalledTimes(2); 202 + }); 203 + });
+31 -2
apps/desktop/src/components/AppShell.tsx
··· 60 60 newRevision, 61 61 rebaseRevision, 62 62 repositoriesCollection, 63 + setupRepoWatcher, 63 64 squashRevision, 64 65 syncRepository, 66 + teardownRepoWatcher, 65 67 } from "@/db"; 66 68 import { useAddRepository } from "@/hooks/useAddRepository"; 67 69 import { useAppTitle } from "@/hooks/useAppTitle"; ··· 75 77 type Repository, 76 78 type Revision, 77 79 } from "@/tauri-commands"; 80 + import { switchProjectWithWatcherCleanup } from "@/lib/project-switch"; 78 81 import { onRenderCallback } from "@/lib/trace"; 79 82 80 83 // Wrapper component that handles the case when no project is selected ··· 156 159 const layoutHydratedRef = useRef(false); 157 160 const selectionRestoredForProjectRef = useRef<string | null>(null); 158 161 const persistLayoutTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); 162 + const skipWatcherCleanupRef = useRef<string | null>(null); 159 163 const isNarrowScreen = useIsNarrowScreen(); 160 164 const { handleAddRepository } = useAddRepository(); 161 165 ··· 168 172 const { data: repositories = [] } = useLiveQuery(repositoriesCollection); 169 173 170 174 const activeProject = repositories.find((p) => p.id === projectId) ?? null; 175 + 176 + useEffect(() => { 177 + const repoPath = activeProject?.path; 178 + if (!repoPath) return; 179 + 180 + void setupRepoWatcher(repoPath).catch(() => {}); 181 + 182 + return () => { 183 + if (skipWatcherCleanupRef.current === repoPath) { 184 + skipWatcherCleanupRef.current = null; 185 + return; 186 + } 187 + void teardownRepoWatcher(repoPath).catch(() => {}); 188 + }; 189 + }, [activeProject?.path]); 171 190 172 191 const { data: persistedLayout } = useQuery({ 173 192 queryKey: ["app-layout"], ··· 343 362 }; 344 363 }, [selectedChangeId, setDebouncedChangeId]); 345 364 346 - function handleSelectRepository(repository: Repository) { 347 - navigate({ to: "/project/$projectId", params: { projectId: repository.id } }); 365 + async function handleSelectRepository(repository: Repository) { 366 + const previousRepoPath = activeProject?.path ?? null; 367 + await switchProjectWithWatcherCleanup(previousRepoPath, repository, { 368 + onTeardownSuccess: (repoPath) => { 369 + skipWatcherCleanupRef.current = repoPath; 370 + }, 371 + navigateToProject: (nextProjectId) => { 372 + navigate({ to: "/project/$projectId", params: { projectId: nextProjectId } }); 373 + }, 374 + }).catch(() => { 375 + // Ignore teardown errors and still allow project switch. 376 + }); 348 377 } 349 378 350 379 function handleSelectRevision(revision: Revision) {
+2 -2
apps/desktop/src/components/Search.tsx
··· 183 183 filtered.sort((a, b) => { 184 184 const aType = matchType(a); 185 185 const bType = matchType(b); 186 - const aPriority = aType ? priority[aType] ?? 3 : 3; 187 - const bPriority = bType ? priority[bType] ?? 3 : 3; 186 + const aPriority = aType ? (priority[aType] ?? 3) : 3; 187 + const bPriority = bType ? (priority[bType] ?? 3) : 3; 188 188 return aPriority - bPriority; 189 189 }); 190 190 }
+6 -2
apps/desktop/src/components/diff/FileList.tsx
··· 274 274 else itemRefs.current?.delete(node.path); 275 275 }} 276 276 type="button" 277 - onClick={(e) => onSelectFile(node.path, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey })} 277 + onClick={(e) => 278 + onSelectFile(node.path, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }) 279 + } 278 280 className={cn( 279 281 "min-w-0 flex-1 flex items-center gap-2 px-3 py-1.5 text-left text-sm", 280 282 isSelected ··· 654 656 title="Tree file list" 655 657 aria-label="Tree file list" 656 658 > 657 - <FolderTreeIcon className={`size-3 ${viewMode === "tree" ? "text-foreground" : ""}`} /> 659 + <FolderTreeIcon 660 + className={`size-3 ${viewMode === "tree" ? "text-foreground" : ""}`} 661 + /> 658 662 </button> 659 663 </div> 660 664 </div>
+6 -1
apps/desktop/src/components/ui/resizable.tsx
··· 12 12 direction?: PanelDirection; 13 13 }; 14 14 15 - function ResizablePanelGroup({ className, orientation, direction, ...props }: ResizablePanelGroupProps) { 15 + function ResizablePanelGroup({ 16 + className, 17 + orientation, 18 + direction, 19 + ...props 20 + }: ResizablePanelGroupProps) { 16 21 return ( 17 22 <Group 18 23 data-slot="resizable-panel-group"
+14
apps/desktop/src/db.pure.ts
··· 1 + /** 2 + * Pure utility functions extracted from db.ts for use in tests 3 + * and other contexts that don't need the full TanStack DB setup. 4 + */ 5 + 6 + import type { Revision } from "@/schemas"; 7 + 8 + /** Key function that handles divergent changes (same change_id, different commits) */ 9 + export function getRevisionKey(revision: Revision): string { 10 + if (revision.divergent_index != null) { 11 + return `${revision.change_id}/${revision.divergent_index}`; 12 + } 13 + return revision.change_id; 14 + }
+18 -4
apps/desktop/src/db.ts
··· 22 22 jjSquash, 23 23 removeRepository, 24 24 undoOperation, 25 + unwatchRepository, 25 26 upsertRepository, 26 27 watchRepository, 27 28 } from "@/tauri-commands"; ··· 123 124 124 125 const repoWatchers = new Map<string, { unlisten: () => void; refCount: number }>(); 125 126 126 - async function setupRepoWatcher(repoPath: string): Promise<void> { 127 + export async function setupRepoWatcher(repoPath: string): Promise<void> { 127 128 const existing = repoWatchers.get(repoPath); 128 129 if (existing) { 129 130 existing.refCount++; ··· 151 152 }); 152 153 153 154 repoWatchers.set(repoPath, { unlisten, refCount: 1 }); 155 + } 156 + 157 + export async function teardownRepoWatcher(repoPath: string): Promise<void> { 158 + const existing = repoWatchers.get(repoPath); 159 + if (!existing) { 160 + return; 161 + } 162 + 163 + existing.refCount--; 164 + if (existing.refCount > 0) { 165 + return; 166 + } 167 + 168 + repoWatchers.delete(repoPath); 169 + existing.unlisten(); 170 + await unwatchRepository(repoPath); 154 171 } 155 172 156 173 function isAuthError(errorText: string): boolean { ··· 337 354 338 355 function createRevisionsCollection(repoPath: string, preset?: string) { 339 356 const limit = preset === "full_history" ? 10000 : 100; 340 - 341 - // Set up the shared watcher (idempotent - increments refCount if already exists) 342 - setupRepoWatcher(repoPath); 343 357 344 358 return createCollection({ 345 359 ...queryCollectionOptions({
+91
apps/desktop/src/db.watchers.test.ts
··· 1 + import { beforeEach, describe, expect, test, vi } from "vitest"; 2 + 3 + const mocks = vi.hoisted(() => ({ 4 + listen: vi.fn(), 5 + unlisten: vi.fn(), 6 + watchRepository: vi.fn(), 7 + unwatchRepository: vi.fn(), 8 + })); 9 + 10 + vi.mock("@tauri-apps/api/event", () => ({ 11 + listen: mocks.listen, 12 + })); 13 + 14 + vi.mock("@/components/ui/sonner", () => ({ 15 + toast: { 16 + success: vi.fn(), 17 + error: vi.fn(), 18 + }, 19 + })); 20 + 21 + vi.mock("@/tauri-commands", () => ({ 22 + generateChangeIds: vi.fn().mockResolvedValue([]), 23 + getCommitRecency: vi.fn().mockResolvedValue({}), 24 + getRepositories: vi.fn().mockResolvedValue([]), 25 + getRevisionChanges: vi.fn().mockResolvedValue([]), 26 + getRevisionDiff: vi.fn().mockResolvedValue(""), 27 + getRevisions: vi.fn().mockResolvedValue([]), 28 + jjAbandon: vi.fn(), 29 + jjDescribe: vi.fn(), 30 + jjEdit: vi.fn(), 31 + jjGitFetch: vi.fn(), 32 + jjGitPush: vi.fn(), 33 + jjNew: vi.fn(), 34 + jjRebase: vi.fn(), 35 + jjSquash: vi.fn(), 36 + removeRepository: vi.fn(), 37 + undoOperation: vi.fn(), 38 + unwatchRepository: mocks.unwatchRepository, 39 + upsertRepository: vi.fn(), 40 + watchRepository: mocks.watchRepository, 41 + })); 42 + 43 + import { setupRepoWatcher, teardownRepoWatcher } from "@/db"; 44 + 45 + describe("repo watcher lifecycle", () => { 46 + beforeEach(() => { 47 + vi.clearAllMocks(); 48 + mocks.listen.mockResolvedValue(mocks.unlisten); 49 + mocks.watchRepository.mockResolvedValue(undefined); 50 + mocks.unwatchRepository.mockResolvedValue(undefined); 51 + }); 52 + 53 + test("setup and teardown are ref-counted", async () => { 54 + const repoPath = "/tmp/repo-watchers-ref-count"; 55 + 56 + await setupRepoWatcher(repoPath); 57 + await setupRepoWatcher(repoPath); 58 + 59 + expect(mocks.watchRepository).toHaveBeenCalledTimes(1); 60 + expect(mocks.listen).toHaveBeenCalledTimes(1); 61 + 62 + await teardownRepoWatcher(repoPath); 63 + 64 + expect(mocks.unwatchRepository).not.toHaveBeenCalled(); 65 + expect(mocks.unlisten).not.toHaveBeenCalled(); 66 + 67 + await teardownRepoWatcher(repoPath); 68 + 69 + expect(mocks.unlisten).toHaveBeenCalledTimes(1); 70 + expect(mocks.unwatchRepository).toHaveBeenCalledTimes(1); 71 + expect(mocks.unwatchRepository).toHaveBeenCalledWith(repoPath); 72 + }); 73 + 74 + test("teardown is a no-op for unknown repositories", async () => { 75 + await teardownRepoWatcher("/tmp/repo-watchers-missing"); 76 + 77 + expect(mocks.unwatchRepository).not.toHaveBeenCalled(); 78 + expect(mocks.unlisten).not.toHaveBeenCalled(); 79 + }); 80 + 81 + test("repeated switches do not accumulate active watchers", async () => { 82 + for (let i = 0; i < 12; i++) { 83 + const repoPath = `/tmp/repo-switch-${i}`; 84 + await setupRepoWatcher(repoPath); 85 + await teardownRepoWatcher(repoPath); 86 + } 87 + 88 + expect(mocks.watchRepository).toHaveBeenCalledTimes(12); 89 + expect(mocks.unwatchRepository).toHaveBeenCalledTimes(12); 90 + }); 91 + });
+108
apps/desktop/src/lib/project-switch.test.ts
··· 1 + import { beforeEach, describe, expect, test, vi } from "vitest"; 2 + 3 + const mocks = vi.hoisted(() => ({ 4 + listen: vi.fn(), 5 + unlisten: vi.fn(), 6 + watchRepository: vi.fn(), 7 + unwatchRepository: vi.fn(), 8 + })); 9 + 10 + vi.mock("@tauri-apps/api/event", () => ({ 11 + listen: mocks.listen, 12 + })); 13 + 14 + vi.mock("@/components/ui/sonner", () => ({ 15 + toast: { 16 + success: vi.fn(), 17 + error: vi.fn(), 18 + }, 19 + })); 20 + 21 + vi.mock("@/tauri-commands", () => ({ 22 + generateChangeIds: vi.fn().mockResolvedValue([]), 23 + getCommitRecency: vi.fn().mockResolvedValue({}), 24 + getRepositories: vi.fn().mockResolvedValue([]), 25 + getRevisionChanges: vi.fn().mockResolvedValue([]), 26 + getRevisionDiff: vi.fn().mockResolvedValue(""), 27 + getRevisions: vi.fn().mockResolvedValue([]), 28 + jjAbandon: vi.fn(), 29 + jjDescribe: vi.fn(), 30 + jjEdit: vi.fn(), 31 + jjGitFetch: vi.fn(), 32 + jjGitPush: vi.fn(), 33 + jjNew: vi.fn(), 34 + jjRebase: vi.fn(), 35 + jjSquash: vi.fn(), 36 + removeRepository: vi.fn(), 37 + undoOperation: vi.fn(), 38 + unwatchRepository: mocks.unwatchRepository, 39 + upsertRepository: vi.fn(), 40 + watchRepository: mocks.watchRepository, 41 + })); 42 + 43 + import { setupRepoWatcher } from "@/db"; 44 + import type { Repository } from "@/tauri-commands"; 45 + import { switchProjectWithWatcherCleanup } from "./project-switch"; 46 + 47 + describe("switchProjectWithWatcherCleanup", () => { 48 + beforeEach(() => { 49 + vi.clearAllMocks(); 50 + mocks.listen.mockResolvedValue(mocks.unlisten); 51 + mocks.watchRepository.mockResolvedValue(undefined); 52 + mocks.unwatchRepository.mockResolvedValue(undefined); 53 + }); 54 + 55 + test("tears down previous watcher before navigation callback", async () => { 56 + const previousRepoPath = "/tmp/repo-previous"; 57 + const nextRepository: Repository = { 58 + id: "repo-next", 59 + path: "/tmp/repo-next", 60 + name: "Next", 61 + last_opened_at: Date.now(), 62 + revset_preset: "full_history", 63 + }; 64 + const events: string[] = []; 65 + 66 + mocks.unwatchRepository.mockImplementation(async (repoPath: string) => { 67 + events.push(`unwatch:${repoPath}`); 68 + }); 69 + 70 + await setupRepoWatcher(previousRepoPath); 71 + 72 + await switchProjectWithWatcherCleanup(previousRepoPath, nextRepository, { 73 + onTeardownSuccess: (repoPath) => { 74 + events.push(`teardown-success:${repoPath}`); 75 + }, 76 + navigateToProject: (projectId) => { 77 + events.push(`navigate:${projectId}`); 78 + }, 79 + }); 80 + 81 + expect(events).toEqual([ 82 + `unwatch:${previousRepoPath}`, 83 + `teardown-success:${previousRepoPath}`, 84 + `navigate:${nextRepository.id}`, 85 + ]); 86 + }); 87 + 88 + test("navigates without teardown when selecting the same repository", async () => { 89 + const repository: Repository = { 90 + id: "repo-a", 91 + path: "/tmp/repo-a", 92 + name: "Repo A", 93 + last_opened_at: Date.now(), 94 + revset_preset: "full_history", 95 + }; 96 + const navigateToProject = vi.fn(); 97 + const onTeardownSuccess = vi.fn(); 98 + 99 + await switchProjectWithWatcherCleanup(repository.path, repository, { 100 + navigateToProject, 101 + onTeardownSuccess, 102 + }); 103 + 104 + expect(mocks.unwatchRepository).not.toHaveBeenCalled(); 105 + expect(onTeardownSuccess).not.toHaveBeenCalled(); 106 + expect(navigateToProject).toHaveBeenCalledWith(repository.id); 107 + }); 108 + });
+22
apps/desktop/src/lib/project-switch.ts
··· 1 + import { teardownRepoWatcher } from "@/db"; 2 + import type { Repository } from "@/tauri-commands"; 3 + 4 + interface SwitchProjectOptions { 5 + navigateToProject: (projectId: string) => void; 6 + onTeardownSuccess?: (repoPath: string) => void; 7 + } 8 + 9 + export async function switchProjectWithWatcherCleanup( 10 + previousRepoPath: string | null, 11 + nextRepository: Repository, 12 + options: SwitchProjectOptions, 13 + ): Promise<void> { 14 + try { 15 + if (previousRepoPath && previousRepoPath !== nextRepository.path) { 16 + await teardownRepoWatcher(previousRepoPath); 17 + options.onTeardownSuccess?.(previousRepoPath); 18 + } 19 + } finally { 20 + options.navigateToProject(nextRepository.id); 21 + } 22 + }
+71 -2
bun.lock
··· 62 62 "@vitejs/plugin-react": "^5.1.2", 63 63 "agentation": "^1.1.0", 64 64 "babel-plugin-react-compiler": "^1.0.0", 65 + "jsdom": "^28.0.0", 65 66 "react-grab": "^0.0.98", 66 67 "tailwindcss": "^4.1.18", 67 68 "typescript": "^5.6.3", ··· 91 92 }, 92 93 }, 93 94 "packages": { 95 + "@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="], 96 + 94 97 "@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="], 98 + 99 + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.2", "", { "dependencies": { "@csstools/css-calc": "^3.0.0", "@csstools/css-color-parser": "^4.0.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.5" } }, "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg=="], 100 + 101 + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.7.8", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.5" } }, "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ=="], 102 + 103 + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], 95 104 96 105 "@ast-grep/cli": ["@ast-grep/cli@0.40.5", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.40.5", "@ast-grep/cli-darwin-x64": "0.40.5", "@ast-grep/cli-linux-arm64-gnu": "0.40.5", "@ast-grep/cli-linux-x64-gnu": "0.40.5", "@ast-grep/cli-win32-arm64-msvc": "0.40.5", "@ast-grep/cli-win32-ia32-msvc": "0.40.5", "@ast-grep/cli-win32-x64-msvc": "0.40.5" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-yVXL7Gz0WIHerQLf+MVaVSkhIhidtWReG5akNVr/JS9OVCVkSdz7gWm7H8jVv2M9OO1tauuG76K3UaRGBPu5lQ=="], 97 106 ··· 193 202 194 203 "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.10", "", { "os": "win32", "cpu": "x64" }, "sha512-pHEFgq7dUEsKnqG9mx9bXihxGI49X+ar+UBrEIj3Wqj3UCZp1rNgV+OoyjFgcXsjCWpuEAF4VJdkZr3TrWdCbQ=="], 195 204 205 + "@csstools/color-helpers": ["@csstools/color-helpers@6.0.1", "", {}, "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ=="], 206 + 207 + "@csstools/css-calc": ["@csstools/css-calc@3.0.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bsDKIP6f4ta2DO9t+rAbSSwv4EMESXy5ZIvzQl1afmD6Z1XHkVu9ijcG9QR/qSgQS1dVa+RaQ/MfQ7FIB/Dn1Q=="], 208 + 209 + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.0.1", "", { "dependencies": { "@csstools/color-helpers": "^6.0.1", "@csstools/css-calc": "^3.0.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw=="], 210 + 211 + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], 212 + 213 + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.27", "", {}, "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow=="], 214 + 215 + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], 216 + 196 217 "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.51.2", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-+693mNflujDZxudSEqSNGpn92QgFhJlBn9q2mDQ9yGWyHuz3hZ8B5g3EXCwdAz4DMJAI+OFCIbfEFZS+YRdrEA=="], 197 218 198 219 "@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="], ··· 258 279 "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], 259 280 260 281 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], 282 + 283 + "@exodus/bytes": ["@exodus/bytes@1.12.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-BuCOHA/EJdPN0qQ5MdgAiJSt9fYDHbghlgrj33gRdy/Yp1/FMCDhU6vJfcKrLC0TPWGSrfH3vYXBQWmFHxlddw=="], 261 284 262 285 "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], 263 286 ··· 639 662 640 663 "baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="], 641 664 665 + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], 666 + 642 667 "bippy": ["bippy@0.2.24", "", { "dependencies": { "@types/react-reconciler": "^0.28.9" }, "peerDependencies": { "react": ">=17.0.1" } }, "sha512-EZ8GSYSyPywsUmcOH2Kss/yhI8Auoku1WGKOK3/Ya7vukriRPJ2/8q+KApvh8LtX4KXNDBE5QD6furYz2Yei+Q=="], 643 668 644 669 "body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="], ··· 721 746 722 747 "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], 723 748 749 + "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], 750 + 724 751 "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], 752 + 753 + "cssstyle": ["cssstyle@5.3.7", "", { "dependencies": { "@asamuzakjp/css-color": "^4.1.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.21", "css-tree": "^3.1.0", "lru-cache": "^11.2.4" } }, "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ=="], 725 754 726 755 "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], 727 756 728 757 "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], 729 758 759 + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], 760 + 730 761 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 762 + 763 + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], 731 764 732 765 "dedent": ["dedent@1.7.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg=="], 733 766 ··· 889 922 890 923 "hono": ["hono@4.11.1", "", {}, "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg=="], 891 924 925 + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], 926 + 892 927 "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], 893 928 894 929 "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], 895 930 896 931 "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], 932 + 933 + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], 897 934 898 935 "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], 899 936 ··· 933 970 934 971 "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], 935 972 973 + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], 974 + 936 975 "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], 937 976 938 977 "is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], ··· 958 997 "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], 959 998 960 999 "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], 1000 + 1001 + "jsdom": ["jsdom@28.0.0", "", { "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.7.6", "@exodus/bytes": "^1.11.0", "cssstyle": "^5.3.7", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "undici": "^7.20.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA=="], 961 1002 962 1003 "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], 963 1004 ··· 1007 1048 1008 1049 "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], 1009 1050 1010 - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], 1051 + "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], 1011 1052 1012 1053 "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], 1013 1054 ··· 1018 1059 "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], 1019 1060 1020 1061 "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], 1062 + 1063 + "mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="], 1021 1064 1022 1065 "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], 1023 1066 ··· 1115 1158 1116 1159 "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], 1117 1160 1118 - "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], 1161 + "parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], 1119 1162 1120 1163 "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], 1121 1164 ··· 1152 1195 "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], 1153 1196 1154 1197 "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], 1198 + 1199 + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], 1155 1200 1156 1201 "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], 1157 1202 ··· 1212 1257 "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], 1213 1258 1214 1259 "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], 1260 + 1261 + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], 1215 1262 1216 1263 "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], 1217 1264 ··· 1289 1336 1290 1337 "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], 1291 1338 1339 + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], 1340 + 1292 1341 "tabbable": ["tabbable@6.3.0", "", {}, "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ=="], 1293 1342 1294 1343 "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], ··· 1324 1373 "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], 1325 1374 1326 1375 "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], 1376 + 1377 + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], 1327 1378 1328 1379 "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], 1329 1380 ··· 1349 1400 1350 1401 "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], 1351 1402 1403 + "undici": ["undici@7.21.0", "", {}, "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg=="], 1404 + 1352 1405 "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 1353 1406 1354 1407 "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], ··· 1395 1448 1396 1449 "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], 1397 1450 1451 + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], 1452 + 1398 1453 "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], 1399 1454 1455 + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], 1456 + 1457 + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], 1458 + 1459 + "whatwg-url": ["whatwg-url@16.0.0", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ=="], 1460 + 1400 1461 "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], 1401 1462 1402 1463 "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], ··· 1408 1469 "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], 1409 1470 1410 1471 "wsl-utils": ["wsl-utils@0.3.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ=="], 1472 + 1473 + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], 1474 + 1475 + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], 1411 1476 1412 1477 "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], 1413 1478 ··· 1429 1494 1430 1495 "@antfu/ni/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], 1431 1496 1497 + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], 1498 + 1432 1499 "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], 1433 1500 1434 1501 "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], ··· 1460 1527 "@tanstack/router-core/seroval-plugins": ["seroval-plugins@1.4.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-zir1aWzoiax6pbBVjoYVd0O1QQXgIL3eVGBMsBsNmM8Ukq90yGaWlfx0AB9dTS8GPqrOrbXn79vmItCUP9U3BQ=="], 1461 1528 1462 1529 "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], 1530 + 1531 + "babel-plugin-jsx-dom-expressions/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], 1463 1532 1464 1533 "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], 1465 1534