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:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user