feat(git): add authenticated fetch and dual sync badge

This updates the Windows taskbar overlay badge to show ahead and
behind separately with distinct colors, and clears it when both are
zero. It also adds a new authenticated fetch command wired through
the Tauri backend and UI, including a silent background fetch to keep
ahead/behind (and the badge) accurate without user interaction.

- Add Windows dual badge rendering for ahead/behind
- Implement Tauri fetch command with auth error handling
- Add UI fetch action plus periodic background fetch updates
This commit is contained in:
Christoph Brandau
2026-07-03 14:29:29 +02:00
parent 276dfbab07
commit 96eb8c109c
8 changed files with 180 additions and 55 deletions
+50 -4
View File
@@ -39,6 +39,7 @@
deleteBranch,
diffFileAgainstWorkingTree,
compareFileToParent,
fetchRemote,
getStatus,
listBranches,
listCommits,
@@ -181,12 +182,15 @@
let autoRefreshEnabled = true;
let autoRefreshInFlight = false;
let credDialogOpen = false;
let credDialogAction: "push" | "pull" | null = null;
let credDialogAction: "push" | "pull" | "fetch" | null = null;
let credDialogError = "";
let credDialogKey: string | null = null;
let lastStatusFingerprint = "";
const AUTO_REFRESH_INTERVAL = 4000;
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
const BACKGROUND_FETCH_INTERVAL = 180_000;
let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined;
let backgroundFetchInFlight = false;
let updateToastOpen = false;
let updateToastState: UpdateToastState = "available";
let pendingUpdate: Update | null = null;
@@ -236,12 +240,14 @@
onMount(() => {
loadRepoLists();
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
backgroundFetchTimer = setInterval(() => { void backgroundFetchTick(); }, BACKGROUND_FETCH_INTERVAL);
void checkForUpdates();
void initCommitAi();
});
onDestroy(() => {
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
});
@@ -252,6 +258,22 @@
return JSON.stringify({ branch: value.current_branch, upstream: value.upstream, ahead: value.ahead, behind: value.behind, files: value.files });
}
// Silent background fetch (every 180s): only updates the local remote-tracking ref so
// ahead/behind (and the taskbar badge) stay accurate without the user pulling manually.
// Errors are swallowed here — auth/network failures surface via the manual Fetch/Pull/
// Push buttons instead, not as a background popup.
async function backgroundFetchTick() {
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || backgroundFetchInFlight) return;
backgroundFetchInFlight = true;
try {
await fetchRemote(activeRepoPath);
} catch {
// ignore — see comment above
} finally {
backgroundFetchInFlight = false;
}
}
async function autoRefreshTick() {
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
autoRefreshInFlight = true;
@@ -848,6 +870,10 @@
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
});
// Silent background fetch on open, same as the periodic tick — no "Fetching" indicator,
// just brings ahead/behind (and the taskbar badge) up to date without blocking the
// repo-open flow.
void backgroundFetchTick();
}
async function chooseRepositoryFolder() {
@@ -1039,7 +1065,7 @@
}
}
async function openCredentialDialog(action: "push" | "pull", key?: string | null) {
async function openCredentialDialog(action: "push" | "pull" | "fetch", key?: string | null) {
if (!activeRepoPath) return;
credDialogError = "";
credDialogAction = action;
@@ -1049,7 +1075,7 @@
// Post-process a pull/push result: surface errors, and on rejected/expired
// credentials drop the stored entry and re-open the login dialog.
function handleRemoteResult(action: "push" | "pull", key: string | null, fromStore: boolean) {
function handleRemoteResult(action: "push" | "pull" | "fetch", key: string | null, fromStore: boolean) {
if (!errorMessage) {
credDialogOpen = false;
credDialogAction = null;
@@ -1093,6 +1119,19 @@
handleRemoteResult("pull", key, fromStore);
}
async function doActualFetch(
username: string,
password: string,
key: string | null,
fromStore: boolean,
) {
errorMessage = "";
await runOperation("Fetching", async () => {
applyStatus(await fetchRemote(activeRepoPath, username, password));
});
handleRemoteResult("fetch", key, fromStore);
}
async function doActualPush(
username: string,
password: string,
@@ -1162,6 +1201,7 @@
const key = credDialogKey;
if (credDialogAction === "pull") await doActualPull(username, password, key, false);
else if (credDialogAction === "push") await doActualPush(username, password, key, false);
else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false);
// Only persist once the operation actually succeeded (dialog has closed).
if (!credDialogOpen && save && key) {
@@ -1173,13 +1213,14 @@
}
}
async function startRemoteAction(action: "push" | "pull") {
async function startRemoteAction(action: "push" | "pull" | "fetch") {
if (!activeRepoPath) return;
const key = await currentCredKey();
const stored = await loadStoredCredential(key);
if (stored && !isCredentialExpired(stored)) {
if (action === "pull") await doActualPull(stored.username, stored.password, key, true);
else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true);
else await doActualPush(stored.username, stored.password, key, true);
return;
}
@@ -1189,6 +1230,10 @@
await openCredentialDialog(action, key);
}
async function fetchRepo() {
await startRemoteAction("fetch");
}
async function pullRepo() {
await startRemoteAction("pull");
}
@@ -1696,6 +1741,7 @@
{operation}
{autoRefreshEnabled}
{autoRefreshInFlight}
onFetch={fetchRepo}
onPull={pullRepo}
onPush={pushRepo}
onRefresh={refreshRepo}
+17 -1
View File
@@ -2,7 +2,7 @@
import { onDestroy, onMount } from "svelte";
import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
export let branch: string = "";
export let ahead: number = 0;
@@ -13,6 +13,7 @@
export let operation: string = "";
export let autoRefreshEnabled: boolean = true;
export let autoRefreshInFlight: boolean = false;
export let onFetch: () => void = () => {};
export let onPull: () => void = () => {};
export let onPush: () => void = () => {};
export let onRefresh: () => void = () => {};
@@ -122,6 +123,21 @@
<span class="tb-action-label">Compare</span>
</button>
<button
class="tb-action"
onclick={onFetch}
disabled={!hasRepository || isBusy}
title="Fetch"
aria-label="Fetch"
>
{#if operation === "Fetching"}
<LoaderCircle class="spin" size={14} aria-hidden="true" />
{:else}
<CloudDownload size={14} aria-hidden="true" />
{/if}
<span class="tb-action-label">Fetch</span>
</button>
<button
class="tb-action"
onclick={onPull}
+5 -3
View File
@@ -14,7 +14,7 @@
} from "@lucide/svelte";
interface Props {
action: "push" | "pull";
action: "push" | "pull" | "fetch";
error: string;
isBusy: boolean;
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
@@ -43,8 +43,10 @@
password.trim().length > 0 &&
(mode === "token" || username.trim().length > 0),
);
let actionLabel = $derived(action === "push" ? "Push" : "Pull");
let actionTitle = $derived(action === "push" ? "Authenticate push" : "Authenticate pull");
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : "Pull");
let actionTitle = $derived(
action === "push" ? "Authenticate push" : action === "fetch" ? "Authenticate fetch" : "Authenticate pull",
);
let actionHint = $derived(action === "push"
? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
: "The remote needs access to the repository. Use your Git credentials or a personal access token.");
+4
View File
@@ -141,6 +141,10 @@ export function pull(path: string, username?: string, password?: string): Promis
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null });
}
export function fetchRemote(path: string, username?: string, password?: string): Promise<GitStatus> {
return invoke<GitStatus>("fetch", { path, username: username ?? null, password: password ?? null });
}
export function push(path: string, username?: string, password?: string): Promise<GitStatus> {
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null });
}