Skip to content

Commit c2d1105

Browse files
authored
Merge pull request #17 from zelon/claude/tauri-feature-review-y3kVA
feat: add auto-fetch with configurable interval
2 parents e38fa5a + 65a52f6 commit c2d1105

7 files changed

Lines changed: 346 additions & 4 deletions

File tree

wimygit-tauri/src/App.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ import {
3030
type LfsLock,
3131
} from "./lib";
3232
import { LfsUnlockModal } from "./components/shared/LfsUnlockModal";
33+
import {
34+
loadAutoFetchSettings,
35+
saveAutoFetchSettings,
36+
type AutoFetchSettings,
37+
} from "./hooks/useAutoFetch";
3338

3439
const BASE_INNER_TABS = [
3540
{ id: "pending", label: "Pending Changes" },
@@ -47,6 +52,7 @@ interface RepoTabState {
4752
repoName: string;
4853
activeTab: string;
4954
refreshKey: number;
55+
silentRefreshKey: number;
5056
}
5157

5258
const STORAGE_KEY = "repoTabs_v2";
@@ -90,6 +96,7 @@ function App() {
9096
const [lfsLockCount, setLfsLockCount] = useState(0);
9197
const [worktreeCount, setWorktreeCount] = useState(0);
9298
const [showPluginModal, setShowPluginModal] = useState(false);
99+
const [autoFetchSettings, setAutoFetchSettings] = useState<AutoFetchSettings>(loadAutoFetchSettings);
93100
const [lfsUnlockConfirm, setLfsUnlockConfirm] = useState<{
94101
repoPath: string;
95102
locks: LfsLock[];
@@ -114,6 +121,7 @@ function App() {
114121
repoName: s.repoName,
115122
activeTab: "pending",
116123
refreshKey: 0,
124+
silentRefreshKey: 0,
117125
}));
118126
setRepoTabs(tabs);
119127
const lastActive = localStorage.getItem("activeRepoId");
@@ -159,6 +167,14 @@ function App() {
159167
updateActiveRepo((t) => ({ refreshKey: t.refreshKey + 1 }));
160168
}, [updateActiveRepo]);
161169

