diff --git a/wimygit-tauri/src/App.tsx b/wimygit-tauri/src/App.tsx index d2ac13b..2b0a0e9 100644 --- a/wimygit-tauri/src/App.tsx +++ b/wimygit-tauri/src/App.tsx @@ -30,6 +30,11 @@ import { type LfsLock, } from "./lib"; import { LfsUnlockModal } from "./components/shared/LfsUnlockModal"; +import { + loadAutoFetchSettings, + saveAutoFetchSettings, + type AutoFetchSettings, +} from "./hooks/useAutoFetch"; const BASE_INNER_TABS = [ { id: "pending", label: "Pending Changes" }, @@ -47,6 +52,7 @@ interface RepoTabState { repoName: string; activeTab: string; refreshKey: number; + silentRefreshKey: number; } const STORAGE_KEY = "repoTabs_v2"; @@ -90,6 +96,7 @@ function App() { const [lfsLockCount, setLfsLockCount] = useState(0); const [worktreeCount, setWorktreeCount] = useState(0); const [showPluginModal, setShowPluginModal] = useState(false); + const [autoFetchSettings, setAutoFetchSettings] = useState(loadAutoFetchSettings); const [lfsUnlockConfirm, setLfsUnlockConfirm] = useState<{ repoPath: string; locks: LfsLock[]; @@ -114,6 +121,7 @@ function App() { repoName: s.repoName, activeTab: "pending", refreshKey: 0, + silentRefreshKey: 0, })); setRepoTabs(tabs); const lastActive = localStorage.getItem("activeRepoId"); @@ -159,6 +167,14 @@ function App() { updateActiveRepo((t) => ({ refreshKey: t.refreshKey + 1 })); }, [updateActiveRepo]); + const handleSilentRefresh = useCallback(() => { + setRepoTabs((prev) => + prev.map((t) => + t.id === activeRepoId ? { ...t, silentRefreshKey: t.silentRefreshKey + 1 } : t + ) + ); + }, [activeRepoId]); + const handleAfterPush = useCallback(async (repoPath: string) => { try { const myLocks = await getLfsLocalLocks(repoPath); @@ -172,6 +188,11 @@ function App() { } }, []); + const handleAutoFetchSettingsChange = useCallback((settings: AutoFetchSettings) => { + saveAutoFetchSettings(settings); + setAutoFetchSettings(settings); + }, []); + const handleLfsUnlockConfirm = useCallback(async () => { if (!lfsUnlockConfirm) return; const { repoPath, locks } = lfsUnlockConfirm; @@ -290,6 +311,7 @@ function App() { repoName: repoNameFromPath(root), activeTab: "pending", refreshKey: 0, + silentRefreshKey: 0, }; setRepoTabs((prev) => { @@ -429,6 +451,9 @@ function App() { plugins={plugins} selectedFilePath={selectedFilePath} onTimeLapse={() => setShowTimeLapse(true)} + autoFetchSettings={autoFetchSettings} + onAutoFetchSettingsChange={handleAutoFetchSettingsChange} + onSilentRefresh={handleSilentRefresh} /> {/* Body: left sidebar + main content */} @@ -466,6 +491,7 @@ function App() { { setSelectedDiff(null); setPendingFilePreview({ filename, staged }); }} onLfsLockCountChange={setLfsLockCount} onShowInWorkspaceFile={(absolutePath) => { @@ -490,6 +516,7 @@ function App() { repoPath={activeRepo.repoPath} filePath={selectedFilePath} refreshKey={activeRepo.refreshKey} + silentRefreshKey={activeRepo.silentRefreshKey} onRefresh={handleRefresh} onFileSelect={setSelectedDiff} onClearPath={() => setSelectedFilePath(null)} @@ -511,6 +538,7 @@ function App() { )} diff --git a/wimygit-tauri/src/components/layout/Header.tsx b/wimygit-tauri/src/components/layout/Header.tsx index ba4cd94..c076dea 100644 --- a/wimygit-tauri/src/components/layout/Header.tsx +++ b/wimygit-tauri/src/components/layout/Header.tsx @@ -12,6 +12,8 @@ import { type PluginInfo, } from "../../lib"; import { PluginButtons } from "./PluginButtons"; +import { useAutoFetch, type AutoFetchSettings } from "../../hooks/useAutoFetch"; +import { AutoFetchIndicator } from "../shared/AutoFetchIndicator"; interface HeaderProps { repoPath: string; @@ -21,6 +23,9 @@ interface HeaderProps { plugins?: PluginInfo[]; selectedFilePath?: string | null; onTimeLapse?: () => void; + autoFetchSettings: AutoFetchSettings; + onAutoFetchSettingsChange: (settings: AutoFetchSettings) => void; + onSilentRefresh: () => void; } type BusyKey = @@ -208,7 +213,7 @@ function Sep() { // ─── Header ─────────────────────────────────────────────────────────────────── -export function Header({ repoPath, refreshKey, onRefresh, onPushSuccess, plugins = [], selectedFilePath, onTimeLapse }: HeaderProps) { +export function Header({ repoPath, refreshKey, onRefresh, onPushSuccess, plugins = [], selectedFilePath, onTimeLapse, autoFetchSettings, onAutoFetchSettingsChange, onSilentRefresh }: HeaderProps) { const [currentBranch, setCurrentBranch] = useState(""); const [repoName, setRepoName] = useState(""); const [author, setAuthor] = useState<{ name: string; email: string } | null>(null); @@ -252,6 +257,18 @@ export function Header({ repoPath, refreshKey, onRefresh, onPushSuccess, plugins const isDisabled = busy !== null; + const handleAutoFetch = useCallback(async () => { + await gitFetchAll(repoPath); + onSilentRefresh(); + }, [repoPath, onSilentRefresh]); + + const { lastFetchedAt, nextFetchIn, isFetching: isAutoFetching } = useAutoFetch({ + settings: autoFetchSettings, + repoPath, + isBusy: busy !== null, + onFetch: handleAutoFetch, + }); + // ── Push dropdown items ────────────────────────────────────────────────── const pushItems: DropdownItem[] = [ @@ -332,6 +349,15 @@ export function Header({ repoPath, refreshKey, onRefresh, onPushSuccess, plugins onClick={() => run("fetchAll", () => gitFetchAll(repoPath), true)} /> + {/* ── 3b. Auto Fetch indicator ── */} + + {/* ── 4. Pull ── */} } diff --git a/wimygit-tauri/src/components/shared/AutoFetchIndicator.tsx b/wimygit-tauri/src/components/shared/AutoFetchIndicator.tsx new file mode 100644 index 0000000..a10bfc6 --- /dev/null +++ b/wimygit-tauri/src/components/shared/AutoFetchIndicator.tsx @@ -0,0 +1,112 @@ +import { useState, useRef, useEffect } from "react"; +import { AUTO_FETCH_INTERVALS, type AutoFetchSettings } from "../../hooks/useAutoFetch"; + +interface AutoFetchIndicatorProps { + settings: AutoFetchSettings; + nextFetchIn: number; + isFetching: boolean; + lastFetchedAt: Date | null; + onChange: (settings: AutoFetchSettings) => void; +} + +function formatCountdown(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${String(s).padStart(2, "0")}`; +} + +export function AutoFetchIndicator({ + settings, + nextFetchIn, + isFetching, + lastFetchedAt, + onChange, +}: AutoFetchIndicatorProps) { + const [open, setOpen] = useState(false); + const wrapRef = useRef(null); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const label = settings.enabled + ? isFetching + ? "Fetching…" + : formatCountdown(nextFetchIn) + : "Auto"; + + const tooltip = settings.enabled + ? `Auto-fetch every ${settings.intervalMinutes}m — click to configure` + : "Auto-fetch disabled — click to enable"; + + return ( +
+ + + {open && ( +
+
+ Auto Fetch +
+ + + +
Interval
+
+ {AUTO_FETCH_INTERVALS.map((m) => ( + + ))} +
+ + {lastFetchedAt && ( +
+ Last fetched: {lastFetchedAt.toLocaleTimeString()} +
+ )} +
+ )} +
+ ); +} diff --git a/wimygit-tauri/src/components/tabs/BranchTab.tsx b/wimygit-tauri/src/components/tabs/BranchTab.tsx index b3222c0..e556fbf 100644 --- a/wimygit-tauri/src/components/tabs/BranchTab.tsx +++ b/wimygit-tauri/src/components/tabs/BranchTab.tsx @@ -12,10 +12,11 @@ import { interface BranchTabProps { repoPath: string; refreshKey: number; + silentRefreshKey?: number; onRefresh: () => void; } -export function BranchTab({ repoPath, refreshKey, onRefresh }: BranchTabProps) { +export function BranchTab({ repoPath, refreshKey, silentRefreshKey, onRefresh }: BranchTabProps) { const [branches, setBranches] = useState([]); const [currentBranch, setCurrentBranch] = useState(""); const [loading, setLoading] = useState(true); @@ -51,6 +52,26 @@ export function BranchTab({ repoPath, refreshKey, onRefresh }: BranchTabProps) { return () => { fetchGenRef.current++; }; }, [repoPath, refreshKey]); + const silentFetchGenRef = useRef(0); + + useEffect(() => { + if (!silentRefreshKey || !repoPath) return; + const gen = ++silentFetchGenRef.current; + (async () => { + try { + const [branchList, current] = await Promise.all([ + getBranches(repoPath), + getCurrentBranch(repoPath), + ]); + if (gen !== silentFetchGenRef.current) return; + setBranches(branchList); + setCurrentBranch(current); + } catch { + // silent refresh errors are ignored + } + })(); + }, [repoPath, silentRefreshKey]); + const handleCheckout = async (branchName: string) => { try { await gitCheckout(repoPath, branchName); diff --git a/wimygit-tauri/src/components/tabs/HistoryTab.tsx b/wimygit-tauri/src/components/tabs/HistoryTab.tsx index a900eb1..b9af9fa 100644 --- a/wimygit-tauri/src/components/tabs/HistoryTab.tsx +++ b/wimygit-tauri/src/components/tabs/HistoryTab.tsx @@ -19,6 +19,7 @@ interface HistoryTabProps { repoPath: string; filePath?: string | null; refreshKey: number; + silentRefreshKey?: number; onRefresh: () => void; onFileSelect?: (info: SelectedDiffInfo) => void; onClearPath?: () => void; @@ -240,7 +241,7 @@ function FileContextMenu({ x, y, absolutePath, onClose, onShowInWorkspace, onSho const PAGE_SIZE = 100; -export function HistoryTab({ repoPath, filePath, refreshKey, onRefresh, onFileSelect, onClearPath, onShowInWorkspace, onShowInWorkspaceFile, onShowInHistoryFile }: HistoryTabProps) { +export function HistoryTab({ repoPath, filePath, refreshKey, silentRefreshKey, onRefresh, onFileSelect, onClearPath, onShowInWorkspace, onShowInWorkspaceFile, onShowInHistoryFile }: HistoryTabProps) { const [commits, setCommits] = useState([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); @@ -295,6 +296,23 @@ export function HistoryTab({ repoPath, filePath, refreshKey, onRefresh, onFileSe return () => { loadHistoryGenRef.current++; }; }, [repoPath, refreshKey, loadHistory]); + const silentHistoryGenRef = useRef(0); + + useEffect(() => { + if (!silentRefreshKey || !repoPath) return; + const gen = ++silentHistoryGenRef.current; + (async () => { + try { + const result = await getHistory(repoPath, filePath ?? "", 0, PAGE_SIZE, allBranches, searchQuery || undefined); + if (gen !== silentHistoryGenRef.current) return; + setCommits(result); + setHasMore(result.length === PAGE_SIZE); + } catch { + // silent refresh errors are ignored + } + })(); + }, [repoPath, silentRefreshKey]); + useEffect(() => { if (!repoPath) return; let cancelled = false; diff --git a/wimygit-tauri/src/components/tabs/PendingTab.tsx b/wimygit-tauri/src/components/tabs/PendingTab.tsx index adc1505..8d9d58e 100644 --- a/wimygit-tauri/src/components/tabs/PendingTab.tsx +++ b/wimygit-tauri/src/components/tabs/PendingTab.tsx @@ -55,6 +55,7 @@ async function generateAiCommitMessage(stagedDiff: string, apiKey: string): Prom interface PendingTabProps { repoPath: string; refreshKey: number; + silentRefreshKey?: number; onFilePreview?: (filename: string, staged: boolean, isUntracked?: boolean) => void; onLfsLockCountChange?: (count: number) => void; onShowInWorkspaceFile?: (absolutePath: string) => void; @@ -538,7 +539,7 @@ function SectionHeader({ label, count, action }: SectionHeaderProps) { // ─── Main component ─────────────────────────────────────────────────────────── -export function PendingTab({ repoPath, refreshKey, onFilePreview, onLfsLockCountChange, onShowInWorkspaceFile, onShowInHistoryFile }: PendingTabProps) { +export function PendingTab({ repoPath, refreshKey, silentRefreshKey, onFilePreview, onLfsLockCountChange, onShowInWorkspaceFile, onShowInHistoryFile }: PendingTabProps) { const [status, setStatus] = useState(null); const [syncStatus, setSyncStatus] = useState(null); const [loading, setLoading] = useState(true); @@ -603,6 +604,35 @@ export function PendingTab({ repoPath, refreshKey, onFilePreview, onLfsLockCount return () => { fetchGenRef.current++; }; }, [repoPath, refreshKey]); + const silentFetchGenRef = useRef(0); + + useEffect(() => { + if (!silentRefreshKey || !repoPath) return; + const gen = ++silentFetchGenRef.current; + (async () => { + try { + const [result, lockableExts, sync] = await Promise.all([ + getGitStatus(repoPath), + getLfsLockableExtensions(repoPath).catch(() => [] as string[]), + getSyncStatus(repoPath), + ]); + if (gen !== silentFetchGenRef.current) return; + const hasLockable = lockableExts.length > 0; + const locks = hasLockable + ? await getLfsLocks(repoPath).catch(() => [] as LfsLock[]) + : []; + if (gen !== silentFetchGenRef.current) return; + setStatus(result); + setSyncStatus(sync); + setLfsLocks(locks); + setHasLfsLockable(hasLockable); + onLfsLockCountChange?.(locks.length); + } catch { + // silent refresh errors are ignored to avoid disrupting the user + } + })(); + }, [repoPath, silentRefreshKey]); + // ── File actions ────────────────────────────────────────────────────────── const handleFileClick = (filename: string, staged: boolean) => { diff --git a/wimygit-tauri/src/hooks/useAutoFetch.ts b/wimygit-tauri/src/hooks/useAutoFetch.ts new file mode 100644 index 0000000..622175e --- /dev/null +++ b/wimygit-tauri/src/hooks/useAutoFetch.ts @@ -0,0 +1,107 @@ +import { useState, useEffect, useRef } from "react"; + +export interface AutoFetchSettings { + enabled: boolean; + intervalMinutes: number; +} + +export const AUTO_FETCH_STORAGE_KEY = "wimygit_auto_fetch"; +export const AUTO_FETCH_INTERVALS = [1, 2, 5, 10, 15, 30] as const; + +export function loadAutoFetchSettings(): AutoFetchSettings { + try { + const raw = localStorage.getItem(AUTO_FETCH_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + return { + enabled: Boolean(parsed.enabled), + intervalMinutes: (AUTO_FETCH_INTERVALS as readonly number[]).includes(parsed.intervalMinutes) + ? parsed.intervalMinutes + : 5, + }; + } + } catch { + // ignore + } + return { enabled: false, intervalMinutes: 5 }; +} + +export function saveAutoFetchSettings(settings: AutoFetchSettings): void { + localStorage.setItem(AUTO_FETCH_STORAGE_KEY, JSON.stringify(settings)); +} + +interface UseAutoFetchOptions { + settings: AutoFetchSettings; + repoPath: string | null; + isBusy: boolean; + onFetch: () => Promise; +} + +export interface UseAutoFetchResult { + lastFetchedAt: Date | null; + nextFetchIn: number; + isFetching: boolean; +} + +export function useAutoFetch({ + settings, + repoPath, + isBusy, + onFetch, +}: UseAutoFetchOptions): UseAutoFetchResult { + const [lastFetchedAt, setLastFetchedAt] = useState(null); + const [nextFetchIn, setNextFetchIn] = useState(settings.intervalMinutes * 60); + const [isFetching, setIsFetching] = useState(false); + + const nextFetchInRef = useRef(settings.intervalMinutes * 60); + const isBusyRef = useRef(isBusy); + const isFetchingRef = useRef(false); + const onFetchRef = useRef(onFetch); + const settingsRef = useRef(settings); + + useEffect(() => { isBusyRef.current = isBusy; }, [isBusy]); + useEffect(() => { onFetchRef.current = onFetch; }, [onFetch]); + useEffect(() => { settingsRef.current = settings; }, [settings]); + + // Reset countdown when interval, enabled state, or repo changes + useEffect(() => { + const secs = settings.intervalMinutes * 60; + nextFetchInRef.current = secs; + setNextFetchIn(secs); + }, [settings.intervalMinutes, settings.enabled, repoPath]); + + useEffect(() => { + if (!settings.enabled || !repoPath) return; + + const tick = setInterval(async () => { + nextFetchInRef.current -= 1; + setNextFetchIn(nextFetchInRef.current); + + if (nextFetchInRef.current > 0) return; + + // Reset timer immediately before fetching + const secs = settingsRef.current.intervalMinutes * 60; + nextFetchInRef.current = secs; + setNextFetchIn(secs); + + // Skip if a manual operation or previous auto-fetch is in progress + if (isBusyRef.current || isFetchingRef.current) return; + + isFetchingRef.current = true; + setIsFetching(true); + try { + await onFetchRef.current(); + setLastFetchedAt(new Date()); + } catch { + // Errors are logged via the git-log event system + } finally { + isFetchingRef.current = false; + setIsFetching(false); + } + }, 1000); + + return () => clearInterval(tick); + }, [settings.enabled, repoPath]); + + return { lastFetchedAt, nextFetchIn, isFetching }; +}