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:
+65
-43
@@ -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;
|
||||
}
|
||||
|
||||
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(())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user