170+
const handleSilentRefresh = useCallback(() => {
171+
setRepoTabs((prev) =>
172+
prev.map((t) =>
173+
t.id === activeRepoId ? { ...t, silentRefreshKey: t.silentRefreshKey + 1 } : t
174+
)
175+
);
176+
}, [activeRepoId]);
177+
162178
const handleAfterPush = useCallback(async (repoPath: string) => {
163179
try {
164180
const myLocks = await getLfsLocalLocks(repoPath);
@@ -172,6 +188,11 @@ function App() {
172188
}
173189
}, []);
174190

191+
const handleAutoFetchSettingsChange = useCallback((settings: AutoFetchSettings) => {
192+
saveAutoFetchSettings(settings);
193+
setAutoFetchSettings(settings);
194+
}, []);
195+
175196
const handleLfsUnlockConfirm = useCallback(async () => {
176197
if (!lfsUnlockConfirm) return;
177198
const { repoPath, locks } = lfsUnlockConfirm;
@@ -290,6 +311,7 @@ function App() {
290311
repoName: repoNameFromPath(root),
291312
activeTab: "pending",
292313
refreshKey: 0,
314+
silentRefreshKey: 0,
293315
};
294316

295317
setRepoTabs((prev) => {
@@ -429,6 +451,9 @@ function App() {
429451
plugins={plugins}
430452
selectedFilePath={selectedFilePath}
431453
onTimeLapse={() => setShowTimeLapse(true)}
454+
autoFetchSettings={autoFetchSettings}
455+
onAutoFetchSettingsChange={handleAutoFetchSettingsChange}
456+
onSilentRefresh={handleSilentRefresh}
432457
/>
433458

434459
{/* Body: left sidebar + main content */}
@@ -466,6 +491,7 @@ function App() {
466491
<PendingTab
467492
repoPath={activeRepo.repoPath}
468493
refreshKey={activeRepo.refreshKey}
494+
silentRefreshKey={activeRepo.silentRefreshKey}
469495
onFilePreview={(filename, staged) => { setSelectedDiff(null); setPendingFilePreview({ filename, staged }); }}
470496
onLfsLockCountChange={setLfsLockCount}
471497
onShowInWorkspaceFile={(absolutePath) => {
@@ -490,6 +516,7 @@ function App() {
490516
repoPath={activeRepo.repoPath}
491517
filePath={selectedFilePath}
492518
refreshKey={activeRepo.refreshKey}
519+
silentRefreshKey={activeRepo.silentRefreshKey}
493520
onRefresh={handleRefresh}
494521
onFileSelect={setSelectedDiff}
495522
onClearPath={() => setSelectedFilePath(null)}
@@ -511,6 +538,7 @@ function App() {
511538
<BranchTab
512539
repoPath={activeRepo.repoPath}
513540
refreshKey={activeRepo.refreshKey}
541+
silentRefreshKey={activeRepo.silentRefreshKey}
514542
onRefresh={handleRefresh}
515543
/>
516544
)}

wimygit-tauri/src/components/layout/Header.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
type PluginInfo,
1313
} from "../../lib";
1414
import { PluginButtons } from "./PluginButtons";
15+
import { useAutoFetch, type AutoFetchSettings } from "../../hooks/useAutoFetch";
16+
import { AutoFetchIndicator } from "../shared/AutoFetchIndicator";
1517

1618
interface HeaderProps {
1719
repoPath: string;
@@ -21,6 +23,9 @@ interface HeaderProps {
2123
plugins?: PluginInfo[];
2224
selectedFilePath?: string | null;
2325
onTimeLapse?: () => void;
26+
autoFetchSettings: AutoFetchSettings;
27+
onAutoFetchSettingsChange: (settings: AutoFetchSettings) => void;
28+
onSilentRefresh: () => void;
2429
}
2530

2631
type BusyKey =
@@ -208,7 +213,7 @@ function Sep() {
208213

209214
// ─── Header ───────────────────────────────────────────────────────────────────
210215

211-
export function Header({ repoPath, refreshKey, onRefresh, onPushSuccess, plugins = [], selectedFilePath, onTimeLapse }: HeaderProps) {
216+
export function Header({ repoPath, refreshKey, onRefresh, onPushSuccess, plugins = [], selectedFilePath, onTimeLapse, autoFetchSettings, onAutoFetchSettingsChange, onSilentRefresh }: HeaderProps) {
212217
const [currentBranch, setCurrentBranch] = useState<string>("");
213218
const [repoName, setRepoName] = useState<string>("");
214219
const [author, setAuthor] = useState<{ name: string; email: string } | null>(null);
@@ -252,6 +257,18 @@ export function Header({ repoPath, refreshKey, onRefresh, onPushSuccess, plugins
252257

253258
const isDisabled = busy !== null;
254259

260+
const handleAutoFetch = useCallback(async () => {
261+
await gitFetchAll(repoPath);
262+
onSilentRefresh();
263+
}, [repoPath, onSilentRefresh]);
264+
265+
const { lastFetchedAt, nextFetchIn, isFetching: isAutoFetching } = useAutoFetch({
266+
settings: autoFetchSettings,
267+
repoPath,
268+
isBusy: busy !== null,
269+
onFetch: handleAutoFetch,
270+
});
271+
255272
// ── Push dropdown items ──────────────────────────────────────────────────
256273

257274
const pushItems: DropdownItem[] = [
@@ -332,6 +349,15 @@ export function Header({ repoPath, refreshKey, onRefresh, onPushSuccess, plugins
332349
onClick={() => run("fetchAll", () => gitFetchAll(repoPath), true)}
333350
/>
334351

352+
{/* ── 3b. Auto Fetch indicator ── */}
353+
<AutoFetchIndicator
354+
settings={autoFetchSettings}
355+
nextFetchIn={nextFetchIn}
356+
isFetching={isAutoFetching}
357+
lastFetchedAt={lastFetchedAt}
358+
onChange={onAutoFetchSettingsChange}
359+
/>
360+
335361
{/* ── 4. Pull ── */}
336362
<ToolButton
337363
icon={<IconPull />}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { useState, useRef, useEffect } from "react";
2+
import { AUTO_FETCH_INTERVALS, type AutoFetchSettings } from "../../hooks/useAutoFetch";
3+
4+
interface AutoFetchIndicatorProps {
5+
settings: AutoFetchSettings;
6+
nextFetchIn: number;
7+
isFetching: boolean;
8+
lastFetchedAt: Date | null;
9+
onChange: (settings: AutoFetchSettings) => void;
10+
}
11+
12+
function formatCountdown(seconds: number): string {
13+
const m = Math.floor(seconds / 60);
14+
const s = seconds % 60;
15+
return `${m}:${String(s).padStart(2, "0")}`;
16+
}
17+
18+
export function AutoFetchIndicator({
19+
settings,
20+
nextFetchIn,
21+
isFetching,
22+
lastFetchedAt,
23+
onChange,
24+
}: AutoFetchIndicatorProps) {
25+
const [open, setOpen] = useState(false);
26+
const wrapRef = useRef<HTMLDivElement>(null);
27+
28+
useEffect(() => {
29+
if (!open) return;
30+
const handler = (e: MouseEvent) => {
31+
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
32+
setOpen(false);
33+
}
34+
};
35+
document.addEventListener("mousedown", handler);
36+
return () => document.removeEventListener("mousedown", handler);
37+
}, [open]);
38+
39+
const label = settings.enabled
40+
? isFetching
41+
? "Fetching…"
42+
: formatCountdown(nextFetchIn)
43+
: "Auto";
44+
45+
const tooltip = settings.enabled
46+
? `Auto-fetch every ${settings.intervalMinutes}m — click to configure`
47+
: "Auto-fetch disabled — click to enable";
48+
49+
return (
50+
<div ref={wrapRef} className="relative shrink-0">
51+
<button
52+
onClick={() => setOpen((v) => !v)}
53+
title={tooltip}
54+
className={`flex items-center gap-1 px-2 py-1 h-full text-[10px] rounded border transition-colors ${
55+
settings.enabled
56+
? "bg-green-50 dark:bg-green-900/30 border-green-300 dark:border-green-700 text-green-700 dark:text-green-300"
57+
: "bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500"
58+
}`}
59+
>
60+
<span
61+
className={isFetching ? "inline-block animate-spin" : ""}
62+
style={{ display: "inline-block" }}
63+
>
64+
65+
</span>
66+
<span className="tabular-nums">{label}</span>
67+
</button>
68+
69+
{open && (
70+
<div className="absolute left-0 top-full mt-0.5 z-50 w-52 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded shadow-lg p-3">
71+
<div className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2">
72+
Auto Fetch
73+
</div>
74+
75+
<label className="flex items-center gap-2 mb-3 cursor-pointer select-none">
76+
<input
77+
type="checkbox"
78+
checked={settings.enabled}
79+
onChange={(e) => onChange({ ...settings, enabled: e.target.checked })}
80+
className="w-3.5 h-3.5 accent-blue-500"
81+
/>
82+
<span className="text-xs text-gray-600 dark:text-gray-400">Enable auto-fetch</span>
83+
</label>
84+
85+
<div className="text-[10px] text-gray-500 dark:text-gray-500 mb-1.5">Interval</div>
86+
<div className="flex flex-wrap gap-1 mb-2">
87+
{AUTO_FETCH_INTERVALS.map((m) => (
88+
<button
89+
key={m}
90+
onClick={() => onChange({ ...settings, intervalMinutes: m })}
91+
disabled={!settings.enabled}
92+
className={`px-2 py-0.5 text-[10px] rounded border transition-colors disabled:opacity-40 ${
93+
settings.intervalMinutes === m
94+
? "bg-blue-500 border-blue-500 text-white"
95+
: "bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-600"
96+
}`}
97+
>
98+
{m}m
99+
</button>
100+
))}
101+
</div>
102+
103+
{lastFetchedAt && (
104+
<div className="text-[10px] text-gray-400 dark:text-gray-500">
105+
Last fetched: {lastFetchedAt.toLocaleTimeString()}
106+
</div>
107+
)}
108+
</div>
109+
)}
110+
</div>
111+
);
112+
}

wimygit-tauri/src/components/tabs/BranchTab.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import {
1212
interface BranchTabProps {
1313
repoPath: string;
1414
refreshKey: number;
15+
silentRefreshKey?: number;
1516
onRefresh: () => void;
1617
}
1718

18-
export function BranchTab({ repoPath, refreshKey, onRefresh }: BranchTabProps) {
19+
export function BranchTab({ repoPath, refreshKey, silentRefreshKey, onRefresh }: BranchTabProps) {
1920
const [branches, setBranches] = useState<BranchInfo[]>([]);
2021
const [currentBranch, setCurrentBranch] = useState<string>("");
2122
const [loading, setLoading] = useState(true);
@@ -51,6 +52,26 @@ export function BranchTab({ repoPath, refreshKey, onRefresh }: BranchTabProps) {
5152
return () => { fetchGenRef.current++; };
5253
}, [repoPath, refreshKey]);
5354

55+
const silentFetchGenRef = useRef(0);
56+
57+
useEffect(() => {
58+
if (!silentRefreshKey || !repoPath) return;
59+
const gen = ++silentFetchGenRef.current;
60+
(async () => {
61+
try {
62+
const [branchList, current] = await Promise.all([
63+
getBranches(repoPath),
64+
getCurrentBranch(repoPath),
65+
]);
66+
if (gen !== silentFetchGenRef.current) return;
67+
setBranches(branchList);
68+
setCurrentBranch(current);
69+
} catch {
70+
// silent refresh errors are ignored
71+
}
72+
})();
73+
}, [repoPath, silentRefreshKey]);
74+
5475
const handleCheckout = async (branchName: string) => {
5576
try {
5677
await gitCheckout(repoPath, branchName);

wimygit-tauri/src/components/tabs/HistoryTab.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ interface HistoryTabProps {
1919
repoPath: string;
2020
filePath?: string | null;
2121
refreshKey: number;
22+
silentRefreshKey?: number;
2223
onRefresh: () => void;
2324
onFileSelect?: (info: SelectedDiffInfo) => void;
2425
onClearPath?: () => void;
@@ -240,7 +241,7 @@ function FileContextMenu({ x, y, absolutePath, onClose, onShowInWorkspace, onSho
240241

241242
const PAGE_SIZE = 100;
242243

243-
export function HistoryTab({ repoPath, filePath, refreshKey, onRefresh, onFileSelect, onClearPath, onShowInWorkspace, onShowInWorkspaceFile, onShowInHistoryFile }: HistoryTabProps) {
244+
export function HistoryTab({ repoPath, filePath, refreshKey, silentRefreshKey, onRefresh, onFileSelect, onClearPath, onShowInWorkspace, onShowInWorkspaceFile, onShowInHistoryFile }: HistoryTabProps) {
244245
const [commits, setCommits] = useState<CommitInfo[]>([]);
245246
const [loading, setLoading] = useState(true);
246247
const [loadingMore, setLoadingMore] = useState(false);
@@ -295,6 +296,23 @@ export function HistoryTab({ repoPath, filePath, refreshKey, onRefresh, onFileSe
295296
return () => { loadHistoryGenRef.current++; };
296297
}, [repoPath, refreshKey, loadHistory]);
297298

299+
const silentHistoryGenRef = useRef(0);
300+
301+
useEffect(() => {
302+
if (!silentRefreshKey || !repoPath) return;
303+
const gen = ++silentHistoryGenRef.current;
304+
(async () => {
305+
try {
306+
const result = await getHistory(repoPath, filePath ?? "", 0, PAGE_SIZE, allBranches, searchQuery || undefined);
307+
if (gen !== silentHistoryGenRef.current) return;
308+
setCommits(result);
309+
setHasMore(result.length === PAGE_SIZE);
310+
} catch {
311+
// silent refresh errors are ignored
312+
}
313+
})();
314+
}, [repoPath, silentRefreshKey]);
315+
298316
useEffect(() => {
299317
if (!repoPath) return;
300318
let cancelled = false;

0 commit comments

Comments
 (0)