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:
+50
-4
@@ -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
@@ -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}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user