Compare commits

...
12 Commits
Author SHA1 Message Date
Christoph 7001616779 Merge pull request 'Features/badges' (#10) from features/badges into master
publish / publish-tauri (, windows-latest) (release) Successful in 27m44s
Reviewed-on: #10
2026-07-03 14:41:35 +00:00
Christoph Brandau 9228cd1e08 Merge branch 'features/badges' of https://git.cbsk-tech.de/Christoph/GitLite into features/badges 2026-07-03 16:40:57 +02:00
Christoph Brandau 399ba80550 feat(tauri): update Windows taskbar badge with combined count
The Windows overlay badge now renders a single combined number and
includes an additional “changes” component in the total. This makes
the badge more informative while keeping the icon compact and
legible across small sizes.

- Simplify badge rendering to one circle with centered text
- Extend the Tauri command and frontend call to pass changes count
2026-07-03 16:40:47 +02:00
Christoph Brandau b69538de85 chore(badge): adjust badge icon sizing constants
The badge icon rendering was tweaked by slightly reducing the
radius and scale constants. This helps align the icon proportions
and spacing for a cleaner visual result.
2026-07-03 14:51:12 +02:00
Christoph 7b90f4bcc2 Update README.md 2026-07-03 12:42:14 +00:00
Christoph Brandau 96eb8c109c 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
2026-07-03 14:29:29 +02:00
Christoph Brandau 276dfbab07 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
2026-07-03 13:49:43 +02:00
Christoph 883cf3ebd1 Update version to 2026.7.8 2026-07-03 07:35:17 +02:00
Christoph 6e283ddf83 Merge pull request 'Features/ai commits' (#9) from features/ai_commits into master
publish / publish-tauri (, windows-latest) (release) Successful in 31m38s
Reviewed-on: #9
2026-07-03 05:27:15 +00:00
Christoph Brandau 979d5aed80 feat(ai): disable local provider and improve commit file display
Local AI is now treated as unavailable in the settings UI, with a
migration to ensure any previously saved "local" selection switches
back to OpenAI. The history panel also improves how commit file
names are presented, including clearer old->new path formatting.

- Migrate stored AI provider away from local to prevent dead state
- Disable local provider option with an "in development" badge
- Refine history panel filename rendering and tooltip context
2026-07-03 07:26:23 +02:00
Christoph Brandau 791d686c48 feat(commit-ai): enhance commit message generation and caching
Improve the commit message generation process by adding a caching mechanism and refining the input handling. This change aims to enhance performance and prevent redundant computations when generating commit messages based on staged changes.

- **src-tauri/crates/commit_ai/src/cloud.rs**:
  - Introduced `looks_like_diff_echo` function to detect if the model's output is a diff instead of a commit message.
  - Updated `openai_compatible_request` and `generate_anthropic` to utilize the new function for error handling.

- **src-tauri/crates/commit_ai/src/lib.rs**:
  - Added `LocalGenerationProfile` enum for managing different generation profiles.
  - Implemented caching for generated messages to avoid redundant processing.
  - Updated `generate_commit_message` to incorporate caching logic.

- **src-tauri/src/git.rs**:
  - Added `staged_diff_local` function to handle local profile generation and exclude specific lock files from the diff.
  - Modified `commit_ai_generate` to accept and process the local generation profile.

- **src/App.svelte**:
  - Added `lastLocalAiGeneratedMessage` state to track the last generated message and prevent unnecessary updates.

- **.claude/settings.local.json**:
  - Updated settings to include additional commands for better functionality.
2026-07-03 07:03:35 +02:00
Christoph 7ca6abf962 Update version to 2026.7.7 2026-07-02 22:17:49 +02:00
18 changed files with 778 additions and 134 deletions
+8 -1
View File
@@ -71,7 +71,14 @@
"Bash(echo \"EXIT:$?\")",
"Bash(ls target/)",
"Bash(rustup target *)",
"Bash(echo \"exit code: $?\")"
"Bash(echo \"exit code: $?\")",
"Read(//home/cbr/.cargo/registry/src/**)",
"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)"
]
}
}
-44
View File
@@ -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.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "tauri-git-lite",
"version": "2026.7.6",
"version": "2026.7.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tauri-git-lite",
"version": "2026.7.6",
"version": "2026.7.8",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "git-lite",
"version": "2026.7.6",
"version": "2026.7.8",
"private": true,
"type": "module",
"scripts": {
+31 -12
View File
@@ -2,7 +2,7 @@ use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::{build_messages, sanitize_message};
use crate::{build_messages, looks_like_diff_echo, sanitize_message};
// Generous sizing so a detailed body with bullet points isn't cut off.
const DEFAULT_MAX_TOKENS: u32 = 1500;
@@ -56,8 +56,14 @@ async fn openai_compatible_request(
let body = OpenAiRequest {
model: model.to_string(),
messages: vec![
OpenAiMessage { role: "system", content: system },
OpenAiMessage { role: "user", content: user },
OpenAiMessage {
role: "system",
content: system,
},
OpenAiMessage {
role: "user",
content: user,
},
],
temperature: 0.3,
};
@@ -82,17 +88,22 @@ async fn openai_compatible_request(
return Err(format!("API error ({status}): {text}"));
}
let parsed: OpenAiResponse = serde_json::from_str(&text)
.map_err(|err| format!("Could not process response: {err}"))?;
let parsed: OpenAiResponse =
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
parsed
let message = parsed
.choices
.into_iter()
.next()
.and_then(|choice| choice.message.content)
.map(|content| sanitize_message(&content))
.filter(|content| !content.is_empty())
.ok_or_else(|| "The model did not return a response.".to_string())
.ok_or_else(|| "The model did not return a response.".to_string())?;
if looks_like_diff_echo(&message) {
return Err("The model returned the diff instead of a commit message.".to_string());
}
Ok(message)
}
pub async fn generate_openai(
@@ -168,7 +179,10 @@ pub async fn generate_anthropic(
model: model.to_string(),
max_tokens: DEFAULT_MAX_TOKENS,
system,
messages: vec![AnthropicMessage { role: "user", content: user }],
messages: vec![AnthropicMessage {
role: "user",
content: user,
}],
};
let client = http_client()?;
@@ -190,14 +204,19 @@ pub async fn generate_anthropic(
return Err(format!("API error ({status}): {text}"));
}
let parsed: AnthropicResponse = serde_json::from_str(&text)
.map_err(|err| format!("Could not process response: {err}"))?;
let parsed: AnthropicResponse =
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
parsed
let message = parsed
.content
.into_iter()
.find_map(|block| block.text)
.map(|text| sanitize_message(&text))
.filter(|text| !text.is_empty())
.ok_or_else(|| "The model did not return a response.".to_string())
.ok_or_else(|| "The model did not return a response.".to_string())?;
if looks_like_diff_echo(&message) {
return Err("The model returned the diff instead of a commit message.".to_string());
}
Ok(message)
}
+213 -27
View File
@@ -2,9 +2,13 @@ mod cloud;
pub use cloud::{generate_anthropic, generate_custom, generate_openai};
use std::sync::Arc;
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
sync::Arc,
};
use mistralrs::{GgufModelBuilder, Model, TextMessageRole, TextMessages};
use mistralrs::{GgufModelBuilder, Model, RequestBuilder, TextMessageRole};
use tokio::sync::RwLock;
/// One selectable local (on-device) model. Larger models produce better commit messages
@@ -19,7 +23,7 @@ pub struct LocalModelOption {
tokenizer_repo: &'static str,
}
pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-1.5b";
pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-0.5b";
pub const LOCAL_MODELS: &[LocalModelOption] = &[
LocalModelOption {
@@ -52,6 +56,59 @@ fn find_local_model(model_id: &str) -> Option<&'static LocalModelOption> {
LOCAL_MODELS.iter().find(|option| option.id == model_id)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LocalGenerationProfile {
Fast,
Balanced,
Detailed,
}
impl Default for LocalGenerationProfile {
fn default() -> Self {
Self::Fast
}
}
impl LocalGenerationProfile {
pub fn from_id(value: Option<&str>) -> Self {
match value
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"balanced" => Self::Balanced,
"detailed" => Self::Detailed,
_ => Self::Fast,
}
}
pub fn diff_unified_context(self) -> &'static str {
match self {
Self::Fast => "--unified=1",
Self::Balanced => "--unified=2",
Self::Detailed => "--unified=3",
}
}
fn max_diff_chars(self) -> usize {
match self {
Self::Fast => 8_000,
Self::Balanced => 12_000,
Self::Detailed => 24_000,
}
}
fn max_output_tokens(self) -> usize {
match self {
Self::Fast => 160,
Self::Balanced => 360,
Self::Detailed => 750,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CommitAiPhase {
@@ -75,6 +132,20 @@ struct Inner {
model_id: Option<String>,
error: Option<String>,
model: Option<Arc<Model>>,
cache: Option<GenerationCache>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GenerationCacheKey {
model_id: String,
profile: LocalGenerationProfile,
input_hash: u64,
}
#[derive(Debug, Clone)]
struct GenerationCache {
key: GenerationCacheKey,
message: String,
}
/// Manages the local (on-device) model only. Cloud providers are stateless HTTP calls
@@ -92,6 +163,7 @@ impl Default for CommitAiEngine {
model_id: None,
error: None,
model: None,
cache: None,
})),
}
}
@@ -129,6 +201,7 @@ impl CommitAiEngine {
guard.phase = CommitAiPhase::Error;
guard.model_id = Some(model_id.to_string());
guard.error = Some(format!("Unknown local model: {model_id}"));
guard.cache = None;
return;
};
@@ -138,6 +211,7 @@ impl CommitAiEngine {
guard.model_id = Some(model_id.to_string());
guard.error = None;
guard.model = None;
guard.cache = None;
}
let result = GgufModelBuilder::new(option.repo, vec![option.file])
@@ -157,10 +231,12 @@ impl CommitAiEngine {
guard.model = Some(Arc::new(model));
guard.phase = CommitAiPhase::Ready;
guard.error = None;
guard.cache = None;
}
Err(err) => {
guard.phase = CommitAiPhase::Error;
guard.error = Some(err.to_string());
guard.cache = None;
}
}
}
@@ -169,22 +245,36 @@ impl CommitAiEngine {
&self,
diff: &str,
notes: Option<&str>,
profile: LocalGenerationProfile,
) -> Result<String, String> {
let model = {
let (model, cache_key) = {
let guard = self.inner.read().await;
match (guard.phase, &guard.model) {
(CommitAiPhase::Ready, Some(model)) => model.clone(),
(CommitAiPhase::Ready, Some(model)) => {
let cache_key = GenerationCacheKey {
model_id: guard.model_id.clone().unwrap_or_default(),
profile,
input_hash: generation_input_hash(diff, notes),
};
if let Some(cache) = &guard.cache {
if cache.key == cache_key {
return Ok(cache.message.clone());
}
}
(model.clone(), cache_key)
}
_ => return Err("The local AI model is not ready yet.".to_string()),
}
};
let (system, user) = build_messages(diff, notes)?;
let messages = TextMessages::new()
let (system, user) = build_local_messages(diff, notes, profile)?;
let request = RequestBuilder::new()
.set_sampler_max_len(profile.max_output_tokens())
.add_message(TextMessageRole::System, system)
.add_message(TextMessageRole::User, user);
let response = model
.send_chat_request(messages)
.send_chat_request(request)
.await
.map_err(|err| err.to_string())?;
@@ -198,10 +288,29 @@ impl CommitAiEngine {
if message.is_empty() {
return Err("The model did not return a response.".to_string());
}
if looks_like_diff_echo(&message) {
return Err(
"The local model returned the diff instead of a commit message. Try a larger local model (1.5B or 3B) or a cloud provider.".to_string(),
);
}
{
let mut guard = self.inner.write().await;
guard.cache = Some(GenerationCache {
key: cache_key,
message: message.clone(),
});
}
Ok(message)
}
}
fn generation_input_hash(diff: &str, notes: Option<&str>) -> u64 {
let mut hasher = DefaultHasher::new();
diff.hash(&mut hasher);
notes.unwrap_or("").hash(&mut hasher);
hasher.finish()
}
/// Models occasionally ignore the "no code fences" instruction (small local models
/// especially) — strip a wrapping ``` fence and wrapping quotes so the result can go
/// straight into the commit-message box.
@@ -221,6 +330,74 @@ pub(crate) fn sanitize_message(raw: &str) -> String {
trimmed.to_string()
}
/// Weak models (small local ones especially) sometimes just echo the prompt's diff
/// sections back instead of writing a commit message. Catch that so the UI can show a
/// clear error instead of dumping raw diff text into the commit-message box.
pub(crate) fn looks_like_diff_echo(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("diff --git")
|| lower.contains("staged files:")
|| lower.contains("staged changes:")
|| lower.contains("diff stat:")
|| lower.contains("detailed diff:")
|| message.lines().any(|line| line.starts_with("@@ "))
}
fn truncate_at_char_boundary(input: &str, max_chars: usize) -> String {
if input.len() <= max_chars {
return input.to_string();
}
let mut cut = max_chars;
while !input.is_char_boundary(cut) {
cut -= 1;
}
format!("{}\n\n[... diff truncated ...]", &input[..cut])
}
pub(crate) fn build_local_messages(
diff: &str,
notes: Option<&str>,
profile: LocalGenerationProfile,
) -> Result<(String, String), String> {
if diff.trim().is_empty() {
return Err("No staged changes available for a commit message.".to_string());
}
let diff = truncate_at_char_boundary(diff, profile.max_diff_chars());
// Appended to every profile below: small local models occasionally just echo the input
// (the "Staged files:" / "Diff stat:" / "Detailed diff:" sections built in git.rs's
// `staged_diff_local`) instead of writing a new commit message. Naming those exact
// section headers here makes the failure mode explicit enough for weak models to avoid.
const ANTI_ECHO: &str = " Never repeat, quote, or paraphrase the diff or its headers — \
do not include 'diff --git', '@@', 'Staged files:', 'Diff stat:', or 'Detailed diff:' \
anywhere in your answer.";
let system = match profile {
LocalGenerationProfile::Fast => {
format!(
"You generate Git commit messages. Respond only with one Conventional Commits subject line: <type>(<scope>): <subject>. Max 72 characters. No body, bullets, preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
LocalGenerationProfile::Balanced => {
format!(
"You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then at most two short bullet points. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
LocalGenerationProfile::Detailed => {
format!(
"You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then a concise body and up to four short bullet points grouped by affected area/file. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
};
let mut user = String::new();
if let Some(n) = notes.filter(|n| !n.trim().is_empty()) {
user.push_str(&format!("Developer notes:\n{n}\n\n"));
}
user.push_str(&format!("Staged changes:\n{diff}"));
Ok((system, user))
}
pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> {
if diff.trim().is_empty() {
return Err("No staged changes available for a commit message.".to_string());
@@ -228,26 +405,35 @@ pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String,
// Rough token estimate — small models often have an 8-32k context window.
const MAX_CHARS: usize = 24_000;
let diff = if diff.len() > MAX_CHARS {
// Pull the byte index back to a valid UTF-8 char boundary, otherwise
// slicing mid-multi-byte-character would panic.
let mut cut = MAX_CHARS;
while !diff.is_char_boundary(cut) {
cut -= 1;
}
format!("{}\n\n[... diff truncated ...]", &diff[..cut])
} else {
diff.to_string()
};
let diff = truncate_at_char_boundary(diff, MAX_CHARS);
let system = "You are a tool that generates Git commit messages. \
Respond only with the commit message in Conventional Commits format \
(<type>(<scope>): <subject>), followed by a body after a blank line. \
Subject in imperative mood, max. 72 characters. \
The body is required: summarize in a short paragraph what changed and why, \
then list the key changes as bullet points (- ...), \
grouped by affected area/file. Lines in the body max. 72 characters. \
No preamble, no explanation, no code fences, answer in English"
let system = "You are a tool that writes a Git commit message describing a staged diff. \
Output ONLY the commit message text. Never quote, restate, or paraphrase the diff itself: \
do not include lines starting with 'diff --git', '@@', '+', '-', 'index ', 'Staged files:', \
or 'Diff stat:' anywhere in your answer. \
Format: a Conventional Commits header (<type>(<scope>): <subject>) in imperative mood, \
max. 72 characters, then a blank line, then a body. \
The body is required: a short, general paragraph (2-4 sentences) summarizing what changed \
and why at a high level — do NOT enumerate every changed file individually. \
You may optionally add up to 3 bullet points (- ...) afterward, but only for the most \
significant changes overall, never one bullet or heading per file. \
Never use bold text, backticks, or markdown headings for file names. \
Lines in the body max. 72 characters. \
No preamble, no explanation, no code fences, answer in English.\n\n\
Example:\n\
Diff:\n\
diff --git a/src/auth.py b/src/auth.py\n\
+def hash_password(pw):\n\
+ return bcrypt.hash(pw)\n\
diff --git a/src/routes.py b/src/routes.py\n\
-if password == stored_password:\n\
+if bcrypt.check(password, stored_password):\n\n\
Commit message:\n\
feat(auth): hash and verify passwords with bcrypt\n\n\
Passwords were previously compared as plain text. This adds a bcrypt-based\n\
hashing helper and updates the login check to verify against the hash\n\
instead of a direct string comparison.\n\n\
- Hash passwords on write, verify with bcrypt on login"
.to_string();
let mut user = String::new();
+186
View File
@@ -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<Image<'static>> {
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(())
}
+110 -20
View File
@@ -540,6 +540,50 @@ fn staged_diff(repo: &Path) -> Result<String, String> {
Ok(format!("Staged files:\n{file_list}\n\n{diff}"))
}
fn staged_diff_local(
repo: &Path,
profile: commit_ai::LocalGenerationProfile,
) -> Result<String, String> {
let name_status = run_git(repo, ["diff", "--cached", "--name-status", "-M"])?;
let file_list = String::from_utf8_lossy(&name_status).trim().to_string();
let stat = run_git(repo, ["diff", "--cached", "--stat", "--summary"])?;
let stat = String::from_utf8_lossy(&stat).trim().to_string();
let diff_args = vec![
"diff",
"--cached",
"--no-ext-diff",
"--no-textconv",
profile.diff_unified_context(),
"--",
".",
":(exclude)*package-lock.json",
":(exclude)*pnpm-lock.yaml",
":(exclude)*yarn.lock",
":(exclude)*bun.lockb",
":(exclude)*Cargo.lock",
":(exclude)*composer.lock",
":(exclude)*Gemfile.lock",
":(exclude)*poetry.lock",
":(exclude)*go.sum",
];
let diff = run_git(repo, diff_args)?;
let diff = String::from_utf8_lossy(&diff).trim().to_string();
let mut sections = Vec::new();
if !file_list.is_empty() {
sections.push(format!("Staged files:\n{file_list}"));
}
if !stat.is_empty() {
sections.push(format!("Diff stat:\n{stat}"));
}
if !diff.is_empty() {
sections.push(format!("Detailed diff:\n{diff}"));
}
Ok(sections.join("\n\n"))
}
#[tauri::command]
pub async fn commit_ai_generate(
path: String,
@@ -548,17 +592,27 @@ pub async fn commit_ai_generate(
model: Option<String>,
api_key: Option<String>,
base_url: Option<String>,
local_profile: Option<String>,
engine: tauri::State<'_, commit_ai::CommitAiEngine>,
) -> Result<String, String> {
let repo = resolve_repo(&path)?;
let diff = staged_diff(&repo)?;
let local_profile = commit_ai::LocalGenerationProfile::from_id(local_profile.as_deref());
let diff = if provider == "local" {
staged_diff_local(&repo, local_profile)?
} else {
staged_diff(&repo)?
};
let notes = notes.as_deref();
let model = model.filter(|value| !value.trim().is_empty());
let api_key = api_key.filter(|value| !value.trim().is_empty());
let base_url = base_url.filter(|value| !value.trim().is_empty());
match provider.as_str() {
"local" => engine.generate_commit_message(&diff, notes).await,
"local" => {
engine
.generate_commit_message(&diff, notes, local_profile)
.await
}
"openai" => {
let api_key = api_key.ok_or_else(|| "OpenAI API key is missing.".to_string())?;
let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string());
@@ -620,9 +674,7 @@ pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
let current_status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&current_status) {
return Err(
"Merge conflicts must be resolved before you can commit.".to_string(),
);
return Err("Merge conflicts must be resolved before you can commit.".to_string());
}
run_git(&repo, ["commit", "-m", message.as_str()])?;
@@ -646,9 +698,7 @@ pub fn pull(
.arg(&repo)
.args(pull_args)
.output()
.map_err(|err| {
format!("Could not start Git. Is Git installed? {err}")
})?,
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
};
if output.status.success() {
@@ -667,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,
@@ -703,8 +784,7 @@ fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
if key.is_empty() {
return Err("No key provided for the credentials.".to_string());
}
keyring::Entry::new(CRED_SERVICE, key)
.map_err(|err| format!("Keychain unavailable: {err}"))
keyring::Entry::new(CRED_SERVICE, key).map_err(|err| format!("Keychain unavailable: {err}"))
}
/// Returns the remote URL used for auth key derivation (upstream remote of the
@@ -790,9 +870,8 @@ fn initial_push_remote_name(repo: &Path) -> Result<String, String> {
return Ok("origin".to_string());
}
first_remote_name(repo).ok_or_else(|| {
"This branch has no upstream and no remote is configured.".to_string()
})
first_remote_name(repo)
.ok_or_else(|| "This branch has no upstream and no remote is configured.".to_string())
}
fn push_args_for_repo(repo: &Path) -> Result<Vec<OsString>, String> {
@@ -1254,6 +1333,8 @@ pub fn compare_commits(
&repo,
[
"diff",
"--no-ext-diff",
"--no-textconv",
"-M",
FULL_FILE_DIFF_CONTEXT,
from_hash.as_str(),
@@ -1296,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),
)?;
@@ -1352,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(),
@@ -1424,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(),
@@ -2554,9 +2646,7 @@ fn validate_existing_local_branch_name(repo: &Path, branch: &str) -> Result<Stri
let normalized = validate_branch_ref_name(branch)?;
if !ref_exists(repo, &format!("refs/heads/{normalized}"))? {
return Err(format!(
"Local branch '{normalized}' was not found."
));
return Err(format!("Local branch '{normalized}' was not found."));
}
Ok(normalized)
@@ -2996,8 +3086,8 @@ where
thread::sleep(Duration::from_millis(60));
};
let stdout = std::fs::read(&stdout_path)
.map_err(|err| format!("Could not read Git output: {err}"))?;
let stdout =
std::fs::read(&stdout_path).map_err(|err| format!("Could not read Git output: {err}"))?;
let stderr = std::fs::read(&stderr_path)
.map_err(|err| format!("Could not read Git error output: {err}"))?;
let _ = std::fs::remove_file(&stdout_path);
@@ -4179,7 +4269,7 @@ mod tests {
);
let err = delete_branch(repo.path.to_string_lossy().to_string(), current).unwrap_err();
assert!(err.contains("aktuelle Branch"));
assert!(err.contains("current branch"));
}
#[test]
+12 -8
View File
@@ -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");
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "GitLite",
"version": "2026.7.6",
"version": "2026.7.8",
"identifier": "com.git-lite",
"build": {
"beforeDevCommand": "npm run dev",
+81 -9
View File
@@ -39,6 +39,7 @@
deleteBranch,
diffFileAgainstWorkingTree,
compareFileToParent,
fetchRemote,
getStatus,
listBranches,
listCommits,
@@ -63,6 +64,7 @@
restoreFiles,
restoreToCommit,
searchCodeIntroductions,
setSyncBadge,
stageFiles,
unstageFiles,
} from "./lib/git";
@@ -141,6 +143,7 @@
let activeFileHistoryRequestId = "";
let lastFileHistoryHeadHash = "";
let commitMessage = "";
let lastLocalAiGeneratedMessage = "";
let commitAiPhase: CommitAiPhase = "idle";
let commitAiGenerating = false;
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
@@ -179,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<typeof setInterval> | undefined;
const BACKGROUND_FETCH_INTERVAL = 180_000;
let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined;
let backgroundFetchInFlight = false;
let updateToastOpen = false;
let updateToastState: UpdateToastState = "available";
let pendingUpdate: Update | null = null;
@@ -234,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);
});
@@ -250,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;
@@ -322,6 +346,13 @@
startCommitAiPolling();
}
function updateCommitMessage(message: string) {
commitMessage = message;
if (message !== lastLocalAiGeneratedMessage) {
lastLocalAiGeneratedMessage = "";
}
}
async function generateCommitMessageWithAi() {
if (!activeRepoPath || commitAiGenerating) return;
if (aiSettings.provider === "local" && commitAiPhase !== "ready") return;
@@ -330,7 +361,13 @@
try {
const notes = commitMessage.trim() || undefined;
if (aiSettings.provider === "local") {
commitMessage = await commitAiGenerate(activeRepoPath, { provider: "local", notes });
const localNotes = notes && notes !== lastLocalAiGeneratedMessage ? notes : undefined;
commitMessage = await commitAiGenerate(activeRepoPath, {
provider: "local",
notes: localNotes,
localProfile: aiSettings.localProfile,
});
lastLocalAiGeneratedMessage = commitMessage;
} else if (aiSettings.provider === "openai") {
const cred = await credLoad("ai:openai");
commitMessage = await commitAiGenerate(activeRepoPath, {
@@ -339,6 +376,7 @@
model: aiSettings.openaiModel,
apiKey: cred?.password,
});
lastLocalAiGeneratedMessage = "";
} else if (aiSettings.provider === "anthropic") {
const cred = await credLoad("ai:anthropic");
commitMessage = await commitAiGenerate(activeRepoPath, {
@@ -347,6 +385,7 @@
model: aiSettings.anthropicModel,
apiKey: cred?.password,
});
lastLocalAiGeneratedMessage = "";
} else {
const cred = await credLoad("ai:custom");
commitMessage = await commitAiGenerate(activeRepoPath, {
@@ -356,6 +395,7 @@
baseUrl: aiSettings.customBaseUrl,
apiKey: cred?.password,
});
lastLocalAiGeneratedMessage = "";
}
} catch (error) {
errorMessage = errorToMessage(error);
@@ -526,8 +566,9 @@
function defaultAiSettings(): AiSettings {
return {
provider: "local",
localModelId: "qwen2.5-1.5b",
provider: "openai",
localModelId: "qwen2.5-0.5b",
localProfile: "fast",
openaiModel: "gpt-4o-mini",
anthropicModel: "claude-3-5-haiku-latest",
customBaseUrl: "",
@@ -539,7 +580,11 @@
try {
const stored = JSON.parse(localStorage.getItem(AI_SETTINGS_KEY) ?? "null") as unknown;
if (stored && typeof stored === "object") {
return { ...defaultAiSettings(), ...(stored as Partial<AiSettings>) };
const merged = { ...defaultAiSettings(), ...(stored as Partial<AiSettings>) };
// Local AI is still in development and disabled in the settings UI — migrate any
// previously saved selection away from it so nobody gets stuck on a dead option.
if (merged.provider === "local") merged.provider = "openai";
return merged;
}
} catch {
// Fall through to defaults below.
@@ -634,6 +679,7 @@
repoPath = "";
status = null;
lastStatusFingerprint = "";
void setSyncBadge(0, 0, 0).catch(() => {});
}
branches = [];
commits = [];
@@ -667,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 {
@@ -823,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() {
@@ -1014,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;
@@ -1024,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;
@@ -1068,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,
@@ -1137,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) {
@@ -1148,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;
}
@@ -1164,6 +1230,10 @@
await openCredentialDialog(action, key);
}
async function fetchRepo() {
await startRemoteAction("fetch");
}
async function pullRepo() {
await startRemoteAction("pull");
}
@@ -1339,6 +1409,7 @@
await runOperation("Committing", async () => {
applyStatus(await commit(activeRepoPath, message));
commitMessage = "";
lastLocalAiGeneratedMessage = "";
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
@@ -1670,6 +1741,7 @@
{operation}
{autoRefreshEnabled}
{autoRefreshInFlight}
onFetch={fetchRepo}
onPull={pullRepo}
onPush={pushRepo}
onRefresh={refreshRepo}
@@ -1968,7 +2040,7 @@
{commitAiPhase}
{commitAiGenerating}
onCommit={commitChanges}
onCommitMessageChange={(msg) => { commitMessage = msg; }}
onCommitMessageChange={updateCommitMessage}
onGenerateCommitMessage={generateCommitMessageWithAi}
onOpenAiSettings={() => { aiSettingsOpen = true; }}
/>
+38
View File
@@ -1721,6 +1721,44 @@
color: #f5f7ff;
background: linear-gradient(180deg, rgba(100,108,255,0.22), rgba(65,209,255,0.1));
}
.ai-provider-option-local {
flex-wrap: wrap;
row-gap: 2px;
}
.ai-provider-badge {
flex-basis: 100%;
text-align: center;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-ink-faint);
}
.ai-local-profile-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.ai-local-profile-option {
min-width: 0;
min-height: 34px;
padding: 0 8px;
border-color: var(--color-border-subtle);
background: rgba(255,255,255,0.03);
color: var(--color-ink-dim);
font-size: 12px;
font-weight: 700;
}
.ai-local-profile-option:hover:not(:disabled) {
border-color: var(--color-border);
color: var(--color-ink);
background: var(--color-surface-hover);
}
.ai-local-profile-option.active {
border-color: rgba(65,209,255,0.48);
color: #f5f7ff;
background: linear-gradient(180deg, rgba(65,209,255,0.16), rgba(100,108,255,0.12));
}
.new-branch-form {
display: flex;
flex-direction: column;
+17 -1
View File
@@ -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 @@
<span class="tb-action-label">Compare</span>
</button>
<button
class="tb-action"
onclick={onFetch}
disabled={!hasRepository || isBusy}
title="Fetch"
aria-label="Fetch"
>
{#if operation === "Fetching"}
<LoaderCircle class="spin" size={14} aria-hidden="true" />
{:else}
<CloudDownload size={14} aria-hidden="true" />
{/if}
<span class="tb-action-label">Fetch</span>
</button>
<button
class="tb-action"
onclick={onPull}
+46 -3
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import { onMount } from "svelte";
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Globe, Key, LoaderCircle, X } from "@lucide/svelte";
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
import { credDelete, credLoad, credSave } from "../git";
import type { AiSettings, CommitAiProvider, LocalModelOption } from "../types";
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
interface Props {
settings: AiSettings;
@@ -23,6 +23,7 @@
let provider = $state<CommitAiProvider>("local");
let localModelId = $state("");
let localProfile = $state<CommitAiLocalProfile>("fast");
let openaiModel = $state("");
let anthropicModel = $state("");
let customBaseUrl = $state("");
@@ -39,6 +40,7 @@
$effect(() => {
provider = settings.provider;
localModelId = settings.localModelId;
localProfile = settings.localProfile ?? "fast";
openaiModel = settings.openaiModel;
anthropicModel = settings.anthropicModel;
customBaseUrl = settings.customBaseUrl;
@@ -86,6 +88,7 @@
onSave({
provider,
localModelId,
localProfile,
openaiModel: openaiModel.trim() || "gpt-4o-mini",
anthropicModel: anthropicModel.trim() || "claude-3-5-haiku-latest",
customBaseUrl: customBaseUrl.trim(),
@@ -102,6 +105,21 @@
return mb >= 1000 ? `${(mb / 1000).toFixed(1)} GB` : `${mb} MB`;
}
function recommendedModelForProfile(profile: CommitAiLocalProfile): string {
if (profile === "balanced") return "qwen2.5-1.5b";
if (profile === "detailed") return "qwen2.5-3b";
return "qwen2.5-0.5b";
}
function selectLocalProfile(profile: CommitAiLocalProfile) {
const previousRecommended = recommendedModelForProfile(localProfile);
localProfile = profile;
const nextRecommended = recommendedModelForProfile(profile);
if (!localModelId || localModelId === previousRecommended) {
localModelId = nextRecommended;
}
}
let selectedLocalModel = $derived(localModels.find((option) => option.id === localModelId));
</script>
@@ -123,9 +141,16 @@
<form class="ai-settings-form" onsubmit={(e) => { e.preventDefault(); void handleSave(); }}>
<div class="ai-provider-options" role="radiogroup" aria-label="AI provider">
<button type="button" class="ai-provider-option" class:active={provider === "local"} onclick={() => { provider = "local"; }}>
<button
type="button"
class="ai-provider-option ai-provider-option-local"
class:active={provider === "local"}
disabled
title="Local AI is still in development and not yet available"
>
<Cpu size={16} aria-hidden="true" />
Local AI
<span class="ai-provider-badge">In development</span>
</button>
<button type="button" class="ai-provider-option" class:active={provider === "openai"} onclick={() => { provider = "openai"; }}>
<Bot size={16} aria-hidden="true" />
@@ -142,6 +167,23 @@
</div>
{#if provider === "local"}
<div class="cred-field">
<span class="cred-field-label">Local speed</span>
<div class="ai-local-profile-options" role="radiogroup" aria-label="Local AI speed">
<button type="button" class="ai-local-profile-option" class:active={localProfile === "fast"} onclick={() => selectLocalProfile("fast")}>
<Zap size={15} aria-hidden="true" />
Fast
</button>
<button type="button" class="ai-local-profile-option" class:active={localProfile === "balanced"} onclick={() => selectLocalProfile("balanced")}>
<Gauge size={15} aria-hidden="true" />
Balanced
</button>
<button type="button" class="ai-local-profile-option" class:active={localProfile === "detailed"} onclick={() => selectLocalProfile("detailed")}>
<Sparkles size={15} aria-hidden="true" />
Detailed
</button>
</div>
</div>
<label class="cred-field">
<span class="cred-field-label">Model</span>
<select bind:value={localModelId}>
@@ -156,6 +198,7 @@
Switching downloads the model{selectedLocalModel ? ` (${formatSize(selectedLocalModel.approx_size_mb)})` : ""}
in the background — depending on your internet connection this can take several minutes.
After that it stays cached locally and loads instantly on the next start.
The speed setting only changes Local AI; API providers keep their existing prompt.
</span>
</div>
{:else if provider === "openai"}
+5 -3
View File
@@ -14,7 +14,7 @@
} from "@lucide/svelte";
interface Props {
action: "push" | "pull";
action: "push" | "pull" | "fetch";
error: string;
isBusy: boolean;
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
@@ -43,8 +43,10 @@
password.trim().length > 0 &&
(mode === "token" || username.trim().length > 0),
);
let actionLabel = $derived(action === "push" ? "Push" : "Pull");
let actionTitle = $derived(action === "push" ? "Authenticate push" : "Authenticate pull");
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : "Pull");
let actionTitle = $derived(
action === "push" ? "Authenticate push" : action === "fetch" ? "Authenticate fetch" : "Authenticate pull",
);
let actionHint = $derived(action === "push"
? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
: "The remote needs access to the repository. Use your Git credentials or a personal access token.");
+12 -2
View File
@@ -113,6 +113,16 @@
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
}
function baseName(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
function commitFileName(file: GitCommitFile): string {
return file.old_path
? `${baseName(file.old_path)} -> ${baseName(file.path)}`
: baseName(file.path);
}
function formatCommitDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
@@ -208,10 +218,10 @@
type="button"
onclick={() => onPreviewCommitFile(item, file)}
disabled={isBusy}
title="Show differences before restoring"
title={`Show differences before restoring - ${displayCommitFile(file)}`}
>
<span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span>
<strong>{displayCommitFile(file)}</strong>
<strong>{commitFileName(file)}</strong>
</button>
{/each}
</div>
+13
View File
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import type {
CommitAiLocalProfile,
CommitAiProvider,
CommitAiStatus,
ConflictFile,
@@ -36,6 +37,12 @@ export function getStatus(path: string): Promise<GitStatus> {
return invoke<GitStatus>("get_status", { path });
}
// Sets the taskbar icon badge to ahead + behind + changed status files (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, changes: number): Promise<void> {
return invoke<void>("set_sync_badge", { ahead, behind, changes });
}
export function listBranches(path: string): Promise<GitBranch[]> {
return invoke<GitBranch[]>("list_branches", { path });
}
@@ -112,6 +119,7 @@ export function commitAiLocalModels(): Promise<LocalModelOption[]> {
export interface CommitAiGenerateOptions {
notes?: string;
provider: CommitAiProvider;
localProfile?: CommitAiLocalProfile;
model?: string;
apiKey?: string;
baseUrl?: string;
@@ -122,6 +130,7 @@ export function commitAiGenerate(path: string, options: CommitAiGenerateOptions)
path,
notes: options.notes,
provider: options.provider,
localProfile: options.localProfile,
model: options.model,
apiKey: options.apiKey,
baseUrl: options.baseUrl,
@@ -132,6 +141,10 @@ export function pull(path: string, username?: string, password?: string): Promis
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null });
}
export function fetchRemote(path: string, username?: string, password?: string): Promise<GitStatus> {
return invoke<GitStatus>("fetch", { path, username: username ?? null, password: password ?? null });
}
export function push(path: string, username?: string, password?: string): Promise<GitStatus> {
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null });
}
+2
View File
@@ -9,6 +9,7 @@ export type FileStatusKind =
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
export interface CommitAiStatus {
phase: CommitAiPhase;
@@ -25,6 +26,7 @@ export interface LocalModelOption {
export interface AiSettings {
provider: CommitAiProvider;
localModelId: string;
localProfile: CommitAiLocalProfile;
openaiModel: string;
anthropicModel: string;
customBaseUrl: string;