From 276dfbab072c69738403faeeb4ceface78a90e36 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 3 Jul 2026 13:49:43 +0200 Subject: [PATCH 1/5] feat(tauri): add Windows taskbar overlay badge for git sync status This change introduces a Windows-only taskbar badge that displays the ahead+behind count as a rendered overlay icon. The UI now updates the badge whenever the repository sync status changes, and the git diff commands are adjusted to avoid external diff/text conversion noise. - Add badge rendering and Tauri command for setting overlay icon - Wire badge updates into the Svelte app based on git status - Update git diff invocations to disable ext-diff/textconv --- .claude/settings.local.json | 5 +- src-tauri/src/badge.rs | 122 ++++++++++++++++++++++++++++++++++++ src-tauri/src/git.rs | 15 ++++- src-tauri/src/main.rs | 5 +- src/App.svelte | 3 + src/lib/git.ts | 6 ++ 6 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 src-tauri/src/badge.rs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bfeaff7..31c3148 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -73,7 +73,10 @@ "Bash(rustup target *)", "Bash(echo \"exit code: $?\")", "Read(//home/cbr/.cargo/registry/src/**)", - "Bash(find / -maxdepth 6 -iname \"mistralrs-*\" -type d)" + "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)" ] } } diff --git a/src-tauri/src/badge.rs b/src-tauri/src/badge.rs new file mode 100644 index 0000000..f59a0b5 --- /dev/null +++ b/src-tauri/src/badge.rs @@ -0,0 +1,122 @@ +//! 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 +//! into a plain RGBA icon ourselves and set via `Window::set_overlay_icon`. + +use tauri::Manager; +use tauri::image::Image; + +const GLYPH_WIDTH: usize = 3; +const GLYPH_HEIGHT: usize = 5; + +/// A minimal 3x5 pixel-grid font, just enough to draw digits and "+" legibly at badge size. +fn glyph_rows(ch: char) -> [&'static str; GLYPH_HEIGHT] { + match ch { + '0' => ["111", "101", "101", "101", "111"], + '1' => ["010", "110", "010", "010", "111"], + '2' => ["111", "001", "111", "100", "111"], + '3' => ["111", "001", "111", "001", "111"], + '4' => ["101", "101", "111", "001", "001"], + '5' => ["111", "100", "111", "001", "111"], + '6' => ["111", "100", "111", "101", "111"], + '7' => ["111", "001", "010", "010", "010"], + '8' => ["111", "101", "111", "101", "111"], + '9' => ["111", "101", "111", "001", "111"], + '+' => ["000", "010", "111", "010", "000"], + _ => ["000", "000", "000", "000", "000"], + } +} + +/// 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 { + let mut rgba = vec![0u8; size * size * 4]; + + // 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]; + 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 dist = (dx * dx + dy * dy).sqrt(); + if dist <= radius { + let color = if dist >= radius - 2.0 { BORDER_COLOR } else { FILL_COLOR }; + let idx = (y * size + x) * 4; + rgba[idx..idx + 4].copy_from_slice(&color); + } + } + } + + 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 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; + + for (gi, rows) in glyphs.iter().enumerate() { + let glyph_x = start_x + gi * (glyph_px_w + gap); + for (gy, row) in rows.iter().enumerate() { + for (gx, pixel) in row.chars().enumerate() { + if pixel != '1' { + continue; + } + 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; + rgba[idx..idx + 4].copy_from_slice(&[255, 255, 255, 255]); + } + } + } + } + } + } + + 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) +} + +/// 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. +#[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)) }; + window + .set_overlay_icon(icon) + .map_err(|err| format!("Could not set taskbar badge: {err}"))?; + } + + #[cfg(not(target_os = "windows"))] + { + let _ = (app, total); + } + + Ok(()) +} diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 0ad8215..f0b1070 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -1302,6 +1302,8 @@ pub fn compare_commits( &repo, [ "diff", + "--no-ext-diff", + "--no-textconv", "-M", FULL_FILE_DIFF_CONTEXT, from_hash.as_str(), @@ -1344,7 +1346,14 @@ pub fn diff_file_against_working_tree( )?; let patch_output = run_git_with_paths( &repo, - &["diff", "-M", FULL_FILE_DIFF_CONTEXT, commit_hash.as_str()], + &[ + "diff", + "--no-ext-diff", + "--no-textconv", + "-M", + FULL_FILE_DIFF_CONTEXT, + commit_hash.as_str(), + ], std::slice::from_ref(&file), )?; @@ -1400,6 +1409,8 @@ pub fn compare_file_to_head( &repo, &[ "diff", + "--no-ext-diff", + "--no-textconv", "-M", FULL_FILE_DIFF_CONTEXT, commit_hash.as_str(), @@ -1472,6 +1483,8 @@ pub fn compare_file_to_parent( &repo, &[ "diff", + "--no-ext-diff", + "--no-textconv", "-M", FULL_FILE_DIFF_CONTEXT, from_hash.as_str(), diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1422785..341669f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,7 +1,9 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod badge; mod git; +use badge::set_sync_badge; use git::{ SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models, @@ -62,7 +64,8 @@ fn main() { get_remote_url, cred_load, cred_save, - cred_delete + cred_delete, + set_sync_badge ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.svelte b/src/App.svelte index 5f6b11d..b0ce520 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -63,6 +63,7 @@ restoreFiles, restoreToCommit, searchCodeIntroductions, + setSyncBadge, stageFiles, unstageFiles, } from "./lib/git"; @@ -656,6 +657,7 @@ repoPath = ""; status = null; lastStatusFingerprint = ""; + void setSyncBadge(0, 0).catch(() => {}); } branches = []; commits = []; @@ -689,6 +691,7 @@ repoPath = activeRepoPath; lastStatusFingerprint = statusFingerprint(nextStatus); upsertRepoTab(activeRepoPath, nextStatus); + void setSyncBadge(nextStatus.ahead, nextStatus.behind).catch(() => {}); } function errorToMessage(error: unknown): string { diff --git a/src/lib/git.ts b/src/lib/git.ts index efe5c8b..8d7e8fb 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -37,6 +37,12 @@ export function getStatus(path: string): Promise { return invoke("get_status", { path }); } +// Sets the taskbar icon badge to ahead + behind (0 clears it). Windows only — a no-op on +// other platforms, since Windows has no native numeric badge to fall back to. +export function setSyncBadge(ahead: number, behind: number): Promise { + return invoke("set_sync_badge", { ahead, behind }); +} + export function listBranches(path: string): Promise { return invoke("list_branches", { path }); } From 96eb8c109c2cceefbe4bd86a920ced8df2e66748 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 3 Jul 2026 14:29:29 +0200 Subject: [PATCH 2/5] 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 --- .claude/settings.local.json | 4 +- src-tauri/src/badge.rs | 108 +++++++++++++-------- src-tauri/src/git.rs | 31 ++++++ src-tauri/src/main.rs | 8 +- src/App.svelte | 54 ++++++++++- src/lib/TitleBar.svelte | 18 +++- src/lib/components/CredentialDialog.svelte | 8 +- src/lib/git.ts | 4 + 8 files changed, 180 insertions(+), 55 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 31c3148..f7ccea6 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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)" ] } } diff --git a/src-tauri/src/badge.rs b/src-tauri/src/badge.rs index f59a0b5..9fa0abc 100644 --- a/src-tauri/src/badge.rs +++ b/src-tauri/src/badge.rs @@ -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 { - 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 { } 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 { } } } - - 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> { + if ahead == 0 && behind == 0 { + return None; + } + + 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 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. +/// 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(()) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index f0b1070..4ae9397 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -717,6 +717,37 @@ pub fn pull( Err(format!("Git command failed: {details}")) } +#[tauri::command] +pub fn fetch( + path: String, + username: Option, + password: Option, +) -> Result { + 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, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 341669f..65589d7 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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, diff --git a/src/App.svelte b/src/App.svelte index b0ce520..3401a52 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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 | undefined; + const BACKGROUND_FETCH_INTERVAL = 180_000; + let backgroundFetchTimer: ReturnType | 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} diff --git a/src/lib/TitleBar.svelte b/src/lib/TitleBar.svelte index 2e6cea7..b3aaa86 100644 --- a/src/lib/TitleBar.svelte +++ b/src/lib/TitleBar.svelte @@ -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 @@ Compare + +