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.
This commit is contained in:
Christoph Brandau
2026-07-02 22:12:20 +02:00
parent 6b7186d040
commit 70de45e1ba
11 changed files with 537 additions and 187 deletions
+14 -1
View File
@@ -58,7 +58,20 @@
"Bash(: *)", "Bash(: *)",
"Bash(exit 0 *)", "Bash(exit 0 *)",
"Bash(rustc -O sanitize_test.rs -o sanitize_test)", "Bash(rustc -O sanitize_test.rs -o sanitize_test)",
"Bash(./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: $?\")"
] ]
} }
} }
+16 -16
View File
@@ -4,16 +4,16 @@ use serde::{Deserialize, Serialize};
use crate::{build_messages, sanitize_message}; use crate::{build_messages, sanitize_message};
// Großzügig bemessen, damit ein ausführlicher Body mit Stichpunkten nicht abgeschnitten wird. // Generous sizing so a detailed body with bullet points isn't cut off.
const DEFAULT_MAX_TOKENS: u32 = 1500; const DEFAULT_MAX_TOKENS: u32 = 1500;
// Ohne Timeout würde ein hängender Endpoint den "AI"-Button dauerhaft blockieren. // Without a timeout, a hanging endpoint would permanently block the "AI" button.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
fn http_client() -> Result<reqwest::Client, String> { fn http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder() reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT) .timeout(REQUEST_TIMEOUT)
.build() .build()
.map_err(|err| format!("HTTP-Client konnte nicht erstellt werden: {err}")) .map_err(|err| format!("Could not create HTTP client: {err}"))
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -71,19 +71,19 @@ async fn openai_compatible_request(
let response = request let response = request
.send() .send()
.await .await
.map_err(|err| format!("Anfrage an das KI-Modell fehlgeschlagen: {err}"))?; .map_err(|err| format!("Request to the AI model failed: {err}"))?;
let status = response.status(); let status = response.status();
let text = response let text = response
.text() .text()
.await .await
.map_err(|err| format!("Antwort konnte nicht gelesen werden: {err}"))?; .map_err(|err| format!("Could not read response: {err}"))?;
if !status.is_success() { if !status.is_success() {
return Err(format!("API-Fehler ({status}): {text}")); return Err(format!("API error ({status}): {text}"));
} }
let parsed: OpenAiResponse = serde_json::from_str(&text) let parsed: OpenAiResponse = serde_json::from_str(&text)
.map_err(|err| format!("Antwort konnte nicht verarbeitet werden: {err}"))?; .map_err(|err| format!("Could not process response: {err}"))?;
parsed parsed
.choices .choices
@@ -92,7 +92,7 @@ async fn openai_compatible_request(
.and_then(|choice| choice.message.content) .and_then(|choice| choice.message.content)
.map(|content| sanitize_message(&content)) .map(|content| sanitize_message(&content))
.filter(|content| !content.is_empty()) .filter(|content| !content.is_empty())
.ok_or_else(|| "Das Modell hat keine Antwort geliefert.".to_string()) .ok_or_else(|| "The model did not return a response.".to_string())
} }
pub async fn generate_openai( pub async fn generate_openai(
@@ -102,7 +102,7 @@ pub async fn generate_openai(
notes: Option<&str>, notes: Option<&str>,
) -> Result<String, String> { ) -> Result<String, String> {
if api_key.trim().is_empty() { if api_key.trim().is_empty() {
return Err("OpenAI-API-Key fehlt.".to_string()); return Err("OpenAI API key is missing.".to_string());
} }
openai_compatible_request( openai_compatible_request(
"https://api.openai.com/v1/chat/completions".to_string(), "https://api.openai.com/v1/chat/completions".to_string(),
@@ -122,7 +122,7 @@ pub async fn generate_custom(
notes: Option<&str>, notes: Option<&str>,
) -> Result<String, String> { ) -> Result<String, String> {
if base_url.trim().is_empty() { if base_url.trim().is_empty() {
return Err("Endpoint-URL fehlt.".to_string()); return Err("Endpoint URL is missing.".to_string());
} }
let url = format!("{}/chat/completions", base_url.trim_end_matches('/')); let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
openai_compatible_request(url, api_key, model, diff, notes).await openai_compatible_request(url, api_key, model, diff, notes).await
@@ -161,7 +161,7 @@ pub async fn generate_anthropic(
notes: Option<&str>, notes: Option<&str>,
) -> Result<String, String> { ) -> Result<String, String> {
if api_key.trim().is_empty() { if api_key.trim().is_empty() {
return Err("Anthropic-API-Key fehlt.".to_string()); return Err("Anthropic API key is missing.".to_string());
} }
let (system, user) = build_messages(diff, notes)?; let (system, user) = build_messages(diff, notes)?;
let body = AnthropicRequest { let body = AnthropicRequest {
@@ -179,19 +179,19 @@ pub async fn generate_anthropic(
.json(&body) .json(&body)
.send() .send()
.await .await
.map_err(|err| format!("Anfrage an Anthropic fehlgeschlagen: {err}"))?; .map_err(|err| format!("Request to Anthropic failed: {err}"))?;
let status = response.status(); let status = response.status();
let text = response let text = response
.text() .text()
.await .await
.map_err(|err| format!("Antwort konnte nicht gelesen werden: {err}"))?; .map_err(|err| format!("Could not read response: {err}"))?;
if !status.is_success() { if !status.is_success() {
return Err(format!("API-Fehler ({status}): {text}")); return Err(format!("API error ({status}): {text}"));
} }
let parsed: AnthropicResponse = serde_json::from_str(&text) let parsed: AnthropicResponse = serde_json::from_str(&text)
.map_err(|err| format!("Antwort konnte nicht verarbeitet werden: {err}"))?; .map_err(|err| format!("Could not process response: {err}"))?;
parsed parsed
.content .content
@@ -199,5 +199,5 @@ pub async fn generate_anthropic(
.find_map(|block| block.text) .find_map(|block| block.text)
.map(|text| sanitize_message(&text)) .map(|text| sanitize_message(&text))
.filter(|text| !text.is_empty()) .filter(|text| !text.is_empty())
.ok_or_else(|| "Das Modell hat keine Antwort geliefert.".to_string()) .ok_or_else(|| "The model did not return a response.".to_string())
} }
+21 -21
View File
@@ -24,7 +24,7 @@ pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-1.5b";
pub const LOCAL_MODELS: &[LocalModelOption] = &[ pub const LOCAL_MODELS: &[LocalModelOption] = &[
LocalModelOption { LocalModelOption {
id: "qwen2.5-0.5b", id: "qwen2.5-0.5b",
label: "Qwen2.5 0.5B Instruct — schnell, geringere Qualität", label: "Qwen2.5 0.5B Instruct — fast, lower quality",
approx_size_mb: 490, approx_size_mb: 490,
repo: "Qwen/Qwen2.5-0.5B-Instruct-GGUF", repo: "Qwen/Qwen2.5-0.5B-Instruct-GGUF",
file: "qwen2.5-0.5b-instruct-q4_k_m.gguf", file: "qwen2.5-0.5b-instruct-q4_k_m.gguf",
@@ -32,7 +32,7 @@ pub const LOCAL_MODELS: &[LocalModelOption] = &[
}, },
LocalModelOption { LocalModelOption {
id: "qwen2.5-1.5b", id: "qwen2.5-1.5b",
label: "Qwen2.5 1.5B Instruct — empfohlen", label: "Qwen2.5 1.5B Instruct — recommended",
approx_size_mb: 1050, approx_size_mb: 1050,
repo: "Qwen/Qwen2.5-1.5B-Instruct-GGUF", repo: "Qwen/Qwen2.5-1.5B-Instruct-GGUF",
file: "qwen2.5-1.5b-instruct-q4_k_m.gguf", file: "qwen2.5-1.5b-instruct-q4_k_m.gguf",
@@ -40,7 +40,7 @@ pub const LOCAL_MODELS: &[LocalModelOption] = &[
}, },
LocalModelOption { LocalModelOption {
id: "qwen2.5-3b", id: "qwen2.5-3b",
label: "Qwen2.5 3B Instruct — beste Qualität, langsamer", label: "Qwen2.5 3B Instruct — best quality, slower",
approx_size_mb: 2100, approx_size_mb: 2100,
repo: "Qwen/Qwen2.5-3B-Instruct-GGUF", repo: "Qwen/Qwen2.5-3B-Instruct-GGUF",
file: "qwen2.5-3b-instruct-q4_k_m.gguf", file: "qwen2.5-3b-instruct-q4_k_m.gguf",
@@ -128,7 +128,7 @@ impl CommitAiEngine {
let mut guard = self.inner.write().await; let mut guard = self.inner.write().await;
guard.phase = CommitAiPhase::Error; guard.phase = CommitAiPhase::Error;
guard.model_id = Some(model_id.to_string()); guard.model_id = Some(model_id.to_string());
guard.error = Some(format!("Unbekanntes lokales Modell: {model_id}")); guard.error = Some(format!("Unknown local model: {model_id}"));
return; return;
}; };
@@ -174,7 +174,7 @@ impl CommitAiEngine {
let guard = self.inner.read().await; let guard = self.inner.read().await;
match (guard.phase, &guard.model) { match (guard.phase, &guard.model) {
(CommitAiPhase::Ready, Some(model)) => model.clone(), (CommitAiPhase::Ready, Some(model)) => model.clone(),
_ => return Err("Das lokale KI-Modell ist noch nicht bereit.".to_string()), _ => return Err("The local AI model is not ready yet.".to_string()),
} }
}; };
@@ -192,11 +192,11 @@ impl CommitAiEngine {
.choices .choices
.first() .first()
.and_then(|choice| choice.message.content.clone()) .and_then(|choice| choice.message.content.clone())
.ok_or_else(|| "Das Modell hat keine Antwort geliefert.".to_string())?; .ok_or_else(|| "The model did not return a response.".to_string())?;
let message = sanitize_message(&content); let message = sanitize_message(&content);
if message.is_empty() { if message.is_empty() {
return Err("Das Modell hat keine Antwort geliefert.".to_string()); return Err("The model did not return a response.".to_string());
} }
Ok(message) Ok(message)
} }
@@ -223,36 +223,36 @@ pub(crate) fn sanitize_message(raw: &str) -> String {
pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> { pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> {
if diff.trim().is_empty() { if diff.trim().is_empty() {
return Err("Keine gestagten Änderungen für eine Commit-Message vorhanden.".to_string()); return Err("No staged changes available for a commit message.".to_string());
} }
// grobe Token-Schätzung, kleine Modelle haben oft 832k Kontext // Rough token estimate — small models often have an 8-32k context window.
const MAX_CHARS: usize = 24_000; const MAX_CHARS: usize = 24_000;
let diff = if diff.len() > MAX_CHARS { let diff = if diff.len() > MAX_CHARS {
// Byte-Index auf eine gültige UTF-8-Zeichengrenze zurückziehen, sonst // Pull the byte index back to a valid UTF-8 char boundary, otherwise
// paniken Slices mitten in einem Umlaut o. Ä. // slicing mid-multi-byte-character would panic.
let mut cut = MAX_CHARS; let mut cut = MAX_CHARS;
while !diff.is_char_boundary(cut) { while !diff.is_char_boundary(cut) {
cut -= 1; cut -= 1;
} }
format!("{}\n\n[... Diff gekürzt ...]", &diff[..cut]) format!("{}\n\n[... diff truncated ...]", &diff[..cut])
} else { } else {
diff.to_string() diff.to_string()
}; };
let system = "Du bist ein Werkzeug, das Git-Commit-Messages erzeugt. \ let system = "You are a tool that generates Git commit messages. \
Antworte ausschließlich mit der Commit-Message im Conventional-Commits-Format \ Respond only with the commit message in Conventional Commits format \
(<type>(<scope>): <subject>), gefolgt von einem Body nach einer Leerzeile. \ (<type>(<scope>): <subject>), followed by a body after a blank line. \
Subject imperativ, max. 72 Zeichen. \ Subject in imperative mood, max. 72 characters. \
Der Body ist Pflicht: Fasse in einem kurzen Absatz zusammen, was und warum geändert wurde, \ The body is required: summarize in a short paragraph what changed and why, \
und liste danach die wesentlichen Änderungen als Stichpunkte (- ...) auf, \ then list the key changes as bullet points (- ...), \
gruppiert nach betroffenem Bereich/Datei. Zeilen im Body max. 72 Zeichen. \ grouped by affected area/file. Lines in the body max. 72 characters. \
Kein Vorspann, keine Erklärung, keine Code-Fences, in Englisch antworten" No preamble, no explanation, no code fences, answer in English"
.to_string(); .to_string();
let mut user = String::new(); let mut user = String::new();
if let Some(n) = notes.filter(|n| !n.trim().is_empty()) { if let Some(n) = notes.filter(|n| !n.trim().is_empty()) {
user.push_str(&format!("Anmerkungen des Entwicklers:\n{n}\n\n")); user.push_str(&format!("Developer notes:\n{n}\n\n"));
} }
user.push_str(&format!("Staged diff:\n{diff}")); user.push_str(&format!("Staged diff:\n{diff}"));
Ok((system, user)) Ok((system, user))
+102 -102
View File
@@ -146,7 +146,7 @@ enum CheckoutPlan {
const FULL_FILE_DIFF_CONTEXT: &str = "--unified=1000000"; const FULL_FILE_DIFF_CONTEXT: &str = "--unified=1000000";
const EMPTY_TREE_HASH: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; const EMPTY_TREE_HASH: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
const SEARCH_CANCELLED_MESSAGE: &str = "Suche wurde abgebrochen."; const SEARCH_CANCELLED_MESSAGE: &str = "Search was cancelled.";
static CANCELLABLE_GIT_OUTPUT_COUNTER: AtomicU64 = AtomicU64::new(0); static CANCELLABLE_GIT_OUTPUT_COUNTER: AtomicU64 = AtomicU64::new(0);
fn git_command() -> Command { fn git_command() -> Command {
@@ -165,7 +165,7 @@ impl SearchCancellationState {
fn cancel(&self, search_id: &str) -> Result<(), String> { fn cancel(&self, search_id: &str) -> Result<(), String> {
self.cancelled self.cancelled
.lock() .lock()
.map_err(|_| "Such-Abbruchstatus ist nicht erreichbar.".to_string())? .map_err(|_| "Search cancellation status is unavailable.".to_string())?
.insert(search_id.to_string()); .insert(search_id.to_string());
Ok(()) Ok(())
} }
@@ -173,7 +173,7 @@ impl SearchCancellationState {
fn clear(&self, search_id: &str) -> Result<(), String> { fn clear(&self, search_id: &str) -> Result<(), String> {
self.cancelled self.cancelled
.lock() .lock()
.map_err(|_| "Such-Abbruchstatus ist nicht erreichbar.".to_string())? .map_err(|_| "Search cancellation status is unavailable.".to_string())?
.remove(search_id); .remove(search_id);
Ok(()) Ok(())
} }
@@ -182,7 +182,7 @@ impl SearchCancellationState {
Ok(self Ok(self
.cancelled .cancelled
.lock() .lock()
.map_err(|_| "Such-Abbruchstatus ist nicht erreichbar.".to_string())? .map_err(|_| "Search cancellation status is unavailable.".to_string())?
.contains(search_id)) .contains(search_id))
} }
} }
@@ -221,10 +221,10 @@ pub fn open_repository_file(path: String, file: String) -> Result<(), String> {
let file_path = resolve_repo_child_path(&repo, &file)?; let file_path = resolve_repo_child_path(&repo, &file)?;
if !file_path.exists() { if !file_path.exists() {
return Err(format!("Datei '{file}' existiert im Working Tree nicht.")); return Err(format!("File '{file}' does not exist in the working tree."));
} }
if !file_path.is_file() { if !file_path.is_file() {
return Err(format!("'{file}' ist keine Datei.")); return Err(format!("'{file}' is not a file."));
} }
reveal_path_in_file_manager(&file_path) reveal_path_in_file_manager(&file_path)
@@ -262,7 +262,7 @@ pub async fn open_repository_bundle(
}) })
}) })
.await .await
.map_err(|err| format!("Repository konnte nicht geladen werden: {err}"))? .map_err(|err| format!("Could not load repository: {err}"))?
} }
#[tauri::command] #[tauri::command]
@@ -321,7 +321,7 @@ pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let branch = branch.trim().to_string(); let branch = branch.trim().to_string();
if branch.is_empty() { if branch.is_empty() {
return Err("Branch-Name darf nicht leer sein.".to_string()); return Err("Branch name must not be empty.".to_string());
} }
match checkout_plan(&repo, &branch)? { match checkout_plan(&repo, &branch)? {
@@ -392,7 +392,7 @@ pub fn delete_branch(path: String, branch: String) -> Result<GitStatus, String>
let branch = validate_existing_local_branch_name(&repo, &branch)?; let branch = validate_existing_local_branch_name(&repo, &branch)?;
let status = status_for_repo(&repo)?; let status = status_for_repo(&repo)?;
if status.current_branch.as_deref() == Some(branch.as_str()) { if status.current_branch.as_deref() == Some(branch.as_str()) {
return Err("Der aktuelle Branch kann nicht geloescht werden.".to_string()); return Err("The current branch cannot be deleted.".to_string());
} }
run_git(&repo, ["branch", "-d", "--", branch.as_str()])?; run_git(&repo, ["branch", "-d", "--", branch.as_str()])?;
@@ -560,21 +560,21 @@ pub async fn commit_ai_generate(
match provider.as_str() { match provider.as_str() {
"local" => engine.generate_commit_message(&diff, notes).await, "local" => engine.generate_commit_message(&diff, notes).await,
"openai" => { "openai" => {
let api_key = api_key.ok_or_else(|| "OpenAI-API-Key fehlt.".to_string())?; let api_key = api_key.ok_or_else(|| "OpenAI API key is missing.".to_string())?;
let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string()); let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string());
commit_ai::generate_openai(&api_key, &model, &diff, notes).await commit_ai::generate_openai(&api_key, &model, &diff, notes).await
} }
"anthropic" => { "anthropic" => {
let api_key = api_key.ok_or_else(|| "Anthropic-API-Key fehlt.".to_string())?; let api_key = api_key.ok_or_else(|| "Anthropic API key is missing.".to_string())?;
let model = model.unwrap_or_else(|| "claude-3-5-haiku-latest".to_string()); let model = model.unwrap_or_else(|| "claude-3-5-haiku-latest".to_string());
commit_ai::generate_anthropic(&api_key, &model, &diff, notes).await commit_ai::generate_anthropic(&api_key, &model, &diff, notes).await
} }
"custom" => { "custom" => {
let base_url = base_url.ok_or_else(|| "Endpoint-URL fehlt.".to_string())?; let base_url = base_url.ok_or_else(|| "Endpoint URL is missing.".to_string())?;
let model = model.ok_or_else(|| "Modellname fehlt.".to_string())?; let model = model.ok_or_else(|| "Model name is missing.".to_string())?;
commit_ai::generate_custom(&base_url, api_key.as_deref(), &model, &diff, notes).await commit_ai::generate_custom(&base_url, api_key.as_deref(), &model, &diff, notes).await
} }
other => Err(format!("Unbekannter KI-Provider: {other}")), other => Err(format!("Unknown AI provider: {other}")),
} }
} }
@@ -588,7 +588,7 @@ pub fn apply_file_patch(
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
validate_files(std::slice::from_ref(&file))?; validate_files(std::slice::from_ref(&file))?;
if patch.trim().is_empty() { if patch.trim().is_empty() {
return Err("Kein Patch ausgewaehlt.".to_string()); return Err("No patch selected.".to_string());
} }
let patch_path = write_temp_patch(&patch)?; let patch_path = write_temp_patch(&patch)?;
@@ -603,7 +603,7 @@ pub fn apply_file_patch(
.and_then(|_| check_apply_patch(&repo, &patch_path, &["--reverse"])) .and_then(|_| check_apply_patch(&repo, &patch_path, &["--reverse"]))
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])) .and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached", "--reverse"]))
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--reverse"])), .and_then(|_| run_apply_patch(&repo, &patch_path, &["--reverse"])),
_ => Err("Ungueltige Patch-Aktion.".to_string()), _ => Err("Invalid patch action.".to_string()),
}; };
let _ = std::fs::remove_file(&patch_path); let _ = std::fs::remove_file(&patch_path);
@@ -615,13 +615,13 @@ pub fn apply_file_patch(
pub fn commit(path: String, message: String) -> Result<GitStatus, String> { pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
if message.trim().is_empty() { if message.trim().is_empty() {
return Err("Commit-Message darf nicht leer sein.".to_string()); return Err("Commit message must not be empty.".to_string());
} }
let current_status = status_for_repo(&repo)?; let current_status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&current_status) { if has_unresolved_conflicts(&current_status) {
return Err( return Err(
"Merge-Konflikte muessen geloest werden, bevor du committen kannst.".to_string(), "Merge conflicts must be resolved before you can commit.".to_string(),
); );
} }
@@ -647,7 +647,7 @@ pub fn pull(
.args(pull_args) .args(pull_args)
.output() .output()
.map_err(|err| { .map_err(|err| {
format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}") format!("Could not start Git. Is Git installed? {err}")
})?, })?,
}; };
@@ -664,7 +664,7 @@ pub fn pull(
if is_auth_error(&details) { if is_auth_error(&details) {
return Err(format!("AUTH_FAILED:{details}")); return Err(format!("AUTH_FAILED:{details}"));
} }
Err(format!("Git-Befehl fehlgeschlagen: {details}")) Err(format!("Git command failed: {details}"))
} }
#[tauri::command] #[tauri::command]
@@ -701,10 +701,10 @@ pub struct StoredCredential {
fn cred_entry(key: &str) -> Result<keyring::Entry, String> { fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
let key = key.trim(); let key = key.trim();
if key.is_empty() { if key.is_empty() {
return Err("Kein Schlüssel für die Zugangsdaten angegeben.".to_string()); return Err("No key provided for the credentials.".to_string());
} }
keyring::Entry::new(CRED_SERVICE, key) keyring::Entry::new(CRED_SERVICE, key)
.map_err(|err| format!("Schlüsselbund nicht verfügbar: {err}")) .map_err(|err| format!("Keychain unavailable: {err}"))
} }
/// Returns the remote URL used for auth key derivation (upstream remote of the /// Returns the remote URL used for auth key derivation (upstream remote of the
@@ -770,7 +770,7 @@ fn current_branch_name(repo: &Path) -> Result<String, String> {
let branch = run_git(repo, ["rev-parse", "--abbrev-ref", "HEAD"])?; let branch = run_git(repo, ["rev-parse", "--abbrev-ref", "HEAD"])?;
let branch = String::from_utf8_lossy(&branch).trim().to_string(); let branch = String::from_utf8_lossy(&branch).trim().to_string();
if branch.is_empty() || branch == "HEAD" { if branch.is_empty() || branch == "HEAD" {
return Err("Aktueller Branch konnte nicht ermittelt werden.".to_string()); return Err("Could not determine current branch.".to_string());
} }
Ok(branch) Ok(branch)
} }
@@ -791,7 +791,7 @@ fn initial_push_remote_name(repo: &Path) -> Result<String, String> {
} }
first_remote_name(repo).ok_or_else(|| { first_remote_name(repo).ok_or_else(|| {
"Dieser Branch hat keinen Upstream und es ist kein Remote konfiguriert.".to_string() "This branch has no upstream and no remote is configured.".to_string()
}) })
} }
@@ -816,11 +816,11 @@ pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
match entry.get_password() { match entry.get_password() {
Ok(json) => { Ok(json) => {
let cred = serde_json::from_str::<StoredCredential>(&json) let cred = serde_json::from_str::<StoredCredential>(&json)
.map_err(|err| format!("Gespeicherte Zugangsdaten unlesbar: {err}"))?; .map_err(|err| format!("Stored credentials unreadable: {err}"))?;
Ok(Some(cred)) Ok(Some(cred))
} }
Err(keyring::Error::NoEntry) => Ok(None), Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(format!("Schlüsselbund-Zugriff fehlgeschlagen: {err}")), Err(err) => Err(format!("Keychain access failed: {err}")),
} }
} }
@@ -839,10 +839,10 @@ pub fn cred_save(
expires_at, expires_at,
}; };
let json = serde_json::to_string(&cred) let json = serde_json::to_string(&cred)
.map_err(|err| format!("Zugangsdaten konnten nicht serialisiert werden: {err}"))?; .map_err(|err| format!("Could not serialize credentials: {err}"))?;
entry entry
.set_password(&json) .set_password(&json)
.map_err(|err| format!("Speichern im Schlüsselbund fehlgeschlagen: {err}")) .map_err(|err| format!("Saving to keychain failed: {err}"))
} }
#[tauri::command] #[tauri::command]
@@ -850,7 +850,7 @@ pub fn cred_delete(key: String) -> Result<(), String> {
let entry = cred_entry(&key)?; let entry = cred_entry(&key)?;
match entry.delete_credential() { match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(err) => Err(format!("Löschen im Schlüsselbund fehlgeschlagen: {err}")), Err(err) => Err(format!("Deleting from keychain failed: {err}")),
} }
} }
@@ -859,7 +859,7 @@ pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let branch = branch.trim(); let branch = branch.trim();
if branch.is_empty() { if branch.is_empty() {
return Err("Branch-Name darf nicht leer sein.".to_string()); return Err("Branch name must not be empty.".to_string());
} }
let output = git_command() let output = git_command()
@@ -867,7 +867,7 @@ pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
.arg(&repo) .arg(&repo)
.args(["merge", "--no-edit", branch]) .args(["merge", "--no-edit", branch])
.output() .output()
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?; .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if output.status.success() { if output.status.success() {
return status_for_repo(&repo); return status_for_repo(&repo);
@@ -888,10 +888,10 @@ pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
} else if !stdout.trim().is_empty() { } else if !stdout.trim().is_empty() {
stdout.trim() stdout.trim()
} else { } else {
"unbekannter Fehler" "unknown error"
}; };
Err(format!("Merge fehlgeschlagen: {details}")) Err(format!("Merge failed: {details}"))
} }
#[tauri::command] #[tauri::command]
@@ -962,7 +962,7 @@ pub async fn list_file_history(
result result
}) })
.await .await
.map_err(|err| format!("Dateihistorie konnte nicht geladen werden: {err}"))? .map_err(|err| format!("Could not load file history: {err}"))?
} }
#[tauri::command] #[tauri::command]
@@ -1003,7 +1003,7 @@ fn list_file_history_core(
args.push(OsString::from("--follow")); args.push(OsString::from("--follow"));
} }
args.extend([OsString::from("--"), OsString::from(file)]); args.extend([OsString::from("--"), OsString::from(file)]);
let output = run_git_cancellable(repo, args, cancellation, "Git-Dateihistorie fehlgeschlagen")?; let output = run_git_cancellable(repo, args, cancellation, "Git file history failed")?;
check_search_cancelled(cancellation)?; check_search_cancelled(cancellation)?;
parse_commit_log(repo, &output) parse_commit_log(repo, &output)
@@ -1025,7 +1025,7 @@ pub async fn search_code_introductions(
let query = normalize_newlines(&query); let query = normalize_newlines(&query);
let query = query.trim_matches('\n').to_string(); let query = query.trim_matches('\n').to_string();
if query.trim().is_empty() { if query.trim().is_empty() {
return Err("Suchtext darf nicht leer sein.".to_string()); return Err("Search text must not be empty.".to_string());
} }
if verify_commit(&repo, "HEAD").is_err() { if verify_commit(&repo, "HEAD").is_err() {
@@ -1058,7 +1058,7 @@ pub async fn search_code_introductions(
result result
}) })
.await .await
.map_err(|err| format!("Such-Task konnte nicht abgeschlossen werden: {err}"))? .map_err(|err| format!("Could not complete search task: {err}"))?
} }
#[tauri::command] #[tauri::command]
@@ -1451,7 +1451,7 @@ pub fn read_conflict(path: String, file: String) -> Result<ConflictFile, String>
validate_files(std::slice::from_ref(&file))?; validate_files(std::slice::from_ref(&file))?;
let bytes = std::fs::read(repo.join(&file)) let bytes = std::fs::read(repo.join(&file))
.map_err(|err| format!("Konfliktdatei konnte nicht gelesen werden: {err}"))?; .map_err(|err| format!("Could not read conflict file: {err}"))?;
let binary = is_binary_bytes(&bytes); let binary = is_binary_bytes(&bytes);
// For binary files we cannot offer a text merge, so we only report the side // For binary files we cannot offer a text merge, so we only report the side
@@ -1493,7 +1493,7 @@ pub fn resolve_conflict_side(
let flag = match side.as_str() { let flag = match side.as_str() {
"ours" => "--ours", "ours" => "--ours",
"theirs" => "--theirs", "theirs" => "--theirs",
_ => return Err("Ungueltige Seite. Erlaubt sind 'ours' oder 'theirs'.".to_string()), _ => return Err("Invalid side. Allowed values are 'ours' or 'theirs'.".to_string()),
}; };
run_git_with_paths(&repo, &["checkout", flag], std::slice::from_ref(&file))?; run_git_with_paths(&repo, &["checkout", flag], std::slice::from_ref(&file))?;
@@ -1531,10 +1531,10 @@ pub fn resolve_conflict(path: String, file: String, content: String) -> Result<G
let target = repo.join(&file); let target = repo.join(&file);
if let Some(parent) = target.parent() { if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent)
.map_err(|err| format!("Verzeichnis konnte nicht erstellt werden: {err}"))?; .map_err(|err| format!("Could not create directory: {err}"))?;
} }
std::fs::write(&target, content) std::fs::write(&target, content)
.map_err(|err| format!("Konfliktdatei konnte nicht geschrieben werden: {err}"))?; .map_err(|err| format!("Could not write conflict file: {err}"))?;
run_git_with_paths(&repo, &["add"], std::slice::from_ref(&file))?; run_git_with_paths(&repo, &["add"], std::slice::from_ref(&file))?;
status_for_repo(&repo) status_for_repo(&repo)
@@ -1562,19 +1562,19 @@ fn short_hash(hash: &str) -> String {
fn resolve_repo(path: &str) -> Result<PathBuf, String> { fn resolve_repo(path: &str) -> Result<PathBuf, String> {
if path.trim().is_empty() { if path.trim().is_empty() {
return Err("Repository-Pfad darf nicht leer sein.".to_string()); return Err("Repository path must not be empty.".to_string());
} }
let input = PathBuf::from(path); let input = PathBuf::from(path);
let output = run_git_at( let output = run_git_at(
&input, &input,
["rev-parse", "--show-toplevel"], ["rev-parse", "--show-toplevel"],
"Kein Git-Repository oder nicht erreichbar", "Not a Git repository or unreachable",
)?; )?;
let top_level = String::from_utf8_lossy(&output).trim().to_string(); let top_level = String::from_utf8_lossy(&output).trim().to_string();
if top_level.is_empty() { if top_level.is_empty() {
return Err("Git konnte keinen Repository-Wurzelpfad ermitteln.".to_string()); return Err("Git could not determine the repository root path.".to_string());
} }
Ok(PathBuf::from(top_level)) Ok(PathBuf::from(top_level))
@@ -1588,7 +1588,7 @@ fn open_path_in_file_manager(path: &Path) -> Result<(), String> {
command.creation_flags(CREATE_NO_WINDOW); command.creation_flags(CREATE_NO_WINDOW);
command command
.spawn() .spawn()
.map_err(|err| format!("Explorer konnte nicht gestartet werden: {err}"))?; .map_err(|err| format!("Could not launch Explorer: {err}"))?;
Ok(()) Ok(())
} }
@@ -1597,7 +1597,7 @@ fn open_path_in_file_manager(path: &Path) -> Result<(), String> {
Command::new("open") Command::new("open")
.arg(path) .arg(path)
.spawn() .spawn()
.map_err(|err| format!("Finder konnte nicht gestartet werden: {err}"))?; .map_err(|err| format!("Could not launch Finder: {err}"))?;
Ok(()) Ok(())
} }
@@ -1606,7 +1606,7 @@ fn open_path_in_file_manager(path: &Path) -> Result<(), String> {
Command::new("xdg-open") Command::new("xdg-open")
.arg(path) .arg(path)
.spawn() .spawn()
.map_err(|err| format!("Dateimanager konnte nicht gestartet werden: {err}"))?; .map_err(|err| format!("Could not launch file manager: {err}"))?;
Ok(()) Ok(())
} }
@@ -1622,19 +1622,19 @@ fn resolve_repo_child_path(repo: &Path, child: &str) -> Result<PathBuf, String>
) )
}) })
{ {
return Err("Dateipfad muss innerhalb des Repositorys liegen.".to_string()); return Err("File path must stay within the repository.".to_string());
} }
let candidate = repo.join(child_path); let candidate = repo.join(child_path);
let repo = repo let repo = repo
.canonicalize() .canonicalize()
.map_err(|err| format!("Repository-Pfad konnte nicht aufgeloest werden: {err}"))?; .map_err(|err| format!("Could not resolve repository path: {err}"))?;
let candidate = candidate let candidate = candidate
.canonicalize() .canonicalize()
.map_err(|err| format!("Dateipfad konnte nicht aufgeloest werden: {err}"))?; .map_err(|err| format!("Could not resolve file path: {err}"))?;
if !candidate.starts_with(&repo) { if !candidate.starts_with(&repo) {
return Err("Dateipfad liegt ausserhalb des Repositorys.".to_string()); return Err("File path lies outside the repository.".to_string());
} }
Ok(candidate) Ok(candidate)
@@ -1648,7 +1648,7 @@ fn reveal_path_in_file_manager(path: &Path) -> Result<(), String> {
command.creation_flags(CREATE_NO_WINDOW); command.creation_flags(CREATE_NO_WINDOW);
command command
.spawn() .spawn()
.map_err(|err| format!("Explorer konnte nicht gestartet werden: {err}"))?; .map_err(|err| format!("Could not launch Explorer: {err}"))?;
Ok(()) Ok(())
} }
@@ -1658,7 +1658,7 @@ fn reveal_path_in_file_manager(path: &Path) -> Result<(), String> {
.arg("-R") .arg("-R")
.arg(path) .arg(path)
.spawn() .spawn()
.map_err(|err| format!("Finder konnte nicht gestartet werden: {err}"))?; .map_err(|err| format!("Could not launch Finder: {err}"))?;
Ok(()) Ok(())
} }
@@ -1669,23 +1669,23 @@ fn reveal_path_in_file_manager(path: &Path) -> Result<(), String> {
Command::new("xdg-open") Command::new("xdg-open")
.arg(target) .arg(target)
.spawn() .spawn()
.map_err(|err| format!("Dateimanager konnte nicht gestartet werden: {err}"))?; .map_err(|err| format!("Could not launch file manager: {err}"))?;
Ok(()) Ok(())
} }
fn verify_commit(repo: &Path, commit: &str) -> Result<String, String> { fn verify_commit(repo: &Path, commit: &str) -> Result<String, String> {
let commit = commit.trim(); let commit = commit.trim();
if commit.is_empty() { if commit.is_empty() {
return Err("Commit darf nicht leer sein.".to_string()); return Err("Commit must not be empty.".to_string());
} }
let rev = format!("{commit}^{{commit}}"); let rev = format!("{commit}^{{commit}}");
let output = run_git(repo, ["rev-parse", "--verify", "--quiet", rev.as_str()]) let output = run_git(repo, ["rev-parse", "--verify", "--quiet", rev.as_str()])
.map_err(|err| format!("Commit konnte nicht gefunden werden: {err}"))?; .map_err(|err| format!("Could not find commit: {err}"))?;
let hash = String::from_utf8_lossy(&output).trim().to_string(); let hash = String::from_utf8_lossy(&output).trim().to_string();
if hash.is_empty() { if hash.is_empty() {
return Err("Commit konnte nicht gefunden werden.".to_string()); return Err("Commit could not be found.".to_string());
} }
Ok(hash) Ok(hash)
@@ -1940,7 +1940,7 @@ fn search_candidate_commits(
repo, repo,
["rev-list", "--all", "--reverse"], ["rev-list", "--all", "--reverse"],
cancellation, cancellation,
"Git-Suche fehlgeschlagen", "Git search failed",
)? )?
} else { } else {
let mut args = vec![ let mut args = vec![
@@ -1957,7 +1957,7 @@ fn search_candidate_commits(
args.push(OsString::from("-i")); args.push(OsString::from("-i"));
} }
args.push(OsString::from(format!("-S{query}"))); args.push(OsString::from(format!("-S{query}")));
run_git_cancellable(repo, args, cancellation, "Git-Suche fehlgeschlagen")? run_git_cancellable(repo, args, cancellation, "Git search failed")?
}; };
Ok(String::from_utf8_lossy(&output) Ok(String::from_utf8_lossy(&output)
@@ -2001,7 +2001,7 @@ fn read_text_blob(repo: &Path, commit: &str, file: &str) -> Result<Option<String
.arg(repo) .arg(repo)
.args(["show", spec.as_str()]) .args(["show", spec.as_str()])
.output() .output()
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?; .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if !output.status.success() { if !output.status.success() {
return Ok(None); return Ok(None);
@@ -2064,7 +2064,7 @@ fn first_added_match_line(
&["diff", "--no-textconv", "--unified=0", parent, commit], &["diff", "--no-textconv", "--unified=0", parent, commit],
&[file.to_string()], &[file.to_string()],
cancellation, cancellation,
"Git-Diff fuer Suchtreffer fehlgeschlagen", "Git diff for search result failed",
)?; )?;
let patch = String::from_utf8_lossy(&output); let patch = String::from_utf8_lossy(&output);
let line_query = first_query_line(query); let line_query = first_query_line(query);
@@ -2169,7 +2169,7 @@ fn commit_search_metadata(repo: &Path, commit: &str) -> Result<GitSearchCommitMe
let text = String::from_utf8_lossy(&output); let text = String::from_utf8_lossy(&output);
let fields: Vec<&str> = text.trim_end().splitn(6, '\x1f').collect(); let fields: Vec<&str> = text.trim_end().splitn(6, '\x1f').collect();
if fields.len() != 6 { if fields.len() != 6 {
return Err(format!("Unerwarteter Git-Commit-Eintrag: {text}")); return Err(format!("Unexpected Git commit entry: {text}"));
} }
Ok(GitSearchCommitMetadata { Ok(GitSearchCommitMetadata {
@@ -2204,7 +2204,7 @@ fn parse_commit_log_inline(output: &[u8]) -> Result<Vec<GitCommit>, String> {
let parts: Vec<&[u8]> = record.splitn(9, |byte| *byte == FIELD_SEPARATOR).collect(); let parts: Vec<&[u8]> = record.splitn(9, |byte| *byte == FIELD_SEPARATOR).collect();
if parts.len() < 8 { if parts.len() < 8 {
return Err(format!( return Err(format!(
"Unerwarteter Git-Log-Eintrag: {}", "Unexpected Git log entry: {}",
String::from_utf8_lossy(record) String::from_utf8_lossy(record)
)); ));
} }
@@ -2261,7 +2261,7 @@ fn parse_commit_log(repo: &Path, output: &[u8]) -> Result<Vec<GitCommit>, String
let fields: Vec<&str> = record.splitn(8, FIELD_SEPARATOR).collect(); let fields: Vec<&str> = record.splitn(8, FIELD_SEPARATOR).collect();
if fields.len() != 8 { if fields.len() != 8 {
return Err(format!("Unerwarteter Git-Log-Eintrag: {record}")); return Err(format!("Unexpected Git log entry: {record}"));
} }
let refs = fields[5] let refs = fields[5]
@@ -2310,7 +2310,7 @@ fn parse_commit_log_metadata(output: &[u8]) -> Result<Vec<GitCommit>, String> {
let fields: Vec<&str> = record.splitn(8, FIELD_SEPARATOR).collect(); let fields: Vec<&str> = record.splitn(8, FIELD_SEPARATOR).collect();
if fields.len() != 8 { if fields.len() != 8 {
return Err(format!("Unerwarteter Git-Log-Eintrag: {record}")); return Err(format!("Unexpected Git log entry: {record}"));
} }
let refs = fields[5] let refs = fields[5]
@@ -2374,7 +2374,7 @@ fn parse_commit_files(output: &[u8]) -> Result<Vec<GitCommitFile>, String> {
let status = map_name_status(&status_text); let status = map_name_status(&status_text);
if index >= entries.len() { if index >= entries.len() {
return Err(format!("Git-Diff-Eintrag ohne Pfad: {status_text}")); return Err(format!("Git diff entry without path: {status_text}"));
} }
if matches!(status, FileStatusKind::Renamed) { if matches!(status, FileStatusKind::Renamed) {
@@ -2517,7 +2517,7 @@ fn local_branch_name_for_remote(remote_branch: &str) -> Option<&str> {
fn validate_new_branch_name(repo: &Path, branch: &str) -> Result<String, String> { fn validate_new_branch_name(repo: &Path, branch: &str) -> Result<String, String> {
let branch = branch.trim(); let branch = branch.trim();
if branch.is_empty() { if branch.is_empty() {
return Err("Branch-Name darf nicht leer sein.".to_string()); return Err("Branch name must not be empty.".to_string());
} }
let output = git_command() let output = git_command()
@@ -2525,11 +2525,11 @@ fn validate_new_branch_name(repo: &Path, branch: &str) -> Result<String, String>
.arg(repo) .arg(repo)
.args(["check-ref-format", "--branch", branch]) .args(["check-ref-format", "--branch", branch])
.output() .output()
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?; .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if !output.status.success() { if !output.status.success() {
let details = command_output_details(&output); let details = command_output_details(&output);
return Err(format!("Ungueltiger Branch-Name: {details}")); return Err(format!("Invalid branch name: {details}"));
} }
let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string(); let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string();
@@ -2540,7 +2540,7 @@ fn validate_new_branch_name(repo: &Path, branch: &str) -> Result<String, String>
}; };
if ref_exists(repo, &format!("refs/heads/{normalized}"))? { if ref_exists(repo, &format!("refs/heads/{normalized}"))? {
return Err(format!("Branch '{normalized}' existiert bereits.")); return Err(format!("Branch '{normalized}' already exists."));
} }
Ok(normalized) Ok(normalized)
@@ -2549,13 +2549,13 @@ fn validate_new_branch_name(repo: &Path, branch: &str) -> Result<String, String>
fn validate_existing_local_branch_name(repo: &Path, branch: &str) -> Result<String, String> { fn validate_existing_local_branch_name(repo: &Path, branch: &str) -> Result<String, String> {
let branch = branch.trim(); let branch = branch.trim();
if branch.is_empty() { if branch.is_empty() {
return Err("Branch-Name darf nicht leer sein.".to_string()); return Err("Branch name must not be empty.".to_string());
} }
let normalized = validate_branch_ref_name(branch)?; let normalized = validate_branch_ref_name(branch)?;
if !ref_exists(repo, &format!("refs/heads/{normalized}"))? { if !ref_exists(repo, &format!("refs/heads/{normalized}"))? {
return Err(format!( return Err(format!(
"Lokaler Branch '{normalized}' wurde nicht gefunden." "Local branch '{normalized}' was not found."
)); ));
} }
@@ -2566,11 +2566,11 @@ fn validate_branch_ref_name(branch: &str) -> Result<String, String> {
let output = git_command() let output = git_command()
.args(["check-ref-format", "--branch", branch]) .args(["check-ref-format", "--branch", branch])
.output() .output()
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?; .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if !output.status.success() { if !output.status.success() {
let details = command_output_details(&output); let details = command_output_details(&output);
return Err(format!("Ungueltiger Branch-Name: {details}")); return Err(format!("Invalid branch name: {details}"));
} }
let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string(); let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string();
@@ -2587,7 +2587,7 @@ fn ref_exists(repo: &Path, ref_name: &str) -> Result<bool, String> {
.arg(repo) .arg(repo)
.args(["show-ref", "--verify", "--quiet", ref_name]) .args(["show-ref", "--verify", "--quiet", ref_name])
.output() .output()
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?; .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
match output.status.code() { match output.status.code() {
Some(0) => Ok(true), Some(0) => Ok(true),
@@ -2595,11 +2595,11 @@ fn ref_exists(repo: &Path, ref_name: &str) -> Result<bool, String> {
_ => { _ => {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
let details = if stderr.trim().is_empty() { let details = if stderr.trim().is_empty() {
"unbekannter Fehler".to_string() "unknown error".to_string()
} else { } else {
stderr.trim().to_string() stderr.trim().to_string()
}; };
Err(format!("Git-Ref konnte nicht geprueft werden: {details}")) Err(format!("Could not verify Git ref: {details}"))
} }
} }
} }
@@ -2755,7 +2755,7 @@ fn find_status<'a>(statuses: &'a [GitFileStatus], file: &str) -> Option<&'a GitF
fn validate_files(files: &[String]) -> Result<(), String> { fn validate_files(files: &[String]) -> Result<(), String> {
if files.iter().any(|file| file.is_empty()) { if files.iter().any(|file| file.is_empty()) {
return Err("Dateiliste enthaelt einen leeren Pfad.".to_string()); return Err("File list contains an empty path.".to_string());
} }
Ok(()) Ok(())
} }
@@ -2768,7 +2768,7 @@ fn write_temp_patch(patch: &str) -> Result<PathBuf, String> {
counter counter
)); ));
std::fs::write(&path, patch.as_bytes()) std::fs::write(&path, patch.as_bytes())
.map_err(|err| format!("Patch-Datei konnte nicht geschrieben werden: {err}"))?; .map_err(|err| format!("Could not write patch file: {err}"))?;
Ok(path) Ok(path)
} }
@@ -2805,9 +2805,9 @@ fn write_askpass_script() -> Result<std::path::PathBuf, String> {
let path = std::env::temp_dir().join("gitlite_askpass.sh"); let path = std::env::temp_dir().join("gitlite_askpass.sh");
let script = "#!/bin/sh\ncase \"$1\" in\n *[Uu]sername*) printf '%s\\n' \"$GIT_CRED_USER\" ;;\n *) printf '%s\\n' \"$GIT_CRED_PASS\" ;;\nesac\n"; let script = "#!/bin/sh\ncase \"$1\" in\n *[Uu]sername*) printf '%s\\n' \"$GIT_CRED_USER\" ;;\n *) printf '%s\\n' \"$GIT_CRED_PASS\" ;;\nesac\n";
std::fs::write(&path, script) std::fs::write(&path, script)
.map_err(|e| format!("Konnte Authentifizierungsskript nicht schreiben: {e}"))?; .map_err(|e| format!("Could not write authentication script: {e}"))?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)) std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
.map_err(|e| format!("Konnte Skriptrechte nicht setzen: {e}"))?; .map_err(|e| format!("Could not set script permissions: {e}"))?;
Ok(path) Ok(path)
} }
@@ -2816,7 +2816,7 @@ fn write_askpass_script() -> Result<std::path::PathBuf, String> {
let path = std::env::temp_dir().join("gitlite_askpass.bat"); let path = std::env::temp_dir().join("gitlite_askpass.bat");
let script = "@echo off\necho %1 | findstr /I \"sername\" >nul 2>&1\nif %errorlevel% == 0 (echo %GIT_CRED_USER%) else (echo %GIT_CRED_PASS%)\n"; let script = "@echo off\necho %1 | findstr /I \"sername\" >nul 2>&1\nif %errorlevel% == 0 (echo %GIT_CRED_USER%) else (echo %GIT_CRED_PASS%)\n";
std::fs::write(&path, script) std::fs::write(&path, script)
.map_err(|e| format!("Konnte Authentifizierungsskript nicht schreiben: {e}"))?; .map_err(|e| format!("Could not write authentication script: {e}"))?;
Ok(path) Ok(path)
} }
@@ -2840,7 +2840,7 @@ where
if is_auth_error(&details) { if is_auth_error(&details) {
return Err(format!("AUTH_FAILED:{details}")); return Err(format!("AUTH_FAILED:{details}"));
} }
Err(format!("Git-Befehl fehlgeschlagen: {details}")) Err(format!("Git command failed: {details}"))
} }
fn run_git_authenticated_output<I, S>( fn run_git_authenticated_output<I, S>(
@@ -2864,7 +2864,7 @@ where
.env("GIT_CRED_USER", username) .env("GIT_CRED_USER", username)
.env("GIT_CRED_PASS", password) .env("GIT_CRED_PASS", password)
.output() .output()
.map_err(|err| format!("Git konnte nicht gestartet werden: {err}")); .map_err(|err| format!("Could not start Git: {err}"));
let _ = std::fs::remove_file(&askpass); let _ = std::fs::remove_file(&askpass);
result result
@@ -2878,7 +2878,7 @@ fn command_output_details(output: &Output) -> String {
} else if !stdout.trim().is_empty() { } else if !stdout.trim().is_empty() {
stdout.trim().to_string() stdout.trim().to_string()
} else { } else {
"Unbekannter Fehler".to_string() "Unknown error".to_string()
} }
} }
@@ -2932,7 +2932,7 @@ where
I: IntoIterator<Item = S>, I: IntoIterator<Item = S>,
S: AsRef<OsStr>, S: AsRef<OsStr>,
{ {
run_git_at(repo, args, "Git-Befehl fehlgeschlagen") run_git_at(repo, args, "Git command failed")
} }
fn run_git_cancellable<I, S>( fn run_git_cancellable<I, S>(
@@ -2960,9 +2960,9 @@ where
counter counter
)); ));
let stdout_file = std::fs::File::create(&stdout_path) let stdout_file = std::fs::File::create(&stdout_path)
.map_err(|err| format!("Git-Ausgabedatei konnte nicht erstellt werden: {err}"))?; .map_err(|err| format!("Could not create Git output file: {err}"))?;
let stderr_file = std::fs::File::create(&stderr_path) let stderr_file = std::fs::File::create(&stderr_path)
.map_err(|err| format!("Git-Fehlerdatei konnte nicht erstellt werden: {err}"))?; .map_err(|err| format!("Could not create Git error file: {err}"))?;
let mut child = git_command() let mut child = git_command()
.arg("-C") .arg("-C")
@@ -2974,7 +2974,7 @@ where
.map_err(|err| { .map_err(|err| {
let _ = std::fs::remove_file(&stdout_path); let _ = std::fs::remove_file(&stdout_path);
let _ = std::fs::remove_file(&stderr_path); let _ = std::fs::remove_file(&stderr_path);
format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}") format!("Could not start Git. Is Git installed? {err}")
})?; })?;
let status = loop { let status = loop {
@@ -2988,7 +2988,7 @@ where
if let Some(status) = child if let Some(status) = child
.try_wait() .try_wait()
.map_err(|err| format!("Git-Prozess konnte nicht geprueft werden: {err}"))? .map_err(|err| format!("Could not check Git process: {err}"))?
{ {
break status; break status;
} }
@@ -2997,9 +2997,9 @@ where
}; };
let stdout = std::fs::read(&stdout_path) let stdout = std::fs::read(&stdout_path)
.map_err(|err| format!("Git-Ausgabe konnte nicht gelesen werden: {err}"))?; .map_err(|err| format!("Could not read Git output: {err}"))?;
let stderr = std::fs::read(&stderr_path) let stderr = std::fs::read(&stderr_path)
.map_err(|err| format!("Git-Fehlerausgabe konnte nicht gelesen werden: {err}"))?; .map_err(|err| format!("Could not read Git error output: {err}"))?;
let _ = std::fs::remove_file(&stdout_path); let _ = std::fs::remove_file(&stdout_path);
let _ = std::fs::remove_file(&stderr_path); let _ = std::fs::remove_file(&stderr_path);
@@ -3014,7 +3014,7 @@ where
} else if !stdout_text.trim().is_empty() { } else if !stdout_text.trim().is_empty() {
stdout_text.trim() stdout_text.trim()
} else { } else {
"unbekannter Fehler" "unknown error"
}; };
Err(format!("{context}: {details}")) Err(format!("{context}: {details}"))
@@ -3030,7 +3030,7 @@ where
.arg(path) .arg(path)
.args(args) .args(args)
.output() .output()
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?; .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if output.status.success() { if output.status.success() {
return Ok(output.stdout); return Ok(output.stdout);
@@ -3043,7 +3043,7 @@ where
} else if !stdout.trim().is_empty() { } else if !stdout.trim().is_empty() {
stdout.trim() stdout.trim()
} else { } else {
"unbekannter Fehler" "unknown error"
}; };
Err(format!("{context}: {details}")) Err(format!("{context}: {details}"))
@@ -3064,17 +3064,17 @@ where
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.spawn() .spawn()
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?; .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if let Some(mut stdin) = child.stdin.take() { if let Some(mut stdin) = child.stdin.take() {
stdin stdin
.write_all(stdin_data) .write_all(stdin_data)
.map_err(|err| format!("Eingabe konnte nicht an Git gesendet werden: {err}"))?; .map_err(|err| format!("Could not send input to Git: {err}"))?;
} }
let output = child let output = child
.wait_with_output() .wait_with_output()
.map_err(|err| format!("Git-Ausgabe konnte nicht gelesen werden: {err}"))?; .map_err(|err| format!("Could not read Git output: {err}"))?;
if output.status.success() { if output.status.success() {
return Ok(output.stdout); return Ok(output.stdout);
@@ -3087,10 +3087,10 @@ where
} else if !stdout.trim().is_empty() { } else if !stdout.trim().is_empty() {
stdout.trim() stdout.trim()
} else { } else {
"unbekannter Fehler" "unknown error"
}; };
Err(format!("Git-Befehl fehlgeschlagen: {details}")) Err(format!("Git command failed: {details}"))
} }
fn parse_status_output(output: &[u8]) -> Result<(BranchInfo, Vec<GitFileStatus>), String> { fn parse_status_output(output: &[u8]) -> Result<(BranchInfo, Vec<GitFileStatus>), String> {
@@ -3978,7 +3978,7 @@ mod tests {
) )
.unwrap_err(); .unwrap_err();
assert!(err.contains("Merge-Konflikte")); assert!(err.contains("Merge conflicts"));
let status = status_for_repo(&repo.path).unwrap(); let status = status_for_repo(&repo.path).unwrap();
assert!(has_unresolved_conflicts(&status)); assert!(has_unresolved_conflicts(&status));
} }
@@ -4134,7 +4134,7 @@ mod tests {
None, None,
) )
.unwrap_err(); .unwrap_err();
assert!(err.contains("existiert bereits")); assert!(err.contains("already exists"));
} }
#[test] #[test]
+153 -16
View File
@@ -11,6 +11,7 @@
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";
@@ -96,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;
@@ -110,6 +114,10 @@
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 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 ──────────────────────────────────────────────────────────────────
@@ -157,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[] = [];
@@ -186,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 ────────────────────────────────────────────────────────────────
@@ -542,6 +555,56 @@
} }
} }
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();
@@ -692,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() {
@@ -766,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,
@@ -975,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;
@@ -984,7 +1047,7 @@
errorMessage = message; errorMessage = message;
} }
} else { } else {
credDialogError = message || "Anmeldung fehlgeschlagen."; credDialogError = message || "Sign-in failed.";
} }
} }
@@ -1022,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;
@@ -1050,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;
} }
@@ -1125,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);
@@ -1176,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 = "";
@@ -1188,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 {
@@ -1204,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;
@@ -1441,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) {
@@ -1454,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) {
@@ -1533,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;
@@ -1813,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}
@@ -1830,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}
@@ -1908,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}
+133 -7
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,9 +1369,35 @@
/* --- 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;
.commit-actions-row { display: flex; gap: 8px; } 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-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-ai-settings-button { flex: 0 0 auto; width: 38px; min-width: 38px; padding: 0; justify-content: center; }
.commit-block-reason { .commit-block-reason {
@@ -1606,6 +1671,14 @@
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 { .ai-settings-dialog {
display: block; display: block;
width: min(560px, calc(100vw - 32px)); width: min(560px, calc(100vw - 32px));
@@ -1857,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);
@@ -2893,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 */
@@ -2919,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; }
+4 -4
View File
@@ -153,9 +153,9 @@
<div class="cred-token-hint"> <div class="cred-token-hint">
<AlertCircle size={13} aria-hidden="true" /> <AlertCircle size={13} aria-hidden="true" />
<span> <span>
Beim Wechsel wird das Modell{selectedLocalModel ? ` (${formatSize(selectedLocalModel.approx_size_mb)})` : ""} Switching downloads the model{selectedLocalModel ? ` (${formatSize(selectedLocalModel.approx_size_mb)})` : ""}
im Hintergrund heruntergeladen — je nach Internetverbindung kann das mehrere Minuten dauern. in the background — depending on your internet connection this can take several minutes.
Danach bleibt es lokal zwischengespeichert und lädt beim nächsten Start sofort. After that it stays cached locally and loads instantly on the next start.
</span> </span>
</div> </div>
{:else if provider === "openai"} {:else if provider === "openai"}
@@ -230,7 +230,7 @@
</div> </div>
<div class="cred-token-hint"> <div class="cred-token-hint">
<Globe size={13} aria-hidden="true" /> <Globe size={13} aria-hidden="true" />
<span>Für lokale OpenAI-kompatible Server wie Ollama oder LM Studio. Die Basis-URL sollte auf /v1 enden.</span> <span>For local OpenAI-compatible servers like Ollama or LM Studio. The base URL should end in /v1.</span>
</div> </div>
{/if} {/if}
+1 -1
View File
@@ -58,7 +58,7 @@
); );
</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>
+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>