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
This commit is contained in:
Christoph Brandau
2026-07-03 13:49:43 +02:00
parent 883cf3ebd1
commit 276dfbab07
6 changed files with 153 additions and 3 deletions
+4 -1
View File
@@ -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)"
]
}
}
+122
View File
@@ -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<u8> {
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(())
}
+14 -1
View File
@@ -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(),
+4 -1
View File
@@ -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");
+3
View File
@@ -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 {
+6
View File
@@ -37,6 +37,12 @@ export function getStatus(path: string): Promise<GitStatus> {
return invoke<GitStatus>("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<void> {
return invoke<void>("set_sync_badge", { ahead, behind });
}
export function listBranches(path: string): Promise<GitBranch[]> {
return invoke<GitBranch[]>("list_branches", { path });
}