Features/ai commits #8

Merged
Christoph merged 4 commits from features/ai_commits into master 2026-07-02 20:15:00 +00:00
18 changed files with 5379 additions and 234 deletions
+38 -1
View File
@@ -34,7 +34,44 @@
"Read(//mnt/c/Users/cbr/Desktop/src-tauri/src/**)", "Read(//mnt/c/Users/cbr/Desktop/src-tauri/src/**)",
"Bash(sudo apt install -y libdbus-1-dev pkg-config)", "Bash(sudo apt install -y libdbus-1-dev pkg-config)",
"Bash(dpkg -l)", "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: $?\")"
] ]
} }
} }
+2 -1
View File
@@ -5,4 +5,5 @@
.idea .idea
.DS_Store .DS_Store
~ ~
.codex* .codex*
target
+3530 -41
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -2,16 +2,23 @@
name = "git_lite" name = "git_lite"
version = "0.1.0" version = "0.1.0"
description = "Rust backend for a lightweight Git desktop client" description = "Rust backend for a lightweight Git desktop client"
edition = "2021" edition = "2024"
rust-version = "1.77" rust-version = "1.88"
build = "build.rs" build = "build.rs"
[workspace]
members = [".", "crates/commit_ai"]
[workspace.package]
edition = "2024"
[dependencies] [dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-dialog = "=2.7.0" tauri-plugin-dialog = "=2.7.0"
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] } keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
commit_ai = { path = "crates/commit_ai" }
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } 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"] }
+203
View File
@@ -0,0 +1,203 @@
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::{build_messages, 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}"))?;
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())
}
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}"))?;
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())
}
+259
View File
@@ -0,0 +1,259 @@
mod cloud;
pub use cloud::{generate_anthropic, generate_custom, generate_openai};
use std::sync::Arc;
use mistralrs::{GgufModelBuilder, Model, TextMessageRole, TextMessages};
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-1.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, 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>>,
}
/// 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,
})),
}
}
}
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}"));
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;
}
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;
}
Err(err) => {
guard.phase = CommitAiPhase::Error;
guard.error = Some(err.to_string());
}
}
}
pub async fn generate_commit_message(
&self,
diff: &str,
notes: Option<&str>,
) -> Result<String, String> {
let model = {
let guard = self.inner.read().await;
match (guard.phase, &guard.model) {
(CommitAiPhase::Ready, Some(model)) => model.clone(),
_ => return Err("The local AI model is not ready yet.".to_string()),
}
};
let (system, user) = build_messages(diff, notes)?;
let messages = TextMessages::new()
.add_message(TextMessageRole::System, system)
.add_message(TextMessageRole::User, user);
let response = model
.send_chat_request(messages)
.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());
}
Ok(message)
}
}
/// 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()
}
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 = if diff.len() > MAX_CHARS {
// Pull the byte index back to a valid UTF-8 char boundary, otherwise
// slicing mid-multi-byte-character would panic.
let mut cut = MAX_CHARS;
while !diff.is_char_boundary(cut) {
cut -= 1;
}
format!("{}\n\n[... diff truncated ...]", &diff[..cut])
} else {
diff.to_string()
};
let system = "You are a tool that generates Git commit messages. \
Respond only with the commit message in Conventional Commits format \
(<type>(<scope>): <subject>), followed by a body after a blank line. \
Subject in imperative mood, max. 72 characters. \
The body is required: summarize in a short paragraph what changed and why, \
then list the key changes as bullet points (- ...), \
grouped by affected area/file. Lines in the body max. 72 characters. \
No preamble, no explanation, no code fences, answer in English"
.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))
}
+350 -128
View File
File diff suppressed because it is too large Load Diff
+14 -9
View File
@@ -3,21 +3,22 @@
mod git; mod git;
use git::{ use git::{
apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, commit, SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, cred_delete, checkout_branch, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models,
cred_load, cred_save, delete_branch, diff_file_against_working_tree, get_file_patch, commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent,
get_remote_url, get_status, list_branches, list_commits, list_file_history, create_branch, cred_delete, cred_load, cred_save, delete_branch,
list_repository_files, merge_branch, open_repo_in_explorer, open_repository, diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, list_branches,
open_repository_bundle, open_repository_file, pull, push, read_conflict, rename_branch, list_commits, list_file_history, list_repository_files, merge_branch, open_repo_in_explorer,
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files, open_repository, open_repository_bundle, open_repository_file, pull, push, read_conflict,
restore_to_commit, search_code_introductions, stage_files, unstage_files, rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
SearchCancellationState, restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files,
}; };
fn main() { fn main() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.manage(SearchCancellationState::default()) .manage(SearchCancellationState::default())
.manage(commit_ai::CommitAiEngine::new())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
open_repository, open_repository,
@@ -35,6 +36,10 @@ fn main() {
get_file_patch, get_file_patch,
apply_file_patch, apply_file_patch,
commit, commit,
commit_ai_status,
commit_ai_load,
commit_ai_local_models,
commit_ai_generate,
pull, pull,
push, push,
list_commits, list_commits,
+315 -17
View File
@@ -5,11 +5,13 @@
import { AlertCircle, BookOpen, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte"; import { AlertCircle, BookOpen, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
import TitleBar from "./lib/TitleBar.svelte"; import TitleBar from "./lib/TitleBar.svelte";
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
import BranchPanel from "./lib/components/BranchPanel.svelte"; import BranchPanel from "./lib/components/BranchPanel.svelte";
import CommitPanel from "./lib/components/CommitPanel.svelte"; import CommitPanel from "./lib/components/CommitPanel.svelte";
import CompareDialog from "./lib/components/CompareDialog.svelte"; import CompareDialog from "./lib/components/CompareDialog.svelte";
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte"; import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
import CredentialDialog from "./lib/components/CredentialDialog.svelte"; import CredentialDialog from "./lib/components/CredentialDialog.svelte";
import DiscardConfirmDialog from "./lib/components/DiscardConfirmDialog.svelte";
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte"; import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte"; import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte"; import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
@@ -25,6 +27,10 @@
import { import {
checkoutBranch, checkoutBranch,
commit, commit,
commitAiGenerate,
commitAiLoad,
commitAiLocalModels,
commitAiStatus,
compareCommits, compareCommits,
cancelCodeSearch, cancelCodeSearch,
cancelFileHistory, cancelFileHistory,
@@ -62,6 +68,8 @@
} from "./lib/git"; } from "./lib/git";
import type { import type {
AiSettings,
CommitAiPhase,
ConflictFile, ConflictFile,
ExplorerNode, ExplorerNode,
ExplorerNodeKind, ExplorerNodeKind,
@@ -74,6 +82,7 @@
GitRepositoryFile, GitRepositoryFile,
GitSearchHit, GitSearchHit,
GitStatus, GitStatus,
LocalModelOption,
PatchApplyAction, PatchApplyAction,
PreparedResolution, PreparedResolution,
StoredCredential, StoredCredential,
@@ -88,6 +97,9 @@
type UpdateToastState = "available" | "downloading" | "installed" | "error"; type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository"; type AppView = "management" | "repository";
type PendingDiscard =
| { kind: "file"; file: GitFileStatus; staged: boolean }
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
interface RepoTab { interface RepoTab {
path: string; path: string;
@@ -101,6 +113,11 @@
const OPEN_REPOS_KEY = "gitlite.openRepos.v1"; const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
const RECENT_REPOS_KEY = "gitlite.recentRepos.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 ────────────────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────────────────
@@ -122,7 +139,14 @@
let fileHistoryLoading = false; let fileHistoryLoading = false;
let fileHistoryRequestId = 0; let fileHistoryRequestId = 0;
let activeFileHistoryRequestId = ""; let activeFileHistoryRequestId = "";
let lastFileHistoryHeadHash = "";
let commitMessage = ""; let commitMessage = "";
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 errorMessage = "";
let operation = ""; let operation = "";
let compareFrom = ""; let compareFrom = "";
@@ -141,6 +165,7 @@
let linePatchText = ""; let linePatchText = "";
let linePatchLoading = false; let linePatchLoading = false;
let linePatchError = ""; let linePatchError = "";
let pendingDiscard: PendingDiscard | null = null;
let globalSearchOpen = false; let globalSearchOpen = false;
let lastSearchQuery = ""; let lastSearchQuery = "";
let globalSearchResults: GitSearchHit[] = []; let globalSearchResults: GitSearchHit[] = [];
@@ -170,6 +195,10 @@
let updateCheckInFlight = false; let updateCheckInFlight = false;
let updateDownloadTotal = 0; let updateDownloadTotal = 0;
let updateDownloadedBytes = 0; let updateDownloadedBytes = 0;
let commitPanelHeight = loadCommitPanelHeight();
let resizingCommitPanel = false;
let resizeStartY = 0;
let resizeStartHeight = 0;
// ── Derived ──────────────────────────────────────────────────────────────── // ── Derived ────────────────────────────────────────────────────────────────
@@ -206,10 +235,12 @@
loadRepoLists(); loadRepoLists();
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL); autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
void checkForUpdates(); void checkForUpdates();
void initCommitAi();
}); });
onDestroy(() => { onDestroy(() => {
if (autoRefreshTimer) clearInterval(autoRefreshTimer); if (autoRefreshTimer) clearInterval(autoRefreshTimer);
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId); if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
}); });
@@ -229,15 +260,110 @@
applyStatus(nextStatus); applyStatus(nextStatus);
// Something changed — reload branches, commits and files in one bundled call. // Something changed — reload branches, commits and files in one bundled call.
const bundle = await openRepositoryBundle(activeRepoPath, 100); const bundle = await openRepositoryBundle(activeRepoPath, 100);
const previousHeadHash = lastFileHistoryHeadHash;
await refreshBranchList(activeRepoPath, bundle.branches); await refreshBranchList(activeRepoPath, bundle.branches);
await refreshCommitHistory(activeRepoPath, bundle.commits); await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files); 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 { } catch { /* ignore transient errors */ } finally {
autoRefreshInFlight = false; 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();
}
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") {
commitMessage = await commitAiGenerate(activeRepoPath, { provider: "local", notes });
} else if (aiSettings.provider === "openai") {
const cred = await credLoad("ai:openai");
commitMessage = await commitAiGenerate(activeRepoPath, {
provider: "openai",
notes,
model: aiSettings.openaiModel,
apiKey: cred?.password,
});
} else if (aiSettings.provider === "anthropic") {
const cred = await credLoad("ai:anthropic");
commitMessage = await commitAiGenerate(activeRepoPath, {
provider: "anthropic",
notes,
model: aiSettings.anthropicModel,
apiKey: cred?.password,
});
} else {
const cred = await credLoad("ai:custom");
commitMessage = await commitAiGenerate(activeRepoPath, {
provider: "custom",
notes,
model: aiSettings.customModel,
baseUrl: aiSettings.customBaseUrl,
apiKey: cred?.password,
});
}
} catch (error) {
errorMessage = errorToMessage(error);
} finally {
commitAiGenerating = false;
}
}
function toggleAutoRefresh() { function toggleAutoRefresh() {
autoRefreshEnabled = !autoRefreshEnabled; autoRefreshEnabled = !autoRefreshEnabled;
if (autoRefreshEnabled) void autoRefreshTick(); if (autoRefreshEnabled) void autoRefreshTick();
@@ -398,6 +524,87 @@
} }
} }
function defaultAiSettings(): AiSettings {
return {
provider: "local",
localModelId: "qwen2.5-1.5b",
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") {
return { ...defaultAiSettings(), ...(stored as Partial<AiSettings>) };
}
} 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) { function rememberRecentRepo(path: string) {
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40); recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
persistRepoLists(); persistRepoLists();
@@ -430,6 +637,7 @@
} }
branches = []; branches = [];
commits = []; commits = [];
lastFileHistoryHeadHash = "";
repoFiles = []; repoFiles = [];
selectedExplorerPath = ""; selectedExplorerPath = "";
selectedExplorerKind = "file"; selectedExplorerKind = "file";
@@ -520,6 +728,7 @@
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) { async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
commits = prefetched ?? (await listCommits(path, 100)); commits = prefetched ?? (await listCommits(path, 100));
lastFileHistoryHeadHash = commits[0]?.hash ?? "";
const hashes = new Set(commits.map((c) => c.hash)); const hashes = new Set(commits.map((c) => c.hash));
if (compareFrom && !hashes.has(compareFrom)) compareFrom = ""; if (compareFrom && !hashes.has(compareFrom)) compareFrom = "";
if (compareTo && !hashes.has(compareTo)) compareTo = ""; if (compareTo && !hashes.has(compareTo)) compareTo = "";
@@ -546,7 +755,7 @@
} }
function isCancellationMessage(message: string): boolean { function isCancellationMessage(message: string): boolean {
return message.toLowerCase().includes("abgebrochen"); return message.toLowerCase().includes("cancelled");
} }
function cancelActiveFileHistoryLoad() { function cancelActiveFileHistoryLoad() {
@@ -620,7 +829,7 @@
if (isBusy) return; if (isBusy) return;
try { try {
const selected = await openDialog({ const selected = await openDialog({
title: "Repository folder auswaehlen", title: "Select repository folder",
directory: true, directory: true,
multiple: false, multiple: false,
defaultPath: repoPath.trim() || activeRepoPath || undefined, defaultPath: repoPath.trim() || activeRepoPath || undefined,
@@ -829,7 +1038,7 @@
if (auth) { if (auth) {
if (key) void credDelete(key).catch(() => {}); if (key) void credDelete(key).catch(() => {});
credDialogError = credDialogError =
"Zugangsdaten wurden abgelehnt oder sind abgelaufen. Bitte erneut anmelden."; "Credentials were rejected or have expired. Please sign in again.";
credDialogAction = action; credDialogAction = action;
credDialogKey = key; credDialogKey = key;
credDialogOpen = true; credDialogOpen = true;
@@ -838,7 +1047,7 @@
errorMessage = message; errorMessage = message;
} }
} else { } else {
credDialogError = message || "Anmeldung fehlgeschlagen."; credDialogError = message || "Sign-in failed.";
} }
} }
@@ -876,11 +1085,11 @@
if (errorMessage && !isAuthError(errorMessage) && isNonFastForwardPushError(errorMessage)) { if (errorMessage && !isAuthError(errorMessage) && isNonFastForwardPushError(errorMessage)) {
errorMessage = ""; errorMessage = "";
const shouldSync = window.confirm( 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) { 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; if (fromStore) errorMessage = message;
else credDialogError = message; else credDialogError = message;
return; return;
@@ -904,7 +1113,7 @@
if (statusHasConflicts(status)) { if (statusHasConflicts(status)) {
credDialogOpen = false; credDialogOpen = false;
credDialogAction = null; 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; return;
} }
@@ -979,7 +1188,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 () => { await runOperation(`Discarding ${file.path}`, async () => {
applyStatus(await restoreFiles(activeRepoPath, [file.path], staged)); applyStatus(await restoreFiles(activeRepoPath, [file.path], staged));
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
@@ -1030,9 +1244,17 @@
} }
} }
async function applyLinePatch(action: PatchApplyAction, patch: string) { function isDiscardPatchAction(action: PatchApplyAction): boolean {
if (!activeRepoPath || !linePatchFile || isBusy) return; return action === "discard-staged" || action === "discard-unstaged";
const file = linePatchFile; }
async function runLinePatchAction(
action: PatchApplyAction,
patch: string,
file: GitFileStatus,
staged: boolean,
) {
if (!activeRepoPath || isBusy) return;
operation = patchOperationLabel(action, file); operation = patchOperationLabel(action, file);
errorMessage = ""; errorMessage = "";
linePatchError = ""; linePatchError = "";
@@ -1042,7 +1264,7 @@
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath); await refreshFileHistory(activeRepoPath);
const updatedPatch = await getFilePatch(activeRepoPath, file.path, linePatchStaged); const updatedPatch = await getFilePatch(activeRepoPath, file.path, staged);
if (updatedPatch.trim()) { if (updatedPatch.trim()) {
linePatchText = updatedPatch; linePatchText = updatedPatch;
} else { } else {
@@ -1058,6 +1280,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() { async function stageAllFiles() {
const paths = changedFiles.filter((f) => f.unstaged !== null).map((f) => f.path); const paths = changedFiles.filter((f) => f.unstaged !== null).map((f) => f.path);
if (paths.length === 0) return; if (paths.length === 0) return;
@@ -1295,7 +1548,7 @@
} catch (error) { } catch (error) {
if (globalSearchId === searchId) { if (globalSearchId === searchId) {
const message = errorToMessage(error); const message = errorToMessage(error);
globalSearchError = message.includes("abgebrochen") ? "Suche wurde abgebrochen." : message; globalSearchError = message.includes("cancelled") ? "Search was cancelled." : message;
} }
} finally { } finally {
if (globalSearchId === searchId) { if (globalSearchId === searchId) {
@@ -1308,7 +1561,7 @@
async function cancelGlobalSearch() { async function cancelGlobalSearch() {
if (!globalSearchId) return; if (!globalSearchId) return;
const searchId = globalSearchId; const searchId = globalSearchId;
globalSearchError = "Abbruch wird angefordert..."; globalSearchError = "Requesting cancellation...";
try { try {
await cancelCodeSearch(searchId); await cancelCodeSearch(searchId);
} catch (error) { } catch (error) {
@@ -1387,7 +1640,8 @@
// ── Event handlers ───────────────────────────────────────────────────────── // ── Event handlers ─────────────────────────────────────────────────────────
function handleWindowKeydown(event: KeyboardEvent) { 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" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null; else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false; else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
@@ -1667,7 +1921,7 @@
</div> </div>
</div> </div>
<div class="top-section"> <div class="top-section" style="--commit-panel-height: {commitPanelHeight}px;">
<StatusPanel <StatusPanel
{changedFiles} {changedFiles}
{stagedCount} {stagedCount}
@@ -1684,6 +1938,24 @@
onStageAll={stageAllFiles} onStageAll={stageAllFiles}
onUnstageAll={unstageAllFiles} 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 <CommitPanel
{commitMessage} {commitMessage}
{canCommit} {canCommit}
@@ -1692,8 +1964,13 @@
{isBusy} {isBusy}
{operation} {operation}
{stagedCount} {stagedCount}
commitAiProvider={aiSettings.provider}
{commitAiPhase}
{commitAiGenerating}
onCommit={commitChanges} onCommit={commitChanges}
onCommitMessageChange={(msg) => { commitMessage = msg; }} onCommitMessageChange={(msg) => { commitMessage = msg; }}
onGenerateCommitMessage={generateCommitMessageWithAi}
onOpenAiSettings={() => { aiSettingsOpen = true; }}
/> />
</div> </div>
</section> </section>
@@ -1757,6 +2034,17 @@
/> />
{/if} {/if}
{#if pendingDiscard}
<DiscardConfirmDialog
file={pendingDiscard.file}
staged={pendingDiscard.staged}
scope={pendingDiscard.kind === "hunk" ? "hunk" : "file"}
{isBusy}
onConfirm={confirmDiscard}
onClose={closeDiscardConfirm}
/>
{/if}
{#if globalSearchOpen} {#if globalSearchOpen}
<GlobalSearchDialog <GlobalSearchDialog
{hasRepository} {hasRepository}
@@ -1797,6 +2085,16 @@
/> />
{/if} {/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 --> <!-- Compare: pick the two commits to diff -->
{#if compareSelectOpen} {#if compareSelectOpen}
<CompareSelectDialog <CompareSelectDialog
+177 -6
View File
@@ -150,6 +150,19 @@
background: linear-gradient(180deg, rgba(65, 209, 255, 0.18), rgba(100, 108, 255, 0.16)); 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 { .panel {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
@@ -940,13 +953,39 @@
.top-section { .top-section {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); 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; min-height: 0;
gap: 8px; gap: 0;
padding: 8px; padding: 8px;
overflow: hidden; 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 / change lanes --- */
.file-list { padding: 6px; overflow: auto; } .file-list { padding: 6px; overflow: auto; }
@@ -1330,8 +1369,37 @@
/* --- Commit form --- */ /* --- Commit form --- */
.commit-form { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; gap: 8px; padding: 10px; } .commit-panel {
.commit-form textarea { flex: 1 1 0; min-height: 80px; resize: none; } 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 { .commit-block-reason {
margin: 0; margin: 0;
padding: 8px 10px; padding: 8px 10px;
@@ -1603,6 +1671,56 @@
max-height: calc(100vh - 32px); max-height: calc(100vh - 32px);
overflow: auto; 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));
}
.new-branch-form { .new-branch-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1812,6 +1930,59 @@
.prepared-tag { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; color: #4eca76; font-size: 12px; font-weight: 700; } .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 { .line-patch-body {
display: grid; display: grid;
grid-template-rows: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr);
@@ -2848,7 +3019,7 @@
/* Stack CommitPanel below StatusPanel; history panels stay side by side */ /* Stack CommitPanel below StatusPanel; history panels stay side by side */
@media (max-width: 1100px) { @media (max-width: 1100px) {
.workspace { grid-template-columns: clamp(185px, 16vw, 220px) minmax(0, 1fr) clamp(380px, 38vw, 500px); } .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 */ /* Compact: stack history panels vertically, narrow sidebars */
@@ -2874,7 +3045,7 @@
.workspace { grid-template-columns: 1fr; gap: 6px; } .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; } .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; } .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-form { grid-template-columns: 1fr; }
.repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; } .repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; }
.repo-tab.management { min-width: 0; } .repo-tab.management { min-width: 0; }
+256
View File
@@ -0,0 +1,256 @@
<script lang="ts">
import { onMount } from "svelte";
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Globe, Key, LoaderCircle, X } from "@lucide/svelte";
import { credDelete, credLoad, credSave } from "../git";
import type { AiSettings, 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 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;
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,
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`;
}
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" class:active={provider === "local"} onclick={() => { provider = "local"; }}>
<Cpu size={16} aria-hidden="true" />
Local AI
</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"}
<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.
</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>
+63 -10
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <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 { interface Props {
commitMessage: string; commitMessage: string;
@@ -9,8 +10,13 @@
isBusy: boolean; isBusy: boolean;
operation: string; operation: string;
stagedCount: number; stagedCount: number;
commitAiProvider: CommitAiProvider;
commitAiPhase: CommitAiPhase;
commitAiGenerating: boolean;
onCommit: () => void; onCommit: () => void;
onCommitMessageChange: (msg: string) => void; onCommitMessageChange: (msg: string) => void;
onGenerateCommitMessage: () => void;
onOpenAiSettings: () => void;
} }
let { let {
@@ -21,17 +27,38 @@
isBusy = false, isBusy = false,
operation = "", operation = "",
stagedCount = 0, stagedCount = 0,
commitAiProvider = "local",
commitAiPhase = "idle",
commitAiGenerating = false,
onCommit = () => {}, onCommit = () => {},
onCommitMessageChange = () => {}, onCommitMessageChange = () => {},
onGenerateCommitMessage = () => {},
onOpenAiSettings = () => {},
}: Props = $props(); }: Props = $props();
function handleSubmit(event: SubmitEvent) { function handleSubmit(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
onCommit(); 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> </script>
<section class="panel flex flex-col" aria-label="Commit"> <section class="panel commit-panel" aria-label="Commit">
<div class="section-head"> <div class="section-head">
<div> <div>
<span class="eyebrow">Commit</span> <span class="eyebrow">Commit</span>
@@ -50,13 +77,39 @@
{#if commitBlockReason} {#if commitBlockReason}
<p class="commit-block-reason">{commitBlockReason}</p> <p class="commit-block-reason">{commitBlockReason}</p>
{/if} {/if}
<button class="btn-primary w-full flex-shrink-0" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}> <div class="commit-actions-row flex-shrink-0">
{#if operation === "Committing"} <button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}>
<LoaderCircle class="spin" size={16} aria-hidden="true" /> {#if operation === "Committing"}
{:else} <LoaderCircle class="spin" size={16} aria-hidden="true" />
<Check size={16} aria-hidden="true" /> {:else}
{/if} <Check size={16} aria-hidden="true" />
Commit {/if}
</button> 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> </form>
</section> </section>
+18 -18
View File
@@ -44,10 +44,10 @@
(mode === "token" || username.trim().length > 0), (mode === "token" || username.trim().length > 0),
); );
let actionLabel = $derived(action === "push" ? "Push" : "Pull"); 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" let actionHint = $derived(action === "push"
? "Der Remote braucht Schreibrechte. Nutze ein Passwort oder einen Token mit passenden Repository-Rechten." ? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
: "Der Remote braucht Zugriff auf das Repository. Nutze deine Git-Zugangsdaten oder einen Personal Access Token."); : "The remote needs access to the repository. Use your Git credentials or a personal access token.");
function handleSubmit(e: SubmitEvent) { function handleSubmit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
@@ -66,7 +66,7 @@
role="presentation" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onCancel(); }} 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">
<div class="cred-hero-top"> <div class="cred-hero-top">
<div class="cred-hero-icon"> <div class="cred-hero-icon">
@@ -80,7 +80,7 @@
<p class="cred-hero-label">{actionLabel} Remote</p> <p class="cred-hero-label">{actionLabel} Remote</p>
<h2 class="cred-hero-title">{actionTitle}</h2> <h2 class="cred-hero-title">{actionTitle}</h2>
</div> </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" /> <X size={16} aria-hidden="true" />
</button> </button>
</div> </div>
@@ -89,12 +89,12 @@
<div class="cred-security-note"> <div class="cred-security-note">
<ShieldCheck size={14} aria-hidden="true" /> <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>
</div> </div>
<form class="cred-body" onsubmit={handleSubmit}> <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 <button
type="button" type="button"
class="cred-seg-btn" class="cred-seg-btn"
@@ -103,7 +103,7 @@
aria-pressed={mode === "credentials"} aria-pressed={mode === "credentials"}
> >
<User size={13} aria-hidden="true" /> <User size={13} aria-hidden="true" />
Username + Passwort Username + password
</button> </button>
<button <button
type="button" type="button"
@@ -127,7 +127,7 @@
id="cred-username" id="cred-username"
type="text" type="text"
bind:value={username} bind:value={username}
placeholder="z. B. mein-github-username" placeholder="e.g. my-github-username"
autocomplete="username" autocomplete="username"
disabled={isBusy} disabled={isBusy}
/> />
@@ -137,7 +137,7 @@
<div class="cred-field"> <div class="cred-field">
<label class="cred-field-label" for="cred-password"> <label class="cred-field-label" for="cred-password">
{mode === "token" ? "Token" : "Passwort"} {mode === "token" ? "Token" : "Password"}
</label> </label>
<div class="cred-input"> <div class="cred-input">
<Lock size={15} class="cred-field-icon" aria-hidden="true" /> <Lock size={15} class="cred-field-icon" aria-hidden="true" />
@@ -146,8 +146,8 @@
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
bind:value={password} bind:value={password}
placeholder={mode === "token" placeholder={mode === "token"
? "ghp_... oder anderer Zugangstoken" ? "ghp_... or another access token"
: "Passwort oder Personal Access Token"} : "Password or personal access token"}
autocomplete="current-password" autocomplete="current-password"
disabled={isBusy} disabled={isBusy}
/> />
@@ -156,7 +156,7 @@
class="cred-reveal" class="cred-reveal"
onclick={() => { showPassword = !showPassword; }} onclick={() => { showPassword = !showPassword; }}
tabindex="-1" tabindex="-1"
aria-label={showPassword ? "Verbergen" : "Anzeigen"} aria-label={showPassword ? "Hide" : "Show"}
> >
{#if showPassword} {#if showPassword}
<EyeOff size={14} aria-hidden="true" /> <EyeOff size={14} aria-hidden="true" />
@@ -171,7 +171,7 @@
{#if mode === "token"} {#if mode === "token"}
<div class="cred-token-hint"> <div class="cred-token-hint">
<Key size={13} aria-hidden="true" /> <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> </div>
{/if} {/if}
@@ -184,26 +184,26 @@
{#if saveSession} {#if saveSession}
<div class="cred-expiry"> <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 <input
id="cred-expiry" id="cred-expiry"
type="date" type="date"
bind:value={expiresAt} bind:value={expiresAt}
disabled={isBusy} 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> </div>
{/if} {/if}
<div class="cred-footer"> <div class="cred-footer">
<label class="cred-save"> <label class="cred-save">
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} /> <input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
<span>Im Schluesselbund speichern</span> <span>Save in keychain</span>
</label> </label>
<div class="cred-btns"> <div class="cred-btns">
<button type="button" class="cred-cancel" onclick={onCancel} disabled={isBusy}> <button type="button" class="cred-cancel" onclick={onCancel} disabled={isBusy}>
Abbrechen Cancel
</button> </button>
<button class="cred-submit" type="submit" disabled={!canSubmit}> <button class="cred-submit" type="submit" disabled={!canSubmit}>
{#if isBusy} {#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>
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
export let label = "Repository wird geöffnet"; export let label = "Opening repository";
export let repoName = ""; export let repoName = "";
</script> </script>
+34
View File
@@ -1,6 +1,8 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { import type {
CommitAiProvider,
CommitAiStatus,
ConflictFile, ConflictFile,
GitBranch, GitBranch,
GitCommit, GitCommit,
@@ -8,6 +10,7 @@ import type {
GitRepositoryFile, GitRepositoryFile,
GitSearchHit, GitSearchHit,
GitStatus, GitStatus,
LocalModelOption,
PatchApplyAction, PatchApplyAction,
RepositoryBundle, RepositoryBundle,
StoredCredential, StoredCredential,
@@ -94,6 +97,37 @@ export function commit(path: string, message: string): Promise<GitStatus> {
return invoke<GitStatus>("commit", { path, message }); 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;
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,
model: options.model,
apiKey: options.apiKey,
baseUrl: options.baseUrl,
});
}
export function pull(path: string, username?: string, password?: string): Promise<GitStatus> { export function pull(path: string, username?: string, password?: string): Promise<GitStatus> {
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null }); return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null });
} }
+24
View File
@@ -7,6 +7,30 @@ export type FileStatusKind =
| "conflicted" | "conflicted"
| "unknown"; | "unknown";
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
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;
openaiModel: string;
anthropicModel: string;
customBaseUrl: string;
customModel: string;
}
export interface GitStatus { export interface GitStatus {
repo_path: string; repo_path: string;
current_branch: string | null; current_branch: string | null;