Compare commits

...
13 Commits
Author SHA1 Message Date
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
Christoph 6c1c4cdbfe Merge pull request 'Features/ai commits' (#8) from features/ai_commits into master
publish / publish-tauri (, windows-latest) (release) Successful in 37m46s
Reviewed-on: #8
2026-07-02 20:15:00 +00:00
Christoph Brandau 70de45e1ba feat(ui): localize commit AI and git error messages to English
Translate commit AI prompts, model labels, and git/keychain errors to
English so the app and generated messages are consistent. Also add a
discard confirmation dialog and update the UI text/styles to match the
new flow.

- src-tauri/crates/commit_ai/src/cloud.rs
  - Translate HTTP and API error messages to English.
  - Keep request timeout and token sizing behavior unchanged.
- src-tauri/crates/commit_ai/src/lib.rs
  - Translate model labels, prompt text, and validation errors.
  - Keep diff truncation and message sanitization logic intact.
- src-tauri/src/git.rs
  - Translate git, credential, merge, and history errors.
  - Update AI provider validation messages to English.
- src/lib/components/*
  - Update AI settings, commit panel, credential, and loading UI text.
  - Add discard confirmation dialog for destructive actions.
- src/App.svelte, src/app.css
  - Adjust app layout and styling for the new dialog and text changes.
2026-07-02 22:12:20 +02:00
Christoph Brandau 6b7186d040 feat(ai): add cloud providers and local model selection
Add OpenAI-compatible, Anthropic, and custom endpoint support while
keeping the local model path intact. The UI now lets users choose the
provider and local model, and staged diffs are prepared more carefully
so generated commit messages stay focused and usable.

- src-tauri/crates/commit_ai/*
  - Add HTTP-based generators for OpenAI, Anthropic, and custom APIs.
  - Introduce shared request/response handling and message sanitizing.
  - Expand prompt building to require a body and trim long diffs safely.
  - Expose selectable local model metadata and loading by model ID.
- src-tauri/src/git.rs
  - Add commands for listing local models and loading them in background.
  - Route generation by provider and include staged file lists in prompts.
  - Exclude noisy lockfiles from detailed staged diffs.
- src-tauri/src/main.rs
  - Wire the new AI commands into the Tauri app setup.
- src/lib/components/*
  - Add an AI settings dialog and update the commit panel for provider
    and model selection.
- src/lib/git.ts, src/lib/types.ts, src/App.svelte, src/app.css
  - Extend frontend state, types, and styling for AI provider settings.
- src-tauri/Cargo.lock, src-tauri/crates/commit_ai/Cargo.toml
  - Add reqwest and serde_json for cloud API requests.
2026-07-02 21:19:16 +02:00
Christoph Brandau d415cbd3a1 add upstream 2026-07-02 20:24:46 +02:00
Christoph Brandau f2aa48d2ec try to use local ai to generate commit message 2026-07-02 19:59:16 +02:00
Christoph 2a96e79d27 Update version to 2026.7.6 2026-07-02 16:27:47 +02:00
Christoph 2f0904e839 Merge pull request 'add new Context Menu for file Explorer' (#7) from bug/blockingUI into master
publish / publish-tauri (, windows-latest) (release) Successful in 7m58s
Reviewed-on: #7
2026-07-02 14:25:56 +00:00
Christoph Brandau e3df75cc38 add new Context Menu for file Explorer
fix blocking UI
when select the file in Status also select in the file History
2026-07-02 16:24:38 +02:00
Christoph 794680a696 Update version to 2026.7.5 2026-07-02 12:43:41 +02:00
26 changed files with 6144 additions and 303 deletions
+40 -1
View File
@@ -34,7 +34,46 @@
"Read(//mnt/c/Users/cbr/Desktop/src-tauri/src/**)",
"Bash(sudo apt install -y libdbus-1-dev pkg-config)",
"Bash(dpkg -l)",
"Bash(apt list *)"
"Bash(apt list *)",
"Bash(cargo search *)",
"Bash(curl -s \"https://crates.io/api/v1/crates/mistralrs\")",
"Bash(cargo info *)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(gh api *)",
"WebFetch(domain:github.com)",
"WebFetch(domain:ericlbuehler.github.io)",
"WebFetch(domain:docs.rs)",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-1.5B-Instruct-GGUF\")",
"Bash(python3 -c ' *)",
"Bash(curl -sI \"https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf\")",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-1.5B-Instruct-GGUF?blobs=true\")",
"Bash(grep -n 'from \"\\\\./lib/git\"\\\\|from \"\\\\./lib/types\"\\\\|^ commit,$' src/App.svelte)",
"Bash(kill 24343 24363 24375 24376)",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-0.5B-Instruct-GGUF?blobs=true\")",
"Bash(curl -s \"https://huggingface.co/api/models/bartowski/Llama-3.2-3B-Instruct-GGUF?blobs=true\")",
"Bash(curl -s \"https://huggingface.co/api/models/bartowski/Llama-3.2-3B-Instruct-GGUF\")",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct-GGUF\")",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct-GGUF?blobs=true\")",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct\")",
"Bash(: *)",
"Bash(exit 0 *)",
"Bash(rustc -O sanitize_test.rs -o sanitize_test)",
"Bash(./sanitize_test)",
"Bash(echo \"exit:$?\")",
"Bash(grep -rlP \"[äöüßÄÖÜ]\" src src-tauri/src src-tauri/crates --include=\"*.rs\" --include=\"*.svelte\" --include=\"*.ts\")",
"Bash(echo \"---exit $?---\")",
"Bash(apt-cache policy *)",
"Bash(timeout 5 curl -sI http://archive.ubuntu.com)",
"Bash(sudo -n apt-get install -y libdbus-1-dev pkg-config)",
"Bash(grep -n '\"Unerwarteter Git-Log-Eintrag: {}\",' src-tauri/src/git.rs)",
"Bash(grep -E \"git_lite$|tauri_git_lite$\")",
"Bash(rustfmt --edition 2024 --check src-tauri/src/git.rs)",
"Bash(echo \"EXIT:$?\")",
"Bash(ls target/)",
"Bash(rustup target *)",
"Bash(echo \"exit code: $?\")",
"Read(//home/cbr/.cargo/registry/src/**)",
"Bash(find / -maxdepth 6 -iname \"mistralrs-*\" -type d)"
]
}
}
+2 -1
View File
@@ -5,4 +5,5 @@
.idea
.DS_Store
~
.codex*
.codex*
target
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "tauri-git-lite",
"version": "2026.7.4",
"version": "2026.7.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tauri-git-lite",
"version": "2026.7.4",
"version": "2026.7.7",
"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.4",
"version": "2026.7.7",
"private": true,
"type": "module",
"scripts": {
+3543 -54
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -1,17 +1,24 @@
[package]
name = "tauri_git_lite"
name = "git_lite"
version = "0.1.0"
description = "Rust backend for a lightweight Git desktop client"
edition = "2021"
rust-version = "1.77"
edition = "2024"
rust-version = "1.88"
build = "build.rs"
[workspace]
members = [".", "crates/commit_ai"]
[workspace.package]
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "=2.7.0"
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
commit_ai = { path = "crates/commit_ai" }
[build-dependencies]
tauri-build = { version = "2", features = [] }
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "commit_ai"
version = "0.1.0"
edition = "2024"
[dependencies]
mistralrs = "0.8"
tokio = { version = "1", features = ["sync"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
+222
View File
@@ -0,0 +1,222 @@
use std::time::Duration;
use serde::{Deserialize, Serialize};
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;
// Without a timeout, a hanging endpoint would permanently block the "AI" button.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
fn http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.map_err(|err| format!("Could not create HTTP client: {err}"))
}
#[derive(Serialize)]
struct OpenAiMessage {
role: &'static str,
content: String,
}
#[derive(Serialize)]
struct OpenAiRequest {
model: String,
messages: Vec<OpenAiMessage>,
temperature: f32,
}
#[derive(Deserialize)]
struct OpenAiResponseMessage {
content: Option<String>,
}
#[derive(Deserialize)]
struct OpenAiChoice {
message: OpenAiResponseMessage,
}
#[derive(Deserialize)]
struct OpenAiResponse {
#[serde(default)]
choices: Vec<OpenAiChoice>,
}
async fn openai_compatible_request(
url: String,
bearer: Option<&str>,
model: &str,
diff: &str,
notes: Option<&str>,
) -> Result<String, String> {
let (system, user) = build_messages(diff, notes)?;
let body = OpenAiRequest {
model: model.to_string(),
messages: vec![
OpenAiMessage {
role: "system",
content: system,
},
OpenAiMessage {
role: "user",
content: user,
},
],
temperature: 0.3,
};
let client = http_client()?;
let mut request = client.post(url).json(&body);
if let Some(key) = bearer.filter(|k| !k.trim().is_empty()) {
request = request.bearer_auth(key);
}
let response = request
.send()
.await
.map_err(|err| format!("Request to the AI model failed: {err}"))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("Could not read response: {err}"))?;
if !status.is_success() {
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 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())?;
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(
api_key: &str,
model: &str,
diff: &str,
notes: Option<&str>,
) -> Result<String, String> {
if api_key.trim().is_empty() {
return Err("OpenAI API key is missing.".to_string());
}
openai_compatible_request(
"https://api.openai.com/v1/chat/completions".to_string(),
Some(api_key),
model,
diff,
notes,
)
.await
}
pub async fn generate_custom(
base_url: &str,
api_key: Option<&str>,
model: &str,
diff: &str,
notes: Option<&str>,
) -> Result<String, String> {
if base_url.trim().is_empty() {
return Err("Endpoint URL is missing.".to_string());
}
let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
openai_compatible_request(url, api_key, model, diff, notes).await
}
#[derive(Serialize)]
struct AnthropicMessage {
role: &'static str,
content: String,
}
#[derive(Serialize)]
struct AnthropicRequest {
model: String,
max_tokens: u32,
system: String,
messages: Vec<AnthropicMessage>,
}
#[derive(Deserialize)]
struct AnthropicContentBlock {
#[serde(default)]
text: Option<String>,
}
#[derive(Deserialize)]
struct AnthropicResponse {
#[serde(default)]
content: Vec<AnthropicContentBlock>,
}
pub async fn generate_anthropic(
api_key: &str,
model: &str,
diff: &str,
notes: Option<&str>,
) -> Result<String, String> {
if api_key.trim().is_empty() {
return Err("Anthropic API key is missing.".to_string());
}
let (system, user) = build_messages(diff, notes)?;
let body = AnthropicRequest {
model: model.to_string(),
max_tokens: DEFAULT_MAX_TOKENS,
system,
messages: vec![AnthropicMessage {
role: "user",
content: user,
}],
};
let client = http_client()?;
let response = client
.post("https://api.anthropic.com/v1/messages")
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01")
.json(&body)
.send()
.await
.map_err(|err| format!("Request to Anthropic failed: {err}"))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("Could not read response: {err}"))?;
if !status.is_success() {
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 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())?;
if looks_like_diff_echo(&message) {
return Err("The model returned the diff instead of a commit message.".to_string());
}
Ok(message)
}
+445
View File
@@ -0,0 +1,445 @@
mod cloud;
pub use cloud::{generate_anthropic, generate_custom, generate_openai};
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
sync::Arc,
};
use mistralrs::{GgufModelBuilder, Model, RequestBuilder, TextMessageRole};
use tokio::sync::RwLock;
/// One selectable local (on-device) model. Larger models produce better commit messages
/// but take longer to download (first run only, then cached) and run slower on CPU.
#[derive(Debug, Clone, serde::Serialize)]
pub struct LocalModelOption {
pub id: &'static str,
pub label: &'static str,
pub approx_size_mb: u32,
repo: &'static str,
file: &'static str,
tokenizer_repo: &'static str,
}
pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-0.5b";
pub const LOCAL_MODELS: &[LocalModelOption] = &[
LocalModelOption {
id: "qwen2.5-0.5b",
label: "Qwen2.5 0.5B Instruct — fast, lower quality",
approx_size_mb: 490,
repo: "Qwen/Qwen2.5-0.5B-Instruct-GGUF",
file: "qwen2.5-0.5b-instruct-q4_k_m.gguf",
tokenizer_repo: "Qwen/Qwen2.5-0.5B-Instruct",
},
LocalModelOption {
id: "qwen2.5-1.5b",
label: "Qwen2.5 1.5B Instruct — recommended",
approx_size_mb: 1050,
repo: "Qwen/Qwen2.5-1.5B-Instruct-GGUF",
file: "qwen2.5-1.5b-instruct-q4_k_m.gguf",
tokenizer_repo: "Qwen/Qwen2.5-1.5B-Instruct",
},
LocalModelOption {
id: "qwen2.5-3b",
label: "Qwen2.5 3B Instruct — best quality, slower",
approx_size_mb: 2100,
repo: "Qwen/Qwen2.5-3B-Instruct-GGUF",
file: "qwen2.5-3b-instruct-q4_k_m.gguf",
tokenizer_repo: "Qwen/Qwen2.5-3B-Instruct",
},
];
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 {
/// Nothing has been requested yet.
Idle,
/// Downloading (first run only, then cached by hf-hub) and/or loading into memory.
Loading,
Ready,
Error,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CommitAiStatus {
pub phase: CommitAiPhase,
pub model_id: Option<String>,
pub error: Option<String>,
}
struct Inner {
phase: CommitAiPhase,
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
/// (see [`cloud`]) and don't need this — there's nothing to download or keep loaded.
#[derive(Clone)]
pub struct CommitAiEngine {
inner: Arc<RwLock<Inner>>,
}
impl Default for CommitAiEngine {
fn default() -> Self {
Self {
inner: Arc::new(RwLock::new(Inner {
phase: CommitAiPhase::Idle,
model_id: None,
error: None,
model: None,
cache: None,
})),
}
}
}
impl CommitAiEngine {
pub fn new() -> Self {
Self::default()
}
pub async fn status(&self) -> CommitAiStatus {
let guard = self.inner.read().await;
CommitAiStatus {
phase: guard.phase,
model_id: guard.model_id.clone(),
error: guard.error.clone(),
}
}
/// Downloads (first run only; hf-hub caches the files afterwards) and loads the given
/// local model. Safe to call repeatedly — a call for the model that's already
/// ready/loading is a no-op; a call for a *different* model switches to it (the
/// previous one is dropped once no generation is still using it).
pub async fn ensure_loaded(&self, model_id: &str) {
{
let guard = self.inner.read().await;
let same_model = guard.model_id.as_deref() == Some(model_id);
if same_model && matches!(guard.phase, CommitAiPhase::Ready | CommitAiPhase::Loading) {
return;
}
}
let Some(option) = find_local_model(model_id) else {
let mut guard = self.inner.write().await;
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;
};
{
let mut guard = self.inner.write().await;
guard.phase = CommitAiPhase::Loading;
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])
.with_tok_model_id(option.tokenizer_repo)
.with_logging()
.build()
.await;
let mut guard = self.inner.write().await;
// If the user switched to yet another model while this one was loading, drop this
// (now stale) result instead of overwriting the newer request's state.
if guard.model_id.as_deref() != Some(model_id) {
return;
}
match result {
Ok(model) => {
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;
}
}
}
pub async fn generate_commit_message(
&self,
diff: &str,
notes: Option<&str>,
profile: LocalGenerationProfile,
) -> Result<String, String> {
let (model, cache_key) = {
let guard = self.inner.read().await;
match (guard.phase, &guard.model) {
(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_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(request)
.await
.map_err(|err| err.to_string())?;
let content = response
.choices
.first()
.and_then(|choice| choice.message.content.clone())
.ok_or_else(|| "The model did not return a response.".to_string())?;
let message = sanitize_message(&content);
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.
pub(crate) fn sanitize_message(raw: &str) -> String {
let mut text = raw.trim().to_string();
if text.starts_with("```") {
text = match text.split_once('\n') {
// Drop the opening fence line (which may carry a language tag) and the closing fence.
Some((_fence, rest)) => rest.trim_end().trim_end_matches("```").trim().to_string(),
None => text.trim_matches('`').trim().to_string(),
};
}
let trimmed = text.trim();
if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
return trimmed[1..trimmed.len() - 1].trim().to_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());
}
// Rough token estimate — small models often have an 8-32k context window.
const MAX_CHARS: usize = 24_000;
let diff = truncate_at_char_boundary(diff, MAX_CHARS);
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();
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 diff:\n{diff}"));
Ok((system, user))
}
+556 -150
View File
File diff suppressed because it is too large Load Diff
+14 -6
View File
@@ -3,24 +3,27 @@
mod git;
use git::{
apply_file_patch, cancel_code_search, checkout_branch, commit, 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, pull, push, read_conflict,
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,
SearchCancellationState,
};
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(SearchCancellationState::default())
.manage(commit_ai::CommitAiEngine::new())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
open_repository,
open_repo_in_explorer,
open_repository_file,
get_status,
list_branches,
checkout_branch,
@@ -33,6 +36,10 @@ fn main() {
get_file_patch,
apply_file_patch,
commit,
commit_ai_status,
commit_ai_load,
commit_ai_local_models,
commit_ai_generate,
pull,
push,
list_commits,
@@ -42,6 +49,7 @@ fn main() {
list_repository_files,
open_repository_bundle,
list_file_history,
cancel_file_history,
compare_commits,
compare_file_to_head,
compare_file_to_parent,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "GitLite",
"version": "2026.7.4",
"version": "2026.7.7",
"identifier": "com.git-lite",
"build": {
"beforeDevCommand": "npm run dev",
+414 -34
View File
@@ -5,11 +5,13 @@
import { AlertCircle, BookOpen, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
import TitleBar from "./lib/TitleBar.svelte";
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
import BranchPanel from "./lib/components/BranchPanel.svelte";
import CommitPanel from "./lib/components/CommitPanel.svelte";
import CompareDialog from "./lib/components/CompareDialog.svelte";
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
import CredentialDialog from "./lib/components/CredentialDialog.svelte";
import DiscardConfirmDialog from "./lib/components/DiscardConfirmDialog.svelte";
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
@@ -25,8 +27,13 @@
import {
checkoutBranch,
commit,
commitAiGenerate,
commitAiLoad,
commitAiLocalModels,
commitAiStatus,
compareCommits,
cancelCodeSearch,
cancelFileHistory,
applyFilePatch,
createBranch,
deleteBranch,
@@ -39,6 +46,7 @@
listRepositoryFiles,
mergeBranch,
openRepoInExplorer,
openRepositoryFile,
openRepositoryBundle,
pull,
push,
@@ -60,6 +68,8 @@
} from "./lib/git";
import type {
AiSettings,
CommitAiPhase,
ConflictFile,
ExplorerNode,
ExplorerNodeKind,
@@ -72,6 +82,7 @@
GitRepositoryFile,
GitSearchHit,
GitStatus,
LocalModelOption,
PatchApplyAction,
PreparedResolution,
StoredCredential,
@@ -86,6 +97,9 @@
type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository";
type PendingDiscard =
| { kind: "file"; file: GitFileStatus; staged: boolean }
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
interface RepoTab {
path: string;
@@ -99,6 +113,11 @@
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
const COMMIT_PANEL_DEFAULT_HEIGHT = 220;
const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT;
const COMMIT_PANEL_MAX_HEIGHT = 640;
// ── State ──────────────────────────────────────────────────────────────────
@@ -119,7 +138,16 @@
let fileHistory: GitCommit[] = [];
let fileHistoryLoading = false;
let fileHistoryRequestId = 0;
let activeFileHistoryRequestId = "";
let lastFileHistoryHeadHash = "";
let commitMessage = "";
let lastLocalAiGeneratedMessage = "";
let commitAiPhase: CommitAiPhase = "idle";
let commitAiGenerating = false;
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
let aiSettings: AiSettings = defaultAiSettings();
let aiSettingsOpen = false;
let localModelOptions: LocalModelOption[] = [];
let errorMessage = "";
let operation = "";
let compareFrom = "";
@@ -138,6 +166,7 @@
let linePatchText = "";
let linePatchLoading = false;
let linePatchError = "";
let pendingDiscard: PendingDiscard | null = null;
let globalSearchOpen = false;
let lastSearchQuery = "";
let globalSearchResults: GitSearchHit[] = [];
@@ -167,6 +196,10 @@
let updateCheckInFlight = false;
let updateDownloadTotal = 0;
let updateDownloadedBytes = 0;
let commitPanelHeight = loadCommitPanelHeight();
let resizingCommitPanel = false;
let resizeStartY = 0;
let resizeStartHeight = 0;
// ── Derived ────────────────────────────────────────────────────────────────
@@ -203,10 +236,13 @@
loadRepoLists();
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
void checkForUpdates();
void initCommitAi();
});
onDestroy(() => {
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
});
// ── Auto-refresh ───────────────────────────────────────────────────────────
@@ -225,15 +261,126 @@
applyStatus(nextStatus);
// Something changed — reload branches, commits and files in one bundled call.
const bundle = await openRepositoryBundle(activeRepoPath, 100);
const previousHeadHash = lastFileHistoryHeadHash;
await refreshBranchList(activeRepoPath, bundle.branches);
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
await refreshFileHistory(activeRepoPath);
// File history reflects `git log`, which only changes when HEAD actually moves
// (new commit, checkout, merge, ...) — skip the reload otherwise so a plain
// working-tree/status change (staging, edits) doesn't keep re-fetching and
// flickering the currently viewed file's history.
if (lastFileHistoryHeadHash !== previousHeadHash) {
await refreshFileHistory(activeRepoPath);
}
} catch { /* ignore transient errors */ } finally {
autoRefreshInFlight = false;
}
}
// ── Commit AI ──────────────────────────────────────────────────────────────
function stopCommitAiPolling() {
if (commitAiPollTimer) { clearInterval(commitAiPollTimer); commitAiPollTimer = undefined; }
}
async function pollCommitAiStatus() {
try {
const result = await commitAiStatus();
commitAiPhase = result.phase;
} catch { /* ignore transient errors */ }
if (commitAiPhase === "ready" || commitAiPhase === "error") stopCommitAiPolling();
}
function startCommitAiPolling() {
// Only the local model has a download/load phase worth polling — cloud providers are
// plain API calls with nothing to wait for.
stopCommitAiPolling();
if (aiSettings.provider !== "local") return;
void pollCommitAiStatus();
commitAiPollTimer = setInterval(() => { void pollCommitAiStatus(); }, 2000);
}
async function initCommitAi() {
aiSettings = loadAiSettings();
try {
localModelOptions = await commitAiLocalModels();
} catch { /* AI features stay disabled if this fails; not fatal to the app */ }
if (aiSettings.provider === "local") {
try { await commitAiLoad(aiSettings.localModelId); } catch { /* surfaced via status polling */ }
}
startCommitAiPolling();
}
function saveAiSettings(next: AiSettings) {
const modelChanged = next.provider === "local" && next.localModelId !== aiSettings.localModelId;
aiSettings = next;
persistAiSettings(next);
aiSettingsOpen = false;
if (next.provider === "local" && (modelChanged || commitAiPhase === "idle")) {
commitAiPhase = "idle";
void commitAiLoad(next.localModelId);
}
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;
commitAiGenerating = true;
errorMessage = "";
try {
const notes = commitMessage.trim() || undefined;
if (aiSettings.provider === "local") {
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, {
provider: "openai",
notes,
model: aiSettings.openaiModel,
apiKey: cred?.password,
});
lastLocalAiGeneratedMessage = "";
} else if (aiSettings.provider === "anthropic") {
const cred = await credLoad("ai:anthropic");
commitMessage = await commitAiGenerate(activeRepoPath, {
provider: "anthropic",
notes,
model: aiSettings.anthropicModel,
apiKey: cred?.password,
});
lastLocalAiGeneratedMessage = "";
} else {
const cred = await credLoad("ai:custom");
commitMessage = await commitAiGenerate(activeRepoPath, {
provider: "custom",
notes,
model: aiSettings.customModel,
baseUrl: aiSettings.customBaseUrl,
apiKey: cred?.password,
});
lastLocalAiGeneratedMessage = "";
}
} catch (error) {
errorMessage = errorToMessage(error);
} finally {
commitAiGenerating = false;
}
}
function toggleAutoRefresh() {
autoRefreshEnabled = !autoRefreshEnabled;
if (autoRefreshEnabled) void autoRefreshTick();
@@ -394,6 +541,92 @@
}
}
function defaultAiSettings(): AiSettings {
return {
provider: "openai",
localModelId: "qwen2.5-0.5b",
localProfile: "fast",
openaiModel: "gpt-4o-mini",
anthropicModel: "claude-3-5-haiku-latest",
customBaseUrl: "",
customModel: "",
};
}
function loadAiSettings(): AiSettings {
try {
const stored = JSON.parse(localStorage.getItem(AI_SETTINGS_KEY) ?? "null") as unknown;
if (stored && typeof stored === "object") {
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.
}
return defaultAiSettings();
}
function persistAiSettings(next: AiSettings) {
try {
localStorage.setItem(AI_SETTINGS_KEY, JSON.stringify(next));
} catch {
// Local storage is best-effort only; AI generation must keep working without it.
}
}
function clampCommitPanelHeight(value: number): number {
return Math.min(COMMIT_PANEL_MAX_HEIGHT, Math.max(COMMIT_PANEL_MIN_HEIGHT, Math.round(value)));
}
function loadCommitPanelHeight(): number {
try {
const stored = Number(localStorage.getItem(COMMIT_PANEL_HEIGHT_KEY));
if (Number.isFinite(stored) && stored > 0) return clampCommitPanelHeight(stored);
} catch {
// Fall through to the default below.
}
return COMMIT_PANEL_DEFAULT_HEIGHT;
}
function persistCommitPanelHeight(value: number) {
try {
localStorage.setItem(COMMIT_PANEL_HEIGHT_KEY, String(value));
} catch {
// Local storage is best-effort only; resizing must keep working without it.
}
}
function startCommitPanelResize(event: PointerEvent) {
event.preventDefault();
resizingCommitPanel = true;
resizeStartY = event.clientY;
resizeStartHeight = commitPanelHeight;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function onCommitPanelResizeMove(event: PointerEvent) {
if (!resizingCommitPanel) return;
commitPanelHeight = clampCommitPanelHeight(resizeStartHeight + (resizeStartY - event.clientY));
}
function endCommitPanelResize(event: PointerEvent) {
if (!resizingCommitPanel) return;
resizingCommitPanel = false;
persistCommitPanelHeight(commitPanelHeight);
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
function onCommitPanelResizeKeydown(event: KeyboardEvent) {
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
event.preventDefault();
commitPanelHeight = clampCommitPanelHeight(commitPanelHeight + (event.key === "ArrowUp" ? 20 : -20));
persistCommitPanelHeight(commitPanelHeight);
}
function rememberRecentRepo(path: string) {
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
persistRepoLists();
@@ -426,6 +659,7 @@
}
branches = [];
commits = [];
lastFileHistoryHeadHash = "";
repoFiles = [];
selectedExplorerPath = "";
selectedExplorerKind = "file";
@@ -516,6 +750,7 @@
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
commits = prefetched ?? (await listCommits(path, 100));
lastFileHistoryHeadHash = commits[0]?.hash ?? "";
const hashes = new Set(commits.map((c) => c.hash));
if (compareFrom && !hashes.has(compareFrom)) compareFrom = "";
if (compareTo && !hashes.has(compareTo)) compareTo = "";
@@ -534,14 +769,56 @@
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
selectedExplorerPath = "";
selectedExplorerKind = "file";
cancelActiveFileHistoryLoad();
activeFileHistoryRequestId = "";
fileHistoryLoading = false;
fileHistory = [];
}
}
function isCancellationMessage(message: string): boolean {
return message.toLowerCase().includes("cancelled");
}
function cancelActiveFileHistoryLoad() {
const requestId = activeFileHistoryRequestId;
if (!requestId) return;
void cancelFileHistory(requestId).catch(() => {});
}
async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath) {
const requestId = ++fileHistoryRequestId;
const history = file ? await listFileHistory(path, file, 100) : [];
if (requestId === fileHistoryRequestId) fileHistory = history;
cancelActiveFileHistoryLoad();
if (!path || !file) {
activeFileHistoryRequestId = "";
fileHistoryLoading = false;
fileHistory = [];
return;
}
const historyRequestId = `file-history-${requestId}-${Date.now()}`;
activeFileHistoryRequestId = historyRequestId;
fileHistoryLoading = true;
fileHistory = [];
try {
const history = await listFileHistory(path, file, 100, historyRequestId);
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
fileHistory = history;
}
} catch (error) {
const message = errorToMessage(error);
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
fileHistory = [];
if (!isCancellationMessage(message)) errorMessage = message;
}
} finally {
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
activeFileHistoryRequestId = "";
fileHistoryLoading = false;
}
}
}
// ── Repository operations ──────────────────────────────────────────────────
@@ -574,7 +851,7 @@
if (isBusy) return;
try {
const selected = await openDialog({
title: "Repository folder auswaehlen",
title: "Select repository folder",
directory: true,
multiple: false,
defaultPath: repoPath.trim() || activeRepoPath || undefined,
@@ -783,7 +1060,7 @@
if (auth) {
if (key) void credDelete(key).catch(() => {});
credDialogError =
"Zugangsdaten wurden abgelehnt oder sind abgelaufen. Bitte erneut anmelden.";
"Credentials were rejected or have expired. Please sign in again.";
credDialogAction = action;
credDialogKey = key;
credDialogOpen = true;
@@ -792,7 +1069,7 @@
errorMessage = message;
}
} else {
credDialogError = message || "Anmeldung fehlgeschlagen.";
credDialogError = message || "Sign-in failed.";
}
}
@@ -830,11 +1107,11 @@
if (errorMessage && !isAuthError(errorMessage) && isNonFastForwardPushError(errorMessage)) {
errorMessage = "";
const shouldSync = window.confirm(
"Der Remote hat neuere Commits, deshalb wurde der Push abgelehnt.\n\nJetzt Pull/Merge ausfuehren und danach den Push erneut versuchen?",
"The remote has newer commits, so the push was rejected.\n\nRun Pull/Merge now and try pushing again afterwards?",
);
if (!shouldSync) {
const message = "Push abgelehnt: Der Remote hat neuere Commits. Pull zuerst ausfuehren, dann erneut pushen.";
const message = "Push rejected: the remote has newer commits. Pull first, then push again.";
if (fromStore) errorMessage = message;
else credDialogError = message;
return;
@@ -858,7 +1135,7 @@
if (statusHasConflicts(status)) {
credDialogOpen = false;
credDialogAction = null;
errorMessage = "Pull hat Merge-Konflikte erzeugt. Loese die Konflikte, committe den Merge und pushe danach erneut.";
errorMessage = "Pull produced merge conflicts. Resolve the conflicts, commit the merge, and then push again.";
return;
}
@@ -933,7 +1210,12 @@
});
}
async function discardFile(file: GitFileStatus, staged: boolean) {
function discardFile(file: GitFileStatus, staged: boolean) {
if (!activeRepoPath || isBusy) return;
pendingDiscard = { kind: "file", file, staged };
}
async function runDiscardFile(file: GitFileStatus, staged: boolean) {
await runOperation(`Discarding ${file.path}`, async () => {
applyStatus(await restoreFiles(activeRepoPath, [file.path], staged));
await refreshExplorerFiles(activeRepoPath);
@@ -984,9 +1266,17 @@
}
}
async function applyLinePatch(action: PatchApplyAction, patch: string) {
if (!activeRepoPath || !linePatchFile || isBusy) return;
const file = linePatchFile;
function isDiscardPatchAction(action: PatchApplyAction): boolean {
return action === "discard-staged" || action === "discard-unstaged";
}
async function runLinePatchAction(
action: PatchApplyAction,
patch: string,
file: GitFileStatus,
staged: boolean,
) {
if (!activeRepoPath || isBusy) return;
operation = patchOperationLabel(action, file);
errorMessage = "";
linePatchError = "";
@@ -996,7 +1286,7 @@
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
const updatedPatch = await getFilePatch(activeRepoPath, file.path, linePatchStaged);
const updatedPatch = await getFilePatch(activeRepoPath, file.path, staged);
if (updatedPatch.trim()) {
linePatchText = updatedPatch;
} else {
@@ -1012,6 +1302,37 @@
}
}
async function applyLinePatch(action: PatchApplyAction, patch: string) {
if (!activeRepoPath || !linePatchFile || isBusy) return;
const file = linePatchFile;
const staged = linePatchStaged;
if (isDiscardPatchAction(action)) {
pendingDiscard = { kind: "hunk", file, staged, action, patch };
return;
}
await runLinePatchAction(action, patch, file, staged);
}
async function confirmDiscard() {
const discard = pendingDiscard;
if (!discard || !activeRepoPath || isBusy) return;
if (discard.kind === "file") {
await runDiscardFile(discard.file, discard.staged);
} else {
await runLinePatchAction(discard.action, discard.patch, discard.file, discard.staged);
}
pendingDiscard = null;
}
function closeDiscardConfirm() {
if (isBusy) return;
pendingDiscard = null;
}
async function stageAllFiles() {
const paths = changedFiles.filter((f) => f.unstaged !== null).map((f) => f.path);
if (paths.length === 0) return;
@@ -1040,6 +1361,7 @@
await runOperation("Committing", async () => {
applyStatus(await commit(activeRepoPath, message));
commitMessage = "";
lastLocalAiGeneratedMessage = "";
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
@@ -1120,26 +1442,14 @@
// (isBusy/runOperation would disable every button in the app while this awaits).
// A request id guards against a slower, stale request overwriting a newer selection.
async function loadSelectedFileHistory(path: string, repo = activeRepoPath) {
const requestId = ++fileHistoryRequestId;
fileHistoryLoading = true;
try {
const history = await listFileHistory(repo, path, 100);
if (requestId === fileHistoryRequestId) fileHistory = history;
} catch (error) {
if (requestId === fileHistoryRequestId) {
fileHistory = [];
errorMessage = errorToMessage(error);
}
} finally {
if (requestId === fileHistoryRequestId) fileHistoryLoading = false;
}
await refreshFileHistory(repo, path);
}
async function selectExplorerNode(node: ExplorerNode) {
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
selectedExplorerPath = node.path;
selectedExplorerKind = node.kind;
await loadSelectedFileHistory(node.path);
void loadSelectedFileHistory(node.path);
}
async function selectFileFromSearch(file: GitRepositoryFile) {
@@ -1148,7 +1458,29 @@
selectedExplorerKind = "file";
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
await loadSelectedFileHistory(file.path);
void loadSelectedFileHistory(file.path);
}
function selectFileFromStatus(file: GitFileStatus) {
if (!activeRepoPath) return;
selectedExplorerPath = file.path;
selectedExplorerKind = "file";
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
void loadSelectedFileHistory(file.path);
}
async function openFileFromExplorer(node: ExplorerNode) {
if (!activeRepoPath || node.kind !== "file") return;
selectedExplorerPath = node.path;
selectedExplorerKind = "file";
void loadSelectedFileHistory(node.path);
try {
await openRepositoryFile(activeRepoPath, node.path);
} catch (error) {
errorMessage = errorToMessage(error);
}
}
async function restoreSelectedFileFromCommit(target: GitCommit) {
@@ -1239,7 +1571,7 @@
} catch (error) {
if (globalSearchId === searchId) {
const message = errorToMessage(error);
globalSearchError = message.includes("abgebrochen") ? "Suche wurde abgebrochen." : message;
globalSearchError = message.includes("cancelled") ? "Search was cancelled." : message;
}
} finally {
if (globalSearchId === searchId) {
@@ -1252,7 +1584,7 @@
async function cancelGlobalSearch() {
if (!globalSearchId) return;
const searchId = globalSearchId;
globalSearchError = "Abbruch wird angefordert...";
globalSearchError = "Requesting cancellation...";
try {
await cancelCodeSearch(searchId);
} catch (error) {
@@ -1331,7 +1663,8 @@
// ── Event handlers ─────────────────────────────────────────────────────────
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
if (event.key === "Escape" && pendingDiscard && !isBusy) closeDiscardConfirm();
else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
@@ -1590,6 +1923,7 @@
onExpandAllFolders={expandAllExplorerFolders}
onCollapseAllFolders={collapseAllExplorerFolders}
onSelectNode={selectExplorerNode}
onOpenFile={openFileFromExplorer}
/>
</aside>
@@ -1610,7 +1944,7 @@
</div>
</div>
<div class="top-section">
<div class="top-section" style="--commit-panel-height: {commitPanelHeight}px;">
<StatusPanel
{changedFiles}
{stagedCount}
@@ -1618,6 +1952,8 @@
{hasRepository}
{isBusy}
{status}
selectedFilePath={selectedExplorerPath}
onSelectFile={selectFileFromStatus}
onStage={stageFile}
onUnstage={unstageFile}
onDiscard={discardFile}
@@ -1625,6 +1961,24 @@
onStageAll={stageAllFiles}
onUnstageAll={unstageAllFiles}
/>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="panel-resize-handle"
class:resizing={resizingCommitPanel}
role="separator"
aria-orientation="horizontal"
aria-label="Resize commit panel height"
aria-valuenow={commitPanelHeight}
aria-valuemin={COMMIT_PANEL_MIN_HEIGHT}
aria-valuemax={COMMIT_PANEL_MAX_HEIGHT}
tabindex="0"
onpointerdown={startCommitPanelResize}
onpointermove={onCommitPanelResizeMove}
onpointerup={endCommitPanelResize}
onpointercancel={endCommitPanelResize}
onkeydown={onCommitPanelResizeKeydown}
></div>
<CommitPanel
{commitMessage}
{canCommit}
@@ -1633,8 +1987,13 @@
{isBusy}
{operation}
{stagedCount}
commitAiProvider={aiSettings.provider}
{commitAiPhase}
{commitAiGenerating}
onCommit={commitChanges}
onCommitMessageChange={(msg) => { commitMessage = msg; }}
onCommitMessageChange={updateCommitMessage}
onGenerateCommitMessage={generateCommitMessageWithAi}
onOpenAiSettings={() => { aiSettingsOpen = true; }}
/>
</div>
</section>
@@ -1698,6 +2057,17 @@
/>
{/if}
{#if pendingDiscard}
<DiscardConfirmDialog
file={pendingDiscard.file}
staged={pendingDiscard.staged}
scope={pendingDiscard.kind === "hunk" ? "hunk" : "file"}
{isBusy}
onConfirm={confirmDiscard}
onClose={closeDiscardConfirm}
/>
{/if}
{#if globalSearchOpen}
<GlobalSearchDialog
{hasRepository}
@@ -1738,6 +2108,16 @@
/>
{/if}
<!-- Choose the AI provider/model used to generate commit messages -->
{#if aiSettingsOpen}
<AiSettingsDialog
settings={aiSettings}
localModels={localModelOptions}
onSave={saveAiSettings}
onClose={() => { aiSettingsOpen = false; }}
/>
{/if}
<!-- Compare: pick the two commits to diff -->
{#if compareSelectOpen}
<CompareSelectDialog
+256 -13
View File
@@ -12,6 +12,7 @@
--color-surface-dim: rgba(18, 18, 30, 0.9);
--color-surface-hover: rgba(47, 48, 78, 0.76);
--color-surface-raised: rgba(28, 29, 48, 0.88);
--color-surface-solid: #1c1d30;
--color-border: rgba(100, 108, 255, 0.28);
--color-border-subtle: rgba(255, 255, 255, 0.08);
@@ -149,6 +150,19 @@
background: linear-gradient(180deg, rgba(65, 209, 255, 0.18), rgba(100, 108, 255, 0.16));
}
.btn-danger {
border-color: rgba(255, 90, 103, 0.72);
color: #ffffff;
background: linear-gradient(135deg, rgba(255, 90, 103, 0.92), rgba(195, 44, 64, 0.9));
box-shadow: 0 0 22px rgba(255, 90, 103, 0.16);
font-weight: 700;
}
.btn-danger:hover:not(:disabled) {
border-color: rgba(255, 161, 169, 0.86);
color: #ffffff;
background: linear-gradient(135deg, rgba(255, 111, 124, 0.96), rgba(214, 55, 77, 0.95));
}
.panel {
min-width: 0;
min-height: 0;
@@ -274,6 +288,15 @@
}
.titlebar-brand svg { color: #ffd343; filter: drop-shadow(0 0 10px rgba(255,211,67,0.3)); flex-shrink: 0; }
.tb-version {
margin-left: 1px;
color: rgba(255,255,255,0.32);
font-size: 10px;
font-weight: 600;
font-family: var(--font-mono);
letter-spacing: 0.01em;
}
.titlebar-info {
display: flex;
align-items: center;
@@ -930,19 +953,61 @@
.top-section {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) minmax(210px, auto);
grid-template-rows: minmax(120px, 1fr) 14px var(--commit-panel-height, 220px);
min-height: 0;
gap: 8px;
gap: 0;
padding: 8px;
overflow: hidden;
}
.panel-resize-handle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
cursor: row-resize;
touch-action: none;
}
.panel-resize-handle::before {
content: "";
width: 40px;
height: 3px;
border-radius: 999px;
background: var(--color-border);
transition: background-color 0.15s ease;
}
.panel-resize-handle:hover::before,
.panel-resize-handle.resizing::before {
background: var(--color-accent);
}
.panel-resize-handle:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: -2px;
border-radius: 4px;
}
/* --- File list / change lanes --- */
.file-list { padding: 6px; overflow: auto; }
.file-row { display: grid; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
.file-row + .file-row { margin-top: 6px; }
.file-row.selected { border-color: rgba(90,140,248,0.36); background: rgba(90,140,248,0.1); }
.file-title-button {
justify-content: flex-start;
width: 100%;
min-height: 24px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
text-align: left;
}
.file-title-button:hover:not(:disabled) {
background: transparent;
color: var(--color-accent);
}
.file-title strong {
display: block;
@@ -1175,8 +1240,8 @@
.branch-actions { display: flex; align-items: center; justify-content: flex-end; gap: 5px; }
.branch-context-menu {
position: absolute;
.branch-context-menu,
.explorer-context-menu {
z-index: 120;
display: grid;
gap: 2px;
@@ -1184,11 +1249,15 @@
padding: 5px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
box-shadow: 0 18px 50px rgba(0,0,0,0.35);
background: var(--color-surface-solid);
box-shadow: 0 18px 50px rgba(0,0,0,0.5);
}
.branch-context-menu button {
.branch-context-menu { position: absolute; }
.explorer-context-menu { position: fixed; }
.branch-context-menu button,
.explorer-context-menu button {
display: flex;
align-items: center;
justify-content: flex-start;
@@ -1205,7 +1274,8 @@
text-align: left;
}
.branch-context-menu button:hover:not(:disabled) {
.branch-context-menu button:hover:not(:disabled),
.explorer-context-menu button:hover:not(:disabled) {
border-color: var(--color-border-subtle);
background: rgba(255,255,255,0.06);
color: var(--color-ink);
@@ -1221,13 +1291,16 @@
color: #ffd0d6;
}
.branch-context-menu button:disabled {
.branch-context-menu button:disabled,
.explorer-context-menu button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
/* --- Explorer --- */
.explorer-panel { position: relative; }
.explorer-head-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
.explorer-bulk-button {
width: 26px;
@@ -1296,8 +1369,37 @@
/* --- Commit form --- */
.commit-form { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; gap: 8px; padding: 10px; }
.commit-form textarea { flex: 1 1 0; min-height: 80px; resize: none; }
.commit-panel {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.commit-panel .section-head { flex: 0 0 auto; }
.commit-form {
display: grid;
grid-template-rows: minmax(0, 1fr) auto auto;
flex: 1 1 0;
min-height: 0;
gap: 8px;
padding: 10px;
overflow: hidden;
}
.commit-form textarea {
min-height: 0;
height: 100%;
resize: none;
overflow: auto;
}
.commit-actions-row {
display: flex;
flex: 0 0 auto;
gap: 8px;
min-width: 0;
}
.commit-actions-row .btn-primary { min-width: 0; }
.commit-ai-button { flex: 0 0 auto; min-width: 64px; justify-content: center; }
.commit-ai-settings-button { flex: 0 0 auto; width: 38px; min-width: 38px; padding: 0; justify-content: center; }
.commit-block-reason {
margin: 0;
padding: 8px 10px;
@@ -1569,6 +1671,94 @@
max-height: calc(100vh - 32px);
overflow: auto;
}
.discard-confirm-dialog {
display: grid;
grid-template-rows: auto auto auto;
width: min(560px, calc(100vw - 32px));
height: auto;
max-height: calc(100vh - 32px);
overflow: auto;
}
.ai-settings-dialog {
display: block;
width: min(560px, calc(100vw - 32px));
height: auto;
max-height: calc(100vh - 32px);
overflow: auto;
}
.ai-settings-form {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
}
.ai-provider-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.ai-provider-option {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 38px;
padding: 0 10px;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
background: rgba(255,255,255,0.03);
color: var(--color-ink-dim);
font-size: 12.5px;
font-weight: 700;
}
.ai-provider-option:hover {
border-color: var(--color-border);
color: var(--color-ink);
background: var(--color-surface-hover);
}
.ai-provider-option.active {
border-color: rgba(100,108,255,0.5);
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;
@@ -1778,6 +1968,59 @@
.prepared-tag { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; color: #4eca76; font-size: 12px; font-weight: 700; }
.discard-confirm-body {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 14px;
padding: 18px 16px 16px;
}
.discard-warning-icon {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border: 1px solid rgba(255, 90, 103, 0.32);
border-radius: 10px;
color: #ff9aa4;
background: rgba(255, 90, 103, 0.1);
}
.discard-confirm-copy {
display: grid;
gap: 10px;
min-width: 0;
color: var(--color-ink-muted);
font-size: 13px;
line-height: 1.45;
}
.discard-confirm-copy p { margin: 0; }
.discard-target {
display: block;
min-width: 0;
max-height: 84px;
overflow: auto;
padding: 8px 9px;
border: 1px solid var(--color-border-subtle);
border-radius: 6px;
color: var(--color-ink);
background: rgba(0, 0, 0, 0.18);
font-family: var(--font-mono);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
.discard-warning-text {
color: #ffb8bf;
font-weight: 650;
}
.discard-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px 14px;
border-top: 1px solid var(--color-border-subtle);
background: var(--color-surface-dim);
}
.line-patch-body {
display: grid;
grid-template-rows: minmax(0, 1fr);
@@ -2814,7 +3057,7 @@
/* Stack CommitPanel below StatusPanel; history panels stay side by side */
@media (max-width: 1100px) {
.workspace { grid-template-columns: clamp(185px, 16vw, 220px) minmax(0, 1fr) clamp(380px, 38vw, 500px); }
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 190px; }
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); }
}
/* Compact: stack history panels vertically, narrow sidebars */
@@ -2840,7 +3083,7 @@
.workspace { grid-template-columns: 1fr; gap: 6px; }
.history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(200px, 1fr) minmax(150px, 0.5fr); min-height: 380px; }
.left-sidebar { grid-template-rows: minmax(200px, 1fr) minmax(240px, 1.2fr); min-height: 440px; }
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 190px; }
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); }
.repo-form { grid-template-columns: 1fr; }
.repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; }
.repo-tab.management { min-width: 0; }
+6
View File
@@ -1,5 +1,6 @@
<script lang="ts">
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";
@@ -23,12 +24,14 @@
const win = getCurrentWindow();
let isMaximized = false;
let unlisten: (() => void) | undefined;
let appVersion = "";
onMount(async () => {
isMaximized = await win.isMaximized();
unlisten = await win.onResized(async () => {
isMaximized = await win.isMaximized();
});
appVersion = await getVersion();
});
onDestroy(() => {
@@ -58,6 +61,9 @@
<line x1="12" y1="12" x2="12" y2="15" />
</svg>
<span data-tauri-drag-region>GitLite</span>
{#if appVersion}
<span class="tb-version" data-tauri-drag-region title="Version {appVersion}">v{appVersion}</span>
{/if}
</div>
<!-- Center: repo + branch info -->
+299
View File
@@ -0,0 +1,299 @@
<script lang="ts">
import { onMount } from "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, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
interface Props {
settings: AiSettings;
localModels: LocalModelOption[];
onSave: (settings: AiSettings) => void;
onClose: () => void;
}
let { settings, localModels = [], onSave, onClose }: Props = $props();
type CloudProvider = Exclude<CommitAiProvider, "local">;
const CRED_KEYS: Record<CloudProvider, string> = {
openai: "ai:openai",
anthropic: "ai:anthropic",
custom: "ai:custom",
};
let provider = $state<CommitAiProvider>("local");
let localModelId = $state("");
let localProfile = $state<CommitAiLocalProfile>("fast");
let openaiModel = $state("");
let anthropicModel = $state("");
let customBaseUrl = $state("");
let customModel = $state("");
let openaiApiKey = $state("");
let anthropicApiKey = $state("");
let customApiKey = $state("");
let showKey = $state(false);
let loadingKeys = $state(true);
let saving = $state(false);
let error = $state("");
$effect(() => {
provider = settings.provider;
localModelId = settings.localModelId;
localProfile = settings.localProfile ?? "fast";
openaiModel = settings.openaiModel;
anthropicModel = settings.anthropicModel;
customBaseUrl = settings.customBaseUrl;
customModel = settings.customModel;
});
onMount(() => {
(async () => {
try {
const [openai, anthropic, custom] = await Promise.all([
credLoad(CRED_KEYS.openai),
credLoad(CRED_KEYS.anthropic),
credLoad(CRED_KEYS.custom),
]);
openaiApiKey = openai?.password ?? "";
anthropicApiKey = anthropic?.password ?? "";
customApiKey = custom?.password ?? "";
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loadingKeys = false;
}
})();
});
async function persistKey(target: CloudProvider, value: string) {
const key = CRED_KEYS[target];
const trimmed = value.trim();
if (trimmed) {
await credSave(key, "api-key", trimmed, null);
} else {
await credDelete(key);
}
}
async function handleSave() {
saving = true;
error = "";
try {
await Promise.all([
persistKey("openai", openaiApiKey),
persistKey("anthropic", anthropicApiKey),
persistKey("custom", customApiKey),
]);
onSave({
provider,
localModelId,
localProfile,
openaiModel: openaiModel.trim() || "gpt-4o-mini",
anthropicModel: anthropicModel.trim() || "claude-3-5-haiku-latest",
customBaseUrl: customBaseUrl.trim(),
customModel: customModel.trim(),
});
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
saving = false;
}
}
function formatSize(mb: number): string {
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>
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog ai-settings-dialog" role="dialog" aria-modal="true" aria-label="AI settings" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Commit AI</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">AI settings</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
<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 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" />
OpenAI
</button>
<button type="button" class="ai-provider-option" class:active={provider === "anthropic"} onclick={() => { provider = "anthropic"; }}>
<Bot size={16} aria-hidden="true" />
Anthropic (Claude)
</button>
<button type="button" class="ai-provider-option" class:active={provider === "custom"} onclick={() => { provider = "custom"; }}>
<Globe size={16} aria-hidden="true" />
Custom endpoint
</button>
</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}>
{#each localModels as option (option.id)}
<option value={option.id}>{option.label} {formatSize(option.approx_size_mb)}</option>
{/each}
</select>
</label>
<div class="cred-token-hint">
<AlertCircle size={13} aria-hidden="true" />
<span>
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"}
<label class="cred-field">
<span class="cred-field-label">Model</span>
<input type="text" bind:value={openaiModel} placeholder="gpt-4o-mini" autocomplete="off" spellcheck="false" />
</label>
<div class="cred-field">
<span class="cred-field-label">API key</span>
<div class="cred-input">
<Key size={15} class="cred-field-icon" aria-hidden="true" />
<input
type={showKey ? "text" : "password"}
bind:value={openaiApiKey}
placeholder="sk-..."
autocomplete="off"
spellcheck="false"
disabled={loadingKeys}
/>
<button type="button" class="cred-reveal" tabindex="-1" onclick={() => { showKey = !showKey; }} aria-label={showKey ? "Hide" : "Show"}>
{#if showKey}<EyeOff size={14} aria-hidden="true" />{:else}<Eye size={14} aria-hidden="true" />{/if}
</button>
</div>
</div>
{:else if provider === "anthropic"}
<label class="cred-field">
<span class="cred-field-label">Model</span>
<input type="text" bind:value={anthropicModel} placeholder="claude-3-5-haiku-latest" autocomplete="off" spellcheck="false" />
</label>
<div class="cred-field">
<span class="cred-field-label">API key</span>
<div class="cred-input">
<Key size={15} class="cred-field-icon" aria-hidden="true" />
<input
type={showKey ? "text" : "password"}
bind:value={anthropicApiKey}
placeholder="sk-ant-..."
autocomplete="off"
spellcheck="false"
disabled={loadingKeys}
/>
<button type="button" class="cred-reveal" tabindex="-1" onclick={() => { showKey = !showKey; }} aria-label={showKey ? "Hide" : "Show"}>
{#if showKey}<EyeOff size={14} aria-hidden="true" />{:else}<Eye size={14} aria-hidden="true" />{/if}
</button>
</div>
</div>
{:else}
<label class="cred-field">
<span class="cred-field-label">Endpoint URL</span>
<input type="text" bind:value={customBaseUrl} placeholder="http://localhost:11434/v1" autocomplete="off" spellcheck="false" />
</label>
<label class="cred-field">
<span class="cred-field-label">Model</span>
<input type="text" bind:value={customModel} placeholder="llama3.1" autocomplete="off" spellcheck="false" />
</label>
<div class="cred-field">
<span class="cred-field-label">API key (optional)</span>
<div class="cred-input">
<Key size={15} class="cred-field-icon" aria-hidden="true" />
<input
type={showKey ? "text" : "password"}
bind:value={customApiKey}
placeholder="Optional"
autocomplete="off"
spellcheck="false"
disabled={loadingKeys}
/>
<button type="button" class="cred-reveal" tabindex="-1" onclick={() => { showKey = !showKey; }} aria-label={showKey ? "Hide" : "Show"}>
{#if showKey}<EyeOff size={14} aria-hidden="true" />{:else}<Eye size={14} aria-hidden="true" />{/if}
</button>
</div>
</div>
<div class="cred-token-hint">
<Globe size={13} aria-hidden="true" />
<span>For local OpenAI-compatible servers like Ollama or LM Studio. The base URL should end in /v1.</span>
</div>
{/if}
{#if error}
<p class="commit-block-reason">{error}</p>
{/if}
<div class="new-branch-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={saving}>
Cancel
</button>
<button class="btn-primary" type="submit" disabled={saving || loadingKeys}>
{#if saving}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Save
</button>
</div>
</form>
</div>
</div>
+1 -1
View File
@@ -252,7 +252,7 @@
}
</script>
<svelte:window on:click={closeBranchContextMenu} on:keydown={handleWindowKeydown} />
<svelte:window on:click={closeBranchContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeBranchContextMenu} />
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
<div class="section-head">
+63 -10
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { Check, LoaderCircle } from "@lucide/svelte";
import { Check, LoaderCircle, Settings, Sparkles } from "@lucide/svelte";
import type { CommitAiPhase, CommitAiProvider } from "../types";
interface Props {
commitMessage: string;
@@ -9,8 +10,13 @@
isBusy: boolean;
operation: string;
stagedCount: number;
commitAiProvider: CommitAiProvider;
commitAiPhase: CommitAiPhase;
commitAiGenerating: boolean;
onCommit: () => void;
onCommitMessageChange: (msg: string) => void;
onGenerateCommitMessage: () => void;
onOpenAiSettings: () => void;
}
let {
@@ -21,17 +27,38 @@
isBusy = false,
operation = "",
stagedCount = 0,
commitAiProvider = "local",
commitAiPhase = "idle",
commitAiGenerating = false,
onCommit = () => {},
onCommitMessageChange = () => {},
onGenerateCommitMessage = () => {},
onOpenAiSettings = () => {},
}: Props = $props();
function handleSubmit(event: SubmitEvent) {
event.preventDefault();
onCommit();
}
function aiButtonTitle(provider: CommitAiProvider, phase: CommitAiPhase, staged: number): string {
if (staged === 0) return "Stage changes first";
if (provider === "local" && phase === "loading") return "AI model is downloading/loading — this happens once";
if (provider === "local" && phase === "error") return "AI model failed to load — check AI settings";
return "Generate commit message with AI from the staged diff";
}
let localModelLoading = $derived(commitAiProvider === "local" && commitAiPhase === "loading");
let canGenerate = $derived(
hasRepository &&
!isBusy &&
!commitAiGenerating &&
stagedCount > 0 &&
(commitAiProvider !== "local" || commitAiPhase === "ready"),
);
</script>
<section class="panel flex flex-col" aria-label="Commit">
<section class="panel commit-panel" aria-label="Commit">
<div class="section-head">
<div>
<span class="eyebrow">Commit</span>
@@ -50,13 +77,39 @@
{#if commitBlockReason}
<p class="commit-block-reason">{commitBlockReason}</p>
{/if}
<button class="btn-primary w-full flex-shrink-0" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}>
{#if operation === "Committing"}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Commit
</button>
<div class="commit-actions-row flex-shrink-0">
<button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}>
{#if operation === "Committing"}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Commit
</button>
<button
class="btn-secondary commit-ai-button"
type="button"
onclick={onGenerateCommitMessage}
disabled={!canGenerate}
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
>
{#if commitAiGenerating || localModelLoading}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Sparkles size={16} aria-hidden="true" />
{/if}
AI
</button>
<button
class="btn-secondary commit-ai-settings-button"
type="button"
onclick={onOpenAiSettings}
disabled={isBusy}
title="AI settings"
aria-label="AI settings"
>
<Settings size={16} aria-hidden="true" />
</button>
</div>
</form>
</section>
+18 -18
View File
@@ -44,10 +44,10 @@
(mode === "token" || username.trim().length > 0),
);
let actionLabel = $derived(action === "push" ? "Push" : "Pull");
let actionTitle = $derived(action === "push" ? "Push authentifizieren" : "Pull authentifizieren");
let actionTitle = $derived(action === "push" ? "Authenticate push" : "Authenticate pull");
let actionHint = $derived(action === "push"
? "Der Remote braucht Schreibrechte. Nutze ein Passwort oder einen Token mit passenden Repository-Rechten."
: "Der Remote braucht Zugriff auf das Repository. Nutze deine Git-Zugangsdaten oder einen Personal Access Token.");
? "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.");
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
@@ -66,7 +66,7 @@
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<div class="cred-card" role="dialog" aria-modal="true" aria-label="Git-Zugangsdaten" tabindex="-1">
<div class="cred-card" role="dialog" aria-modal="true" aria-label="Git credentials" tabindex="-1">
<div class="cred-hero">
<div class="cred-hero-top">
<div class="cred-hero-icon">
@@ -80,7 +80,7 @@
<p class="cred-hero-label">{actionLabel} Remote</p>
<h2 class="cred-hero-title">{actionTitle}</h2>
</div>
<button class="cred-close" type="button" onclick={onCancel} title="Abbrechen" aria-label="Abbrechen">
<button class="cred-close" type="button" onclick={onCancel} title="Cancel" aria-label="Cancel">
<X size={16} aria-hidden="true" />
</button>
</div>
@@ -89,12 +89,12 @@
<div class="cred-security-note">
<ShieldCheck size={14} aria-hidden="true" />
<span>Beim Speichern landet der Token verschluesselt im Schluesselbund des Betriebssystems nie im Klartext.</span>
<span>When saved, the token is stored encrypted in the operating system's keychain — never in plain text.</span>
</div>
</div>
<form class="cred-body" onsubmit={handleSubmit}>
<div class="cred-segment" role="group" aria-label="Authentifizierungsart">
<div class="cred-segment" role="group" aria-label="Authentication method">
<button
type="button"
class="cred-seg-btn"
@@ -103,7 +103,7 @@
aria-pressed={mode === "credentials"}
>
<User size={13} aria-hidden="true" />
Username + Passwort
Username + password
</button>
<button
type="button"
@@ -127,7 +127,7 @@
id="cred-username"
type="text"
bind:value={username}
placeholder="z. B. mein-github-username"
placeholder="e.g. my-github-username"
autocomplete="username"
disabled={isBusy}
/>
@@ -137,7 +137,7 @@
<div class="cred-field">
<label class="cred-field-label" for="cred-password">
{mode === "token" ? "Token" : "Passwort"}
{mode === "token" ? "Token" : "Password"}
</label>
<div class="cred-input">
<Lock size={15} class="cred-field-icon" aria-hidden="true" />
@@ -146,8 +146,8 @@
type={showPassword ? "text" : "password"}
bind:value={password}
placeholder={mode === "token"
? "ghp_... oder anderer Zugangstoken"
: "Passwort oder Personal Access Token"}
? "ghp_... or another access token"
: "Password or personal access token"}
autocomplete="current-password"
disabled={isBusy}
/>
@@ -156,7 +156,7 @@
class="cred-reveal"
onclick={() => { showPassword = !showPassword; }}
tabindex="-1"
aria-label={showPassword ? "Verbergen" : "Anzeigen"}
aria-label={showPassword ? "Hide" : "Show"}
>
{#if showPassword}
<EyeOff size={14} aria-hidden="true" />
@@ -171,7 +171,7 @@
{#if mode === "token"}
<div class="cred-token-hint">
<Key size={13} aria-hidden="true" />
<span>Username wird automatisch auf <code>oauth2</code> gesetzt. Das funktioniert mit GitHub, GitLab und Bitbucket.</span>
<span>Username is automatically set to <code>oauth2</code>. This works with GitHub, GitLab, and Bitbucket.</span>
</div>
{/if}
@@ -184,26 +184,26 @@
{#if saveSession}
<div class="cred-expiry">
<label class="cred-field-label" for="cred-expiry">Ablaufdatum (optional)</label>
<label class="cred-field-label" for="cred-expiry">Expiration date (optional)</label>
<input
id="cred-expiry"
type="date"
bind:value={expiresAt}
disabled={isBusy}
/>
<span class="cred-expiry-hint">Nach diesem Datum wird automatisch erneut nach dem Login gefragt.</span>
<span class="cred-expiry-hint">After this date you'll automatically be asked to log in again.</span>
</div>
{/if}
<div class="cred-footer">
<label class="cred-save">
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
<span>Im Schluesselbund speichern</span>
<span>Save in keychain</span>
</label>
<div class="cred-btns">
<button type="button" class="cred-cancel" onclick={onCancel} disabled={isBusy}>
Abbrechen
Cancel
</button>
<button class="cred-submit" type="submit" disabled={!canSubmit}>
{#if isBusy}
@@ -0,0 +1,74 @@
<script lang="ts">
import { AlertTriangle, LoaderCircle, RotateCcw, X } from "@lucide/svelte";
import type { GitFileStatus } from "../types";
interface Props {
file: GitFileStatus;
staged: boolean;
scope: "file" | "hunk";
isBusy: boolean;
onConfirm: () => void | Promise<void>;
onClose: () => void;
}
let {
file,
staged = false,
scope = "file",
isBusy = false,
onConfirm = () => {},
onClose = () => {},
}: Props = $props();
let targetPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
let title = $derived(scope === "hunk" ? "Discard hunk?" : "Discard file changes?");
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
function closeFromBackdrop(event: MouseEvent) {
if (isBusy || event.target !== event.currentTarget) return;
onClose();
}
</script>
<div class="dialog-backdrop" role="presentation" onclick={closeFromBackdrop}>
<div class="dialog discard-confirm-dialog" role="dialog" aria-modal="true" aria-label={title}>
<header class="dialog-header">
<div>
<span class="eyebrow">Confirm discard</span>
<p class="dialog-title">{title}</p>
</div>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="discard-confirm-body">
<div class="discard-warning-icon" aria-hidden="true">
<AlertTriangle size={22} />
</div>
<div class="discard-confirm-copy">
<p>
This will reset the {sourceLabel} for the {scopeLabel.toLowerCase()} below.
</p>
<code class="discard-target" title={targetPath}>{targetPath}</code>
<p class="discard-warning-text">
This cannot be undone. If the file only exists in your working tree, it can be deleted entirely.
</p>
</div>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
<button class="btn-danger" type="button" onclick={onConfirm} disabled={isBusy}>
{#if isBusy}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
{:else}
<RotateCcw size={15} aria-hidden="true" />
{/if}
Discard
</button>
</footer>
</div>
</div>
+58 -1
View File
@@ -17,6 +17,7 @@
FileVideo,
Folder,
FolderOpen,
ExternalLink,
Terminal,
} from "@lucide/svelte";
import { languageIconForPath } from "../languageIcons";
@@ -34,6 +35,7 @@
onExpandAllFolders: () => void;
onCollapseAllFolders: () => void;
onSelectNode: (node: ExplorerNode) => void;
onOpenFile: (node: ExplorerNode) => void;
}
let {
@@ -47,8 +49,13 @@
onExpandAllFolders = () => {},
onCollapseAllFolders = () => {},
onSelectNode = () => {},
onOpenFile = () => {},
}: Props = $props();
let contextNode = $state<ExplorerNode | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
if (!next) return current;
if (!current) return next;
@@ -154,12 +161,39 @@
return "text";
}
function openFileContextMenu(event: MouseEvent, node: ExplorerNode) {
if (node.kind !== "file") return;
event.preventDefault();
event.stopPropagation();
contextNode = node;
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192));
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 56));
}
function closeFileContextMenu() {
contextNode = null;
}
function openContextFile() {
const node = contextNode;
if (!node || node.kind !== "file") return;
closeFileContextMenu();
onOpenFile(node);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeFileContextMenu();
}
let explorerTree = $derived(buildExplorerTree(repoFiles));
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
</script>
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="File explorer">
<svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} />
<section class="panel explorer-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="File explorer">
<div class="section-head">
<div>
<span class="eyebrow">Explorer</span>
@@ -264,6 +298,7 @@
class="explorer-select"
type="button"
onclick={() => onSelectNode(node)}
oncontextmenu={(event) => openFileContextMenu(event, node)}
disabled={isBusy}
title={`Show history for ${node.path}`}
>
@@ -279,4 +314,26 @@
{/each}
</div>
{/if}
</section>
{#if contextNode}
<div
class="explorer-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextNode.path}`}
>
<button
type="button"
role="menuitem"
onclick={openContextFile}
disabled={contextNode.status === "deleted"}
title={contextNode.status === "deleted" ? "Deleted files cannot be revealed in Explorer" : "Reveal this file in Explorer"}
>
<ExternalLink size={14} aria-hidden="true" />
Open in Explorer
</button>
</div>
{/if}
+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>
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
export let label = "Repository wird geöffnet";
export let label = "Opening repository";
export let repoName = "";
</script>
+16 -2
View File
@@ -9,6 +9,8 @@
hasRepository: boolean;
isBusy: boolean;
status: GitStatus | null;
selectedFilePath: string;
onSelectFile: (file: GitFileStatus) => void;
onStage: (file: GitFileStatus) => void;
onUnstage: (file: GitFileStatus) => void;
onDiscard: (file: GitFileStatus, staged: boolean) => void;
@@ -24,6 +26,8 @@
hasRepository = false,
isBusy = false,
status = null,
selectedFilePath = "",
onSelectFile = () => {},
onStage = () => {},
onUnstage = () => {},
onDiscard = () => {},
@@ -102,9 +106,19 @@
{:else}
<div class="overflow-auto p-2">
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
<article class="file-row">
<article
class="file-row"
class:selected={selectedFilePath === file.path}
>
<div class="file-title">
<strong title={displayPath(file)}>{fileName(file)}</strong>
<button
class="file-title-button"
type="button"
onclick={() => onSelectFile(file)}
title={`Select ${displayPath(file)} in Explorer`}
>
<strong>{fileName(file)}</strong>
</button>
</div>
<div class="change-lanes">
+52 -2
View File
@@ -1,6 +1,9 @@
import { invoke } from "@tauri-apps/api/core";
import type {
CommitAiLocalProfile,
CommitAiProvider,
CommitAiStatus,
ConflictFile,
GitBranch,
GitCommit,
@@ -8,6 +11,7 @@ import type {
GitRepositoryFile,
GitSearchHit,
GitStatus,
LocalModelOption,
PatchApplyAction,
RepositoryBundle,
StoredCredential,
@@ -21,6 +25,10 @@ export function openRepoInExplorer(path: string): Promise<void> {
return invoke<void>("open_repo_in_explorer", { path });
}
export function openRepositoryFile(path: string, file: string): Promise<void> {
return invoke<void>("open_repository_file", { path, file });
}
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
}
@@ -90,6 +98,39 @@ export function commit(path: string, message: string): Promise<GitStatus> {
return invoke<GitStatus>("commit", { path, message });
}
export function commitAiStatus(): Promise<CommitAiStatus> {
return invoke<CommitAiStatus>("commit_ai_status");
}
export function commitAiLoad(modelId: string): Promise<void> {
return invoke<void>("commit_ai_load", { modelId });
}
export function commitAiLocalModels(): Promise<LocalModelOption[]> {
return invoke<LocalModelOption[]>("commit_ai_local_models");
}
export interface CommitAiGenerateOptions {
notes?: string;
provider: CommitAiProvider;
localProfile?: CommitAiLocalProfile;
model?: string;
apiKey?: string;
baseUrl?: string;
}
export function commitAiGenerate(path: string, options: CommitAiGenerateOptions): Promise<string> {
return invoke<string>("commit_ai_generate", {
path,
notes: options.notes,
provider: options.provider,
localProfile: options.localProfile,
model: options.model,
apiKey: options.apiKey,
baseUrl: options.baseUrl,
});
}
export function pull(path: string, username?: string, password?: string): Promise<GitStatus> {
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null });
}
@@ -143,8 +184,17 @@ export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]>
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
}
export function listFileHistory(path: string, file: string, limit = 100): Promise<GitCommit[]> {
return invoke<GitCommit[]>("list_file_history", { path, file, limit });
export function listFileHistory(
path: string,
file: string,
limit = 100,
requestId?: string,
): Promise<GitCommit[]> {
return invoke<GitCommit[]>("list_file_history", { path, file, limit, requestId: requestId ?? null });
}
export function cancelFileHistory(requestId: string): Promise<void> {
return invoke<void>("cancel_file_history", { requestId });
}
export function compareCommits(
+26
View File
@@ -7,6 +7,32 @@ export type FileStatusKind =
| "conflicted"
| "unknown";
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;
model_id: string | null;
error: string | null;
}
export interface LocalModelOption {
id: string;
label: string;
approx_size_mb: number;
}
export interface AiSettings {
provider: CommitAiProvider;
localModelId: string;
localProfile: CommitAiLocalProfile;
openaiModel: string;
anthropicModel: string;
customBaseUrl: string;
customModel: string;
}
export interface GitStatus {
repo_path: string;
current_branch: string | null;