diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bfeaff7..f7ccea6 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -73,7 +73,12 @@ "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)", + "Bash(rustfmt --edition 2024 --check src/badge.rs)", + "Bash(rustfmt --edition 2024 --check src/git.rs src/main.rs)" ] } } diff --git a/README.md b/README.md index ca40410..8b13789 100644 --- a/README.md +++ b/README.md @@ -1,45 +1 @@ -# Tauri Git Lite -Eine kleine Git-Desktop-App mit Tauri, Rust und Svelte. - -## Funktionen - -- Repository per lokalem Pfad öffnen -- aktuellen Branch, Upstream, Ahead/Behind und Arbeitsbaumstatus anzeigen -- Branches auflisten und auschecken -- Branches in den aktuellen Branch mergen -- Commit-History mit geänderten Dateien anzeigen -- Explorer-Ansicht mit Ordnerbaum sowie getrackten und ungetrackten Dateien -- Datei- und Ordner-History direkt aus dem Explorer anzeigen -- einzelne Dateien stagen, unstagen und wiederherstellen -- einzelne Dateien oder ganze Ordner aus einem History-Commit wiederherstellen -- aktuellen Branch auf einen ausgewählten Commit zurücksetzen -- Commit mit Message erstellen -- Pull mit Fast-Forward-Strategie -- Push auf den konfigurierten Upstream - -## Entwicklung - -```powershell -npm install -npm run tauri:dev -``` - -Frontend allein: - -```powershell -npm run dev -``` - -Checks: - -```powershell -npm run check -npm run build -cd src-tauri -cargo check -``` - -## Hinweis - -Die App nutzt das lokal installierte `git` CLI. Ein Repository muss daher bereits auf der Maschine vorhanden sein, und Push/Pull verwenden die Credentials, die Git lokal kennt. diff --git a/src-tauri/src/badge.rs b/src-tauri/src/badge.rs new file mode 100644 index 0000000..6775718 --- /dev/null +++ b/src-tauri/src/badge.rs @@ -0,0 +1,186 @@ +//! Renders the combined repository attention count as a taskbar badge 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; +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"], + } +} + +const BADGE_FILL: [u8; 4] = [224, 160, 64, 255]; +const BADGE_BORDER: [u8; 4] = [176, 118, 40, 255]; +const TEXT_FILL: [u8; 4] = [255, 255, 255, 255]; +const TEXT_SHADOW: [u8; 4] = [10, 12, 24, 190]; +const CIRCLE_BORDER_WIDTH: f32 = 3.0; + +/// Caps the displayed text at three characters ("99+") so it always fits legibly. +fn cap_text(count: u32) -> String { + if count > 99 { + "99+".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 - cx; + let dy = y as f32 + 0.5 - cy; + let dist = (dx * dx + dy * dy).sqrt(); + if dist <= radius { + let color = if dist >= radius - CIRCLE_BORDER_WIDTH { + border + } else { + fill + }; + let idx = (y * size + x) * 4; + rgba[idx..idx + 4].copy_from_slice(&color); + } + } + } +} + +fn draw_text_layer( + rgba: &mut [u8], + size: usize, + text: &str, + cx: f32, + cy: f32, + scale: usize, + offset: (isize, isize), + color: [u8; 4], +) { + 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.max(1); + let total_w = glyphs.len() * glyph_px_w + gap * glyphs.len().saturating_sub(1); + let start_x = (cx - total_w as f32 / 2.0).round() as isize + offset.0; + let start_y = (cy - glyph_px_h as f32 / 2.0).round() as isize + offset.1; + + for (gi, rows) in glyphs.iter().enumerate() { + 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' { + continue; + } + for py in 0..scale { + for px in 0..scale { + 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(&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) { + for offset in [(-1, 0), (1, 0), (0, -1), (0, 1)] { + draw_text_layer(rgba, size, text, cx, cy, scale, offset, TEXT_SHADOW); + } + draw_text_layer(rgba, size, text, cx, cy, scale, (0, 0), TEXT_FILL); +} + +fn text_scale(text: &str) -> usize { + match text.len() { + 0 | 1 => 7, + 2 => 5, + _ => 4, + } +} + +/// Renders a single large badge with `count`. Returns `None` when there's nothing to show, +/// so the caller can clear the overlay icon. +fn render_badge_icon(count: u32) -> Option> { + if count == 0 { + return None; + } + + const SIZE: usize = 64; + const RADIUS: f32 = 29.0; + let mut rgba = vec![0u8; SIZE * SIZE * 4]; + let center = SIZE as f32 / 2.0; + + let text = cap_text(count); + draw_circle( + &mut rgba, + SIZE, + center, + center, + RADIUS, + BADGE_FILL, + BADGE_BORDER, + ); + draw_text(&mut rgba, SIZE, &text, center, center, text_scale(&text)); + + Some(Image::new_owned(rgba, SIZE as u32, SIZE as u32)) +} + +/// Sets the taskbar badge to `ahead + behind + changes` (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, + changes: u32, +) -> Result<(), String> { + let count = ahead.saturating_add(behind).saturating_add(changes); + + #[cfg(target_os = "windows")] + { + let Some(window) = app.get_webview_window("main") else { + return Ok(()); + }; + let icon = render_badge_icon(count); + window + .set_overlay_icon(icon) + .map_err(|err| format!("Could not set taskbar badge: {err}"))?; + } + + #[cfg(not(target_os = "windows"))] + { + let _ = (app, count); + } + + Ok(()) +} diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 0ad8215..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, @@ -1302,6 +1333,8 @@ pub fn compare_commits( &repo, [ "diff", + "--no-ext-diff", + "--no-textconv", "-M", FULL_FILE_DIFF_CONTEXT, from_hash.as_str(), @@ -1344,7 +1377,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 +1440,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 +1514,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..cbd57ed 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,17 +1,19 @@ #![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, - commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, - 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, + commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, + cred_delete, cred_load, cred_save, delete_branch, diff_file_against_working_tree, fetch, + 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, }; fn main() { @@ -42,6 +44,7 @@ fn main() { commit_ai_generate, pull, push, + fetch, list_commits, restore_to_commit, restore_file_from_commit, @@ -62,7 +65,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..bfe317f 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -39,6 +39,7 @@ deleteBranch, diffFileAgainstWorkingTree, compareFileToParent, + fetchRemote, getStatus, listBranches, listCommits, @@ -63,6 +64,7 @@ restoreFiles, restoreToCommit, searchCodeIntroductions, + setSyncBadge, stageFiles, unstageFiles, } from "./lib/git"; @@ -180,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; @@ -235,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); }); @@ -251,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; @@ -656,6 +679,7 @@ repoPath = ""; status = null; lastStatusFingerprint = ""; + void setSyncBadge(0, 0, 0).catch(() => {}); } branches = []; commits = []; @@ -689,6 +713,7 @@ repoPath = activeRepoPath; lastStatusFingerprint = statusFingerprint(nextStatus); upsertRepoTab(activeRepoPath, nextStatus); + void setSyncBadge(nextStatus.ahead, nextStatus.behind, nextStatus.files.length).catch(() => {}); } function errorToMessage(error: unknown): string { @@ -845,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() { @@ -1036,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; @@ -1046,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; @@ -1090,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, @@ -1159,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) { @@ -1170,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; } @@ -1186,6 +1230,10 @@ await openCredentialDialog(action, key); } + async function fetchRepo() { + await startRemoteAction("fetch"); + } + async function pullRepo() { await startRemoteAction("pull"); } @@ -1693,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 + +