From 58c03c3ba417641d145dace17a4d637d97797d41 Mon Sep 17 00:00:00 2001 From: Brian Akaka Date: Sun, 26 Apr 2026 19:50:00 -1000 Subject: [PATCH 1/3] fix(useGitWatch): skip polling on non-git cwds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the polling loop described in #62: when cwd is set to a directory that isn't a git working tree (e.g. \$HOME, common for sessions started via plain claude/codex CLI), useGitWatch's 2.5s interval keeps firing the consumer's refresh callback. Each call hits gitStatus / gitBranchInfo / etc., which throws, and the consumer re-renders its error state. Visible as a perpetual "loading → error" flash in GitDiffPanel and RightPanelHeader. Adds a one-shot isGitRepo preflight at the top of the useEffect: - Wraps the watcher + interval setup in an async initialize(). - isGitRepo(cwd) is a single gitBranchInfo round-trip with try/catch. Cheap and runs once per cwd change. - If not a repo, return early — no startWatchFile, no setInterval. - A `cancelled` flag covers the (rare) case where cwd changes while the preflight is in flight. - Cleanup unchanged — clears refs that are still null when no setup ran, which is a safe no-op. Consumers' one-shot refresh on cwd change still fires once and may error, which is fine — those already wrap in try/catch and don't loop. The polling loop was the actual noise. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hooks/useGitWatch.ts | 20 +++++++++++++++----- src/services/tauri/git.ts | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/hooks/useGitWatch.ts b/src/hooks/useGitWatch.ts index f396e537..c1bfe988 100644 --- a/src/hooks/useGitWatch.ts +++ b/src/hooks/useGitWatch.ts @@ -1,6 +1,7 @@ import { useEffect, useCallback, useRef } from 'react'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { startWatchFile, stopWatchFile } from '@/services/tauri/filesystem'; +import { isGitRepo } from '@/services/tauri/git'; import { isDesktopTauri } from '@/hooks/runtime'; /** @@ -24,6 +25,7 @@ export function useGitWatch(cwd: string | null, onRefresh: () => void, enabled = useEffect(() => { if (!cwd || !enabled) return; + let cancelled = false; const gitIndexPath = `${cwd}/.git/index`; const normalizedGitIndexPath = gitIndexPath.replace(/\\/g, '/'); @@ -64,14 +66,22 @@ export function useGitWatch(cwd: string | null, onRefresh: () => void, enabled = } }; - // Keep a low-frequency fallback for platforms where file events may be missed. - pollingRef.current = setInterval(() => { - debouncedRefresh(); - }, 2500); + // Preflight: if cwd isn't a git repo, skip polling and watcher entirely. + // Otherwise consumers' refresh callbacks loop on errors every 2.5s. + const initialize = async () => { + if (!(await isGitRepo(cwd))) return; + if (cancelled) return; + // Keep a low-frequency fallback for platforms where file events may be missed. + pollingRef.current = setInterval(() => { + debouncedRefresh(); + }, 2500); + void setupWatcher(); + }; - setupWatcher(); + void initialize(); return () => { + cancelled = true; if (unlistenRef.current) { unlistenRef.current(); unlistenRef.current = null; diff --git a/src/services/tauri/git.ts b/src/services/tauri/git.ts index 2e6b3179..8695f13b 100644 --- a/src/services/tauri/git.ts +++ b/src/services/tauri/git.ts @@ -66,6 +66,20 @@ export async function gitBranchInfo(cwd: string) { return await postJson('/api/git/branch-info', { cwd }); } +// Returns true iff `cwd` is a git working tree. Used by useGitWatch to gate +// polling — non-git cwds otherwise loop on errors every 2.5s. Single +// gitBranchInfo round-trip; result is not cached because cwd changes are +// rare and the call is cheap. +export async function isGitRepo(cwd: string): Promise { + if (!cwd) return false; + try { + await gitBranchInfo(cwd); + return true; + } catch { + return false; + } +} + export async function gitListBranches(cwd: string) { if (isDesktopTauri()) { return await invokeTauri('git_list_branches', { cwd }); From a0ec442b2b7c44e2f23f7c3db1950d716d2a3f2b Mon Sep 17 00:00:00 2001 From: Brian Akaka Date: Sun, 26 Apr 2026 20:19:26 -1000 Subject: [PATCH 2/3] fix(git): also gate one-shot consumer refreshes on isGitRepo Sibling fix to the useGitWatch polling gate: GitDiffPanel and useGitStatsStore have their own one-shot refreshes that fire on cwd change (independent of useGitWatch). On boot or when switching to a non-git cwd, those throw and render an error flash before the polling gate kicks in. - useGitStatsStore.refreshStats: clears stats and returns when cwd isn't a git repo. Same UX as the !cwd branch. - GitDiffPanel.refreshGitStatus: clears gitData/gitError/gitLoading and returns early on non-git cwd. Net result: opening codexia with a non-git cwd or switching to one shows empty git UI silently, no error flash. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/features/git/GitDiffPanel.tsx | 8 ++++++++ src/stores/useGitStatsStore.ts | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/src/components/features/git/GitDiffPanel.tsx b/src/components/features/git/GitDiffPanel.tsx index 7a4aa1cd..00593223 100644 --- a/src/components/features/git/GitDiffPanel.tsx +++ b/src/components/features/git/GitDiffPanel.tsx @@ -6,6 +6,7 @@ import { gitUnstageFiles, type GitStatusResponse, } from '@/services/tauri'; +import { isGitRepo } from '@/services/tauri/git'; import { useGitWatch } from '@/hooks/useGitWatch'; import { useWorkspaceStore } from '@/stores'; import { useLayoutStore } from '@/stores/settings'; @@ -41,6 +42,13 @@ export function GitDiffPanel({ cwd, isActive }: GitDiffPanelProps) { const refreshGitStatus = useCallback(async () => { if (!cwd) return; + // Non-git cwd: clear, don't error-flash. Same gate as useGitWatch. + if (!(await isGitRepo(cwd))) { + setGitData(null); + setGitError(null); + setGitLoading(false); + return; + } setGitLoading(true); setGitError(null); try { diff --git a/src/stores/useGitStatsStore.ts b/src/stores/useGitStatsStore.ts index cd902443..3494d6b9 100644 --- a/src/stores/useGitStatsStore.ts +++ b/src/stores/useGitStatsStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { gitDiffStats, gitStatus } from '@/services/tauri'; +import { isGitRepo } from '@/services/tauri/git'; interface GitStats { stagedFiles: number; @@ -42,6 +43,13 @@ export const useGitStatsStore = create((set) => ({ return; } + // Non-git cwds (e.g. claude session that lived in $HOME) — render empty + // instead of error-flashing. Same gate as useGitWatch's polling. + if (!(await isGitRepo(cwd))) { + set({ stats: null }); + return; + } + set((state) => ({ stats: state.stats ? { ...state.stats, isLoading: true } : { ...initialStats, isLoading: true }, })); From e1f290b26b910588e40c80d223ca685266aa9864 Mon Sep 17 00:00:00 2001 From: Brian Akaka Date: Sun, 26 Apr 2026 20:39:05 -1000 Subject: [PATCH 3/3] fix(git): suppress toast in isGitRepo probe itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The isGitRepo helper called gitBranchInfo, which routes through the shared postJson helper that auto-renders a "Request failed" toast on HTTP errors. So every call to isGitRepo on a non-git cwd surfaced a red toast — visible at boot and on every cwd change. Inline the API call with postJsonWithOptions(..., { suppressToast: true }) so the probe is silent. Tauri-desktop path is unchanged (invoke doesn't go through the toast helper). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/tauri/git.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/services/tauri/git.ts b/src/services/tauri/git.ts index 8695f13b..fa60c088 100644 --- a/src/services/tauri/git.ts +++ b/src/services/tauri/git.ts @@ -1,4 +1,4 @@ -import { invokeTauri, isDesktopTauri, postJson, postNoContent } from './shared'; +import { invokeTauri, isDesktopTauri, postJson, postJsonWithOptions, postNoContent } from './shared'; export type GitStatusEntry = { path: string; @@ -66,14 +66,24 @@ export async function gitBranchInfo(cwd: string) { return await postJson('/api/git/branch-info', { cwd }); } -// Returns true iff `cwd` is a git working tree. Used by useGitWatch to gate -// polling — non-git cwds otherwise loop on errors every 2.5s. Single -// gitBranchInfo round-trip; result is not cached because cwd changes are -// rare and the call is cheap. +// Returns true iff `cwd` is a git working tree. Used by useGitWatch and +// downstream consumers to gate polling — non-git cwds otherwise loop on +// errors. Hits the same backend endpoint as gitBranchInfo but passes +// suppressToast so the shared API helper doesn't render a "Request failed" +// toast on the (expected) non-git case. Result is not cached because cwd +// changes are rare and the call is cheap. export async function isGitRepo(cwd: string): Promise { if (!cwd) return false; try { - await gitBranchInfo(cwd); + if (isDesktopTauri()) { + await invokeTauri('git_branch_info', { cwd }); + } else { + await postJsonWithOptions( + '/api/git/branch-info', + { cwd }, + { suppressToast: true }, + ); + } return true; } catch { return false;