diff --git a/Cargo.lock b/Cargo.lock index fec4afca..ad5b8037 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1448,6 +1448,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "funty" version = "2.0.0" @@ -2354,6 +2363,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.11.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "ipc" version = "0.1.0" @@ -2361,10 +2390,12 @@ dependencies = [ "git", "log", "logger", + "notify", "serde", "serde_json", "tauri", "tauri-plugin-store", + "tempfile", "tokio", "uuid", ] @@ -2567,6 +2598,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.11.0", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2823,6 +2874,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -2884,6 +2936,33 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "num-conv" version = "0.2.0" diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9f37cdc4..07893b2f 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ use git::AppState; use ipc::{ self, repo_manager::{RepoManager, STORE_FILE}, + repository_watcher::RepoContextRuntime, session_manager::SessionManager, }; use log::LevelFilter; @@ -37,6 +38,7 @@ pub fn run() { .manage(AppState { services: RwLock::new(HashMap::new()), }) + .manage(RepoContextRuntime::default()) .manage(Arc::new(SessionManager::new())); #[cfg(target_os = "macos")] @@ -63,6 +65,8 @@ pub fn run() { ipc::commands::init_repository, ipc::commands::create_repo_context, ipc::commands::dispose_repo_context, + ipc::commands::dispose_repo_context_owner, + ipc::commands::invalidate_repo_context_caches, ipc::commands::open_with_app, ipc::repo_manager::list_repositories, ipc::repo_manager::add_repository, diff --git a/apps/desktop/src/bootstrap/app-root.tsx b/apps/desktop/src/bootstrap/app-root.tsx index 75a1cdaa..d9976435 100644 --- a/apps/desktop/src/bootstrap/app-root.tsx +++ b/apps/desktop/src/bootstrap/app-root.tsx @@ -30,6 +30,7 @@ import { isEmbeddedRuntime, isGitRoutePath, normalizeWorkspaceRoutePath, + resolveRepositoryContextScope, SNAPSHOT_EMIT_DEBOUNCE_MS, sanitizeTabWebviewLabel, stripEmbeddedQueryFromRoutePath, @@ -496,10 +497,16 @@ const AppRouter = () => { }; }, [syncTabMetadata]); - const tabScopeId = - embeddedRuntime && embeddedTabId - ? embeddedTabId - : (activeRuntimeId ?? activeTabId ?? "tab-main"); + const tabScopeId = resolveRepositoryContextScope( + embeddedRuntime, + embeddedTabId, + activeRuntimeId, + activeTabId, + ); + + if (!tabScopeId) { + return ; + } return ( diff --git a/apps/desktop/src/bootstrap/query-bridge.ts b/apps/desktop/src/bootstrap/query-bridge.ts index aa0b26ad..9f691bdd 100644 --- a/apps/desktop/src/bootstrap/query-bridge.ts +++ b/apps/desktop/src/bootstrap/query-bridge.ts @@ -1,5 +1,7 @@ +import { initializeRepositoryChangeBridge } from "../state/core/repository-change-bridge"; import { initializeQueryFocusBridge } from "../state/core/state-manager"; export function initializeQueryBridge() { initializeQueryFocusBridge(); + initializeRepositoryChangeBridge(); } diff --git a/apps/desktop/src/bootstrap/runtime-utils.ts b/apps/desktop/src/bootstrap/runtime-utils.ts index 6cbe5b4a..94d843a6 100644 --- a/apps/desktop/src/bootstrap/runtime-utils.ts +++ b/apps/desktop/src/bootstrap/runtime-utils.ts @@ -45,6 +45,19 @@ export const isEmbeddedRuntime = () => { export const isDesktopHostRuntime = () => !isEmbeddedRuntime(); +export const resolveRepositoryContextScope = ( + embeddedRuntime: boolean, + embeddedTabId: string | null, + activeRuntimeId: string | null, + activeTabId: string | null, +) => { + if (!embeddedRuntime) { + return null; + } + + return embeddedTabId ?? activeRuntimeId ?? activeTabId ?? "tab-main"; +}; + export type TabRuntimeStatePayload = { tabId: string; routePath?: string; diff --git a/apps/desktop/src/components/webview-tab-host.tsx b/apps/desktop/src/components/webview-tab-host.tsx index bca46a1b..b6c1a6bd 100644 --- a/apps/desktop/src/components/webview-tab-host.tsx +++ b/apps/desktop/src/components/webview-tab-host.tsx @@ -1,7 +1,13 @@ +import { disposeRepoContextOwner } from "@gitru/commands"; import { LogicalPosition, LogicalSize } from "@tauri-apps/api/dpi"; import { Webview } from "@tauri-apps/api/webview"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useEffect, useMemo, useRef, useState } from "react"; +import { + sanitizeTabWebviewLabel, + TAB_WEBVIEW_LABEL_PREFIX, +} from "@/bootstrap/runtime-utils"; +import { createRepoContextOwnerId } from "@/state/core/repo-context-registry"; import { useAppStore } from "@/store/use-app-store"; import type { WorkspaceTab } from "@/types/store"; @@ -14,12 +20,12 @@ type HostBounds = { type ManagedWebview = { tabId: string; + ownerId: string; webview: Webview; ready: Promise; bounds: HostBounds; }; -const WEBVIEW_LABEL_PREFIX = "tab-webview:"; const CREATE_TIMEOUT_MS = 1200; const managedWebviews = new Map(); @@ -29,9 +35,6 @@ let visibleTabId: string | null = null; let liveTabIds = new Set(); let pendingCleanupTimer: number | null = null; -const sanitizeWebviewLabel = (tabId: string) => - `${WEBVIEW_LABEL_PREFIX}${tabId.replace(/[^a-zA-Z0-9\-/:_]/g, "_")}`; - const getRoutePathname = (routePath: string) => { try { return new URL(routePath, window.location.origin).pathname; @@ -79,7 +82,13 @@ const updateManagedBounds = async ( }; const closeManagedWebview = async (entry: ManagedWebview) => { - await Promise.allSettled([entry.webview.close()]); + try { + await entry.webview.close(); + } finally { + await Promise.allSettled([ + disposeRepoContextOwner({ ownerId: entry.ownerId }), + ]); + } }; const hideUnlessActive = async (entry: ManagedWebview) => { @@ -103,12 +112,15 @@ const ensureTabWebview = async ( const task = (async (): Promise => { const normalized = normalizeBounds(bounds); - const label = sanitizeWebviewLabel(tab.id); + const label = sanitizeTabWebviewLabel(tab.id); + const childScopeId = label.slice(TAB_WEBVIEW_LABEL_PREFIX.length); + const ownerId = createRepoContextOwnerId(label, childScopeId); const existingByLabel = await Webview.getByLabel(label); if (existingByLabel) { const reused: ManagedWebview = { tabId: tab.id, + ownerId, webview: existingByLabel, ready: Promise.resolve(), // Force one geometry sync because the native view can outlive a host @@ -168,6 +180,7 @@ const ensureTabWebview = async ( void webview.once("tauri://created", () => { void hideUnlessActive({ tabId: tab.id, + ownerId, webview, ready: Promise.resolve(), bounds: normalized, @@ -184,6 +197,7 @@ const ensureTabWebview = async ( const created: ManagedWebview = { tabId: tab.id, + ownerId, webview, ready, bounds: normalized, @@ -196,6 +210,7 @@ const ensureTabWebview = async ( if (recovered) { const entry: ManagedWebview = { tabId: tab.id, + ownerId, webview: recovered, ready: Promise.resolve(), bounds: normalized, @@ -299,7 +314,7 @@ const cleanupAllWebviews = async () => { ensureInFlightByTabId.clear(); const entries = Array.from(managedWebviews.values()); managedWebviews.clear(); - await Promise.all(entries.map(closeManagedWebview)); + await Promise.all(entries.map((entry) => closeManagedWebview(entry))); }; const readHostBounds = (element: HTMLDivElement | null): HostBounds | null => { diff --git a/apps/desktop/src/context/tab-context-provider.tsx b/apps/desktop/src/context/tab-context-provider.tsx index 71a2640a..f3083523 100644 --- a/apps/desktop/src/context/tab-context-provider.tsx +++ b/apps/desktop/src/context/tab-context-provider.tsx @@ -1,3 +1,4 @@ +import { getCurrentWebview } from "@tauri-apps/api/webview"; import { createContext, type PropsWithChildren, @@ -6,7 +7,10 @@ import { useMemo, useState, } from "react"; -import { repoContextRegistry } from "@/state/core/repo-context-registry"; +import { + createRepoContextOwnerId, + repoContextRegistry, +} from "@/state/core/repo-context-registry"; import { useAppStore } from "@/store/use-app-store"; type TabContextValue = { @@ -31,6 +35,17 @@ export function TabContextProvider({ children, scopeId = "main", }: TabContextProviderProps) { + const ownerId = useMemo(() => { + let webviewLabel = "browser"; + + try { + webviewLabel = getCurrentWebview().label; + } catch { + // Plain browser runtimes do not expose a native webview label. + } + + return createRepoContextOwnerId(webviewLabel, scopeId); + }, [scopeId]); const activeSessionRepositoryId = useAppStore((state) => { const runtimeId = state.activeSessionId ?? state.activeTabId; if (!runtimeId) { @@ -72,7 +87,7 @@ export function TabContextProvider({ setIsInitializing(true); try { - await repoContextRegistry.ensureScopeContext(scopeId, repoId); + await repoContextRegistry.ensureScopeContext(scopeId, repoId, ownerId); } finally { if (!cancelled) { setIsInitializing(false); @@ -85,7 +100,7 @@ export function TabContextProvider({ return () => { cancelled = true; }; - }, [scopeId, activeSessionRepositoryId]); + }, [scopeId, ownerId, activeSessionRepositoryId]); useEffect(() => { return () => { diff --git a/apps/desktop/src/state/core/repo-context-registry.ts b/apps/desktop/src/state/core/repo-context-registry.ts index 5928635b..23eb7319 100644 --- a/apps/desktop/src/state/core/repo-context-registry.ts +++ b/apps/desktop/src/state/core/repo-context-registry.ts @@ -1,17 +1,47 @@ import { createRepoContext, disposeRepoContext } from "@gitru/commands"; +import { repositories } from "../domains/repository-manager"; type ScopeId = string; type ScopeContextEntry = { repoId: string; contextId: string; + ownerId: string; }; -class RepoContextRegistry { +export const createRepoContextOwnerId = ( + webviewLabel: string, + scopeId: ScopeId, +) => JSON.stringify([webviewLabel, scopeId]); + +type RepoContextRegistryDependencies = { + createContext: (input: { + repoId: string; + ownerId: string; + }) => Promise; + disposeContext: (contextId: string) => Promise; + disposeRepositoryState: (contextId: string) => Promise; +}; + +const defaultDependencies: RepoContextRegistryDependencies = { + createContext: ({ repoId, ownerId }) => + createRepoContext({ repoId, ownerId }), + disposeContext: async (contextId) => { + await disposeRepoContext({ contextId }); + }, + disposeRepositoryState: (contextId) => repositories.disposeContext(contextId), +}; + +export class RepoContextRegistry { private readonly contextsByScope = new Map(); + private readonly operationsByScope = new Map>(); private readonly listeners = new Set<(contextId: string | null) => void>(); private activeScopeId: ScopeId | null = null; + constructor( + private readonly dependencies: RepoContextRegistryDependencies = defaultDependencies, + ) {} + getScopeContext(scopeId: ScopeId): ScopeContextEntry | null { const context = this.contextsByScope.get(scopeId); return context ? { ...context } : null; @@ -28,48 +58,38 @@ class RepoContextRegistry { this.notify(); } - async ensureScopeContext(scopeId: ScopeId, repoId: string): Promise { - const existing = this.contextsByScope.get(scopeId); - - if (existing?.repoId === repoId) { - this.notify(); - return existing.contextId; - } - - if (existing?.contextId) { - await this.disposeContext(existing.contextId); - } - - const contextId = await createRepoContext({ repoId }); - this.contextsByScope.set(scopeId, { repoId, contextId }); - this.notify(); - - return contextId; + async ensureScopeContext( + scopeId: ScopeId, + repoId: string, + ownerId: string, + ): Promise { + return this.runSerialized(scopeId, async () => { + return this.ensureScopeContextNow(scopeId, repoId, ownerId); + }); } async disposeScope(scopeId: ScopeId) { - const existing = this.contextsByScope.get(scopeId); + await this.runSerialized(scopeId, async () => { + const existing = this.contextsByScope.get(scopeId); - this.contextsByScope.delete(scopeId); - - if (existing?.contextId) { - await this.disposeContext(existing.contextId); - } + this.contextsByScope.delete(scopeId); + this.notify(); - this.notify(); + if (existing?.contextId) { + await this.disposeContext(existing.contextId); + } + }); } async disposeAll() { - const contextIds = Array.from(this.contextsByScope.values()).map( - (entry) => entry.contextId, - ); - - this.contextsByScope.clear(); - this.notify(); + const scopeIds = new Set([ + ...this.contextsByScope.keys(), + ...this.operationsByScope.keys(), + ]); await Promise.all( - contextIds.map(async (contextId) => { - await this.disposeContext(contextId); + Array.from(scopeIds, async (scopeId) => { + await this.disposeScope(scopeId); }), ); } @@ -91,6 +111,34 @@ class RepoContextRegistry { }; } + private async ensureScopeContextNow( + scopeId: ScopeId, + repoId: string, + ownerId: string, + ): Promise { + const existing = this.contextsByScope.get(scopeId); + + if (existing?.repoId === repoId && existing.ownerId === ownerId) { + this.notify(); + return existing.contextId; + } + + if (existing?.contextId) { + this.contextsByScope.delete(scopeId); + this.notify(); + await this.disposeContext(existing.contextId); + } + + const contextId = await this.dependencies.createContext({ + repoId, + ownerId, + }); + this.contextsByScope.set(scopeId, { repoId, contextId, ownerId }); + this.notify(); + + return contextId; + } + private notify() { const activeContextId = this.getActiveContextId(); for (const listener of this.listeners) { @@ -100,11 +148,34 @@ class RepoContextRegistry { private async disposeContext(contextId: string) { try { - await disposeRepoContext({ contextId }); + await this.dependencies.disposeContext(contextId); } catch { // Ignore cleanup errors when context is already disposed server-side. + } finally { + await this.dependencies.disposeRepositoryState(contextId); } } + + private runSerialized( + scopeId: ScopeId, + operation: () => Promise, + ): Promise { + const previous = this.operationsByScope.get(scopeId) ?? Promise.resolve(); + const result = previous.catch(() => undefined).then(operation); + const tail = result.then( + () => undefined, + () => undefined, + ); + + this.operationsByScope.set(scopeId, tail); + void tail.then(() => { + if (this.operationsByScope.get(scopeId) === tail) { + this.operationsByScope.delete(scopeId); + } + }); + + return result; + } } export const repoContextRegistry = new RepoContextRegistry(); diff --git a/apps/desktop/src/state/core/repository-change-bridge.ts b/apps/desktop/src/state/core/repository-change-bridge.ts new file mode 100644 index 00000000..45efa796 --- /dev/null +++ b/apps/desktop/src/state/core/repository-change-bridge.ts @@ -0,0 +1,186 @@ +import type { QueryClient, QueryKey } from "@tanstack/react-query"; +import { queryClient } from "./state-manager"; + +export const REPOSITORY_CHANGED_EVENT = "gitru://repository-changed"; + +export type RepositoryChangeKind = + | "worktree" + | "index" + | "head" + | "refs" + | "stash" + | "operation" + | "config"; + +export type RepositoryChangedPayload = { + contextId: string; + changes: RepositoryChangeKind[]; +}; + +const isRepositoryQueryForContext = (queryKey: QueryKey, contextId: string) => + queryKey[0] === "repository" && queryKey[1] === contextId; + +const isWorktreeFileQueryForContext = (queryKey: QueryKey, contextId: string) => + queryKey[0] === "worktree-file" && queryKey[1] === contextId; + +const hasBranchKey = (queryKey: QueryKey, keys: readonly string[]) => + queryKey[3] === "branches" && + typeof queryKey[4] === "string" && + keys.includes(queryKey[4]); + +const isWorktreeDiff = (queryKey: QueryKey) => + queryKey[3] === "diff" && queryKey[4] === "worktree"; + +const isStashDiff = (queryKey: QueryKey) => + queryKey[3] === "diff" && + typeof queryKey[4] === "string" && + queryKey[4].startsWith("stash:"); + +const isCommitTimeline = (queryKey: QueryKey) => + queryKey[3] === "commit" && + ["last", "history", "historyGraph", "commitActivity"].includes( + String(queryKey[4]), + ); + +const includesStash = (value: unknown) => + typeof value === "object" && + value !== null && + "include_stash" in value && + value.include_stash === true; + +const isStashTimeline = (queryKey: QueryKey) => + queryKey[3] === "commit" && + ["historyGraph", "commitActivity"].includes(String(queryKey[4])) && + queryKey.some(includesStash); + +const isAffectedByChange = ( + queryKey: QueryKey, + change: RepositoryChangeKind, +) => { + const domain = queryKey[3]; + + switch (change) { + case "worktree": + return ( + domain === "status" || + isWorktreeDiff(queryKey) || + hasBranchKey(queryKey, ["hasUncommittedChanges"]) + ); + case "index": + return ( + domain === "status" || + domain === "operation" || + isWorktreeDiff(queryKey) || + hasBranchKey(queryKey, ["hasUncommittedChanges"]) + ); + case "head": + return ( + domain === "status" || + isWorktreeDiff(queryKey) || + isCommitTimeline(queryKey) || + hasBranchKey(queryKey, [ + "list", + "current", + "statusAheadBehind", + "hasUncommittedChanges", + "currentBranchStash", + ]) + ); + case "refs": + return ( + domain === "status" || + isWorktreeDiff(queryKey) || + isCommitTimeline(queryKey) || + hasBranchKey(queryKey, [ + "list", + "statusAheadBehind", + "hasUncommittedChanges", + ]) + ); + case "stash": + return ( + domain === "stash" || + isStashDiff(queryKey) || + isStashTimeline(queryKey) || + hasBranchKey(queryKey, ["currentBranchStash"]) + ); + case "operation": + return ( + domain === "operation" || + domain === "status" || + isWorktreeDiff(queryKey) || + hasBranchKey(queryKey, ["current", "hasUncommittedChanges"]) + ); + case "config": + return ( + domain === "status" || + domain === "diff" || + domain === "origin" || + hasBranchKey(queryKey, [ + "list", + "statusAheadBehind", + "hasUncommittedChanges", + ]) + ); + } +}; + +export const shouldInvalidateRepositoryQuery = ( + queryKey: QueryKey, + payload: RepositoryChangedPayload, +) => { + if (isWorktreeFileQueryForContext(queryKey, payload.contextId)) { + return payload.changes.includes("worktree"); + } + + return ( + isRepositoryQueryForContext(queryKey, payload.contextId) && + payload.changes.some((change) => isAffectedByChange(queryKey, change)) + ); +}; + +export const invalidateRepositoryQueries = async ( + client: QueryClient, + payload: RepositoryChangedPayload, +) => { + const predicate = (query: { queryKey: QueryKey }) => + shouldInvalidateRepositoryQuery(query.queryKey, payload); + + // An invalidation alone does not restart an active initial fetch with no + // cached data. Cancel first so a pre-change result cannot clear the + // invalidation and leave the query stale indefinitely. + await client.cancelQueries({ predicate }); + await client.invalidateQueries({ + predicate, + refetchType: "active", + }); +}; + +let repositoryChangeBridgeInitialized = false; + +/** + * Installs one native change listener for this webview runtime. The context ID + * in each event keeps unrelated repository tabs from refetching. + */ +export function initializeRepositoryChangeBridge() { + if (repositoryChangeBridgeInitialized || typeof window === "undefined") { + return; + } + + repositoryChangeBridgeInitialized = true; + + void import("@tauri-apps/api/event") + .then(async ({ listen }) => { + await listen( + REPOSITORY_CHANGED_EVENT, + ({ payload }) => { + if (!payload?.contextId || !Array.isArray(payload.changes)) return; + void invalidateRepositoryQueries(queryClient, payload); + }, + ); + }) + .catch(() => { + // The bridge is optional in a plain browser runtime. Native focus + // refresh remains the recovery path if listener setup fails in Tauri. + }); +} diff --git a/apps/desktop/src/state/core/state-manager.ts b/apps/desktop/src/state/core/state-manager.ts index caeabcb1..97f31d97 100644 --- a/apps/desktop/src/state/core/state-manager.ts +++ b/apps/desktop/src/state/core/state-manager.ts @@ -12,13 +12,28 @@ export const queryClient = new QueryClient({ gcTime: 1000 * 60 * 30, retry: 2, refetchOnWindowFocus: true, - refetchInterval: 30000, }, }, }); let focusBridgeInitialized = false; +export async function refreshActiveQueriesAfterNativeFocus( + client: QueryClient, + invalidateBackendCaches: () => Promise, +) { + await client.cancelQueries({ type: "active" }); + + try { + await invalidateBackendCaches(); + } catch { + // A frontend refetch is still useful if the native cache bridge is + // unavailable; the watcher remains the primary freshness path. + } + + await client.invalidateQueries({ refetchType: "active" }); +} + /** * React Query relies on browser focus events by default. In Tauri, those can be * inconsistent, so we bridge Tauri's native focus change signal into Query's @@ -41,6 +56,7 @@ export function initializeQueryFocusBridge() { let disposed = false; let tauriUnlisten: (() => void) | undefined; + let nativeFocusGeneration = 0; // Use dynamic import so this remains safe in non-Tauri runtime contexts. void import("@tauri-apps/api/window") @@ -48,10 +64,28 @@ export function initializeQueryFocusBridge() { if (disposed) return; tauriUnlisten = await getCurrentWindow().onFocusChanged( ({ payload: focused }) => { - handleFocus(focused); - if (focused) { - void queryClient.invalidateQueries({ refetchType: "active" }); + nativeFocusGeneration += 1; + const generation = nativeFocusGeneration; + + if (!focused) { + handleFocus(false); + return; } + + // Pause focus-triggered refetches until the Rust cache is cleared. + // This keeps the fallback correct even when filesystem watching + // could not be established for a repository. + handleFocus(false); + void refreshActiveQueriesAfterNativeFocus(queryClient, async () => { + const { invalidateRepoContextCaches } = await import( + "@gitru/commands" + ); + await invalidateRepoContextCaches(); + }).finally(() => { + if (!disposed && generation === nativeFocusGeneration) { + handleFocus(true); + } + }); }, ); }) diff --git a/apps/desktop/src/state/domains/repository-manager.ts b/apps/desktop/src/state/domains/repository-manager.ts index 12ee9d24..c5b68fe6 100644 --- a/apps/desktop/src/state/domains/repository-manager.ts +++ b/apps/desktop/src/state/domains/repository-manager.ts @@ -1,9 +1,12 @@ +import type { QueryClient } from "@tanstack/react-query"; import { queryClient } from "../core/state-manager"; import { RepositoryState } from "./repository-state"; -class RepositoryManager { +export class RepositoryManager { private instances = new Map(); + constructor(private readonly client: QueryClient = queryClient) {} + for(repoPath: string, contextId: string): RepositoryState { const normalizedPath = this.normalizePath(repoPath); const key = this.getKey(normalizedPath, contextId); @@ -11,7 +14,7 @@ class RepositoryManager { if (!this.instances.has(key)) { this.instances.set( key, - new RepositoryState(queryClient, normalizedPath, contextId), + new RepositoryState(this.client, normalizedPath, contextId), ); } @@ -24,22 +27,52 @@ class RepositoryManager { async dispose(repoPath: string, contextId: string): Promise { const normalizedPath = this.normalizePath(repoPath); const key = this.getKey(normalizedPath, contextId); - const instance = this.instances.get(key); + const queryKey = ["repository", contextId, normalizedPath]; + const worktreeFileKey = ["worktree-file", contextId]; + + this.instances.delete(key); + await Promise.all([ + this.client.cancelQueries({ queryKey }), + this.client.cancelQueries({ queryKey: worktreeFileKey }), + ]); + this.client.removeQueries({ queryKey }); + this.client.removeQueries({ queryKey: worktreeFileKey }); + } - if (instance) { - await instance.invalidateAll(); - this.instances.delete(key); + /** + * Remove every cached query and state facade owned by a native context. + * Cancelling before removal prevents a disposing tab from refetching. + */ + async disposeContext(contextId: string): Promise { + for (const [key, instance] of this.instances) { + if (instance.contextId === contextId) { + this.instances.delete(key); + } } + + const queryKey = ["repository", contextId]; + const worktreeFileKey = ["worktree-file", contextId]; + await Promise.all([ + this.client.cancelQueries({ queryKey }), + this.client.cancelQueries({ queryKey: worktreeFileKey }), + ]); + this.client.removeQueries({ queryKey }); + this.client.removeQueries({ queryKey: worktreeFileKey }); } /** * Clear all repository state instances */ async disposeAll(): Promise { - for (const instance of this.instances.values()) { - await instance.invalidateAll(); - } this.instances.clear(); + const queryKey = ["repository"]; + const worktreeFileKey = ["worktree-file"]; + await Promise.all([ + this.client.cancelQueries({ queryKey }), + this.client.cancelQueries({ queryKey: worktreeFileKey }), + ]); + this.client.removeQueries({ queryKey }); + this.client.removeQueries({ queryKey: worktreeFileKey }); } private getKey(repoPath: string, contextId: string): string { diff --git a/apps/desktop/src/state/domains/repository-state.ts b/apps/desktop/src/state/domains/repository-state.ts index b90f7638..b8259be1 100644 --- a/apps/desktop/src/state/domains/repository-state.ts +++ b/apps/desktop/src/state/domains/repository-state.ts @@ -86,22 +86,8 @@ class DiffState extends StateDomain { diffScope?: DiffScope; }, ) { - const sourceScope = options?.stashReference - ? `stash:${options.stashReference}` - : options?.commitHash - ? `commit:${options.commitHash}:p${options.parentIndex ?? 1}` - : "worktree"; const diffScope = options?.diffScope ?? "Worktree"; - const queryKey = [ - ...this.baseKey, - sourceScope, - diffScope, - filePath, - options?.fileNewPath ?? "", - options?.status?.join(",") ?? "", - ]; - - const data = await getPatchByFilePath({ + return await getPatchByFilePath({ contextId: this.contextId, filePath: filePath, fileNewPath: options?.fileNewPath, @@ -111,10 +97,6 @@ class DiffState extends StateDomain { parentIndex: options?.parentIndex, diffScope, }); - - this.queryClient.setQueryData(queryKey, data); - - return data; } getDiffQueryKey( @@ -169,15 +151,9 @@ class StatusState extends StateDomain { } async get() { - await this.queryClient.cancelQueries({ queryKey: [...this.baseKey] }); - - const data = await getStatus({ + return await getStatus({ contextId: this.contextId, }); - - this.queryClient.setQueryData([...this.baseKey], data); - - return data; } // For React hooks @@ -210,43 +186,22 @@ class BranchState extends StateDomain { } async list(kind: BranchKind) { - await this.queryClient.cancelQueries({ - queryKey: [...this.baseKey, "list", kind], - }); - - const data = await listBranches({ + return await listBranches({ contextId: this.contextId, kind, }); - - this.queryClient.setQueryData([...this.baseKey, "list", kind], data); - return data; } async current() { - await this.queryClient.cancelQueries({ - queryKey: [...this.baseKey, "current"], - }); - - const data = await currentBranch({ + return await currentBranch({ contextId: this.contextId, }); - - this.queryClient.setQueryData([...this.baseKey, "current"], data); - return data; } async statusAheadBehind() { - await this.queryClient.cancelQueries({ - queryKey: [...this.baseKey, "statusAheadBehind"], - }); - - const data = await statusAheadBehind({ + return await statusAheadBehind({ contextId: this.contextId, }); - - this.queryClient.setQueryData([...this.baseKey, "statusAheadBehind"], data); - return data; } getQueryKey( @@ -461,35 +416,16 @@ class Commit extends StateDomain { this.contextId = contextId; } async last() { - await this.queryClient.cancelQueries({ - queryKey: [...this.baseKey, "last"], - }); - - const data = await lastCommit({ + return await lastCommit({ contextId: this.contextId, }); - - this.queryClient.setQueryData([...this.baseKey, "last"], data); - - return data; } async getCommitById(hash: string) { - await this.queryClient.cancelQueries({ - queryKey: [...this.baseKey, "getCommitById", hash], - }); - - const data = await commitById({ + return await commitById({ contextId: this.contextId, hash, }); - - this.queryClient.setQueryData( - [...this.baseKey, "getCommitById", hash], - data, - ); - - return data; } async createCommit(payload: CreateCommitPayload) { @@ -502,19 +438,11 @@ class Commit extends StateDomain { } async history() { - await this.queryClient.cancelQueries({ - queryKey: [...this.baseKey, "history"], - }); - - const data = await history({ + return await history({ contextId: this.contextId, limit: 100, skip: 0, }); - - this.queryClient.setQueryData([...this.baseKey, "history"], data); - - return data; } async historyGraph(params: HistoryGraphParams["query"]) { @@ -565,10 +493,7 @@ class OperationState extends StateDomain { } async get(): Promise { - await this.queryClient.cancelQueries({ queryKey: [...this.baseKey] }); - const data = await getRepoOperation({ contextId: this.contextId }); - this.queryClient.setQueryData([...this.baseKey], data); - return data; + return await getRepoOperation({ contextId: this.contextId }); } get queryKey() { @@ -701,18 +626,9 @@ class RepositoryState extends StateDomain { } async getRepositoryOrigin() { - // repositoryOrigin() - await this.queryClient.cancelQueries({ - queryKey: [...this.baseKey, "origin"], - }); - - const data = await repositoryOrigin({ + return await repositoryOrigin({ contextId: this.contextId, }); - - this.queryClient.setQueryData([...this.baseKey, "origin"], data); - - return data; } getQueryKey(key: "origin") { diff --git a/apps/desktop/src/state/domains/stash-state.ts b/apps/desktop/src/state/domains/stash-state.ts index ae7bb15b..31ff7032 100644 --- a/apps/desktop/src/state/domains/stash-state.ts +++ b/apps/desktop/src/state/domains/stash-state.ts @@ -42,47 +42,23 @@ class StashState extends StateDomain { } async list(): Promise { - await this.queryClient.cancelQueries({ - queryKey: [...this.baseKey, "list"], - }); - - const data = await stashList({ + return await stashList({ contextId: this.contextId, }); - - this.queryClient.setQueryData([...this.baseKey, "list"], data); - return data; } async quickStat(reference: string): Promise { - await this.queryClient.cancelQueries({ - queryKey: [...this.baseKey, "quickStat", reference], - }); - - const data = await stashQuickStat({ + return await stashQuickStat({ contextId: this.contextId, reference, }); - - this.queryClient.setQueryData( - [...this.baseKey, "quickStat", reference], - data, - ); - return data; } async show(reference: string): Promise { - await this.queryClient.cancelQueries({ - queryKey: [...this.baseKey, "show", reference], - }); - - const data = await stashShow({ + return await stashShow({ contextId: this.contextId, reference, }); - - this.queryClient.setQueryData([...this.baseKey, "show", reference], data); - return data; } async push(params?: StashPushInput): Promise { diff --git a/apps/desktop/tests/repo-context-registry.test.ts b/apps/desktop/tests/repo-context-registry.test.ts new file mode 100644 index 00000000..05624e40 --- /dev/null +++ b/apps/desktop/tests/repo-context-registry.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test, vi } from "vitest"; +import { + createRepoContextOwnerId, + RepoContextRegistry, +} from "../src/state/core/repo-context-registry"; + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +}; + +const createDependencies = () => ({ + createContext: vi.fn( + async ({ repoId }: { repoId: string; ownerId: string }) => + `context-${repoId}`, + ), + disposeContext: vi.fn(async (_contextId: string) => {}), + disposeRepositoryState: vi.fn(async (_contextId: string) => {}), +}); + +describe("RepoContextRegistry lifecycle", () => { + test("builds collision-safe owners from the webview and scope", () => { + expect(createRepoContextOwnerId("main", "tab-a")).toBe('["main","tab-a"]'); + expect(createRepoContextOwnerId("main:tab", "a")).not.toBe( + createRepoContextOwnerId("main", "tab:a"), + ); + }); + + test("passes the runtime-specific owner to the backend", async () => { + const dependencies = createDependencies(); + const registry = new RepoContextRegistry(dependencies); + const ownerId = createRepoContextOwnerId("main", "tab-a"); + + await expect( + registry.ensureScopeContext("tab-a", "repo-a", ownerId), + ).resolves.toBe("context-repo-a"); + + expect(dependencies.createContext).toHaveBeenCalledWith({ + repoId: "repo-a", + ownerId, + }); + }); + + test("replaces a context when the same scope moves to another runtime", async () => { + const dependencies = createDependencies(); + dependencies.createContext + .mockResolvedValueOnce("context-root") + .mockResolvedValueOnce("context-child"); + const registry = new RepoContextRegistry(dependencies); + const rootOwner = createRepoContextOwnerId("main", "tab-a"); + const childOwner = createRepoContextOwnerId("tab-webview:tab-a", "tab-a"); + + await registry.ensureScopeContext("tab-a", "repo-a", rootOwner); + await registry.ensureScopeContext("tab-a", "repo-a", childOwner); + + expect(dependencies.disposeContext).toHaveBeenCalledWith("context-root"); + expect(registry.getScopeContext("tab-a")).toEqual({ + repoId: "repo-a", + contextId: "context-child", + ownerId: childOwner, + }); + }); + + test("serializes rapid repository switches without leaking the late context", async () => { + const firstContext = deferred(); + const dependencies = createDependencies(); + dependencies.createContext.mockImplementation( + async ({ repoId }: { repoId: string; ownerId: string }) => { + if (repoId === "repo-a") return firstContext.promise; + return "context-repo-b"; + }, + ); + const registry = new RepoContextRegistry(dependencies); + + const ownerId = createRepoContextOwnerId("tab-webview:tab-a", "tab-a"); + const first = registry.ensureScopeContext("tab-a", "repo-a", ownerId); + const second = registry.ensureScopeContext("tab-a", "repo-b", ownerId); + + await vi.waitFor(() => { + expect(dependencies.createContext).toHaveBeenCalledTimes(1); + }); + firstContext.resolve("context-repo-a"); + + await expect(first).resolves.toBe("context-repo-a"); + await expect(second).resolves.toBe("context-repo-b"); + + expect(dependencies.disposeContext).toHaveBeenCalledWith("context-repo-a"); + expect(dependencies.disposeRepositoryState).toHaveBeenCalledWith( + "context-repo-a", + ); + expect(registry.getScopeContext("tab-a")).toEqual({ + repoId: "repo-b", + contextId: "context-repo-b", + ownerId, + }); + }); + + test("queues scope disposal behind an in-flight context creation", async () => { + const createdContext = deferred(); + const dependencies = createDependencies(); + dependencies.createContext.mockImplementation(async () => { + return createdContext.promise; + }); + const registry = new RepoContextRegistry(dependencies); + + const ensure = registry.ensureScopeContext( + "tab-a", + "repo-a", + createRepoContextOwnerId("main", "tab-a"), + ); + const dispose = registry.disposeScope("tab-a"); + + createdContext.resolve("context-repo-a"); + await ensure; + await dispose; + + expect(registry.getScopeContext("tab-a")).toBeNull(); + expect(dependencies.disposeContext).toHaveBeenCalledWith("context-repo-a"); + expect(dependencies.disposeRepositoryState).toHaveBeenCalledWith( + "context-repo-a", + ); + }); + + test("disposeAll includes scopes whose context creation is still pending", async () => { + const createdContext = deferred(); + const dependencies = createDependencies(); + dependencies.createContext.mockImplementation(async () => { + return createdContext.promise; + }); + const registry = new RepoContextRegistry(dependencies); + + const ensure = registry.ensureScopeContext( + "tab-a", + "repo-a", + createRepoContextOwnerId("main", "tab-a"), + ); + const disposeAll = registry.disposeAll(); + + createdContext.resolve("context-repo-a"); + await ensure; + await disposeAll; + + expect(registry.getScopeContext("tab-a")).toBeNull(); + expect(dependencies.disposeContext).toHaveBeenCalledWith("context-repo-a"); + }); +}); diff --git a/apps/desktop/tests/repository-change-bridge.test.ts b/apps/desktop/tests/repository-change-bridge.test.ts new file mode 100644 index 00000000..4f79f968 --- /dev/null +++ b/apps/desktop/tests/repository-change-bridge.test.ts @@ -0,0 +1,234 @@ +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { describe, expect, test, vi } from "vitest"; +import { + invalidateRepositoryQueries, + type RepositoryChangeKind, + shouldInvalidateRepositoryQuery, +} from "../src/state/core/repository-change-bridge"; +import { + queryClient, + refreshActiveQueriesAfterNativeFocus, +} from "../src/state/core/state-manager"; + +const repoKey = (...suffix: unknown[]) => [ + "repository", + "context-a", + "/repos/alpha", + ...suffix, +]; + +const affected = (change: RepositoryChangeKind, ...suffix: unknown[]) => + shouldInvalidateRepositoryQuery(repoKey(...suffix), { + contextId: "context-a", + changes: [change], + }); + +describe("repository filesystem change query mapping", () => { + test("worktree changes refresh only worktree state", () => { + expect(affected("worktree", "status")).toBe(true); + expect(affected("worktree", "diff", "worktree", "Worktree")).toBe(true); + expect(affected("worktree", "branches", "hasUncommittedChanges")).toBe( + true, + ); + + expect(affected("worktree", "diff", "commit:abc:p1")).toBe(false); + expect(affected("worktree", "commit", "history")).toBe(false); + expect( + shouldInvalidateRepositoryQuery( + ["worktree-file", "context-a", "src/conflicted.ts", 0], + { contextId: "context-a", changes: ["worktree"] }, + ), + ).toBe(true); + expect( + shouldInvalidateRepositoryQuery( + ["worktree-file", "context-b", "src/conflicted.ts", 0], + { contextId: "context-a", changes: ["worktree"] }, + ), + ).toBe(false); + expect( + shouldInvalidateRepositoryQuery( + ["worktree-file", "context-a", "src/conflicted.ts", 0], + { contextId: "context-a", changes: ["index"] }, + ), + ).toBe(false); + }); + + test("index changes also refresh operation state", () => { + expect(affected("index", "status")).toBe(true); + expect(affected("index", "diff", "worktree", "Staged")).toBe(true); + expect(affected("index", "operation")).toBe(true); + expect(affected("index", "branches", "list")).toBe(false); + }); + + test("HEAD changes refresh branch, timeline, status, and worktree data", () => { + for (const suffix of [ + ["branches", "list"], + ["branches", "current"], + ["branches", "statusAheadBehind"], + ["branches", "hasUncommittedChanges"], + ["branches", "currentBranchStash"], + ["commit", "last"], + ["commit", "historyGraph"], + ["status"], + ["diff", "worktree"], + ]) { + expect(affected("head", ...suffix)).toBe(true); + } + + expect(affected("head", "stash", "list")).toBe(false); + expect(affected("head", "commit", "getCommitById", "abc")).toBe(false); + }); + + test("ref changes refresh branch and commit timeline queries", () => { + expect(affected("refs", "branches", "list")).toBe(true); + expect(affected("refs", "branches", "statusAheadBehind")).toBe(true); + expect(affected("refs", "branches", "hasUncommittedChanges")).toBe(true); + expect(affected("refs", "commit", "history")).toBe(true); + expect(affected("refs", "status")).toBe(true); + expect(affected("refs", "diff", "worktree")).toBe(true); + + expect(affected("refs", "branches", "current")).toBe(false); + expect(affected("refs", "diff", "commit:abc:p1")).toBe(false); + }); + + test("stash changes refresh stash identities and stash-aware timelines", () => { + expect(affected("stash", "stash", "list")).toBe(true); + expect(affected("stash", "diff", "stash:stash@{0}")).toBe(true); + expect(affected("stash", "branches", "currentBranchStash")).toBe(true); + expect( + affected("stash", "commit", "historyGraph", { + include_stash: true, + }), + ).toBe(true); + + expect( + affected("stash", "commit", "historyGraph", { + include_stash: false, + }), + ).toBe(false); + expect(affected("stash", "diff", "worktree")).toBe(false); + }); + + test("operation and config changes stay within their affected domains", () => { + expect(affected("operation", "operation")).toBe(true); + expect(affected("operation", "status")).toBe(true); + expect(affected("operation", "diff", "worktree")).toBe(true); + expect(affected("operation", "branches", "current")).toBe(true); + expect(affected("operation", "branches", "hasUncommittedChanges")).toBe( + true, + ); + expect(affected("operation", "branches", "list")).toBe(false); + + expect(affected("config", "origin")).toBe(true); + expect(affected("config", "branches", "list")).toBe(true); + expect(affected("config", "branches", "statusAheadBehind")).toBe(true); + expect(affected("config", "branches", "hasUncommittedChanges")).toBe(true); + expect(affected("config", "status")).toBe(true); + expect(affected("config", "diff", "worktree")).toBe(true); + expect(affected("config", "diff", "commit:abc:p1")).toBe(true); + expect(affected("config", "diff", "stash:stash@{0}")).toBe(true); + }); + + test("never invalidates another repository context", () => { + expect( + shouldInvalidateRepositoryQuery( + ["repository", "context-b", "/repos/beta", "status"], + { contextId: "context-a", changes: ["worktree", "head"] }, + ), + ).toBe(false); + }); + + test("marks only matching cached queries stale", async () => { + const client = new QueryClient(); + const matching = repoKey("status"); + const unrelated = repoKey("commit", "getCommitById", "abc"); + const otherContext = ["repository", "context-b", "/repos/beta", "status"]; + + client.setQueryData(matching, "status"); + client.setQueryData(unrelated, "commit"); + client.setQueryData(otherContext, "other"); + + await invalidateRepositoryQueries(client, { + contextId: "context-a", + changes: ["worktree"], + }); + + expect(client.getQueryState(matching)?.isInvalidated).toBe(true); + expect(client.getQueryState(unrelated)?.isInvalidated).toBe(false); + expect(client.getQueryState(otherContext)?.isInvalidated).toBe(false); + }); + + test("restarts an active initial fetch so an old result cannot win", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const key = repoKey("status"); + let resolveFirst!: (value: string) => void; + const firstResult = new Promise((resolve) => { + resolveFirst = resolve; + }); + let calls = 0; + const observer = new QueryObserver(client, { + queryKey: key, + queryFn: () => { + calls += 1; + return calls === 1 ? firstResult : Promise.resolve("fresh"); + }, + }); + const unsubscribe = observer.subscribe(() => {}); + + await vi.waitFor(() => expect(calls).toBe(1)); + const refresh = invalidateRepositoryQueries(client, { + contextId: "context-a", + changes: ["worktree"], + }); + + await vi.waitFor(() => expect(calls).toBe(2)); + resolveFirst("stale"); + await refresh; + + expect(client.getQueryData(key)).toBe("fresh"); + expect(client.getQueryState(key)?.isInvalidated).toBe(false); + unsubscribe(); + }); +}); + +test("global queries keep focus recovery without interval polling", () => { + const options = queryClient.getDefaultOptions().queries; + + expect(options?.refetchOnWindowFocus).toBe(true); + expect(options?.refetchInterval).toBeUndefined(); +}); + +test("focus recovery clears native caches before refetching", async () => { + const calls: string[] = []; + const client = { + cancelQueries: vi.fn(async () => { + calls.push("cancel"); + }), + invalidateQueries: vi.fn(async () => { + calls.push("refetch"); + }), + } as unknown as QueryClient; + + await refreshActiveQueriesAfterNativeFocus(client, async () => { + calls.push("backend"); + }); + + expect(calls).toEqual(["cancel", "backend", "refetch"]); +}); + +test("focus recovery still refetches if native cache clearing fails", async () => { + const client = { + cancelQueries: vi.fn(async () => {}), + invalidateQueries: vi.fn(async () => {}), + } as unknown as QueryClient; + + await refreshActiveQueriesAfterNativeFocus(client, async () => { + throw new Error("native bridge unavailable"); + }); + + expect(client.invalidateQueries).toHaveBeenCalledWith({ + refetchType: "active", + }); +}); diff --git a/apps/desktop/tests/repository-manager.test.ts b/apps/desktop/tests/repository-manager.test.ts new file mode 100644 index 00000000..169b6e01 --- /dev/null +++ b/apps/desktop/tests/repository-manager.test.ts @@ -0,0 +1,61 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, test, vi } from "vitest"; +import { RepositoryManager } from "../src/state/domains/repository-manager"; + +describe("RepositoryManager disposal", () => { + test("cancels and removes every query for a disposed context", async () => { + const client = new QueryClient(); + const manager = new RepositoryManager(client); + const alphaStatus = ["repository", "context-a", "/repos/alpha", "status"]; + const alphaDiff = [ + "repository", + "context-a", + "/repos/alpha", + "diff", + "worktree", + ]; + const betaStatus = ["repository", "context-b", "/repos/beta", "status"]; + const alphaWorktreeFile = [ + "worktree-file", + "context-a", + "src/conflicted.ts", + 0, + ]; + + manager.for("/repos/alpha", "context-a"); + manager.for("/repos/beta", "context-b"); + client.setQueryData(alphaStatus, "alpha-status"); + client.setQueryData(alphaDiff, "alpha-diff"); + client.setQueryData(alphaWorktreeFile, "file contents"); + client.setQueryData(betaStatus, "beta-status"); + const cancel = vi.spyOn(client, "cancelQueries"); + + await manager.disposeContext("context-a"); + + expect(cancel).toHaveBeenCalledWith({ + queryKey: ["repository", "context-a"], + }); + expect(cancel).toHaveBeenCalledWith({ + queryKey: ["worktree-file", "context-a"], + }); + expect(client.getQueryData(alphaStatus)).toBeUndefined(); + expect(client.getQueryData(alphaDiff)).toBeUndefined(); + expect(client.getQueryData(alphaWorktreeFile)).toBeUndefined(); + expect(client.getQueryData(betaStatus)).toBe("beta-status"); + }); + + test("repository disposal removes cache instead of invalidating it", async () => { + const client = new QueryClient(); + const manager = new RepositoryManager(client); + const status = ["repository", "context-a", "/repos/alpha", "status"]; + const invalidate = vi.spyOn(client, "invalidateQueries"); + + manager.for("/repos/alpha/", "context-a"); + client.setQueryData(status, "status"); + + await manager.dispose("/repos/alpha", "context-a"); + + expect(invalidate).not.toHaveBeenCalled(); + expect(client.getQueryData(status)).toBeUndefined(); + }); +}); diff --git a/apps/desktop/tests/runtime-utils.test.ts b/apps/desktop/tests/runtime-utils.test.ts new file mode 100644 index 00000000..ab3a49aa --- /dev/null +++ b/apps/desktop/tests/runtime-utils.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "vitest"; +import { resolveRepositoryContextScope } from "../src/bootstrap/runtime-utils"; + +describe("repository context runtime scope", () => { + test("does not create a repository context for the native host shell", () => { + expect( + resolveRepositoryContextScope( + false, + null, + "active-runtime", + "active-tab", + ), + ).toBeNull(); + }); + + test("uses the embedded tab identity for child runtimes", () => { + expect( + resolveRepositoryContextScope( + true, + "embedded-tab", + "active-runtime", + "active-tab", + ), + ).toBe("embedded-tab"); + }); + + test("keeps the active-tab fallback for browser embedded mode", () => { + expect(resolveRepositoryContextScope(true, null, null, "active-tab")).toBe( + "active-tab", + ); + }); +}); diff --git a/crates/git/cache.rs b/crates/git/cache.rs index 8bd8e92e..514f15b0 100644 --- a/crates/git/cache.rs +++ b/crates/git/cache.rs @@ -63,6 +63,7 @@ struct RepoCacheState { pub struct RepoCache { generation: AtomicU64, + namespace_generations: Mutex>, state: Mutex, } @@ -76,6 +77,7 @@ impl RepoCache { pub fn new() -> Self { Self { generation: AtomicU64::new(0), + namespace_generations: Mutex::new(HashMap::new()), state: Mutex::new(RepoCacheState::default()), } } @@ -88,6 +90,28 @@ impl RepoCache { } } + pub fn invalidate_namespaces(&self, namespaces: &[&str]) { + if namespaces.is_empty() { + return; + } + + if let Ok(mut generations) = self.namespace_generations.lock() { + for namespace in namespaces { + let generation = generations.entry((*namespace).to_string()).or_default(); + *generation = generation.wrapping_add(1); + } + } + + if let Ok(mut state) = self.state.lock() { + state + .entries + .retain(|key, _| !storage_key_matches_namespace(key, namespaces)); + state + .inflight + .retain(|key, _| !storage_key_matches_namespace(key, namespaces)); + } + } + pub async fn get_or_refresh( &self, policy: CachePolicy, @@ -243,10 +267,22 @@ impl RepoCache { fn build_storage_key(&self, namespace: &str, key: &str) -> String { let generation = self.generation.load(Ordering::SeqCst); - format!("{generation}:{namespace}:{key}") + let namespace_generation = self + .namespace_generations + .lock() + .map(|generations| generations.get(namespace).copied().unwrap_or_default()) + .unwrap_or_default(); + format!("{generation}:{namespace_generation}:{namespace}:{key}") } } +fn storage_key_matches_namespace(storage_key: &str, namespaces: &[&str]) -> bool { + let Some(namespace) = storage_key.split(':').nth(2) else { + return false; + }; + namespaces.contains(&namespace) +} + #[cfg(test)] mod tests { use super::{CachePolicy, RepoCache}; @@ -254,6 +290,7 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use tokio::runtime::Builder; + use tokio::sync::Notify; fn run_async(f: F) where @@ -463,4 +500,85 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 2); }); } + + #[test] + fn cache_invalidate_namespaces_preserves_unaffected_entries() { + run_async(async { + let cache = RepoCache::new(); + let affected = CachePolicy { + namespace: "affected", + ttl: Duration::from_secs(5), + }; + let unaffected = CachePolicy { + namespace: "unaffected", + ttl: Duration::from_secs(5), + }; + + cache + .get_or_refresh(affected, "k".into(), || async { Ok::<_, String>(1) }) + .await + .unwrap(); + cache + .get_or_refresh(unaffected, "k".into(), || async { Ok::<_, String>(10) }) + .await + .unwrap(); + cache.invalidate_namespaces(&["affected"]); + + let affected_value = cache + .get_or_refresh(affected, "k".into(), || async { Ok::<_, String>(2) }) + .await + .unwrap(); + let unaffected_value = cache + .get_or_refresh(unaffected, "k".into(), || async { Ok::<_, String>(20) }) + .await + .unwrap(); + + assert_eq!(affected_value, 2); + assert_eq!(unaffected_value, 10); + }); + } + + #[test] + fn namespace_epoch_prevents_an_old_inflight_result_from_repopulating() { + run_async(async { + let cache = Arc::new(RepoCache::new()); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let policy = CachePolicy { + namespace: "racy", + ttl: Duration::from_secs(5), + }; + + let old_request = { + let cache = cache.clone(); + let started = started.clone(); + let release = release.clone(); + tokio::spawn(async move { + cache + .get_or_refresh(policy, "k".into(), move || async move { + started.notify_one(); + release.notified().await; + Ok::<_, String>(1) + }) + .await + }) + }; + + started.notified().await; + cache.invalidate_namespaces(&["racy"]); + let fresh = cache + .get_or_refresh(policy, "k".into(), || async { Ok::<_, String>(2) }) + .await + .unwrap(); + release.notify_one(); + assert_eq!(old_request.await.unwrap().unwrap(), 1); + + let cached = cache + .get_or_refresh(policy, "k".into(), || async { Ok::<_, String>(3) }) + .await + .unwrap(); + assert_eq!(fresh, 2); + assert_eq!(cached, 2); + }); + } } diff --git a/crates/git/context.rs b/crates/git/context.rs index c0be3e44..125b90ca 100644 --- a/crates/git/context.rs +++ b/crates/git/context.rs @@ -1,7 +1,48 @@ +use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::{cache::RepoCache, runner::GitCommandRunner}; +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RepositoryWatchPaths { + pub worktree: PathBuf, + pub git_dir: PathBuf, + pub common_dir: PathBuf, +} + +impl RepositoryWatchPaths { + pub(crate) fn discover(repo_path: &str) -> Result { + let repository = open_worktree_repository(repo_path)?; + let worktree = repository + .workdir() + .expect("worktree repository validation must reject bare repositories"); + + Ok(Self { + worktree: canonicalize_watch_path(worktree)?, + git_dir: canonicalize_watch_path(repository.path())?, + common_dir: canonicalize_watch_path(repository.commondir())?, + }) + } +} + +fn open_worktree_repository(repo_path: &str) -> Result { + let repository = git2::Repository::open(repo_path) + .map_err(|error| format!("Failed to open Git repository: {error}"))?; + if repository.workdir().is_none() { + return Err("Bare Git repositories are not supported".to_string()); + } + Ok(repository) +} + +fn canonicalize_watch_path(path: &Path) -> Result { + path.canonicalize().map_err(|error| { + format!( + "Failed to resolve repository path {}: {error}", + path.display() + ) + }) +} + pub struct RepoContext { pub repo_path: String, pub runner: GitCommandRunner, @@ -20,3 +61,91 @@ impl RepoContext { }) } } + +#[cfg(test)] +mod tests { + use super::{RepoContext, RepositoryWatchPaths}; + use tempfile::tempdir; + + #[test] + fn discovers_normal_repository_watch_paths() { + let directory = tempdir().expect("temp dir"); + git2::Repository::init(directory.path()).expect("init repository"); + + let paths = RepositoryWatchPaths::discover( + directory + .path() + .to_str() + .expect("temporary path should be UTF-8"), + ) + .expect("discover watch paths"); + + assert_eq!(paths.worktree, directory.path().canonicalize().unwrap()); + assert_eq!( + paths.git_dir, + directory.path().join(".git").canonicalize().unwrap() + ); + assert_eq!(paths.common_dir, paths.git_dir); + } + + #[test] + fn discovers_linked_worktree_private_and_common_git_dirs() { + let directory = tempdir().expect("temp dir"); + let main_path = directory.path().join("main"); + let linked_path = directory.path().join("linked"); + let repository = git2::Repository::init(&main_path).expect("init repository"); + let tree_id = repository + .index() + .and_then(|mut index| index.write_tree()) + .expect("write empty tree"); + { + let tree = repository.find_tree(tree_id).expect("find empty tree"); + let signature = git2::Signature::now("Gitru", "gitru@example.com").unwrap(); + repository + .commit( + Some("HEAD"), + &signature, + &signature, + "Initial commit", + &tree, + &[], + ) + .expect("create initial commit"); + } + repository + .worktree("linked", &linked_path, None) + .expect("create linked worktree"); + + let paths = RepositoryWatchPaths::discover(linked_path.to_str().unwrap()) + .expect("discover linked worktree paths"); + + assert_eq!(paths.worktree, linked_path.canonicalize().unwrap()); + assert_ne!(paths.git_dir, paths.common_dir); + assert_eq!( + paths.common_dir, + main_path.join(".git").canonicalize().unwrap() + ); + assert!( + paths + .git_dir + .starts_with(paths.common_dir.join("worktrees")) + ); + assert!(linked_path.join(".git").is_file()); + } + + #[test] + fn watcher_path_failure_does_not_prevent_context_construction() { + let directory = tempdir().expect("temp dir"); + git2::Repository::init(directory.path()).expect("init repository"); + let repo_path = directory.path().to_str().unwrap(); + + std::fs::rename( + directory.path().join(".git"), + directory.path().join(".git-away"), + ) + .expect("make watcher path unavailable"); + + let _context = RepoContext::new(repo_path).expect("construct repository context"); + assert!(RepositoryWatchPaths::discover(repo_path).is_err()); + } +} diff --git a/crates/git/core.rs b/crates/git/core.rs index ab667bb9..7880d9f5 100644 --- a/crates/git/core.rs +++ b/crates/git/core.rs @@ -1,8 +1,10 @@ +use std::path::Path; use std::sync::Arc; use crate::{ AppState, - context::RepoContext, + context::{RepoContext, RepositoryWatchPaths}, + runner::GitRunOptions, service::{ actions::ActionService, branch::BranchService, commit::CommitService, diff::DiffService, history::HistoryService, operation::OperationService, origin::OriginService, @@ -64,6 +66,61 @@ impl RepoServices { pub fn rebase(&self) -> RebaseService { RebaseService::new(self.ctx.clone()) } + + pub fn watch_paths(&self) -> Result { + RepositoryWatchPaths::discover(&self.ctx.repo_path) + } + + pub async fn validate_worktree(&self) -> Result<(), String> { + let top_level = self + .ctx + .runner + .run_with_options( + &["rev-parse", "--show-toplevel"], + GitRunOptions::default_read(), + ) + .await?; + let selected_path = Path::new(&self.ctx.repo_path) + .canonicalize() + .map_err(|error| format!("Failed to resolve selected repository path: {error}"))?; + let top_level_path = Path::new(top_level.trim()) + .canonicalize() + .map_err(|error| format!("Failed to resolve Git worktree root: {error}"))?; + + if canonical_paths_equal(&selected_path, &top_level_path) { + Ok(()) + } else { + Err(format!( + "Select the Git worktree root: {}", + top_level_path.display() + )) + } + } + + pub fn invalidate_cache_namespaces(&self, namespaces: &[&str]) { + self.ctx.cache.invalidate_namespaces(namespaces); + } + + pub fn invalidate_cache(&self) { + self.ctx.cache.invalidate_all(); + } +} + +#[cfg(not(windows))] +fn canonical_paths_equal(left: &Path, right: &Path) -> bool { + left == right +} + +#[cfg(windows)] +fn canonical_paths_equal(left: &Path, right: &Path) -> bool { + fn normalized(path: &Path) -> String { + path.to_string_lossy() + .trim_start_matches(r"\\?\") + .replace('/', r"\") + .to_lowercase() + } + + normalized(left) == normalized(right) } pub async fn get_services( @@ -89,3 +146,59 @@ pub async fn remove_services(state: tauri::State<'_, AppState>, context_id: &str let mut lock = state.services.write().await; lock.remove(context_id).is_some() } + +#[cfg(test)] +mod tests { + use super::RepoServices; + use std::process::Command; + use tempfile::tempdir; + + #[tokio::test] + async fn cli_supported_repository_formats_do_not_require_libgit2() { + let directory = tempdir().expect("temp dir"); + let output = Command::new("git") + .args(["init", "--object-format=sha256"]) + .arg(directory.path()) + .output() + .expect("run git init"); + + // Older Git versions cannot create SHA-256 repositories. On newer + // versions this guards against making libgit2 watcher discovery a + // prerequisite for otherwise CLI-compatible repository services. + if !output.status.success() { + return; + } + + let services = RepoServices::new(directory.path().to_str().unwrap()) + .expect("construct CLI-backed services"); + services + .validate_worktree() + .await + .expect("validate SHA-256 worktree with Git CLI"); + } + + #[tokio::test] + async fn worktree_validation_rejects_a_plain_directory() { + let directory = tempdir().expect("temp dir"); + let services = RepoServices::new(directory.path().to_str().unwrap()) + .expect("construct path-backed services"); + + assert!(services.validate_worktree().await.is_err()); + } + + #[tokio::test] + async fn worktree_validation_rejects_a_nested_directory() { + let directory = tempdir().expect("temp dir"); + git2::Repository::init(directory.path()).expect("init repository"); + let nested = directory.path().join("nested"); + std::fs::create_dir(&nested).expect("create nested directory"); + let services = + RepoServices::new(nested.to_str().unwrap()).expect("construct path-backed services"); + + let error = services + .validate_worktree() + .await + .expect_err("nested directory must not be stored as repository root"); + assert!(error.contains("Select the Git worktree root")); + } +} diff --git a/crates/git/service/actions.rs b/crates/git/service/actions.rs index 2133adc8..a8351f68 100644 --- a/crates/git/service/actions.rs +++ b/crates/git/service/actions.rs @@ -42,9 +42,17 @@ impl ActionService { }, "porcelain_v2".to_string(), move || async move { + // An optional index refresh would feed this read back into + // the repository watcher and trigger another status read. let output = runner .run_with_options( - &["status", "--porcelain=v2", "--untracked-files=all", "-z"], + &[ + "--no-optional-locks", + "status", + "--porcelain=v2", + "--untracked-files=all", + "-z", + ], GitRunOptions::default_read(), ) .await?; diff --git a/crates/git/service/query.rs b/crates/git/service/query.rs index f1763253..bdde0f0c 100644 --- a/crates/git/service/query.rs +++ b/crates/git/service/query.rs @@ -121,9 +121,17 @@ impl QueryService { }, "porcelain".to_string(), move || async move { + // An optional index refresh would feed this read back into + // the repository watcher and trigger another status read. let output = runner .run_with_options( - &["status", "--porcelain", "-z", "--untracked-files=all"], + &[ + "--no-optional-locks", + "status", + "--porcelain", + "-z", + "--untracked-files=all", + ], GitRunOptions::default_read(), ) .await?; diff --git a/crates/git/tests/actions_service.rs b/crates/git/tests/actions_service.rs index e1a4b8dd..0a7db6cc 100644 --- a/crates/git/tests/actions_service.rs +++ b/crates/git/tests/actions_service.rs @@ -10,6 +10,7 @@ use git::models::diff::{DiffScope, PatchAction, PatchRange}; use git::service::actions::ActionService; use serial_test::serial; use std::sync::Arc; +use std::time::Duration; fn setup_action_service(repo: &TestRepo) -> ActionService { let ctx = Arc::new(RepoContext::new(repo.path_str()).expect("failed to create repo context")); @@ -305,6 +306,30 @@ fn get_status_clean() { }); } +#[test] +#[serial] +fn get_status_does_not_refresh_the_index() { + run_async(async { + let repo = TestRepo::new(); + repo.commit_file("README.md", "# Test", "Initial commit"); + let index_path = repo.path().join(".git/index"); + let index_before = std::fs::read(&index_path).unwrap(); + + // Rewriting identical contents after the filesystem timestamp advances + // makes the index stat cache stale without creating a worktree change. + // Background status queries must not rewrite the index, otherwise a + // repository watcher can invalidate and refetch status forever. + std::thread::sleep(Duration::from_millis(1_100)); + repo.create_file("README.md", "# Test"); + + let service = setup_action_service(&repo); + let status = service.get_status().await.unwrap(); + + assert!(status.files.is_empty()); + assert_eq!(std::fs::read(index_path).unwrap(), index_before); + }); +} + #[test] #[serial] fn get_status_untracked() { diff --git a/crates/git/tests/branch_service.rs b/crates/git/tests/branch_service.rs index 040ad8ae..1a03ec8c 100644 --- a/crates/git/tests/branch_service.rs +++ b/crates/git/tests/branch_service.rs @@ -11,6 +11,7 @@ use git::service::branch::BranchService; use serial_test::serial; use std::process::Command; use std::sync::Arc; +use std::time::Duration; fn setup_branch_service(repo: &TestRepo) -> BranchService { let ctx = Arc::new(RepoContext::new(repo.path_str()).expect("failed to create repo context")); @@ -848,6 +849,29 @@ fn has_uncommitted_changes_false_when_clean() { }); } +#[test] +#[serial] +fn has_uncommitted_changes_does_not_refresh_the_index() { + run_async(async { + let repo = TestRepo::new(); + repo.commit_file("README.md", "# Test", "Initial commit"); + let index_path = repo.path().join(".git/index"); + let index_before = std::fs::read(&index_path).unwrap(); + + // Keep contents clean while making the index's cached stat data stale. + // A background query that refreshes the index would feed its own write + // back into the repository watcher and trigger another query. + std::thread::sleep(Duration::from_millis(1_100)); + repo.create_file("README.md", "# Test"); + + let service = setup_branch_service(&repo); + let has_changes = service.has_uncommitted_changes().await.unwrap(); + + assert!(!has_changes); + assert_eq!(std::fs::read(index_path).unwrap(), index_before); + }); +} + #[test] #[serial] fn has_uncommitted_changes_true_with_modified() { diff --git a/crates/ipc/Cargo.toml b/crates/ipc/Cargo.toml index ecbd3609..8f879d0a 100644 --- a/crates/ipc/Cargo.toml +++ b/crates/ipc/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -serde = "1.0.225" +serde = { version = "1.0.225", features = ["derive"] } serde_json = "1.0.145" tauri = { version = "2", features = [] } tauri-plugin-store = "2" @@ -12,4 +12,9 @@ uuid = { version = "1", features = ["v4"] } logger = { path = "../logger" } log = "0.4" git = { path = "../git" } -tokio = { version = "1", features = ["sync"] } +notify = "8.2.0" +tokio = { version = "1", features = ["macros", "rt", "sync", "time"] } + +[dev-dependencies] +tempfile = "3" +tokio = { version = "1", features = ["macros", "rt", "sync", "test-util", "time"] } diff --git a/crates/ipc/src/commands.rs b/crates/ipc/src/commands.rs index 583e007d..b55310c5 100644 --- a/crates/ipc/src/commands.rs +++ b/crates/ipc/src/commands.rs @@ -14,6 +14,10 @@ use tauri::Emitter; use uuid::Uuid; use crate::repo_manager::{RepoManager, RepositoryInfo, SELECTED_REPO_KEY}; +use crate::repository_watcher::{ + REPOSITORY_CHANGED_EVENT, RepoContextRuntime, RepositoryChangedEvent, RepositoryWatcher, + cache_namespaces_for_changes, +}; use crate::session_manager::{SessionManager, SessionNavigationInfo}; #[derive(Serialize)] @@ -41,49 +45,38 @@ pub async fn add_local_git_repo(repo_path: String) -> Result { - let origin = services - .origin() - .repository_origin() - .await - .ok() - .map(|o| o.remote_url); - let current_branch = services - .branch() - .get_current_branch() - .await - .ok() - .map(|b| b.name); - let ahead_behind = services - .branch() - .status_ahead_behind() - .await - .ok() - .map(|status| (status.ahead as u32, status.behind as u32)); - let has_uncommitted_changes = services - .branch() - .has_uncommitted_changes() - .await - .unwrap_or(false); - ( - origin, - current_branch, - ahead_behind, - has_uncommitted_changes, - ) - } - Err(_) => (None, None, None, false), - }; + // Let Git itself validate normal repositories, linked worktrees (`.git` + // is a file), external gitdirs, and newer repository formats that the + // pinned libgit2 may not understand yet. + let services = RepoServices::new(&repo_path) + .map_err(|error| format!("Not a valid Git worktree: {repo_path}: {error}"))?; + services + .validate_worktree() + .await + .map_err(|error| format!("Not a valid Git worktree: {repo_path}: {error}"))?; + let origin = services + .origin() + .repository_origin() + .await + .ok() + .map(|origin| origin.remote_url); + let current_branch = services + .branch() + .get_current_branch() + .await + .ok() + .map(|branch| branch.name); + let ahead_behind = services + .branch() + .status_ahead_behind() + .await + .ok() + .map(|status| (status.ahead as u32, status.behind as u32)); + let has_uncommitted_changes = services + .branch() + .has_uncommitted_changes() + .await + .unwrap_or(false); let name = if let Some(ref o) = origin { Path::new(o) @@ -197,9 +190,24 @@ async fn persist_and_select_repository( #[tauri::command] pub async fn create_repo_context( repo_id: String, + owner_id: String, state: tauri::State<'_, AppState>, + runtime: tauri::State<'_, RepoContextRuntime>, manager: tauri::State<'_, Arc>>, + app: tauri::AppHandle, ) -> Result { + // Capture the owner epoch before any repository work. Disposal advances + // this epoch, so an in-flight create cannot register after its tab closes. + // Beginning a new create also retires an older context after a reload or + // rapid repository switch. + let (owner_generation, retired_context_ids) = runtime.begin_owner_context(&owner_id)?; + if !retired_context_ids.is_empty() { + let mut services = state.services.write().await; + for context_id in retired_context_ids { + services.remove(&context_id); + } + } + let repos = { let app = { let manager_guard = manager.lock().map_err(|e| e.to_string())?; @@ -216,18 +224,57 @@ pub async fn create_repo_context( let services = Arc::new(RepoServices::new(&repo.path)?); let context_id = Uuid::new_v4().to_string(); + let event_context_id = context_id.clone(); + let event_services = services.clone(); + let watcher = match services.watch_paths().and_then(|watch_paths| { + RepositoryWatcher::new(watch_paths, move |changes| { + let namespaces = cache_namespaces_for_changes(&changes); + event_services.invalidate_cache_namespaces(&namespaces); + if let Err(error) = app.emit( + REPOSITORY_CHANGED_EVENT, + RepositoryChangedEvent { + context_id: event_context_id.clone(), + changes, + }, + ) { + log::warn!("failed to emit repository change event: {error}"); + } + }) + }) { + Ok(watcher) => Some(watcher), + Err(error) => { + log::warn!( + "repository context {context_id} will rely on focus refresh because watching failed: {error}" + ); + None + } + }; + { let mut lock = state.services.write().await; - lock.insert(context_id.clone(), services); + lock.insert(context_id.clone(), services.clone()); } - let manager_guard = manager.lock().map_err(|e| e.to_string())?; - let store = manager_guard - .get_store() - .map_err(|e| format!("Failed to get store: {e}"))?; + if let Err(error) = runtime.register(owner_id, owner_generation, context_id.clone(), watcher) { + state.services.write().await.remove(&context_id); + return Err(error); + } - store.set(SELECTED_REPO_KEY, repo_id); - store.save().map_err(|e| e.to_string())?; + let persist_result = (|| -> Result<(), String> { + let manager_guard = manager.lock().map_err(|error| error.to_string())?; + let store = manager_guard + .get_store() + .map_err(|error| format!("Failed to get store: {error}"))?; + store.set(SELECTED_REPO_KEY, repo_id); + store.save().map_err(|error| error.to_string()) + })(); + if let Err(error) = persist_result { + if let Err(cleanup_error) = runtime.dispose_context(&context_id) { + log::error!("failed to dispose context after store error: {cleanup_error}"); + } + state.services.write().await.remove(&context_id); + return Err(error); + } Ok(context_id) } @@ -236,9 +283,47 @@ pub async fn create_repo_context( pub async fn dispose_repo_context( context_id: String, state: tauri::State<'_, AppState>, + runtime: tauri::State<'_, RepoContextRuntime>, ) -> Result { + let watcher_removed = runtime.dispose_context(&context_id)?; let mut lock = state.services.write().await; - Ok(lock.remove(&context_id).is_some()) + Ok(lock.remove(&context_id).is_some() || watcher_removed) +} + +#[tauri::command] +pub async fn dispose_repo_context_owner( + owner_id: String, + state: tauri::State<'_, AppState>, + runtime: tauri::State<'_, RepoContextRuntime>, +) -> Result { + let context_ids = runtime.dispose_owner(&owner_id)?; + let mut services = state.services.write().await; + let mut removed = 0; + for context_id in context_ids { + if services.remove(&context_id).is_some() { + removed += 1; + } + } + Ok(removed) +} + +#[tauri::command] +pub async fn invalidate_repo_context_caches( + state: tauri::State<'_, AppState>, +) -> Result { + let services = state + .services + .read() + .await + .values() + .cloned() + .collect::>(); + + for service in &services { + service.invalidate_cache(); + } + + Ok(services.len() as u32) } #[tauri::command] diff --git a/crates/ipc/src/lib.rs b/crates/ipc/src/lib.rs index b86fabc6..15279108 100644 --- a/crates/ipc/src/lib.rs +++ b/crates/ipc/src/lib.rs @@ -1,3 +1,4 @@ pub mod commands; pub mod repo_manager; +pub mod repository_watcher; pub mod session_manager; diff --git a/crates/ipc/src/repository_watcher.rs b/crates/ipc/src/repository_watcher.rs new file mode 100644 index 00000000..1a8c6102 --- /dev/null +++ b/crates/ipc/src/repository_watcher.rs @@ -0,0 +1,1014 @@ +use git::context::RepositoryWatchPaths; +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use serde::Serialize; +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +pub const REPOSITORY_CHANGED_EVENT: &str = "gitru://repository-changed"; +const CHANGE_DEBOUNCE: Duration = Duration::from_millis(150); + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum RepositoryChangeKind { + Worktree, + Index, + Head, + Refs, + Stash, + Operation, + Config, +} + +const ALL_REPOSITORY_CHANGE_KINDS: [RepositoryChangeKind; 7] = [ + RepositoryChangeKind::Worktree, + RepositoryChangeKind::Index, + RepositoryChangeKind::Head, + RepositoryChangeKind::Refs, + RepositoryChangeKind::Stash, + RepositoryChangeKind::Operation, + RepositoryChangeKind::Config, +]; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RepositoryChangedEvent { + pub context_id: String, + pub changes: Vec, +} + +#[derive(Clone)] +struct ChangeSink { + pending: Arc>, + signal: mpsc::Sender<()>, + active: Arc, +} + +#[derive(Default)] +struct PendingChanges { + changes: BTreeSet, + revision: u64, +} + +impl ChangeSink { + fn push(&self, changes: I) + where + I: IntoIterator, + { + if !self.active.load(Ordering::Acquire) { + return; + } + + let received_change = match self.pending.lock() { + Ok(mut pending) => { + let mut received_change = false; + for change in changes { + received_change = true; + pending.changes.insert(change); + } + if received_change { + pending.revision = pending.revision.wrapping_add(1); + } + received_change + } + Err(error) => { + log::error!("repository watcher pending set was poisoned: {error}"); + false + } + }; + + if received_change { + // Capacity one keeps wake-ups bounded while still letting repeated + // events reset the trailing debounce once the worker receives them. + let _ = self.signal.try_send(()); + } + } +} + +struct ChangeCoalescer { + sink: ChangeSink, + callback_gate: Arc>, + task: Option>, +} + +impl ChangeCoalescer { + fn new(delay: Duration, callback: F) -> Self + where + F: Fn(Vec) + Send + Sync + 'static, + { + let pending = Arc::new(Mutex::new(PendingChanges::default())); + let active = Arc::new(AtomicBool::new(false)); + let callback_gate = Arc::new(Mutex::new(())); + let (signal, mut receiver) = mpsc::channel(1); + let callback = Arc::new(callback); + + let task_pending = pending.clone(); + let task_active = active.clone(); + let task_callback_gate = callback_gate.clone(); + let task = tokio::spawn(async move { + while receiver.recv().await.is_some() { + let mut observed_revision = match task_pending.lock() { + Ok(pending) => pending.revision, + Err(error) => { + log::error!("repository watcher pending set was poisoned: {error}"); + return; + } + }; + let quiet = tokio::time::sleep(delay); + tokio::pin!(quiet); + + let changes = loop { + tokio::select! { + biased; + signal = receiver.recv() => match signal { + Some(()) => { + observed_revision = match task_pending.lock() { + Ok(pending) => pending.revision, + Err(error) => { + log::error!("repository watcher pending set was poisoned: {error}"); + return; + } + }; + quiet.as_mut().reset(tokio::time::Instant::now() + delay); + } + None => return, + }, + _ = &mut quiet => { + let settled = match task_pending.lock() { + Ok(mut pending) => { + if pending.revision == observed_revision { + Some(std::mem::take(&mut pending.changes)) + } else { + observed_revision = pending.revision; + None + } + } + Err(error) => { + log::error!("repository watcher pending set was poisoned: {error}"); + return; + } + }; + + if let Some(changes) = settled { + break changes; + } + quiet.as_mut().reset(tokio::time::Instant::now() + delay); + }, + } + }; + + if changes.is_empty() { + continue; + } + + let Ok(_callback_guard) = task_callback_gate.lock() else { + log::error!("repository watcher callback gate was poisoned"); + continue; + }; + if task_active.load(Ordering::Acquire) { + callback(changes.into_iter().collect()); + } + } + }); + + Self { + sink: ChangeSink { + pending, + signal, + active, + }, + callback_gate, + task: Some(task), + } + } + + fn sink(&self) -> ChangeSink { + self.sink.clone() + } + + fn activate(&self) { + self.sink.active.store(true, Ordering::Release); + } + + fn stop(&mut self) { + self.sink.active.store(false, Ordering::Release); + if let Ok(mut pending) = self.sink.pending.lock() { + pending.changes.clear(); + } + + // Waiting for this gate makes disposal a hard boundary: when stop + // returns, no callback can still be emitting an event. + if let Ok(callback_guard) = self.callback_gate.lock() { + drop(callback_guard); + } + + if let Some(task) = self.task.take() { + task.abort(); + } + } +} + +impl Drop for ChangeCoalescer { + fn drop(&mut self) { + self.stop(); + } +} + +pub struct RepositoryWatcher { + watcher: Option, + coalescer: ChangeCoalescer, +} + +impl RepositoryWatcher { + pub fn new(paths: RepositoryWatchPaths, callback: F) -> Result + where + F: Fn(Vec) + Send + Sync + 'static, + { + let coalescer = ChangeCoalescer::new(CHANGE_DEBOUNCE, callback); + let sink = coalescer.sink(); + let event_paths = paths.clone(); + let mut watcher = notify::recommended_watcher(move |result: notify::Result| { + let event = match result { + Ok(event) => event, + Err(error) => { + log::warn!("repository watcher error: {error}"); + sink.push(ALL_REPOSITORY_CHANGE_KINDS); + return; + } + }; + + if event.need_rescan() { + sink.push(ALL_REPOSITORY_CHANGE_KINDS); + return; + } + + if matches!(event.kind, EventKind::Access(_)) { + return; + } + + let changes = event + .paths + .iter() + .filter_map(|path| classify_path(path, &event_paths)); + sink.push(changes); + }) + .map_err(|error| format!("Failed to create repository watcher: {error}"))?; + + for target in watch_targets(&paths) { + watcher.watch(&target.path, target.mode).map_err(|error| { + format!( + "Failed to watch repository path {}: {error}", + target.path.display() + ) + })?; + } + + Ok(Self { + watcher: Some(watcher), + coalescer, + }) + } + + fn activate(&self) { + self.coalescer.activate(); + } + + fn stop(&mut self) { + self.coalescer.stop(); + self.watcher.take(); + } +} + +impl Drop for RepositoryWatcher { + fn drop(&mut self) { + self.stop(); + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct WatchTarget { + path: PathBuf, + mode: RecursiveMode, +} + +fn watch_targets(paths: &RepositoryWatchPaths) -> Vec { + let mut targets = Vec::new(); + push_target( + &mut targets, + paths.worktree.clone(), + RecursiveMode::Recursive, + ); + if paths.git_dir != paths.common_dir || !paths.git_dir.starts_with(&paths.worktree) { + // A linked worktree's private gitdir is small and owns its index, + // HEAD, and nested sequencer state, so it is safe to watch fully. A + // separate external gitdir also needs recursion because the worktree + // watch cannot observe nested operation state there. + push_target( + &mut targets, + paths.git_dir.clone(), + RecursiveMode::Recursive, + ); + } + // The shared common dir can contain a very large object database and + // metadata for other worktrees. Watch only its direct files and ref trees. + push_metadata_targets(&mut targets, &paths.common_dir); + targets +} + +fn push_metadata_targets(targets: &mut Vec, root: &Path) { + push_target(targets, root.to_path_buf(), RecursiveMode::NonRecursive); + for relative in [Path::new("refs"), Path::new("logs/refs"), Path::new("info")] { + let path = root.join(relative); + if path.is_dir() { + push_target(targets, path, RecursiveMode::Recursive); + } + } +} + +fn push_target(targets: &mut Vec, path: PathBuf, mode: RecursiveMode) { + if targets.iter().any(|target| { + target.path == path + || (target.mode == RecursiveMode::Recursive && path.starts_with(&target.path)) + }) { + return; + } + + if mode == RecursiveMode::Recursive { + targets.retain(|target| !target.path.starts_with(&path)); + } + + if !targets + .iter() + .any(|target| target.path == path && target.mode == mode) + { + targets.push(WatchTarget { path, mode }); + } +} + +fn classify_path(path: &Path, paths: &RepositoryWatchPaths) -> Option { + if let Ok(relative) = path.strip_prefix(&paths.git_dir) { + return classify_git_path(relative); + } + if paths.common_dir != paths.git_dir + && let Ok(relative) = path.strip_prefix(&paths.common_dir) + { + return classify_git_path(relative); + } + if path.strip_prefix(&paths.worktree).is_ok() { + return Some(RepositoryChangeKind::Worktree); + } + None +} + +fn classify_git_path(relative: &Path) -> Option { + if relative == Path::new("index") || relative == Path::new("index.lock") { + return Some(RepositoryChangeKind::Index); + } + + if matches_path(relative, &["HEAD", "HEAD.lock", "ORIG_HEAD", "logs/HEAD"]) { + return Some(RepositoryChangeKind::Head); + } + + if matches_path( + relative, + &[ + "refs/stash", + "refs/stash.lock", + "logs/refs/stash", + "logs/refs/stash.lock", + ], + ) { + return Some(RepositoryChangeKind::Stash); + } + + if relative.starts_with("refs") + || relative.starts_with(Path::new("logs").join("refs")) + || matches_path(relative, &["packed-refs", "packed-refs.lock", "FETCH_HEAD"]) + { + return Some(RepositoryChangeKind::Refs); + } + + if matches_path( + relative, + &[ + "config", + "config.lock", + "config.worktree", + "info/exclude", + "info/attributes", + ], + ) { + return Some(RepositoryChangeKind::Config); + } + + if is_operation_path(relative) { + return Some(RepositoryChangeKind::Operation); + } + + // Object storage, hook temp files, and other Git internals do not map to + // UI query domains and are intentionally ignored. + None +} + +fn matches_path(path: &Path, candidates: &[&str]) -> bool { + candidates + .iter() + .any(|candidate| path == Path::new(candidate)) +} + +fn is_operation_path(relative: &Path) -> bool { + if relative.starts_with("rebase-merge") + || relative.starts_with("rebase-apply") + || relative.starts_with("sequencer") + || relative.starts_with("gitru-rebase") + { + return true; + } + + let file_name = relative + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + matches!( + file_name, + "MERGE_HEAD" + | "MERGE_MSG" + | "CHERRY_PICK_HEAD" + | "REVERT_HEAD" + | "REBASE_HEAD" + | "SQUASH_MSG" + ) || file_name.starts_with("BISECT_") +} + +pub fn cache_namespaces_for_changes(changes: &[RepositoryChangeKind]) -> Vec<&'static str> { + let mut namespaces = BTreeSet::new(); + for change in changes { + let affected: &[&str] = match change { + RepositoryChangeKind::Worktree | RepositoryChangeKind::Index => { + &["status", "has_uncommitted_changes", "patch_by_file_path"] + } + RepositoryChangeKind::Head => &[ + "status", + "has_uncommitted_changes", + "patch_by_file_path", + "current_branch", + "list_branches", + "ahead_behind_status", + "branch_info", + "last_commit", + "history", + ], + RepositoryChangeKind::Refs => &[ + "status", + "has_uncommitted_changes", + "patch_by_file_path", + "list_branches", + "ahead_behind_status", + "branch_info", + "last_commit", + "history", + ], + RepositoryChangeKind::Stash => &["stash_list", "patch_by_file_path"], + RepositoryChangeKind::Operation => &[ + "status", + "has_uncommitted_changes", + "patch_by_file_path", + "current_branch", + ], + RepositoryChangeKind::Config => &[ + "repository_origin", + "list_branches", + "ahead_behind_status", + "branch_info", + "status", + "has_uncommitted_changes", + "patch_by_file_path", + ], + }; + namespaces.extend(affected.iter().copied()); + } + namespaces.into_iter().collect() +} + +struct ContextRegistry { + contexts: HashMap, + context_owners: HashMap, + owner_contexts: HashMap>, + owner_generations: HashMap, +} + +impl Default for ContextRegistry { + fn default() -> Self { + Self { + contexts: HashMap::new(), + context_owners: HashMap::new(), + owner_contexts: HashMap::new(), + owner_generations: HashMap::new(), + } + } +} + +impl ContextRegistry { + fn begin_owner(&mut self, owner_id: &str) -> Result<(u64, Vec, Vec), String> { + if owner_id.is_empty() { + return Err("Repository context owner cannot be empty".to_string()); + } + + let generation = self + .owner_generations + .entry(owner_id.to_string()) + .or_default(); + *generation = generation.wrapping_add(1); + let generation = *generation; + let (context_ids, values) = self.remove_owner_contexts(owner_id); + Ok((generation, context_ids, values)) + } + + fn can_register( + &self, + owner_id: &str, + owner_generation: u64, + context_id: &str, + ) -> Result<(), String> { + if self + .owner_generations + .get(owner_id) + .copied() + .unwrap_or_default() + != owner_generation + { + return Err(format!( + "Repository context owner '{owner_id}' changed while its context was being created" + )); + } + if self.contexts.contains_key(context_id) { + return Err(format!( + "Repository context '{context_id}' is already registered" + )); + } + Ok(()) + } + + fn insert(&mut self, owner_id: String, context_id: String, value: T) { + self.context_owners + .insert(context_id.clone(), owner_id.clone()); + self.owner_contexts + .entry(owner_id) + .or_default() + .insert(context_id.clone()); + self.contexts.insert(context_id, value); + } + + fn remove_context(&mut self, context_id: &str) -> Option { + if let Some(owner_id) = self.context_owners.remove(context_id) + && let Some(contexts) = self.owner_contexts.get_mut(&owner_id) + { + contexts.remove(context_id); + if contexts.is_empty() { + self.owner_contexts.remove(&owner_id); + } + } + self.contexts.remove(context_id) + } + + fn dispose_owner(&mut self, owner_id: &str) -> (Vec, Vec) { + let generation = self + .owner_generations + .entry(owner_id.to_string()) + .or_default(); + *generation = generation.wrapping_add(1); + self.remove_owner_contexts(owner_id) + } + + fn remove_owner_contexts(&mut self, owner_id: &str) -> (Vec, Vec) { + let context_ids = self + .owner_contexts + .remove(owner_id) + .unwrap_or_default() + .into_iter() + .collect::>(); + let mut values = Vec::with_capacity(context_ids.len()); + for context_id in &context_ids { + self.context_owners.remove(context_id); + if let Some(value) = self.contexts.remove(context_id) { + values.push(value); + } + } + (context_ids, values) + } +} + +#[derive(Default)] +pub struct RepoContextRuntime { + registry: Mutex>>, +} + +impl RepoContextRuntime { + pub fn begin_owner_context(&self, owner_id: &str) -> Result<(u64, Vec), String> { + let (generation, context_ids, watchers) = self + .registry + .lock() + .map_err(|_| "Repository watcher registry was poisoned".to_string())? + .begin_owner(owner_id)?; + drop(watchers); + Ok((generation, context_ids)) + } + + pub fn register( + &self, + owner_id: String, + owner_generation: u64, + context_id: String, + watcher: Option, + ) -> Result<(), String> { + let mut registry = self + .registry + .lock() + .map_err(|_| "Repository watcher registry was poisoned".to_string())?; + registry.can_register(&owner_id, owner_generation, &context_id)?; + if let Some(watcher) = &watcher { + watcher.activate(); + } + registry.insert(owner_id, context_id, watcher); + Ok(()) + } + + pub fn dispose_context(&self, context_id: &str) -> Result { + let watcher = self + .registry + .lock() + .map_err(|_| "Repository watcher registry was poisoned".to_string())? + .remove_context(context_id); + let found = watcher.is_some(); + drop(watcher); + Ok(found) + } + + pub fn dispose_owner(&self, owner_id: &str) -> Result, String> { + let (context_ids, watchers) = self + .registry + .lock() + .map_err(|_| "Repository watcher registry was poisoned".to_string())? + .dispose_owner(owner_id); + drop(watchers); + Ok(context_ids) + } +} + +#[cfg(test)] +mod tests { + #[cfg(target_os = "linux")] + use super::RepositoryWatcher; + use super::{ + ALL_REPOSITORY_CHANGE_KINDS, ChangeCoalescer, ContextRegistry, RepositoryChangeKind, + cache_namespaces_for_changes, classify_path, watch_targets, + }; + use git::context::RepositoryWatchPaths; + #[cfg(target_os = "linux")] + use git::core::RepoServices; + use notify::RecursiveMode; + use std::collections::BTreeSet; + use std::path::PathBuf; + #[cfg(target_os = "linux")] + use std::process::Command; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + use tempfile::tempdir; + + fn test_paths() -> RepositoryWatchPaths { + RepositoryWatchPaths { + worktree: PathBuf::from("/repo"), + git_dir: PathBuf::from("/repo/.git/worktrees/feature"), + common_dir: PathBuf::from("/repo/.git"), + } + } + + #[test] + fn classifies_worktree_and_git_metadata_domains() { + let paths = test_paths(); + let cases = [ + ("/repo/src/main.rs", RepositoryChangeKind::Worktree), + ( + "/repo/.git/worktrees/feature/index.lock", + RepositoryChangeKind::Index, + ), + ( + "/repo/.git/worktrees/feature/HEAD", + RepositoryChangeKind::Head, + ), + ("/repo/.git/refs/heads/main", RepositoryChangeKind::Refs), + ("/repo/.git/logs/refs/stash", RepositoryChangeKind::Stash), + ("/repo/.git/refs/stash.lock", RepositoryChangeKind::Stash), + ("/repo/.git/config", RepositoryChangeKind::Config), + ("/repo/.git/info/exclude", RepositoryChangeKind::Config), + ("/repo/.git/info/attributes", RepositoryChangeKind::Config), + ( + "/repo/.git/worktrees/feature/rebase-merge/git-rebase-todo", + RepositoryChangeKind::Operation, + ), + ( + "/repo/.git/worktrees/feature/gitru-rebase/state.json", + RepositoryChangeKind::Operation, + ), + ]; + + for (path, expected) in cases { + assert_eq!( + classify_path(PathBuf::from(path).as_path(), &paths), + Some(expected) + ); + } + assert_eq!( + classify_path(PathBuf::from("/repo/.git/objects/aa/bb").as_path(), &paths), + None + ); + } + + #[test] + fn creates_targeted_metadata_watches_for_linked_worktrees() { + let directory = tempdir().unwrap(); + let worktree = directory.path().join("worktree"); + let git_dir = directory.path().join("common/worktrees/feature"); + let common_dir = directory.path().join("common"); + for path in [ + &worktree, + &git_dir, + &common_dir, + &common_dir.join("refs"), + &common_dir.join("logs/refs"), + &common_dir.join("info"), + ] { + std::fs::create_dir_all(path).unwrap(); + } + let targets = watch_targets(&RepositoryWatchPaths { + worktree: worktree.clone(), + git_dir: git_dir.clone(), + common_dir: common_dir.clone(), + }); + + assert!( + targets.iter().any(|target| { + target.path == worktree && target.mode == RecursiveMode::Recursive + }) + ); + assert!( + targets.iter().any(|target| { + target.path == git_dir && target.mode == RecursiveMode::Recursive + }) + ); + assert!(targets.iter().any(|target| { + target.path == common_dir.join("refs") && target.mode == RecursiveMode::Recursive + })); + assert!(targets.iter().any(|target| { + target.path == common_dir.join("info") && target.mode == RecursiveMode::Recursive + })); + assert!(!targets.iter().any(|target| { + target.path == common_dir && target.mode == RecursiveMode::Recursive + })); + } + + #[test] + fn recursive_worktree_watch_subsumes_normal_git_metadata_targets() { + let directory = tempdir().unwrap(); + let worktree = directory.path().join("worktree"); + let git_dir = worktree.join(".git"); + std::fs::create_dir_all(git_dir.join("refs")).unwrap(); + std::fs::create_dir_all(git_dir.join("logs/refs")).unwrap(); + + let targets = watch_targets(&RepositoryWatchPaths { + worktree: worktree.clone(), + git_dir: git_dir.clone(), + common_dir: git_dir, + }); + + assert_eq!( + targets, + vec![super::WatchTarget { + path: worktree, + mode: RecursiveMode::Recursive, + }] + ); + } + + #[tokio::test(start_paused = true)] + async fn coalesces_a_burst_into_one_bounded_change_set() { + let seen = Arc::new(Mutex::new(Vec::new())); + let callback_seen = seen.clone(); + let mut coalescer = ChangeCoalescer::new(Duration::from_millis(100), move |changes| { + callback_seen.lock().unwrap().push(changes); + }); + coalescer.activate(); + let sink = coalescer.sink(); + + sink.push([RepositoryChangeKind::Worktree, RepositoryChangeKind::Index]); + sink.push([RepositoryChangeKind::Worktree, RepositoryChangeKind::Head]); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_millis(100)).await; + tokio::task::yield_now().await; + + assert_eq!( + *seen.lock().unwrap(), + vec![vec![ + RepositoryChangeKind::Worktree, + RepositoryChangeKind::Index, + RepositoryChangeKind::Head, + ]] + ); + coalescer.stop(); + } + + #[tokio::test(start_paused = true)] + async fn repeated_events_reset_the_quiet_period() { + let seen = Arc::new(Mutex::new(Vec::new())); + let callback_seen = seen.clone(); + let mut coalescer = ChangeCoalescer::new(Duration::from_millis(100), move |changes| { + callback_seen.lock().unwrap().push(changes); + }); + coalescer.activate(); + let sink = coalescer.sink(); + + sink.push([RepositoryChangeKind::Worktree]); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_millis(90)).await; + sink.push([RepositoryChangeKind::Worktree]); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_millis(20)).await; + tokio::task::yield_now().await; + + assert!(seen.lock().unwrap().is_empty()); + + tokio::time::advance(Duration::from_millis(80)).await; + tokio::task::yield_now().await; + assert_eq!( + *seen.lock().unwrap(), + vec![vec![RepositoryChangeKind::Worktree]] + ); + coalescer.stop(); + } + + #[tokio::test(start_paused = true)] + async fn stopping_discards_a_pending_callback() { + let seen = Arc::new(Mutex::new(Vec::new())); + let callback_seen = seen.clone(); + let mut coalescer = ChangeCoalescer::new(Duration::from_millis(100), move |changes| { + callback_seen.lock().unwrap().push(changes); + }); + coalescer.activate(); + coalescer.sink().push([RepositoryChangeKind::Worktree]); + tokio::task::yield_now().await; + + coalescer.stop(); + tokio::time::advance(Duration::from_millis(100)).await; + tokio::task::yield_now().await; + + assert!(seen.lock().unwrap().is_empty()); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn read_only_status_queries_do_not_feed_the_watcher() { + let directory = tempdir().unwrap(); + let run_git = |args: &[&str]| { + let output = Command::new("git") + .current_dir(directory.path()) + .args(args) + .output() + .expect("run git command"); + assert!( + output.status.success(), + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + }; + + run_git(&["init", "-b", "main"]); + run_git(&["config", "user.email", "test@example.com"]); + run_git(&["config", "user.name", "Test User"]); + let tracked_file = directory.path().join("tracked.txt"); + std::fs::write(&tracked_file, "unchanged\n").unwrap(); + run_git(&["add", "tracked.txt"]); + run_git(&["commit", "-m", "initial"]); + + let index_path = directory.path().join(".git/index"); + let index_before = std::fs::read(&index_path).unwrap(); + std::thread::sleep(Duration::from_millis(1_100)); + std::fs::write(&tracked_file, "unchanged\n").unwrap(); + + let services = RepoServices::new(directory.path().to_str().unwrap()).unwrap(); + let (change_tx, mut change_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut watcher = RepositoryWatcher::new(services.watch_paths().unwrap(), move |changes| { + let _ = change_tx.send(changes); + }) + .unwrap(); + watcher.activate(); + + assert!( + services + .action() + .get_status() + .await + .unwrap() + .files + .is_empty() + ); + assert!(!services.query().has_uncommitted_changes().await.unwrap()); + assert_eq!(std::fs::read(index_path).unwrap(), index_before); + assert!( + tokio::time::timeout(Duration::from_millis(500), change_rx.recv()) + .await + .is_err(), + "background status queries emitted a repository change" + ); + + watcher.stop(); + } + + #[test] + fn registry_rejects_pre_dispose_create_but_allows_owner_reuse() { + let mut registry = ContextRegistry::default(); + let (generation, _, _) = registry.begin_owner("tab-1").unwrap(); + registry + .can_register("tab-1", generation, "context-1") + .unwrap(); + registry.insert("tab-1".into(), "context-1".into(), ()); + + assert!( + registry + .can_register("tab-1", generation, "context-1") + .is_err() + ); + let (context_ids, values) = registry.dispose_owner("tab-1"); + assert_eq!(context_ids, vec!["context-1"]); + assert_eq!(values.len(), 1); + assert!( + registry + .can_register("tab-1", generation, "context-late") + .is_err() + ); + + let (new_generation, _, _) = registry.begin_owner("tab-1").unwrap(); + assert!(new_generation > generation); + registry + .can_register("tab-1", new_generation, "context-new") + .unwrap(); + } + + #[test] + fn beginning_a_new_context_retires_the_existing_owner_context() { + let mut registry = ContextRegistry::default(); + let (generation, _, _) = registry.begin_owner("tab-1").unwrap(); + registry + .can_register("tab-1", generation, "context-1") + .unwrap(); + registry.insert("tab-1".into(), "context-1".into(), ()); + + let (next_generation, context_ids, values) = registry.begin_owner("tab-1").unwrap(); + assert!(next_generation > generation); + assert_eq!(context_ids, vec!["context-1"]); + assert_eq!(values.len(), 1); + } + + #[test] + fn change_kinds_map_to_scoped_cache_namespaces() { + let namespaces = cache_namespaces_for_changes(&[ + RepositoryChangeKind::Worktree, + RepositoryChangeKind::Config, + ]); + assert!(namespaces.contains(&"status")); + assert!(namespaces.contains(&"patch_by_file_path")); + assert!(namespaces.contains(&"repository_origin")); + assert!(!namespaces.contains(&"history")); + assert!(!namespaces.contains(&"stash_list")); + + let config_namespaces = cache_namespaces_for_changes(&[RepositoryChangeKind::Config]); + assert!(config_namespaces.contains(&"status")); + assert!(config_namespaces.contains(&"has_uncommitted_changes")); + assert!(config_namespaces.contains(&"patch_by_file_path")); + + let stash_namespaces = cache_namespaces_for_changes(&[RepositoryChangeKind::Stash]); + assert!(stash_namespaces.contains(&"stash_list")); + assert!(stash_namespaces.contains(&"patch_by_file_path")); + + let all_changes = ALL_REPOSITORY_CHANGE_KINDS + .into_iter() + .collect::>(); + assert_eq!(all_changes.len(), 7); + } + + #[test] + fn registry_tracks_contexts_even_when_native_watching_is_unavailable() { + let mut registry = ContextRegistry::>::default(); + let (generation, _, _) = registry.begin_owner("tab-1").unwrap(); + registry + .can_register("tab-1", generation, "context-1") + .unwrap(); + registry.insert("tab-1".into(), "context-1".into(), None); + + assert!(registry.remove_context("context-1").is_some()); + } +} diff --git a/packages/commands/src/.typecache b/packages/commands/src/.typecache index fa8ba9e6..947e4d73 100644 --- a/packages/commands/src/.typecache +++ b/packages/commands/src/.typecache @@ -1,7 +1,7 @@ { "version": 1, - "commands_hash": "8958207ae1c63ce7", + "commands_hash": "3f96c8edf565b8a2", "structs_hash": "427ff297a8652f7c", "config_hash": "e14d37710ecfcc74", - "combined_hash": "b9dc49844ff8d843" + "combined_hash": "facb939a1033bdff" } \ No newline at end of file diff --git a/packages/commands/src/commands.ts b/packages/commands/src/commands.ts index 224da68e..970cd8f6 100644 --- a/packages/commands/src/commands.ts +++ b/packages/commands/src/commands.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.4.2 - * Generated at: 2026-09-11T05:17:50.458215+00:00 + * Generated at: 2026-09-20T15:35:44.270476+00:00 * Generator: zod * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -25,15 +25,15 @@ export interface CommandHooks { onSettled?: () => void; } -export async function repositoryOrigin(params: types.RepositoryOriginParams, hooks?: CommandHooks): Promise { +export async function gitVersion(params: types.GitVersionParams, hooks?: CommandHooks): Promise { try { - const result = types.RepositoryOriginParamsSchema.safeParse(params); + const result = types.GitVersionParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('repository_origin', result.data); + const data = await invoke('git_version', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -47,15 +47,15 @@ export async function repositoryOrigin(params: types.RepositoryOriginParams, hoo } -export async function startPickaxe(params: types.StartPickaxeParams, hooks?: CommandHooks): Promise { +export async function getStatus(params: types.GetStatusParams, hooks?: CommandHooks): Promise { try { - const result = types.StartPickaxeParamsSchema.safeParse(params); + const result = types.GetStatusParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('start_pickaxe', result.data); + const data = await invoke('get_status', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -69,15 +69,15 @@ export async function startPickaxe(params: types.StartPickaxeParams, hooks?: Com } -export async function cancelPickaxe(params: types.CancelPickaxeParams, hooks?: CommandHooks): Promise { +export async function gitFetch(params: types.GitFetchParams, hooks?: CommandHooks): Promise { try { - const result = types.CancelPickaxeParamsSchema.safeParse(params); + const result = types.GitFetchParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('cancel_pickaxe', result.data); + const data = await invoke('git_fetch', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -91,15 +91,15 @@ export async function cancelPickaxe(params: types.CancelPickaxeParams, hooks?: C } -export async function listRepositories(params: types.ListRepositoriesParams, hooks?: CommandHooks): Promise { +export async function gitAdd(params: types.GitAddParams, hooks?: CommandHooks): Promise { try { - const result = types.ListRepositoriesParamsSchema.safeParse(params); + const result = types.GitAddParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('list_repositories', result.data); + const data = await invoke('git_add', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -113,15 +113,15 @@ export async function listRepositories(params: types.ListRepositoriesParams, hoo } -export async function addRepository(params: types.AddRepositoryParams, hooks?: CommandHooks): Promise { +export async function gitRemove(params: types.GitRemoveParams, hooks?: CommandHooks): Promise { try { - const result = types.AddRepositoryParamsSchema.safeParse(params); + const result = types.GitRemoveParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('add_repository', result.data); + const data = await invoke('git_remove', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -135,15 +135,15 @@ export async function addRepository(params: types.AddRepositoryParams, hooks?: C } -export async function removeRepository(params: types.RemoveRepositoryParams, hooks?: CommandHooks): Promise { +export async function gitDiscard(params: types.GitDiscardParams, hooks?: CommandHooks): Promise { try { - const result = types.RemoveRepositoryParamsSchema.safeParse(params); + const result = types.GitDiscardParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('remove_repository', result.data); + const data = await invoke('git_discard', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -157,15 +157,15 @@ export async function removeRepository(params: types.RemoveRepositoryParams, hoo } -export async function refreshRepositoryInfo(params: types.RefreshRepositoryInfoParams, hooks?: CommandHooks): Promise { +export async function gitApplyPatchBlock(params: types.GitApplyPatchBlockParams, hooks?: CommandHooks): Promise { try { - const result = types.RefreshRepositoryInfoParamsSchema.safeParse(params); + const result = types.GitApplyPatchBlockParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('refresh_repository_info', result.data); + const data = await invoke('git_apply_patch_block', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -179,15 +179,15 @@ export async function refreshRepositoryInfo(params: types.RefreshRepositoryInfoP } -export async function getRepoOperation(params: types.GetRepoOperationParams, hooks?: CommandHooks): Promise { +export async function readWorktreeFile(params: types.ReadWorktreeFileParams, hooks?: CommandHooks): Promise { try { - const result = types.GetRepoOperationParamsSchema.safeParse(params); + const result = types.ReadWorktreeFileParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('get_repo_operation', result.data); + const data = await invoke('read_worktree_file', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -201,15 +201,15 @@ export async function getRepoOperation(params: types.GetRepoOperationParams, hoo } -export async function rebasePlan(params: types.RebasePlanParams, hooks?: CommandHooks): Promise { +export async function writeWorktreeFile(params: types.WriteWorktreeFileParams, hooks?: CommandHooks): Promise { try { - const result = types.RebasePlanParamsSchema.safeParse(params); + const result = types.WriteWorktreeFileParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rebase_plan', result.data); + const data = await invoke('write_worktree_file', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -223,15 +223,15 @@ export async function rebasePlan(params: types.RebasePlanParams, hooks?: Command } -export async function rebaseStart(params: types.RebaseStartParams, hooks?: CommandHooks): Promise { +export async function currentBranch(params: types.CurrentBranchParams, hooks?: CommandHooks): Promise { try { - const result = types.RebaseStartParamsSchema.safeParse(params); + const result = types.CurrentBranchParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rebase_start', result.data); + const data = await invoke('current_branch', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -245,15 +245,15 @@ export async function rebaseStart(params: types.RebaseStartParams, hooks?: Comma } -export async function rebaseContinue(params: types.RebaseContinueParams, hooks?: CommandHooks): Promise { +export async function listBranches(params: types.ListBranchesParams, hooks?: CommandHooks): Promise { try { - const result = types.RebaseContinueParamsSchema.safeParse(params); + const result = types.ListBranchesParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rebase_continue', result.data); + const data = await invoke('list_branches', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -267,15 +267,15 @@ export async function rebaseContinue(params: types.RebaseContinueParams, hooks?: } -export async function rebaseSkip(params: types.RebaseSkipParams, hooks?: CommandHooks): Promise { +export async function statusAheadBehind(params: types.StatusAheadBehindParams, hooks?: CommandHooks): Promise { try { - const result = types.RebaseSkipParamsSchema.safeParse(params); + const result = types.StatusAheadBehindParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rebase_skip', result.data); + const data = await invoke('status_ahead_behind', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -289,15 +289,15 @@ export async function rebaseSkip(params: types.RebaseSkipParams, hooks?: Command } -export async function rebaseAbort(params: types.RebaseAbortParams, hooks?: CommandHooks): Promise { +export async function getBranchInfo(params: types.GetBranchInfoParams, hooks?: CommandHooks): Promise { try { - const result = types.RebaseAbortParamsSchema.safeParse(params); + const result = types.GetBranchInfoParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rebase_abort', result.data); + const data = await invoke('get_branch_info', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -311,15 +311,15 @@ export async function rebaseAbort(params: types.RebaseAbortParams, hooks?: Comma } -export async function rebaseAbortPreview(params: types.RebaseAbortPreviewParams, hooks?: CommandHooks): Promise { +export async function switchBranch(params: types.SwitchBranchParams, hooks?: CommandHooks): Promise { try { - const result = types.RebaseAbortPreviewParamsSchema.safeParse(params); + const result = types.SwitchBranchParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rebase_abort_preview', result.data); + const data = await invoke('switch_branch', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -333,15 +333,15 @@ export async function rebaseAbortPreview(params: types.RebaseAbortPreviewParams, } -export async function rebaseUpdateTodo(params: types.RebaseUpdateTodoParams, hooks?: CommandHooks): Promise { +export async function createBranch(params: types.CreateBranchParams, hooks?: CommandHooks): Promise { try { - const result = types.RebaseUpdateTodoParamsSchema.safeParse(params); + const result = types.CreateBranchParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rebase_update_todo', result.data); + const data = await invoke('create_branch', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -355,15 +355,15 @@ export async function rebaseUpdateTodo(params: types.RebaseUpdateTodoParams, hoo } -export async function rebaseSetCommitMessage(params: types.RebaseSetCommitMessageParams, hooks?: CommandHooks): Promise { +export async function renameBranch(params: types.RenameBranchParams, hooks?: CommandHooks): Promise { try { - const result = types.RebaseSetCommitMessageParamsSchema.safeParse(params); + const result = types.RenameBranchParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rebase_set_commit_message', result.data); + const data = await invoke('rename_branch', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -377,15 +377,15 @@ export async function rebaseSetCommitMessage(params: types.RebaseSetCommitMessag } -export async function rebaseResolveConflict(params: types.RebaseResolveConflictParams, hooks?: CommandHooks): Promise { +export async function deleteLocalBranch(params: types.DeleteLocalBranchParams, hooks?: CommandHooks): Promise { try { - const result = types.RebaseResolveConflictParamsSchema.safeParse(params); + const result = types.DeleteLocalBranchParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rebase_resolve_conflict', result.data); + const data = await invoke('delete_local_branch', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -399,15 +399,15 @@ export async function rebaseResolveConflict(params: types.RebaseResolveConflictP } -export async function checkForUpdateByChannel(params: types.CheckForUpdateByChannelParams, hooks?: CommandHooks): Promise { +export async function deleteRemoteBranch(params: types.DeleteRemoteBranchParams, hooks?: CommandHooks): Promise { try { - const result = types.CheckForUpdateByChannelParamsSchema.safeParse(params); + const result = types.DeleteRemoteBranchParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('check_for_update_by_channel', result.data); + const data = await invoke('delete_remote_branch', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -421,15 +421,15 @@ export async function checkForUpdateByChannel(params: types.CheckForUpdateByChan } -export async function downloadAndInstallUpdateByChannel(params: types.DownloadAndInstallUpdateByChannelParams, hooks?: CommandHooks): Promise { +export async function setBranchUpstream(params: types.SetBranchUpstreamParams, hooks?: CommandHooks): Promise { try { - const result = types.DownloadAndInstallUpdateByChannelParamsSchema.safeParse(params); + const result = types.SetBranchUpstreamParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('download_and_install_update_by_channel', result.data); + const data = await invoke('set_branch_upstream', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -443,15 +443,15 @@ export async function downloadAndInstallUpdateByChannel(params: types.DownloadAn } -export async function gitVersion(params: types.GitVersionParams, hooks?: CommandHooks): Promise { +export async function unsetBranchUpstream(params: types.UnsetBranchUpstreamParams, hooks?: CommandHooks): Promise { try { - const result = types.GitVersionParamsSchema.safeParse(params); + const result = types.UnsetBranchUpstreamParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('git_version', result.data); + const data = await invoke('unset_branch_upstream', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -465,15 +465,15 @@ export async function gitVersion(params: types.GitVersionParams, hooks?: Command } -export async function getStatus(params: types.GetStatusParams, hooks?: CommandHooks): Promise { +export async function push(params: types.PushParams, hooks?: CommandHooks): Promise { try { - const result = types.GetStatusParamsSchema.safeParse(params); + const result = types.PushParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('get_status', result.data); + const data = await invoke('push', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -487,15 +487,15 @@ export async function getStatus(params: types.GetStatusParams, hooks?: CommandHo } -export async function gitFetch(params: types.GitFetchParams, hooks?: CommandHooks): Promise { +export async function publishBranch(params: types.PublishBranchParams, hooks?: CommandHooks): Promise { try { - const result = types.GitFetchParamsSchema.safeParse(params); + const result = types.PublishBranchParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('git_fetch', result.data); + const data = await invoke('publish_branch', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -509,15 +509,15 @@ export async function gitFetch(params: types.GitFetchParams, hooks?: CommandHook } -export async function gitAdd(params: types.GitAddParams, hooks?: CommandHooks): Promise { +export async function pull(params: types.PullParams, hooks?: CommandHooks): Promise { try { - const result = types.GitAddParamsSchema.safeParse(params); + const result = types.PullParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('git_add', result.data); + const data = await invoke('pull', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -531,15 +531,15 @@ export async function gitAdd(params: types.GitAddParams, hooks?: CommandHooks): Promise { +export async function hasUncommittedChanges(params: types.HasUncommittedChangesParams, hooks?: CommandHooks): Promise { try { - const result = types.GitRemoveParamsSchema.safeParse(params); + const result = types.HasUncommittedChangesParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('git_remove', result.data); + const data = await invoke('has_uncommitted_changes', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -553,15 +553,15 @@ export async function gitRemove(params: types.GitRemoveParams, hooks?: CommandHo } -export async function gitDiscard(params: types.GitDiscardParams, hooks?: CommandHooks): Promise { +export async function currentBranchStash(params: types.CurrentBranchStashParams, hooks?: CommandHooks): Promise { try { - const result = types.GitDiscardParamsSchema.safeParse(params); + const result = types.CurrentBranchStashParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('git_discard', result.data); + const data = await invoke('current_branch_stash', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -575,15 +575,15 @@ export async function gitDiscard(params: types.GitDiscardParams, hooks?: Command } -export async function gitApplyPatchBlock(params: types.GitApplyPatchBlockParams, hooks?: CommandHooks): Promise { +export async function popCurrentBranchStash(params: types.PopCurrentBranchStashParams, hooks?: CommandHooks): Promise { try { - const result = types.GitApplyPatchBlockParamsSchema.safeParse(params); + const result = types.PopCurrentBranchStashParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('git_apply_patch_block', result.data); + const data = await invoke('pop_current_branch_stash', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -597,15 +597,15 @@ export async function gitApplyPatchBlock(params: types.GitApplyPatchBlockParams, } -export async function readWorktreeFile(params: types.ReadWorktreeFileParams, hooks?: CommandHooks): Promise { +export async function getPatchByFilePath(params: types.GetPatchByFilePathParams, hooks?: CommandHooks): Promise { try { - const result = types.ReadWorktreeFileParamsSchema.safeParse(params); + const result = types.GetPatchByFilePathParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('read_worktree_file', result.data); + const data = await invoke('get_patch_by_file_path', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -619,15 +619,15 @@ export async function readWorktreeFile(params: types.ReadWorktreeFileParams, hoo } -export async function writeWorktreeFile(params: types.WriteWorktreeFileParams, hooks?: CommandHooks): Promise { +export async function lastCommit(params: types.LastCommitParams, hooks?: CommandHooks): Promise { try { - const result = types.WriteWorktreeFileParamsSchema.safeParse(params); + const result = types.LastCommitParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('write_worktree_file', result.data); + const data = await invoke('last_commit', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -641,15 +641,15 @@ export async function writeWorktreeFile(params: types.WriteWorktreeFileParams, h } -export async function lastCommit(params: types.LastCommitParams, hooks?: CommandHooks): Promise { +export async function commitById(params: types.CommitByIdParams, hooks?: CommandHooks): Promise { try { - const result = types.LastCommitParamsSchema.safeParse(params); + const result = types.CommitByIdParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('last_commit', result.data); + const data = await invoke('commit_by_id', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -663,15 +663,15 @@ export async function lastCommit(params: types.LastCommitParams, hooks?: Command } -export async function commitById(params: types.CommitByIdParams, hooks?: CommandHooks): Promise { +export async function createCommit(params: types.CreateCommitParams, hooks?: CommandHooks): Promise { try { - const result = types.CommitByIdParamsSchema.safeParse(params); + const result = types.CreateCommitParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('commit_by_id', result.data); + const data = await invoke('create_commit', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -685,15 +685,15 @@ export async function commitById(params: types.CommitByIdParams, hooks?: Command } -export async function createCommit(params: types.CreateCommitParams, hooks?: CommandHooks): Promise { +export async function stashList(params: types.StashListParams, hooks?: CommandHooks): Promise { try { - const result = types.CreateCommitParamsSchema.safeParse(params); + const result = types.StashListParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('create_commit', result.data); + const data = await invoke('stash_list', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -707,15 +707,15 @@ export async function createCommit(params: types.CreateCommitParams, hooks?: Com } -export async function currentBranch(params: types.CurrentBranchParams, hooks?: CommandHooks): Promise { +export async function stashQuickStat(params: types.StashQuickStatParams, hooks?: CommandHooks): Promise { try { - const result = types.CurrentBranchParamsSchema.safeParse(params); + const result = types.StashQuickStatParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('current_branch', result.data); + const data = await invoke('stash_quick_stat', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -729,15 +729,15 @@ export async function currentBranch(params: types.CurrentBranchParams, hooks?: C } -export async function listBranches(params: types.ListBranchesParams, hooks?: CommandHooks): Promise { +export async function stashShow(params: types.StashShowParams, hooks?: CommandHooks): Promise { try { - const result = types.ListBranchesParamsSchema.safeParse(params); + const result = types.StashShowParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('list_branches', result.data); + const data = await invoke('stash_show', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -751,15 +751,15 @@ export async function listBranches(params: types.ListBranchesParams, hooks?: Com } -export async function statusAheadBehind(params: types.StatusAheadBehindParams, hooks?: CommandHooks): Promise { +export async function stashPush(params: types.StashPushParams, hooks?: CommandHooks): Promise { try { - const result = types.StatusAheadBehindParamsSchema.safeParse(params); + const result = types.StashPushParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('status_ahead_behind', result.data); + const data = await invoke('stash_push', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -773,15 +773,15 @@ export async function statusAheadBehind(params: types.StatusAheadBehindParams, h } -export async function getBranchInfo(params: types.GetBranchInfoParams, hooks?: CommandHooks): Promise { +export async function stashPop(params: types.StashPopParams, hooks?: CommandHooks): Promise { try { - const result = types.GetBranchInfoParamsSchema.safeParse(params); + const result = types.StashPopParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('get_branch_info', result.data); + const data = await invoke('stash_pop', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -795,15 +795,15 @@ export async function getBranchInfo(params: types.GetBranchInfoParams, hooks?: C } -export async function switchBranch(params: types.SwitchBranchParams, hooks?: CommandHooks): Promise { +export async function stashApply(params: types.StashApplyParams, hooks?: CommandHooks): Promise { try { - const result = types.SwitchBranchParamsSchema.safeParse(params); + const result = types.StashApplyParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('switch_branch', result.data); + const data = await invoke('stash_apply', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -817,15 +817,15 @@ export async function switchBranch(params: types.SwitchBranchParams, hooks?: Com } -export async function createBranch(params: types.CreateBranchParams, hooks?: CommandHooks): Promise { +export async function stashDrop(params: types.StashDropParams, hooks?: CommandHooks): Promise { try { - const result = types.CreateBranchParamsSchema.safeParse(params); + const result = types.StashDropParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('create_branch', result.data); + const data = await invoke('stash_drop', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -839,15 +839,15 @@ export async function createBranch(params: types.CreateBranchParams, hooks?: Com } -export async function renameBranch(params: types.RenameBranchParams, hooks?: CommandHooks): Promise { +export async function stashClear(params: types.StashClearParams, hooks?: CommandHooks): Promise { try { - const result = types.RenameBranchParamsSchema.safeParse(params); + const result = types.StashClearParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rename_branch', result.data); + const data = await invoke('stash_clear', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -861,15 +861,15 @@ export async function renameBranch(params: types.RenameBranchParams, hooks?: Com } -export async function deleteLocalBranch(params: types.DeleteLocalBranchParams, hooks?: CommandHooks): Promise { +export async function stashBranch(params: types.StashBranchParams, hooks?: CommandHooks): Promise { try { - const result = types.DeleteLocalBranchParamsSchema.safeParse(params); + const result = types.StashBranchParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('delete_local_branch', result.data); + const data = await invoke('stash_branch', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -883,15 +883,15 @@ export async function deleteLocalBranch(params: types.DeleteLocalBranchParams, h } -export async function deleteRemoteBranch(params: types.DeleteRemoteBranchParams, hooks?: CommandHooks): Promise { +export async function stashRestoreFile(params: types.StashRestoreFileParams, hooks?: CommandHooks): Promise { try { - const result = types.DeleteRemoteBranchParamsSchema.safeParse(params); + const result = types.StashRestoreFileParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('delete_remote_branch', result.data); + const data = await invoke('stash_restore_file', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -905,15 +905,15 @@ export async function deleteRemoteBranch(params: types.DeleteRemoteBranchParams, } -export async function setBranchUpstream(params: types.SetBranchUpstreamParams, hooks?: CommandHooks): Promise { +export async function history(params: types.HistoryParams, hooks?: CommandHooks): Promise { try { - const result = types.SetBranchUpstreamParamsSchema.safeParse(params); + const result = types.HistoryParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('set_branch_upstream', result.data); + const data = await invoke('history', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -927,15 +927,15 @@ export async function setBranchUpstream(params: types.SetBranchUpstreamParams, h } -export async function unsetBranchUpstream(params: types.UnsetBranchUpstreamParams, hooks?: CommandHooks): Promise { +export async function historyGraph(params: types.HistoryGraphParams, hooks?: CommandHooks): Promise { try { - const result = types.UnsetBranchUpstreamParamsSchema.safeParse(params); + const result = types.HistoryGraphParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('unset_branch_upstream', result.data); + const data = await invoke('history_graph', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -949,15 +949,15 @@ export async function unsetBranchUpstream(params: types.UnsetBranchUpstreamParam } -export async function push(params: types.PushParams, hooks?: CommandHooks): Promise { +export async function commitActivity(params: types.CommitActivityParams, hooks?: CommandHooks): Promise { try { - const result = types.PushParamsSchema.safeParse(params); + const result = types.CommitActivityParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('push', result.data); + const data = await invoke('commit_activity', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -971,15 +971,15 @@ export async function push(params: types.PushParams, hooks?: CommandHooks): Promise { +export async function openExternalUrl(params: types.OpenExternalUrlParams, hooks?: CommandHooks): Promise { try { - const result = types.PublishBranchParamsSchema.safeParse(params); + const result = types.OpenExternalUrlParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('publish_branch', result.data); + const data = await invoke('open_external_url', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -993,15 +993,15 @@ export async function publishBranch(params: types.PublishBranchParams, hooks?: C } -export async function pull(params: types.PullParams, hooks?: CommandHooks): Promise { +export async function addLocalGitRepo(params: types.AddLocalGitRepoParams, hooks?: CommandHooks): Promise { try { - const result = types.PullParamsSchema.safeParse(params); + const result = types.AddLocalGitRepoParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('pull', result.data); + const data = await invoke('add_local_git_repo', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1015,15 +1015,15 @@ export async function pull(params: types.PullParams, hooks?: CommandHooks): Promise { +export async function cloneRepository(params: types.CloneRepositoryParams, hooks?: CommandHooks): Promise { try { - const result = types.HasUncommittedChangesParamsSchema.safeParse(params); + const result = types.CloneRepositoryParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('has_uncommitted_changes', result.data); + const data = await invoke('clone_repository', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1037,15 +1037,15 @@ export async function hasUncommittedChanges(params: types.HasUncommittedChangesP } -export async function currentBranchStash(params: types.CurrentBranchStashParams, hooks?: CommandHooks): Promise { +export async function initRepository(params: types.InitRepositoryParams, hooks?: CommandHooks): Promise { try { - const result = types.CurrentBranchStashParamsSchema.safeParse(params); + const result = types.InitRepositoryParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('current_branch_stash', result.data); + const data = await invoke('init_repository', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1059,15 +1059,15 @@ export async function currentBranchStash(params: types.CurrentBranchStashParams, } -export async function popCurrentBranchStash(params: types.PopCurrentBranchStashParams, hooks?: CommandHooks): Promise { +export async function cancelCloneRepository(params: types.CancelCloneRepositoryParams, hooks?: CommandHooks): Promise { try { - const result = types.PopCurrentBranchStashParamsSchema.safeParse(params); + const result = types.CancelCloneRepositoryParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('pop_current_branch_stash', result.data); + const data = await invoke('cancel_clone_repository', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1081,15 +1081,15 @@ export async function popCurrentBranchStash(params: types.PopCurrentBranchStashP } -export async function stashList(params: types.StashListParams, hooks?: CommandHooks): Promise { +export async function createRepoContext(params: types.CreateRepoContextParams, hooks?: CommandHooks): Promise { try { - const result = types.StashListParamsSchema.safeParse(params); + const result = types.CreateRepoContextParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_list', result.data); + const data = await invoke('create_repo_context', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1103,15 +1103,15 @@ export async function stashList(params: types.StashListParams, hooks?: CommandHo } -export async function stashQuickStat(params: types.StashQuickStatParams, hooks?: CommandHooks): Promise { +export async function disposeRepoContext(params: types.DisposeRepoContextParams, hooks?: CommandHooks): Promise { try { - const result = types.StashQuickStatParamsSchema.safeParse(params); + const result = types.DisposeRepoContextParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_quick_stat', result.data); + const data = await invoke('dispose_repo_context', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1125,15 +1125,15 @@ export async function stashQuickStat(params: types.StashQuickStatParams, hooks?: } -export async function stashShow(params: types.StashShowParams, hooks?: CommandHooks): Promise { +export async function disposeRepoContextOwner(params: types.DisposeRepoContextOwnerParams, hooks?: CommandHooks): Promise { try { - const result = types.StashShowParamsSchema.safeParse(params); + const result = types.DisposeRepoContextOwnerParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_show', result.data); + const data = await invoke('dispose_repo_context_owner', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1147,15 +1147,30 @@ export async function stashShow(params: types.StashShowParams, hooks?: CommandHo } -export async function stashPush(params: types.StashPushParams, hooks?: CommandHooks): Promise { + +export async function invalidateRepoContextCaches(hooks?: CommandHooks): Promise { try { - const result = types.StashPushParamsSchema.safeParse(params); + const data = await invoke('invalidate_repo_context_caches'); + hooks?.onSuccess?.(data); + return data; + } catch (error) { + hooks?.onInvokeError?.(error); + throw error; + } finally { + hooks?.onSettled?.(); + } +} + + +export async function openWithApp(params: types.OpenWithAppParams, hooks?: CommandHooks): Promise { + try { + const result = types.OpenWithAppParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_push', result.data); + const data = await invoke('open_with_app', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1169,15 +1184,15 @@ export async function stashPush(params: types.StashPushParams, hooks?: CommandHo } -export async function stashPop(params: types.StashPopParams, hooks?: CommandHooks): Promise { +export async function sessionPushToHistory(params: types.SessionPushToHistoryParams, hooks?: CommandHooks): Promise { try { - const result = types.StashPopParamsSchema.safeParse(params); + const result = types.SessionPushToHistoryParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_pop', result.data); + const data = await invoke('session_push_to_history', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1191,15 +1206,15 @@ export async function stashPop(params: types.StashPopParams, hooks?: CommandHook } -export async function stashApply(params: types.StashApplyParams, hooks?: CommandHooks): Promise { +export async function sessionGoBack(params: types.SessionGoBackParams, hooks?: CommandHooks): Promise { try { - const result = types.StashApplyParamsSchema.safeParse(params); + const result = types.SessionGoBackParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_apply', result.data); + const data = await invoke('session_go_back', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1213,15 +1228,15 @@ export async function stashApply(params: types.StashApplyParams, hooks?: Command } -export async function stashDrop(params: types.StashDropParams, hooks?: CommandHooks): Promise { +export async function sessionGoForward(params: types.SessionGoForwardParams, hooks?: CommandHooks): Promise { try { - const result = types.StashDropParamsSchema.safeParse(params); + const result = types.SessionGoForwardParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_drop', result.data); + const data = await invoke('session_go_forward', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1235,15 +1250,15 @@ export async function stashDrop(params: types.StashDropParams, hooks?: CommandHo } -export async function stashClear(params: types.StashClearParams, hooks?: CommandHooks): Promise { +export async function sessionGetNavigationState(params: types.SessionGetNavigationStateParams, hooks?: CommandHooks): Promise { try { - const result = types.StashClearParamsSchema.safeParse(params); + const result = types.SessionGetNavigationStateParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_clear', result.data); + const data = await invoke('session_get_navigation_state', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1257,15 +1272,15 @@ export async function stashClear(params: types.StashClearParams, hooks?: Command } -export async function stashBranch(params: types.StashBranchParams, hooks?: CommandHooks): Promise { +export async function sessionClearHistory(params: types.SessionClearHistoryParams, hooks?: CommandHooks): Promise { try { - const result = types.StashBranchParamsSchema.safeParse(params); + const result = types.SessionClearHistoryParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_branch', result.data); + const data = await invoke('session_clear_history', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1279,15 +1294,15 @@ export async function stashBranch(params: types.StashBranchParams, hooks?: Comma } -export async function stashRestoreFile(params: types.StashRestoreFileParams, hooks?: CommandHooks): Promise { +export async function checkForUpdateByChannel(params: types.CheckForUpdateByChannelParams, hooks?: CommandHooks): Promise { try { - const result = types.StashRestoreFileParamsSchema.safeParse(params); + const result = types.CheckForUpdateByChannelParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_restore_file', result.data); + const data = await invoke('check_for_update_by_channel', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1301,15 +1316,15 @@ export async function stashRestoreFile(params: types.StashRestoreFileParams, hoo } -export async function history(params: types.HistoryParams, hooks?: CommandHooks): Promise { +export async function downloadAndInstallUpdateByChannel(params: types.DownloadAndInstallUpdateByChannelParams, hooks?: CommandHooks): Promise { try { - const result = types.HistoryParamsSchema.safeParse(params); + const result = types.DownloadAndInstallUpdateByChannelParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('history', result.data); + const data = await invoke('download_and_install_update_by_channel', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1323,15 +1338,15 @@ export async function history(params: types.HistoryParams, hooks?: CommandHooks< } -export async function historyGraph(params: types.HistoryGraphParams, hooks?: CommandHooks): Promise { +export async function getRepoOperation(params: types.GetRepoOperationParams, hooks?: CommandHooks): Promise { try { - const result = types.HistoryGraphParamsSchema.safeParse(params); + const result = types.GetRepoOperationParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('history_graph', result.data); + const data = await invoke('get_repo_operation', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1345,15 +1360,15 @@ export async function historyGraph(params: types.HistoryGraphParams, hooks?: Com } -export async function commitActivity(params: types.CommitActivityParams, hooks?: CommandHooks): Promise { +export async function rebasePlan(params: types.RebasePlanParams, hooks?: CommandHooks): Promise { try { - const result = types.CommitActivityParamsSchema.safeParse(params); + const result = types.RebasePlanParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('commit_activity', result.data); + const data = await invoke('rebase_plan', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1367,15 +1382,15 @@ export async function commitActivity(params: types.CommitActivityParams, hooks?: } -export async function openExternalUrl(params: types.OpenExternalUrlParams, hooks?: CommandHooks): Promise { +export async function rebaseStart(params: types.RebaseStartParams, hooks?: CommandHooks): Promise { try { - const result = types.OpenExternalUrlParamsSchema.safeParse(params); + const result = types.RebaseStartParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('open_external_url', result.data); + const data = await invoke('rebase_start', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1389,15 +1404,15 @@ export async function openExternalUrl(params: types.OpenExternalUrlParams, hooks } -export async function getPatchByFilePath(params: types.GetPatchByFilePathParams, hooks?: CommandHooks): Promise { +export async function rebaseContinue(params: types.RebaseContinueParams, hooks?: CommandHooks): Promise { try { - const result = types.GetPatchByFilePathParamsSchema.safeParse(params); + const result = types.RebaseContinueParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('get_patch_by_file_path', result.data); + const data = await invoke('rebase_continue', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1411,15 +1426,15 @@ export async function getPatchByFilePath(params: types.GetPatchByFilePathParams, } -export async function addLocalGitRepo(params: types.AddLocalGitRepoParams, hooks?: CommandHooks): Promise { +export async function rebaseSkip(params: types.RebaseSkipParams, hooks?: CommandHooks): Promise { try { - const result = types.AddLocalGitRepoParamsSchema.safeParse(params); + const result = types.RebaseSkipParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('add_local_git_repo', result.data); + const data = await invoke('rebase_skip', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1433,15 +1448,15 @@ export async function addLocalGitRepo(params: types.AddLocalGitRepoParams, hooks } -export async function cloneRepository(params: types.CloneRepositoryParams, hooks?: CommandHooks): Promise { +export async function rebaseAbort(params: types.RebaseAbortParams, hooks?: CommandHooks): Promise { try { - const result = types.CloneRepositoryParamsSchema.safeParse(params); + const result = types.RebaseAbortParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('clone_repository', result.data); + const data = await invoke('rebase_abort', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1455,15 +1470,15 @@ export async function cloneRepository(params: types.CloneRepositoryParams, hooks } -export async function initRepository(params: types.InitRepositoryParams, hooks?: CommandHooks): Promise { +export async function rebaseAbortPreview(params: types.RebaseAbortPreviewParams, hooks?: CommandHooks): Promise { try { - const result = types.InitRepositoryParamsSchema.safeParse(params); + const result = types.RebaseAbortPreviewParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('init_repository', result.data); + const data = await invoke('rebase_abort_preview', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1477,15 +1492,15 @@ export async function initRepository(params: types.InitRepositoryParams, hooks?: } -export async function cancelCloneRepository(params: types.CancelCloneRepositoryParams, hooks?: CommandHooks): Promise { +export async function rebaseUpdateTodo(params: types.RebaseUpdateTodoParams, hooks?: CommandHooks): Promise { try { - const result = types.CancelCloneRepositoryParamsSchema.safeParse(params); + const result = types.RebaseUpdateTodoParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('cancel_clone_repository', result.data); + const data = await invoke('rebase_update_todo', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1499,15 +1514,15 @@ export async function cancelCloneRepository(params: types.CancelCloneRepositoryP } -export async function createRepoContext(params: types.CreateRepoContextParams, hooks?: CommandHooks): Promise { +export async function rebaseSetCommitMessage(params: types.RebaseSetCommitMessageParams, hooks?: CommandHooks): Promise { try { - const result = types.CreateRepoContextParamsSchema.safeParse(params); + const result = types.RebaseSetCommitMessageParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('create_repo_context', result.data); + const data = await invoke('rebase_set_commit_message', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1521,15 +1536,15 @@ export async function createRepoContext(params: types.CreateRepoContextParams, h } -export async function disposeRepoContext(params: types.DisposeRepoContextParams, hooks?: CommandHooks): Promise { +export async function rebaseResolveConflict(params: types.RebaseResolveConflictParams, hooks?: CommandHooks): Promise { try { - const result = types.DisposeRepoContextParamsSchema.safeParse(params); + const result = types.RebaseResolveConflictParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('dispose_repo_context', result.data); + const data = await invoke('rebase_resolve_conflict', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1543,15 +1558,15 @@ export async function disposeRepoContext(params: types.DisposeRepoContextParams, } -export async function openWithApp(params: types.OpenWithAppParams, hooks?: CommandHooks): Promise { +export async function repositoryOrigin(params: types.RepositoryOriginParams, hooks?: CommandHooks): Promise { try { - const result = types.OpenWithAppParamsSchema.safeParse(params); + const result = types.RepositoryOriginParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('open_with_app', result.data); + const data = await invoke('repository_origin', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1565,15 +1580,15 @@ export async function openWithApp(params: types.OpenWithAppParams, hooks?: Comma } -export async function sessionPushToHistory(params: types.SessionPushToHistoryParams, hooks?: CommandHooks): Promise { +export async function startPickaxe(params: types.StartPickaxeParams, hooks?: CommandHooks): Promise { try { - const result = types.SessionPushToHistoryParamsSchema.safeParse(params); + const result = types.StartPickaxeParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('session_push_to_history', result.data); + const data = await invoke('start_pickaxe', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1587,15 +1602,15 @@ export async function sessionPushToHistory(params: types.SessionPushToHistoryPar } -export async function sessionGoBack(params: types.SessionGoBackParams, hooks?: CommandHooks): Promise { +export async function cancelPickaxe(params: types.CancelPickaxeParams, hooks?: CommandHooks): Promise { try { - const result = types.SessionGoBackParamsSchema.safeParse(params); + const result = types.CancelPickaxeParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('session_go_back', result.data); + const data = await invoke('cancel_pickaxe', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1609,15 +1624,15 @@ export async function sessionGoBack(params: types.SessionGoBackParams, hooks?: C } -export async function sessionGoForward(params: types.SessionGoForwardParams, hooks?: CommandHooks): Promise { +export async function listRepositories(params: types.ListRepositoriesParams, hooks?: CommandHooks): Promise { try { - const result = types.SessionGoForwardParamsSchema.safeParse(params); + const result = types.ListRepositoriesParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('session_go_forward', result.data); + const data = await invoke('list_repositories', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1631,15 +1646,15 @@ export async function sessionGoForward(params: types.SessionGoForwardParams, hoo } -export async function sessionGetNavigationState(params: types.SessionGetNavigationStateParams, hooks?: CommandHooks): Promise { +export async function addRepository(params: types.AddRepositoryParams, hooks?: CommandHooks): Promise { try { - const result = types.SessionGetNavigationStateParamsSchema.safeParse(params); + const result = types.AddRepositoryParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('session_get_navigation_state', result.data); + const data = await invoke('add_repository', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1653,15 +1668,37 @@ export async function sessionGetNavigationState(params: types.SessionGetNavigati } -export async function sessionClearHistory(params: types.SessionClearHistoryParams, hooks?: CommandHooks): Promise { +export async function removeRepository(params: types.RemoveRepositoryParams, hooks?: CommandHooks): Promise { try { - const result = types.SessionClearHistoryParamsSchema.safeParse(params); + const result = types.RemoveRepositoryParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('session_clear_history', result.data); + const data = await invoke('remove_repository', result.data); + hooks?.onSuccess?.(data); + return data; + } catch (error) { + if (!(error instanceof ZodError)) { + hooks?.onInvokeError?.(error); + } + throw error; + } finally { + hooks?.onSettled?.(); + } +} + + +export async function refreshRepositoryInfo(params: types.RefreshRepositoryInfoParams, hooks?: CommandHooks): Promise { + try { + const result = types.RefreshRepositoryInfoParamsSchema.safeParse(params); + + if (!result.success) { + hooks?.onValidationError?.(result.error); + throw result.error; + } + const data = await invoke('refresh_repository_info', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index c2382143..1b708f6d 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.4.2 - * Generated at: 2026-09-11T05:17:50.458802+00:00 + * Generated at: 2026-09-20T15:35:44.270853+00:00 * Generator: zod * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/packages/commands/src/types.ts b/packages/commands/src/types.ts index 8eec4cfb..d1481c55 100644 --- a/packages/commands/src/types.ts +++ b/packages/commands/src/types.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.4.2 - * Generated at: 2026-09-11T05:17:50.457453+00:00 + * Generated at: 2026-09-20T15:35:44.270077+00:00 * Generator: zod * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -10,65 +10,22 @@ import { z } from 'zod'; -export const RepositoryInfoSchema = z.object({ - id: z.string(), - name: z.string(), - path: z.string(), - origin: z.string().optional(), - current_branch: z.string().optional(), - ahead_behind: z.tuple([z.coerce.number(), z.coerce.number()]).optional(), - has_uncommitted_changes: z.coerce.boolean(), - last_updated: z.coerce.number(), -}); - -export type RepositoryInfo = z.infer; - -export const UpdateCheckResponseSchema = z.object({ - available: z.coerce.boolean(), - channel: z.string(), - current_version: z.string(), - version: z.string().optional(), - notes: z.string().optional(), - pub_date: z.string().optional(), -}); - -export type UpdateCheckResponse = z.infer; - -export const AssetDiffKindSchema = z.enum(["Image", "Binary"]); - -export const AssetDiffEntrySchema = z.object({ - absolute_path: z.string(), - mime: z.string(), - bytes: z.coerce.number(), - logical_path: z.string(), - contents_base64: z.string(), -}); - -export type AssetDiffEntry = z.infer; - -export const AssetDiffSchema = z.object({ - kind: AssetDiffKindSchema, - before: AssetDiffEntrySchema.optional(), - after: AssetDiffEntrySchema.optional(), -}); - -export type AssetDiff = z.infer; - -export const RepoOperationKindSchema = z.enum(["clean", "merge", "revert", "cherryPick", "bisect", "rebase", "rebaseInteractive", "rebaseMerge", "applyMailbox", "other"]); +export const UncommittedChangesStrategySchema = z.enum(["StashOnCurrentBranch", "BringChanges"]); -export const StashEntrySchema = z.object({ - index: z.coerce.number(), - reference: z.string(), - message: z.string(), - branch: z.string().optional(), - is_gitru: z.coerce.boolean(), +export const AheadBehindStatusSchema = z.object({ + ahead: z.coerce.number(), + behind: z.coerce.number(), + local_branch: z.string(), + local_branch_id: z.string(), + upstream_branch: z.string().optional(), + upstream_branch_id: z.string().optional(), + is_published: z.coerce.boolean(), + is_detached: z.coerce.boolean(), }); -export type StashEntry = z.infer; - -export const RebaseTodoStatusSchema = z.enum(["pending", "current", "done", "skipped"]); +export type AheadBehindStatus = z.infer; -export const GraphRefKindSchema = z.enum(["Local", "Remote", "Tag", "Stash", "Other"]); +export const RebaseEngineSchema = z.enum(["git", "gitru"]); export const AuthorSchema = z.object({ name: z.string(), @@ -85,18 +42,6 @@ export const CommitAuthorsSchema = z.object({ export type CommitAuthors = z.infer; -export const CommitActivityQuerySchema = z.object({ - limit: z.coerce.number(), - include_local: z.coerce.boolean(), - include_remotes: z.coerce.boolean(), - include_tags: z.coerce.boolean(), - include_stash: z.coerce.boolean(), -}); - -export type CommitActivityQuery = z.infer; - -export const RebasePauseReasonSchema = z.enum(["conflict", "edit", "reword", "waiting"]); - export const CommitInfoSchema = z.object({ id: z.string(), summary: z.string(), @@ -107,68 +52,138 @@ export const CommitInfoSchema = z.object({ export type CommitInfo = z.infer; -export const BranchInfoSchema = z.object({ - name: z.string(), - display_name: z.string(), - is_remote: z.coerce.boolean(), - is_head: z.coerce.boolean(), - commit: CommitInfoSchema, - upstream: z.string().optional(), - ahead: z.coerce.number().optional(), - behind: z.coerce.number().optional(), - is_protected: z.coerce.boolean(), - is_merged: z.coerce.boolean(), +export const FileStatusKindSchema = z.enum(["IndexNew", "IndexModified", "IndexDeleted", "IndexRenamed", "IndexTypechange", "WorktreeNew", "WorktreeModified", "WorktreeDeleted", "WorktreeRenamed", "WorktreeTypechange", "WorktreeUnreadable", "Conflicted"]); + +export const FileStatusSchema = z.object({ + path: z.string(), + new_path: z.string().optional(), + status: z.array(FileStatusKindSchema), }); -export type BranchInfo = z.infer; +export type FileStatus = z.infer; -export const CommitStatsSchema = z.object({ - insertions: z.coerce.number(), - deletions: z.coerce.number(), - files_changed: z.coerce.number(), +export const GetStatusResponseSchema = z.object({ + files: z.array(FileStatusSchema), }); -export type CommitStats = z.infer; +export type GetStatusResponse = z.infer; + +export const RepoOperationKindSchema = z.enum(["clean", "merge", "revert", "cherryPick", "bisect", "rebase", "rebaseInteractive", "rebaseMerge", "applyMailbox", "other"]); + +export const RebasePauseReasonSchema = z.enum(["conflict", "edit", "reword", "waiting"]); export const RebaseActionSchema = z.enum(["pick", "reword", "edit", "squash", "fixup", "drop"]); -export const RepoSitoryStoreSchema = z.object({ - id: z.string(), - name: z.string(), - path: z.string(), - origin: z.string().optional(), - current_branch: z.string().optional(), - ahead_behind: z.tuple([z.coerce.number(), z.coerce.number()]).optional(), - has_uncommitted_changes: z.coerce.boolean(), - last_updated: z.coerce.number(), +export const RebaseTodoStatusSchema = z.enum(["pending", "current", "done", "skipped"]); + +export const RebaseTodoEntrySchema = z.object({ + index: z.coerce.number(), + action: RebaseActionSchema, + commit: z.string(), + shortCommit: z.string(), + message: z.string(), + status: RebaseTodoStatusSchema, + authoredAt: z.string().optional(), }); -export type RepoSitoryStore = z.infer; +export type RebaseTodoEntry = z.infer; -export const ConflictResolveStrategySchema = z.enum(["ours", "theirs", "union"]); +export const RepoOperationSchema = z.object({ + kind: RepoOperationKindSchema, + isRebasing: z.coerce.boolean(), + engine: RebaseEngineSchema.optional(), + headName: z.string().optional(), + onto: z.string().optional(), + pausedAt: z.string().optional(), + pauseReason: RebasePauseReasonSchema.optional(), + current: z.coerce.number().optional(), + total: z.coerce.number().optional(), + remaining: z.coerce.number().optional(), + label: z.string().optional(), + origHead: z.string().optional(), + commitMessage: z.string().optional(), + todo: z.array(RebaseTodoEntrySchema), + conflictPaths: z.array(z.string()), +}); -export const ConflictResolveRequestSchema = z.object({ - path: z.string(), - strategy: ConflictResolveStrategySchema, +export type RepoOperation = z.infer; + +export const UpdateCheckResponseSchema = z.object({ + available: z.coerce.boolean(), + channel: z.string(), + current_version: z.string(), + version: z.string().optional(), + notes: z.string().optional(), + pub_date: z.string().optional(), }); -export type ConflictResolveRequest = z.infer; +export type UpdateCheckResponse = z.infer; -export const GraphRowTypeSchema = z.enum(["Commit", "Stash"]); +export const SessionNavigationInfoSchema = z.object({ + session_id: z.string(), + can_go_back: z.coerce.boolean(), + can_go_forward: z.coerce.boolean(), + current_path: z.string().optional(), +}); -export const FileStatusKindSchema = z.enum(["IndexNew", "IndexModified", "IndexDeleted", "IndexRenamed", "IndexTypechange", "WorktreeNew", "WorktreeModified", "WorktreeDeleted", "WorktreeRenamed", "WorktreeTypechange", "WorktreeUnreadable", "Conflicted"]); +export type SessionNavigationInfo = z.infer; -export const FileStatusSchema = z.object({ - path: z.string(), - new_path: z.string().optional(), - status: z.array(FileStatusKindSchema), +export const RebasePlanEntrySchema = z.object({ + action: RebaseActionSchema, + commit: z.string(), + message: z.string().optional(), }); -export type FileStatus = z.infer; +export type RebasePlanEntry = z.infer; -export const BranchKindSchema = z.enum(["Local", "Remote"]); +export const RebaseStartRequestSchema = z.object({ + onto: z.string(), + upstream: z.string().optional(), + entries: z.array(RebasePlanEntrySchema).optional(), + autostash: z.coerce.boolean(), +}); -export const DiffScopeSchema = z.enum(["Worktree", "Staged", "Unstaged"]); +export type RebaseStartRequest = z.infer; + +export const ParentEdgeSchema = z.object({ + oid: z.string(), + lane: z.coerce.number(), +}); + +export type ParentEdge = z.infer; + +export const SessionPushToHistoryRequestSchema = z.object({ + sessionId: z.string(), + routePath: z.string(), +}); + +export type SessionPushToHistoryRequest = z.infer; + +export const AssetDiffEntrySchema = z.object({ + absolute_path: z.string(), + mime: z.string(), + bytes: z.coerce.number(), + logical_path: z.string(), + contents_base64: z.string(), +}); + +export type AssetDiffEntry = z.infer; + +export const GraphRowTypeSchema = z.enum(["Commit", "Stash"]); + +export const HistoryQuerySchema = z.object({ + cursor: z.string().optional(), + limit: z.coerce.number(), + search: z.string().optional(), + branch: z.string().optional(), + graph_state: z.string().optional(), + include_local: z.coerce.boolean(), + include_remotes: z.coerce.boolean(), + include_tags: z.coerce.boolean(), + include_stash: z.coerce.boolean(), +}); + +export type HistoryQuery = z.infer; export const StashQuickStatSchema = z.object({ reference: z.string(), @@ -179,52 +194,70 @@ export const StashQuickStatSchema = z.object({ export type StashQuickStat = z.infer; -export const StashShowResponseSchema = z.object({ - reference: z.string(), - stat: StashQuickStatSchema, - files: z.array(FileStatusSchema), +export const GraphRefKindSchema = z.enum(["Local", "Remote", "Tag", "Stash", "Other"]); + +export const CommitActivityItemSchema = z.object({ + oid: z.string(), + timestamp: z.coerce.number(), + insertions: z.coerce.number(), + deletions: z.coerce.number(), }); -export type StashShowResponse = z.infer; +export type CommitActivityItem = z.infer; -export const RebaseTodoEntrySchema = z.object({ - index: z.coerce.number(), - action: RebaseActionSchema, - commit: z.string(), - shortCommit: z.string(), - message: z.string(), - status: RebaseTodoStatusSchema, - authoredAt: z.string().optional(), +export const RebaseAbortPreviewSchema = z.object({ + origHead: z.string().optional(), + headName: z.string().optional(), + onto: z.string().optional(), + current: z.coerce.number().optional(), + total: z.coerce.number().optional(), + commitsApplied: z.coerce.number(), + warning: z.string(), }); -export type RebaseTodoEntry = z.infer; +export type RebaseAbortPreview = z.infer; -export const DiffTextFileSchema = z.object({ - name: z.string(), - contents: z.string(), - byte_length: z.coerce.number(), - encoding: z.string(), +export const CommitStatsSchema = z.object({ + insertions: z.coerce.number(), + deletions: z.coerce.number(), + files_changed: z.coerce.number(), }); -export type DiffTextFile = z.infer; +export type CommitStats = z.infer; -export const FileDiffSchema = z.object({ - patch: z.string(), - asset_diff: AssetDiffSchema.optional(), - oldFile: DiffTextFileSchema.optional(), - newFile: DiffTextFileSchema.optional(), +export const FullCommitInfoSchema = z.object({ + id: z.string(), + timestamp: z.coerce.number(), + summary: z.string(), + body: z.string(), + authors: CommitAuthorsSchema, + stats: CommitStatsSchema, + files: z.array(FileStatusSchema), }); -export type FileDiff = z.infer; +export type FullCommitInfo = z.infer; -export const RebaseEngineSchema = z.enum(["git", "gitru"]); +export const PatchRangeSchema = z.object({ + start: z.coerce.number().optional(), + count: z.coerce.number(), +}); -export const ParentEdgeSchema = z.object({ - oid: z.string(), - lane: z.coerce.number(), +export type PatchRange = z.infer; + +export const PickaxeQuerySchema = z.object({ + query: z.string(), + isRegex: z.coerce.boolean(), + matchCase: z.coerce.boolean(), + matchWholeWord: z.coerce.boolean(), + author: z.string().optional(), + since: z.string().optional(), + until: z.string().optional(), + filePatterns: z.array(z.string()), + limit: z.coerce.number().optional(), + operationId: z.string(), }); -export type ParentEdge = z.infer; +export type PickaxeQuery = z.infer; export const SwimlaneSchema = z.object({ id: z.string(), @@ -234,6 +267,19 @@ export const SwimlaneSchema = z.object({ export type Swimlane = z.infer; +export const RepoSitoryStoreSchema = z.object({ + id: z.string(), + name: z.string(), + path: z.string(), + origin: z.string().optional(), + current_branch: z.string().optional(), + ahead_behind: z.tuple([z.coerce.number(), z.coerce.number()]).optional(), + has_uncommitted_changes: z.coerce.boolean(), + last_updated: z.coerce.number(), +}); + +export type RepoSitoryStore = z.infer; + export const GraphRefSchema = z.object({ name: z.string(), display_name: z.string(), @@ -243,18 +289,6 @@ export const GraphRefSchema = z.object({ export type GraphRef = z.infer; -export const FullCommitInfoSchema = z.object({ - id: z.string(), - timestamp: z.coerce.number(), - summary: z.string(), - body: z.string(), - authors: CommitAuthorsSchema, - stats: CommitStatsSchema, - files: z.array(FileStatusSchema), -}); - -export type FullCommitInfo = z.infer; - export const GraphRowSchema = z.object({ oid: z.string(), lane: z.coerce.number(), @@ -273,6 +307,15 @@ export const GraphRowSchema = z.object({ export type GraphRow = z.infer; +export const DiffTextFileSchema = z.object({ + name: z.string(), + contents: z.string(), + byte_length: z.coerce.number(), + encoding: z.string(), +}); + +export type DiffTextFile = z.infer; + export const CommitMessageSchema = z.object({ title: z.string(), description: z.string().optional(), @@ -281,36 +324,27 @@ export const CommitMessageSchema = z.object({ export type CommitMessage = z.infer; -export const RebasePlanEntrySchema = z.object({ - action: RebaseActionSchema, - commit: z.string(), - message: z.string().optional(), +export const RebaseUpdateTodoRequestSchema = z.object({ + entries: z.array(RebasePlanEntrySchema), }); -export type RebasePlanEntry = z.infer; +export type RebaseUpdateTodoRequest = z.infer; -export const RebaseAbortPreviewSchema = z.object({ - origHead: z.string().optional(), - headName: z.string().optional(), - onto: z.string().optional(), - current: z.coerce.number().optional(), - total: z.coerce.number().optional(), - commitsApplied: z.coerce.number(), - warning: z.string(), +export const ConflictResolveStrategySchema = z.enum(["ours", "theirs", "union"]); + +export const ConflictResolveRequestSchema = z.object({ + path: z.string(), + strategy: ConflictResolveStrategySchema, }); -export type RebaseAbortPreview = z.infer; +export type ConflictResolveRequest = z.infer; -export const RebaseStartRequestSchema = z.object({ - onto: z.string(), - upstream: z.string().optional(), - entries: z.array(RebasePlanEntrySchema).optional(), - autostash: z.coerce.boolean(), +export const GraphPagingSchema = z.object({ + starting_cursor: z.string().optional(), + has_more: z.coerce.boolean(), }); -export type RebaseStartRequest = z.infer; - -export const UncommittedChangesStrategySchema = z.enum(["StashOnCurrentBranch", "BringChanges"]); +export type GraphPaging = z.infer; export const BranchSchema = z.object({ name: z.string(), @@ -321,12 +355,24 @@ export const BranchSchema = z.object({ export type Branch = z.infer; -export const GraphPagingSchema = z.object({ - starting_cursor: z.string().optional(), - has_more: z.coerce.boolean(), +export const AssetDiffKindSchema = z.enum(["Image", "Binary"]); + +export const AssetDiffSchema = z.object({ + kind: AssetDiffKindSchema, + before: AssetDiffEntrySchema.optional(), + after: AssetDiffEntrySchema.optional(), }); -export type GraphPaging = z.infer; +export type AssetDiff = z.infer; + +export const FileDiffSchema = z.object({ + patch: z.string(), + asset_diff: AssetDiffSchema.optional(), + oldFile: DiffTextFileSchema.optional(), + newFile: DiffTextFileSchema.optional(), +}); + +export type FileDiff = z.infer; export const HistoryGraphResponseSchema = z.object({ rows: z.array(GraphRowSchema), @@ -338,25 +384,15 @@ export const HistoryGraphResponseSchema = z.object({ export type HistoryGraphResponse = z.infer; -export const SessionActionRequestSchema = z.object({ - sessionId: z.string(), -}); - -export type SessionActionRequest = z.infer; - -export const BranchStashSchema = z.object({ - reference: z.string(), - message: z.string(), - from_branch: z.string(), - to_branch: z.string(), - files_changed: z.coerce.number(), - insertions: z.coerce.number(), - deletions: z.coerce.number(), +export const CommitActivityQuerySchema = z.object({ + limit: z.coerce.number(), + include_local: z.coerce.boolean(), + include_remotes: z.coerce.boolean(), + include_tags: z.coerce.boolean(), + include_stash: z.coerce.boolean(), }); -export type BranchStash = z.infer; - -export const PatchActionSchema = z.enum(["Stage", "Unstage", "Discard"]); +export type CommitActivityQuery = z.infer; export const RepositoryOriginSchema = z.object({ remote_name: z.string(), @@ -370,205 +406,93 @@ export const RepositoryOriginSchema = z.object({ export type RepositoryOrigin = z.infer; -export const GetStatusResponseSchema = z.object({ +export const StashShowResponseSchema = z.object({ + reference: z.string(), + stat: StashQuickStatSchema, files: z.array(FileStatusSchema), }); -export type GetStatusResponse = z.infer; - -export const HistoryQuerySchema = z.object({ - cursor: z.string().optional(), - limit: z.coerce.number(), - search: z.string().optional(), - branch: z.string().optional(), - graph_state: z.string().optional(), - include_local: z.coerce.boolean(), - include_remotes: z.coerce.boolean(), - include_tags: z.coerce.boolean(), - include_stash: z.coerce.boolean(), -}); - -export type HistoryQuery = z.infer; - -export const RepoOperationSchema = z.object({ - kind: RepoOperationKindSchema, - isRebasing: z.coerce.boolean(), - engine: RebaseEngineSchema.optional(), - headName: z.string().optional(), - onto: z.string().optional(), - pausedAt: z.string().optional(), - pauseReason: RebasePauseReasonSchema.optional(), - current: z.coerce.number().optional(), - total: z.coerce.number().optional(), - remaining: z.coerce.number().optional(), - label: z.string().optional(), - origHead: z.string().optional(), - commitMessage: z.string().optional(), - todo: z.array(RebaseTodoEntrySchema), - conflictPaths: z.array(z.string()), -}); - -export type RepoOperation = z.infer; - -export const RebaseUpdateTodoRequestSchema = z.object({ - entries: z.array(RebasePlanEntrySchema), -}); - -export type RebaseUpdateTodoRequest = z.infer; - -export const SessionPushToHistoryRequestSchema = z.object({ - sessionId: z.string(), - routePath: z.string(), -}); - -export type SessionPushToHistoryRequest = z.infer; - -export const PickaxeQuerySchema = z.object({ - query: z.string(), - isRegex: z.coerce.boolean(), - matchCase: z.coerce.boolean(), - matchWholeWord: z.coerce.boolean(), - author: z.string().optional(), - since: z.string().optional(), - until: z.string().optional(), - filePatterns: z.array(z.string()), - limit: z.coerce.number().optional(), - operationId: z.string(), -}); - -export type PickaxeQuery = z.infer; - -export const RebasePlanSchema = z.object({ - onto: z.string(), - upstream: z.string().optional(), - entries: z.array(RebasePlanEntrySchema), - autostash: z.coerce.boolean(), -}); - -export type RebasePlan = z.infer; - -export const SessionNavigationInfoSchema = z.object({ - session_id: z.string(), - can_go_back: z.coerce.boolean(), - can_go_forward: z.coerce.boolean(), - current_path: z.string().optional(), -}); - -export type SessionNavigationInfo = z.infer; - -export const PatchRangeSchema = z.object({ - start: z.coerce.number().optional(), - count: z.coerce.number(), -}); - -export type PatchRange = z.infer; - -export const CommitActivityItemSchema = z.object({ - oid: z.string(), - timestamp: z.coerce.number(), - insertions: z.coerce.number(), - deletions: z.coerce.number(), -}); - -export type CommitActivityItem = z.infer; - -export const CommitActivityResponseSchema = z.object({ - items: z.array(CommitActivityItemSchema), - head_index: z.coerce.number().optional(), - total: z.coerce.number(), -}); - -export type CommitActivityResponse = z.infer; - -export const AheadBehindStatusSchema = z.object({ - ahead: z.coerce.number(), - behind: z.coerce.number(), - local_branch: z.string(), - local_branch_id: z.string(), - upstream_branch: z.string().optional(), - upstream_branch_id: z.string().optional(), - is_published: z.coerce.boolean(), - is_detached: z.coerce.boolean(), -}); - -export type AheadBehindStatus = z.infer; - - -export const RepositoryOriginParamsSchema = z.object({ - contextId: z.string(), -}); - -export const StartPickaxeParamsSchema = z.object({ - contextId: z.string(),query: PickaxeQuerySchema, -}); +export type StashShowResponse = z.infer; -export const CancelPickaxeParamsSchema = z.object({ - operationId: z.string(), +export const RepositoryInfoSchema = z.object({ + id: z.string(), + name: z.string(), + path: z.string(), + origin: z.string().optional(), + current_branch: z.string().optional(), + ahead_behind: z.tuple([z.coerce.number(), z.coerce.number()]).optional(), + has_uncommitted_changes: z.coerce.boolean(), + last_updated: z.coerce.number(), }); -export const ListRepositoriesParamsSchema = z.object({ - refreshStale: z.coerce.boolean().optional().optional(), -}); +export type RepositoryInfo = z.infer; -export const AddRepositoryParamsSchema = z.object({ - repo: RepositoryInfoSchema, -}); +export const BranchKindSchema = z.enum(["Local", "Remote"]); -export const RemoveRepositoryParamsSchema = z.object({ - repoId: z.string(), +export const CommitActivityResponseSchema = z.object({ + items: z.array(CommitActivityItemSchema), + head_index: z.coerce.number().optional(), + total: z.coerce.number(), }); -export const RefreshRepositoryInfoParamsSchema = z.object({ - repoId: z.string(), -}); +export type CommitActivityResponse = z.infer; -export const GetRepoOperationParamsSchema = z.object({ - contextId: z.string(), -}); +export const PatchActionSchema = z.enum(["Stage", "Unstage", "Discard"]); -export const RebasePlanParamsSchema = z.object({ - contextId: z.string(),onto: z.string(),upstream: z.string().optional().optional(), +export const RebasePlanSchema = z.object({ + onto: z.string(), + upstream: z.string().optional(), + entries: z.array(RebasePlanEntrySchema), + autostash: z.coerce.boolean(), }); -export const RebaseStartParamsSchema = z.object({ - contextId: z.string(),request: RebaseStartRequestSchema, -}); +export type RebasePlan = z.infer; -export const RebaseContinueParamsSchema = z.object({ - contextId: z.string(),message: z.string().optional().optional(), -}); +export const DiffScopeSchema = z.enum(["Worktree", "Staged", "Unstaged"]); -export const RebaseSkipParamsSchema = z.object({ - contextId: z.string(), +export const BranchStashSchema = z.object({ + reference: z.string(), + message: z.string(), + from_branch: z.string(), + to_branch: z.string(), + files_changed: z.coerce.number(), + insertions: z.coerce.number(), + deletions: z.coerce.number(), }); -export const RebaseAbortParamsSchema = z.object({ - contextId: z.string(), -}); +export type BranchStash = z.infer; -export const RebaseAbortPreviewParamsSchema = z.object({ - contextId: z.string(), +export const SessionActionRequestSchema = z.object({ + sessionId: z.string(), }); -export const RebaseUpdateTodoParamsSchema = z.object({ - contextId: z.string(),request: RebaseUpdateTodoRequestSchema, -}); +export type SessionActionRequest = z.infer; -export const RebaseSetCommitMessageParamsSchema = z.object({ - contextId: z.string(),message: z.string(), +export const StashEntrySchema = z.object({ + index: z.coerce.number(), + reference: z.string(), + message: z.string(), + branch: z.string().optional(), + is_gitru: z.coerce.boolean(), }); -export const RebaseResolveConflictParamsSchema = z.object({ - contextId: z.string(),request: ConflictResolveRequestSchema, -}); +export type StashEntry = z.infer; -export const CheckForUpdateByChannelParamsSchema = z.object({ - channel: z.string(), +export const BranchInfoSchema = z.object({ + name: z.string(), + display_name: z.string(), + is_remote: z.coerce.boolean(), + is_head: z.coerce.boolean(), + commit: CommitInfoSchema, + upstream: z.string().optional(), + ahead: z.coerce.number().optional(), + behind: z.coerce.number().optional(), + is_protected: z.coerce.boolean(), + is_merged: z.coerce.boolean(), }); -export const DownloadAndInstallUpdateByChannelParamsSchema = z.object({ - channel: z.string(), -}); +export type BranchInfo = z.infer; + export const GitVersionParamsSchema = z.object({ contextId: z.string(), @@ -606,18 +530,6 @@ export const WriteWorktreeFileParamsSchema = z.object({ contextId: z.string(),path: z.string(),contents: z.string(), }); -export const LastCommitParamsSchema = z.object({ - contextId: z.string(), -}); - -export const CommitByIdParamsSchema = z.object({ - contextId: z.string(),hash: z.string(), -}); - -export const CreateCommitParamsSchema = z.object({ - contextId: z.string(),commitMeta: CommitMessageSchema,allowEmpty: z.coerce.boolean(), -}); - export const CurrentBranchParamsSchema = z.object({ contextId: z.string(), }); @@ -686,6 +598,22 @@ export const PopCurrentBranchStashParamsSchema = z.object({ contextId: z.string(), }); +export const GetPatchByFilePathParamsSchema = z.object({ + contextId: z.string(),filePath: z.string(),fileNewPath: z.string().optional().optional(),status: z.array(FileStatusKindSchema).optional().optional(),stashReference: z.string().optional().optional(),commitHash: z.string().optional().optional(),parentIndex: z.coerce.number().optional().optional(),diffScope: DiffScopeSchema.optional().optional(), +}); + +export const LastCommitParamsSchema = z.object({ + contextId: z.string(), +}); + +export const CommitByIdParamsSchema = z.object({ + contextId: z.string(),hash: z.string(), +}); + +export const CreateCommitParamsSchema = z.object({ + contextId: z.string(),commitMeta: CommitMessageSchema,allowEmpty: z.coerce.boolean(), +}); + export const StashListParamsSchema = z.object({ contextId: z.string(), }); @@ -742,10 +670,6 @@ export const OpenExternalUrlParamsSchema = z.object({ url: z.string(), }); -export const GetPatchByFilePathParamsSchema = z.object({ - contextId: z.string(),filePath: z.string(),fileNewPath: z.string().optional().optional(),status: z.array(FileStatusKindSchema).optional().optional(),stashReference: z.string().optional().optional(),commitHash: z.string().optional().optional(),parentIndex: z.coerce.number().optional().optional(),diffScope: DiffScopeSchema.optional().optional(), -}); - export const AddLocalGitRepoParamsSchema = z.object({ repoPath: z.string(), }); @@ -763,13 +687,17 @@ export const CancelCloneRepositoryParamsSchema = z.object({ }); export const CreateRepoContextParamsSchema = z.object({ - repoId: z.string(), + repoId: z.string(),ownerId: z.string(), }); export const DisposeRepoContextParamsSchema = z.object({ contextId: z.string(), }); +export const DisposeRepoContextOwnerParamsSchema = z.object({ + ownerId: z.string(), +}); + export const OpenWithAppParamsSchema = z.object({ filePath: z.string(),line: z.coerce.number().optional().optional(),app: z.string().optional().optional(), }); @@ -794,46 +722,84 @@ export const SessionClearHistoryParamsSchema = z.object({ req: SessionActionRequestSchema, }); +export const CheckForUpdateByChannelParamsSchema = z.object({ + channel: z.string(), +}); +export const DownloadAndInstallUpdateByChannelParamsSchema = z.object({ + channel: z.string(), +}); +export const GetRepoOperationParamsSchema = z.object({ + contextId: z.string(), +}); -export type RepositoryOriginParams = z.infer; +export const RebasePlanParamsSchema = z.object({ + contextId: z.string(),onto: z.string(),upstream: z.string().optional().optional(), +}); -export type StartPickaxeParams = z.infer; +export const RebaseStartParamsSchema = z.object({ + contextId: z.string(),request: RebaseStartRequestSchema, +}); -export type CancelPickaxeParams = z.infer; +export const RebaseContinueParamsSchema = z.object({ + contextId: z.string(),message: z.string().optional().optional(), +}); -export type ListRepositoriesParams = z.infer; +export const RebaseSkipParamsSchema = z.object({ + contextId: z.string(), +}); -export type AddRepositoryParams = z.infer; +export const RebaseAbortParamsSchema = z.object({ + contextId: z.string(), +}); -export type RemoveRepositoryParams = z.infer; +export const RebaseAbortPreviewParamsSchema = z.object({ + contextId: z.string(), +}); -export type RefreshRepositoryInfoParams = z.infer; +export const RebaseUpdateTodoParamsSchema = z.object({ + contextId: z.string(),request: RebaseUpdateTodoRequestSchema, +}); -export type GetRepoOperationParams = z.infer; +export const RebaseSetCommitMessageParamsSchema = z.object({ + contextId: z.string(),message: z.string(), +}); -export type RebasePlanParams = z.infer; +export const RebaseResolveConflictParamsSchema = z.object({ + contextId: z.string(),request: ConflictResolveRequestSchema, +}); -export type RebaseStartParams = z.infer; +export const RepositoryOriginParamsSchema = z.object({ + contextId: z.string(), +}); -export type RebaseContinueParams = z.infer; +export const StartPickaxeParamsSchema = z.object({ + contextId: z.string(),query: PickaxeQuerySchema, +}); -export type RebaseSkipParams = z.infer; +export const CancelPickaxeParamsSchema = z.object({ + operationId: z.string(), +}); -export type RebaseAbortParams = z.infer; +export const ListRepositoriesParamsSchema = z.object({ + refreshStale: z.coerce.boolean().optional().optional(), +}); -export type RebaseAbortPreviewParams = z.infer; +export const AddRepositoryParamsSchema = z.object({ + repo: RepositoryInfoSchema, +}); -export type RebaseUpdateTodoParams = z.infer; +export const RemoveRepositoryParamsSchema = z.object({ + repoId: z.string(), +}); -export type RebaseSetCommitMessageParams = z.infer; +export const RefreshRepositoryInfoParamsSchema = z.object({ + repoId: z.string(), +}); -export type RebaseResolveConflictParams = z.infer; -export type CheckForUpdateByChannelParams = z.infer; -export type DownloadAndInstallUpdateByChannelParams = z.infer; export type GitVersionParams = z.infer; @@ -853,12 +819,6 @@ export type ReadWorktreeFileParams = z.infer; -export type LastCommitParams = z.infer; - -export type CommitByIdParams = z.infer; - -export type CreateCommitParams = z.infer; - export type CurrentBranchParams = z.infer; export type ListBranchesParams = z.infer; @@ -893,6 +853,14 @@ export type CurrentBranchStashParams = z.infer; +export type GetPatchByFilePathParams = z.infer; + +export type LastCommitParams = z.infer; + +export type CommitByIdParams = z.infer; + +export type CreateCommitParams = z.infer; + export type StashListParams = z.infer; export type StashQuickStatParams = z.infer; @@ -921,8 +889,6 @@ export type CommitActivityParams = z.infer; export type OpenExternalUrlParams = z.infer; -export type GetPatchByFilePathParams = z.infer; - export type AddLocalGitRepoParams = z.infer; export type CloneRepositoryParams = z.infer; @@ -935,6 +901,8 @@ export type CreateRepoContextParams = z.infer; +export type DisposeRepoContextOwnerParams = z.infer; + export type OpenWithAppParams = z.infer; export type SessionPushToHistoryParams = z.infer; @@ -947,3 +915,41 @@ export type SessionGetNavigationStateParams = z.infer; +export type CheckForUpdateByChannelParams = z.infer; + +export type DownloadAndInstallUpdateByChannelParams = z.infer; + +export type GetRepoOperationParams = z.infer; + +export type RebasePlanParams = z.infer; + +export type RebaseStartParams = z.infer; + +export type RebaseContinueParams = z.infer; + +export type RebaseSkipParams = z.infer; + +export type RebaseAbortParams = z.infer; + +export type RebaseAbortPreviewParams = z.infer; + +export type RebaseUpdateTodoParams = z.infer; + +export type RebaseSetCommitMessageParams = z.infer; + +export type RebaseResolveConflictParams = z.infer; + +export type RepositoryOriginParams = z.infer; + +export type StartPickaxeParams = z.infer; + +export type CancelPickaxeParams = z.infer; + +export type ListRepositoriesParams = z.infer; + +export type AddRepositoryParams = z.infer; + +export type RemoveRepositoryParams = z.infer; + +export type RefreshRepositoryInfoParams = z.infer; +