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
+3 -1
View File
@@ -76,7 +76,9 @@
"Bash(find / -maxdepth 6 -iname \"mistralrs-*\" -type d)",
"Bash(grep -A2 '^name = \"tauri\"$' \"/mnt/c/Users/cbr/Desktop/Neuer Ordner \\(6\\)/src-tauri/Cargo.lock\")",
"Bash(rustfmt --edition 2024 --check src/badge.rs src/main.rs)",
"Bash(rustfmt --edition 2024 --check src/git.rs)"
"Bash(rustfmt --edition 2024 --check src/git.rs)",
"Bash(rustfmt --edition 2024 --check src/badge.rs)",
"Bash(rustfmt --edition 2024 --check src/git.rs src/main.rs)"
]
}
}
+65 -43
View File
@@ -1,6 +1,6 @@
//! Renders the "ahead + behind" count as a small circular icon and applies it as the
//! Windows taskbar overlay badge. Windows has no native numeric taskbar badge (unlike
//! macOS/Linux, which support `Window::set_badge_count`), so the number has to be drawn
//! Renders the "ahead"/"behind" counts as a small dual-badge icon and applies it as the
//! Windows taskbar overlay. Windows has no native numeric taskbar badge (unlike
//! macOS/Linux, which support `Window::set_badge_count`), so the numbers have to be drawn
//! into a plain RGBA icon ourselves and set via `Window::set_overlay_icon`.
use tauri::Manager;
@@ -27,41 +27,47 @@ fn glyph_rows(ch: char) -> [&'static str; GLYPH_HEIGHT] {
}
}
/// Renders `text` (digits / "+" only) centered on a filled circle, at `size`x`size` pixels,
/// scaling each font pixel up by `scale` so it stays legible once Windows shrinks it down
/// for the taskbar.
fn render_circle_with_text(text: &str, size: usize, scale: usize) -> Vec<u8> {
let mut rgba = vec![0u8; size * size * 4];
// Match the sync-stats pills in the status bar (see `.sync-stats strong` in app.css):
// ahead is orange (#e0a040), behind is blue (#7aacff). Border shades are a darkened tint
// of the same color so the circle reads clearly against any taskbar background.
const AHEAD_FILL: [u8; 4] = [224, 160, 64, 255];
const AHEAD_BORDER: [u8; 4] = [176, 118, 40, 255];
const BEHIND_FILL: [u8; 4] = [122, 172, 255, 255];
const BEHIND_BORDER: [u8; 4] = [78, 128, 216, 255];
// Filled circle background with a slightly darker rim for contrast against any
// taskbar/background color.
let center = size as f32 / 2.0;
let radius = size as f32 / 2.0 - 1.0;
const FILL_COLOR: [u8; 4] = [220, 53, 69, 255];
const BORDER_COLOR: [u8; 4] = [176, 32, 48, 255];
/// Caps the displayed text at two characters ("9+") so it always fits legibly in a badge.
fn cap_text(count: u32) -> String {
if count > 9 { "9+".to_string() } else { count.to_string() }
}
/// Draws a filled circle (with a slightly darker rim) centered at `(cx, cy)`.
fn draw_circle(rgba: &mut [u8], size: usize, cx: f32, cy: f32, radius: f32, fill: [u8; 4], border: [u8; 4]) {
for y in 0..size {
for x in 0..size {
let dx = x as f32 + 0.5 - center;
let dy = y as f32 + 0.5 - center;
let dx = x as f32 + 0.5 - cx;
let dy = y as f32 + 0.5 - cy;
let dist = (dx * dx + dy * dy).sqrt();
if dist <= radius {
let color = if dist >= radius - 2.0 { BORDER_COLOR } else { FILL_COLOR };
let color = if dist >= radius - 1.5 { border } else { fill };
let idx = (y * size + x) * 4;
rgba[idx..idx + 4].copy_from_slice(&color);
}
}
}
}
/// Draws white `text` centered at `(cx, cy)`, scaling each font pixel up by `scale`.
fn draw_text(rgba: &mut [u8], size: usize, text: &str, cx: f32, cy: f32, scale: usize) {
let glyphs: Vec<[&'static str; GLYPH_HEIGHT]> = text.chars().map(glyph_rows).collect();
let glyph_px_w = GLYPH_WIDTH * scale;
let glyph_px_h = GLYPH_HEIGHT * scale;
let gap = scale;
let gap = scale.max(1);
let total_w = glyphs.len() * glyph_px_w + gap * glyphs.len().saturating_sub(1);
let start_x = size.saturating_sub(total_w) / 2;
let start_y = size.saturating_sub(glyph_px_h) / 2;
let start_x = (cx - total_w as f32 / 2.0).round() as isize;
let start_y = (cy - glyph_px_h as f32 / 2.0).round() as isize;
for (gi, rows) in glyphs.iter().enumerate() {
let glyph_x = start_x + gi * (glyph_px_w + gap);
let glyph_x = start_x + (gi * (glyph_px_w + gap)) as isize;
for (gy, row) in rows.iter().enumerate() {
for (gx, pixel) in row.chars().enumerate() {
if pixel != '1' {
@@ -69,10 +75,10 @@ fn render_circle_with_text(text: &str, size: usize, scale: usize) -> Vec<u8> {
}
for py in 0..scale {
for px in 0..scale {
let x = glyph_x + gx * scale + px;
let y = start_y + gy * scale + py;
if x < size && y < size {
let idx = (y * size + x) * 4;
let x = glyph_x + (gx * scale + px) as isize;
let y = start_y + (gy * scale + py) as isize;
if x >= 0 && y >= 0 && (x as usize) < size && (y as usize) < size {
let idx = (y as usize * size + x as usize) * 4;
rgba[idx..idx + 4].copy_from_slice(&[255, 255, 255, 255]);
}
}
@@ -80,34 +86,50 @@ fn render_circle_with_text(text: &str, size: usize, scale: usize) -> Vec<u8> {
}
}
}
rgba
}
/// Renders the badge icon for a given count. Caps the displayed text at "9+" since the
/// badge is only large enough for two characters to stay legible.
fn render_badge_icon(count: u32) -> Image<'static> {
const SIZE: usize = 40;
const SCALE: usize = 4;
let text = if count > 9 { "9+".to_string() } else { count.to_string() };
let rgba = render_circle_with_text(&text, SIZE, SCALE);
Image::new_owned(rgba, SIZE as u32, SIZE as u32)
/// Renders a dual badge: "behind" (blue) top-left and "ahead" (orange) top-right, matching
/// the colors of the sync-stats pills in the status bar. A side is only drawn when its
/// count is non-zero. Returns `None` when there's nothing to show at all, so the caller
/// can clear the overlay icon entirely.
fn render_badge_icon(ahead: u32, behind: u32) -> Option<Image<'static>> {
if ahead == 0 && behind == 0 {
return None;
}
/// Sets the taskbar badge to `ahead + behind` (0 clears it). Windows-only: Windows has no
/// native numeric badge API, so this draws and applies a small overlay icon instead. No-op
/// on other platforms — non-Windows desktops should use `Window::set_badge_count` for a
/// real native badge instead, which this app doesn't currently wire up.
const SIZE: usize = 64;
const RADIUS: f32 = 27.0;
const SCALE: usize = 6;
const MARGIN: f32 = 2.0;
let mut rgba = vec![0u8; SIZE * SIZE * 4];
let (left_cx, left_cy) = (RADIUS + MARGIN, RADIUS + MARGIN);
let (right_cx, right_cy) = (SIZE as f32 - RADIUS - MARGIN, RADIUS + MARGIN);
if behind > 0 {
draw_circle(&mut rgba, SIZE, left_cx, left_cy, RADIUS, BEHIND_FILL, BEHIND_BORDER);
draw_text(&mut rgba, SIZE, &cap_text(behind), left_cx, left_cy, SCALE);
}
if ahead > 0 {
draw_circle(&mut rgba, SIZE, right_cx, right_cy, RADIUS, AHEAD_FILL, AHEAD_BORDER);
draw_text(&mut rgba, SIZE, &cap_text(ahead), right_cx, right_cy, SCALE);
}
Some(Image::new_owned(rgba, SIZE as u32, SIZE as u32))
}
/// Sets the taskbar badge from `ahead`/`behind` (both 0 clears it). Windows-only: Windows
/// has no native numeric badge API, so this draws and applies a small overlay icon instead.
/// No-op on other platforms — non-Windows desktops should use `Window::set_badge_count`
/// for a real native badge instead, which this app doesn't currently wire up.
#[tauri::command]
pub fn set_sync_badge(app: tauri::AppHandle, ahead: u32, behind: u32) -> Result<(), String> {
let total = ahead + behind;
#[cfg(target_os = "windows")]
{
let Some(window) = app.get_webview_window("main") else {
return Ok(());
};
let icon = if total == 0 { None } else { Some(render_badge_icon(total)) };
let icon = render_badge_icon(ahead, behind);
window
.set_overlay_icon(icon)
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
@@ -115,7 +137,7 @@ pub fn set_sync_badge(app: tauri::AppHandle, ahead: u32, behind: u32) -> Result<
#[cfg(not(target_os = "windows"))]
{
let _ = (app, total);
let _ = (app, ahead, behind);
}
Ok(())
+31
View File
@@ -717,6 +717,37 @@ pub fn pull(
Err(format!("Git command failed: {details}"))
}
#[tauri::command]
pub fn fetch(
path: String,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let fetch_args = ["fetch"];
let output = match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated_output(&repo, fetch_args, u, p)?
}
_ => git_command()
.arg("-C")
.arg(&repo)
.args(fetch_args)
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
};
if output.status.success() {
return status_for_repo(&repo);
}
let details = command_output_details(&output);
if is_auth_error(&details) {
return Err(format!("AUTH_FAILED:{details}"));
}
Err(format!("Git command failed: {details}"))
}
#[tauri::command]
pub fn push(
path: String,
+5 -3
View File
@@ -11,9 +11,10 @@ use git::{
create_branch, cred_delete, cred_load, cred_save, delete_branch,
diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, list_branches,
list_commits, list_file_history, list_repository_files, merge_branch, open_repo_in_explorer,
open_repository, open_repository_bundle, open_repository_file, pull, push, read_conflict,
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files,
fetch, open_repository, open_repository_bundle, open_repository_file, pull, push,
read_conflict, rename_branch, resolve_conflict, resolve_conflict_side,
restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions,
stage_files, unstage_files,
};
fn main() {
@@ -44,6 +45,7 @@ fn main() {
commit_ai_generate,
pull,
push,
fetch,
list_commits,
restore_to_commit,
restore_file_from_commit,
+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 });
}