diff --git a/.claude/settings.local.json b/.claude/settings.local.json index aba8357..bbc1b0f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -58,7 +58,20 @@ "Bash(: *)", "Bash(exit 0 *)", "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: $?\")" ] } } diff --git a/src-tauri/crates/commit_ai/src/cloud.rs b/src-tauri/crates/commit_ai/src/cloud.rs index 39377e3..320640d 100644 --- a/src-tauri/crates/commit_ai/src/cloud.rs +++ b/src-tauri/crates/commit_ai/src/cloud.rs @@ -4,16 +4,16 @@ use serde::{Deserialize, Serialize}; 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; -// 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); fn http_client() -> Result { reqwest::Client::builder() .timeout(REQUEST_TIMEOUT) .build() - .map_err(|err| format!("HTTP-Client konnte nicht erstellt werden: {err}")) + .map_err(|err| format!("Could not create HTTP client: {err}")) } #[derive(Serialize)] @@ -71,19 +71,19 @@ async fn openai_compatible_request( let response = request .send() .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 text = response .text() .await - .map_err(|err| format!("Antwort konnte nicht gelesen werden: {err}"))?; + .map_err(|err| format!("Could not read response: {err}"))?; 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) - .map_err(|err| format!("Antwort konnte nicht verarbeitet werden: {err}"))?; + .map_err(|err| format!("Could not process response: {err}"))?; parsed .choices @@ -92,7 +92,7 @@ async fn openai_compatible_request( .and_then(|choice| choice.message.content) .map(|content| sanitize_message(&content)) .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( @@ -102,7 +102,7 @@ pub async fn generate_openai( notes: Option<&str>, ) -> Result { 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( "https://api.openai.com/v1/chat/completions".to_string(), @@ -122,7 +122,7 @@ pub async fn generate_custom( notes: Option<&str>, ) -> Result { 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('/')); openai_compatible_request(url, api_key, model, diff, notes).await @@ -161,7 +161,7 @@ pub async fn generate_anthropic( notes: Option<&str>, ) -> Result { 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 body = AnthropicRequest { @@ -179,19 +179,19 @@ pub async fn generate_anthropic( .json(&body) .send() .await - .map_err(|err| format!("Anfrage an Anthropic fehlgeschlagen: {err}"))?; + .map_err(|err| format!("Request to Anthropic failed: {err}"))?; let status = response.status(); let text = response .text() .await - .map_err(|err| format!("Antwort konnte nicht gelesen werden: {err}"))?; + .map_err(|err| format!("Could not read response: {err}"))?; 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) - .map_err(|err| format!("Antwort konnte nicht verarbeitet werden: {err}"))?; + .map_err(|err| format!("Could not process response: {err}"))?; parsed .content @@ -199,5 +199,5 @@ pub async fn generate_anthropic( .find_map(|block| block.text) .map(|text| sanitize_message(&text)) .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()) } diff --git a/src-tauri/crates/commit_ai/src/lib.rs b/src-tauri/crates/commit_ai/src/lib.rs index 6ada392..ec30de6 100644 --- a/src-tauri/crates/commit_ai/src/lib.rs +++ b/src-tauri/crates/commit_ai/src/lib.rs @@ -24,7 +24,7 @@ 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 — schnell, geringere Qualität", + 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", @@ -32,7 +32,7 @@ pub const LOCAL_MODELS: &[LocalModelOption] = &[ }, LocalModelOption { id: "qwen2.5-1.5b", - label: "Qwen2.5 1.5B Instruct — empfohlen", + 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", @@ -40,7 +40,7 @@ pub const LOCAL_MODELS: &[LocalModelOption] = &[ }, LocalModelOption { 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, repo: "Qwen/Qwen2.5-3B-Instruct-GGUF", file: "qwen2.5-3b-instruct-q4_k_m.gguf", @@ -128,7 +128,7 @@ impl CommitAiEngine { let mut guard = self.inner.write().await; guard.phase = CommitAiPhase::Error; 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; }; @@ -174,7 +174,7 @@ impl CommitAiEngine { let guard = self.inner.read().await; match (guard.phase, &guard.model) { (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 .first() .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); 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) } @@ -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> { 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 8–32k Kontext + // 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 { - // Byte-Index auf eine gültige UTF-8-Zeichengrenze zurückziehen, sonst - // paniken Slices mitten in einem Umlaut o. Ä. + // 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 gekürzt ...]", &diff[..cut]) + format!("{}\n\n[... diff truncated ...]", &diff[..cut]) } else { diff.to_string() }; - let system = "Du bist ein Werkzeug, das Git-Commit-Messages erzeugt. \ -Antworte ausschließlich mit der Commit-Message im Conventional-Commits-Format \ -((): ), gefolgt von einem Body nach einer Leerzeile. \ -Subject imperativ, max. 72 Zeichen. \ -Der Body ist Pflicht: Fasse in einem kurzen Absatz zusammen, was und warum geändert wurde, \ -und liste danach die wesentlichen Änderungen als Stichpunkte (- ...) auf, \ -gruppiert nach betroffenem Bereich/Datei. Zeilen im Body max. 72 Zeichen. \ -Kein Vorspann, keine Erklärung, keine Code-Fences, in Englisch antworten" + let system = "You are a tool that generates Git commit messages. \ +Respond only with the commit message in Conventional Commits format \ +((): ), 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!("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}")); Ok((system, user)) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index bab89be..cfaed67 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -146,7 +146,7 @@ enum CheckoutPlan { const FULL_FILE_DIFF_CONTEXT: &str = "--unified=1000000"; 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); fn git_command() -> Command { @@ -165,7 +165,7 @@ impl SearchCancellationState { fn cancel(&self, search_id: &str) -> Result<(), String> { self.cancelled .lock() - .map_err(|_| "Such-Abbruchstatus ist nicht erreichbar.".to_string())? + .map_err(|_| "Search cancellation status is unavailable.".to_string())? .insert(search_id.to_string()); Ok(()) } @@ -173,7 +173,7 @@ impl SearchCancellationState { fn clear(&self, search_id: &str) -> Result<(), String> { self.cancelled .lock() - .map_err(|_| "Such-Abbruchstatus ist nicht erreichbar.".to_string())? + .map_err(|_| "Search cancellation status is unavailable.".to_string())? .remove(search_id); Ok(()) } @@ -182,7 +182,7 @@ impl SearchCancellationState { Ok(self .cancelled .lock() - .map_err(|_| "Such-Abbruchstatus ist nicht erreichbar.".to_string())? + .map_err(|_| "Search cancellation status is unavailable.".to_string())? .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)?; 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() { - return Err(format!("'{file}' ist keine Datei.")); + return Err(format!("'{file}' is not a file.")); } reveal_path_in_file_manager(&file_path) @@ -262,7 +262,7 @@ pub async fn open_repository_bundle( }) }) .await - .map_err(|err| format!("Repository konnte nicht geladen werden: {err}"))? + .map_err(|err| format!("Could not load repository: {err}"))? } #[tauri::command] @@ -321,7 +321,7 @@ pub fn checkout_branch(path: String, branch: String) -> Result Result let branch = validate_existing_local_branch_name(&repo, &branch)?; let status = status_for_repo(&repo)?; 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()])?; @@ -560,21 +560,21 @@ pub async fn commit_ai_generate( match provider.as_str() { "local" => engine.generate_commit_message(&diff, notes).await, "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()); commit_ai::generate_openai(&api_key, &model, &diff, notes).await } "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()); commit_ai::generate_anthropic(&api_key, &model, &diff, notes).await } "custom" => { - let base_url = base_url.ok_or_else(|| "Endpoint-URL fehlt.".to_string())?; - let model = model.ok_or_else(|| "Modellname fehlt.".to_string())?; + let base_url = base_url.ok_or_else(|| "Endpoint URL is missing.".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 } - 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)?; validate_files(std::slice::from_ref(&file))?; 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)?; @@ -603,7 +603,7 @@ pub fn apply_file_patch( .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, &["--reverse"])), - _ => Err("Ungueltige Patch-Aktion.".to_string()), + _ => Err("Invalid patch action.".to_string()), }; let _ = std::fs::remove_file(&patch_path); @@ -615,13 +615,13 @@ pub fn apply_file_patch( pub fn commit(path: String, message: String) -> Result { let repo = resolve_repo(&path)?; 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)?; if has_unresolved_conflicts(¤t_status) { 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) .output() .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) { return Err(format!("AUTH_FAILED:{details}")); } - Err(format!("Git-Befehl fehlgeschlagen: {details}")) + Err(format!("Git command failed: {details}")) } #[tauri::command] @@ -701,10 +701,10 @@ pub struct StoredCredential { fn cred_entry(key: &str) -> Result { let key = key.trim(); 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) - .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 @@ -770,7 +770,7 @@ fn current_branch_name(repo: &Path) -> Result { let branch = run_git(repo, ["rev-parse", "--abbrev-ref", "HEAD"])?; let branch = String::from_utf8_lossy(&branch).trim().to_string(); 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) } @@ -791,7 +791,7 @@ fn initial_push_remote_name(repo: &Path) -> Result { } 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, String> { match entry.get_password() { Ok(json) => { let cred = serde_json::from_str::(&json) - .map_err(|err| format!("Gespeicherte Zugangsdaten unlesbar: {err}"))?; + .map_err(|err| format!("Stored credentials unreadable: {err}"))?; Ok(Some(cred)) } 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, }; 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 .set_password(&json) - .map_err(|err| format!("Speichern im Schlüsselbund fehlgeschlagen: {err}")) + .map_err(|err| format!("Saving to keychain failed: {err}")) } #[tauri::command] @@ -850,7 +850,7 @@ pub fn cred_delete(key: String) -> Result<(), String> { let entry = cred_entry(&key)?; match entry.delete_credential() { 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 { let repo = resolve_repo(&path)?; let branch = branch.trim(); 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() @@ -867,7 +867,7 @@ pub fn merge_branch(path: String, branch: String) -> Result { .arg(&repo) .args(["merge", "--no-edit", branch]) .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() { return status_for_repo(&repo); @@ -888,10 +888,10 @@ pub fn merge_branch(path: String, branch: String) -> Result { } else if !stdout.trim().is_empty() { stdout.trim() } else { - "unbekannter Fehler" + "unknown error" }; - Err(format!("Merge fehlgeschlagen: {details}")) + Err(format!("Merge failed: {details}")) } #[tauri::command] @@ -962,7 +962,7 @@ pub async fn list_file_history( result }) .await - .map_err(|err| format!("Dateihistorie konnte nicht geladen werden: {err}"))? + .map_err(|err| format!("Could not load file history: {err}"))? } #[tauri::command] @@ -1003,7 +1003,7 @@ fn list_file_history_core( args.push(OsString::from("--follow")); } 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)?; parse_commit_log(repo, &output) @@ -1025,7 +1025,7 @@ pub async fn search_code_introductions( let query = normalize_newlines(&query); let query = query.trim_matches('\n').to_string(); 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() { @@ -1058,7 +1058,7 @@ pub async fn search_code_introductions( result }) .await - .map_err(|err| format!("Such-Task konnte nicht abgeschlossen werden: {err}"))? + .map_err(|err| format!("Could not complete search task: {err}"))? } #[tauri::command] @@ -1451,7 +1451,7 @@ pub fn read_conflict(path: String, file: String) -> Result validate_files(std::slice::from_ref(&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); // 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() { "ours" => "--ours", "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))?; @@ -1531,10 +1531,10 @@ pub fn resolve_conflict(path: String, file: String, content: String) -> Result String { fn resolve_repo(path: &str) -> Result { 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 output = run_git_at( &input, ["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(); 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)) @@ -1588,7 +1588,7 @@ fn open_path_in_file_manager(path: &Path) -> Result<(), String> { command.creation_flags(CREATE_NO_WINDOW); command .spawn() - .map_err(|err| format!("Explorer konnte nicht gestartet werden: {err}"))?; + .map_err(|err| format!("Could not launch Explorer: {err}"))?; Ok(()) } @@ -1597,7 +1597,7 @@ fn open_path_in_file_manager(path: &Path) -> Result<(), String> { Command::new("open") .arg(path) .spawn() - .map_err(|err| format!("Finder konnte nicht gestartet werden: {err}"))?; + .map_err(|err| format!("Could not launch Finder: {err}"))?; Ok(()) } @@ -1606,7 +1606,7 @@ fn open_path_in_file_manager(path: &Path) -> Result<(), String> { Command::new("xdg-open") .arg(path) .spawn() - .map_err(|err| format!("Dateimanager konnte nicht gestartet werden: {err}"))?; + .map_err(|err| format!("Could not launch file manager: {err}"))?; Ok(()) } @@ -1622,19 +1622,19 @@ fn resolve_repo_child_path(repo: &Path, child: &str) -> Result ) }) { - 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 repo = repo .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 .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) { - return Err("Dateipfad liegt ausserhalb des Repositorys.".to_string()); + return Err("File path lies outside the repository.".to_string()); } Ok(candidate) @@ -1648,7 +1648,7 @@ fn reveal_path_in_file_manager(path: &Path) -> Result<(), String> { command.creation_flags(CREATE_NO_WINDOW); command .spawn() - .map_err(|err| format!("Explorer konnte nicht gestartet werden: {err}"))?; + .map_err(|err| format!("Could not launch Explorer: {err}"))?; Ok(()) } @@ -1658,7 +1658,7 @@ fn reveal_path_in_file_manager(path: &Path) -> Result<(), String> { .arg("-R") .arg(path) .spawn() - .map_err(|err| format!("Finder konnte nicht gestartet werden: {err}"))?; + .map_err(|err| format!("Could not launch Finder: {err}"))?; Ok(()) } @@ -1669,23 +1669,23 @@ fn reveal_path_in_file_manager(path: &Path) -> Result<(), String> { Command::new("xdg-open") .arg(target) .spawn() - .map_err(|err| format!("Dateimanager konnte nicht gestartet werden: {err}"))?; + .map_err(|err| format!("Could not launch file manager: {err}"))?; Ok(()) } fn verify_commit(repo: &Path, commit: &str) -> Result { let commit = commit.trim(); 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 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(); if hash.is_empty() { - return Err("Commit konnte nicht gefunden werden.".to_string()); + return Err("Commit could not be found.".to_string()); } Ok(hash) @@ -1940,7 +1940,7 @@ fn search_candidate_commits( repo, ["rev-list", "--all", "--reverse"], cancellation, - "Git-Suche fehlgeschlagen", + "Git search failed", )? } else { let mut args = vec![ @@ -1957,7 +1957,7 @@ fn search_candidate_commits( args.push(OsString::from("-i")); } 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) @@ -2001,7 +2001,7 @@ fn read_text_blob(repo: &Path, commit: &str, file: &str) -> Result Result = text.trim_end().splitn(6, '\x1f').collect(); if fields.len() != 6 { - return Err(format!("Unerwarteter Git-Commit-Eintrag: {text}")); + return Err(format!("Unexpected Git commit entry: {text}")); } Ok(GitSearchCommitMetadata { @@ -2204,7 +2204,7 @@ fn parse_commit_log_inline(output: &[u8]) -> Result, String> { let parts: Vec<&[u8]> = record.splitn(9, |byte| *byte == FIELD_SEPARATOR).collect(); if parts.len() < 8 { return Err(format!( - "Unerwarteter Git-Log-Eintrag: {}", + "Unexpected Git log entry: {}", String::from_utf8_lossy(record) )); } @@ -2261,7 +2261,7 @@ fn parse_commit_log(repo: &Path, output: &[u8]) -> Result, String let fields: Vec<&str> = record.splitn(8, FIELD_SEPARATOR).collect(); if fields.len() != 8 { - return Err(format!("Unerwarteter Git-Log-Eintrag: {record}")); + return Err(format!("Unexpected Git log entry: {record}")); } let refs = fields[5] @@ -2310,7 +2310,7 @@ fn parse_commit_log_metadata(output: &[u8]) -> Result, String> { let fields: Vec<&str> = record.splitn(8, FIELD_SEPARATOR).collect(); if fields.len() != 8 { - return Err(format!("Unerwarteter Git-Log-Eintrag: {record}")); + return Err(format!("Unexpected Git log entry: {record}")); } let refs = fields[5] @@ -2374,7 +2374,7 @@ fn parse_commit_files(output: &[u8]) -> Result, String> { let status = map_name_status(&status_text); 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) { @@ -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 { let branch = branch.trim(); 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() @@ -2525,11 +2525,11 @@ fn validate_new_branch_name(repo: &Path, branch: &str) -> Result .arg(repo) .args(["check-ref-format", "--branch", branch]) .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() { 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(); @@ -2540,7 +2540,7 @@ fn validate_new_branch_name(repo: &Path, branch: &str) -> Result }; if ref_exists(repo, &format!("refs/heads/{normalized}"))? { - return Err(format!("Branch '{normalized}' existiert bereits.")); + return Err(format!("Branch '{normalized}' already exists.")); } Ok(normalized) @@ -2549,13 +2549,13 @@ fn validate_new_branch_name(repo: &Path, branch: &str) -> Result fn validate_existing_local_branch_name(repo: &Path, branch: &str) -> Result { let branch = branch.trim(); 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)?; if !ref_exists(repo, &format!("refs/heads/{normalized}"))? { 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 { let output = git_command() .args(["check-ref-format", "--branch", branch]) .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() { 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(); @@ -2587,7 +2587,7 @@ fn ref_exists(repo: &Path, ref_name: &str) -> Result { .arg(repo) .args(["show-ref", "--verify", "--quiet", ref_name]) .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() { Some(0) => Ok(true), @@ -2595,11 +2595,11 @@ fn ref_exists(repo: &Path, ref_name: &str) -> Result { _ => { let stderr = String::from_utf8_lossy(&output.stderr); let details = if stderr.trim().is_empty() { - "unbekannter Fehler".to_string() + "unknown error".to_string() } else { 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> { 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(()) } @@ -2768,7 +2768,7 @@ fn write_temp_patch(patch: &str) -> Result { counter )); 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) } @@ -2805,9 +2805,9 @@ fn write_askpass_script() -> Result { 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"; 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)) - .map_err(|e| format!("Konnte Skriptrechte nicht setzen: {e}"))?; + .map_err(|e| format!("Could not set script permissions: {e}"))?; Ok(path) } @@ -2816,7 +2816,7 @@ fn write_askpass_script() -> Result { 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"; 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) } @@ -2840,7 +2840,7 @@ where if is_auth_error(&details) { return Err(format!("AUTH_FAILED:{details}")); } - Err(format!("Git-Befehl fehlgeschlagen: {details}")) + Err(format!("Git command failed: {details}")) } fn run_git_authenticated_output( @@ -2864,7 +2864,7 @@ where .env("GIT_CRED_USER", username) .env("GIT_CRED_PASS", password) .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); result @@ -2878,7 +2878,7 @@ fn command_output_details(output: &Output) -> String { } else if !stdout.trim().is_empty() { stdout.trim().to_string() } else { - "Unbekannter Fehler".to_string() + "Unknown error".to_string() } } @@ -2932,7 +2932,7 @@ where I: IntoIterator, S: AsRef, { - run_git_at(repo, args, "Git-Befehl fehlgeschlagen") + run_git_at(repo, args, "Git command failed") } fn run_git_cancellable( @@ -2960,9 +2960,9 @@ where counter )); 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) - .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() .arg("-C") @@ -2974,7 +2974,7 @@ where .map_err(|err| { let _ = std::fs::remove_file(&stdout_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 { @@ -2988,7 +2988,7 @@ where if let Some(status) = child .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; } @@ -2997,9 +2997,9 @@ where }; 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) - .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(&stderr_path); @@ -3014,7 +3014,7 @@ where } else if !stdout_text.trim().is_empty() { stdout_text.trim() } else { - "unbekannter Fehler" + "unknown error" }; Err(format!("{context}: {details}")) @@ -3030,7 +3030,7 @@ where .arg(path) .args(args) .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() { return Ok(output.stdout); @@ -3043,7 +3043,7 @@ where } else if !stdout.trim().is_empty() { stdout.trim() } else { - "unbekannter Fehler" + "unknown error" }; Err(format!("{context}: {details}")) @@ -3064,17 +3064,17 @@ where .stdout(Stdio::piped()) .stderr(Stdio::piped()) .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() { stdin .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 .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() { return Ok(output.stdout); @@ -3087,10 +3087,10 @@ where } else if !stdout.trim().is_empty() { stdout.trim() } 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), String> { @@ -3978,7 +3978,7 @@ mod tests { ) .unwrap_err(); - assert!(err.contains("Merge-Konflikte")); + assert!(err.contains("Merge conflicts")); let status = status_for_repo(&repo.path).unwrap(); assert!(has_unresolved_conflicts(&status)); } @@ -4134,7 +4134,7 @@ mod tests { None, ) .unwrap_err(); - assert!(err.contains("existiert bereits")); + assert!(err.contains("already exists")); } #[test] diff --git a/src/App.svelte b/src/App.svelte index e96df25..07175a2 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -11,6 +11,7 @@ import CompareDialog from "./lib/components/CompareDialog.svelte"; import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte"; import CredentialDialog from "./lib/components/CredentialDialog.svelte"; + import DiscardConfirmDialog from "./lib/components/DiscardConfirmDialog.svelte"; import ExplorerPanel from "./lib/components/ExplorerPanel.svelte"; import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte"; import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte"; @@ -96,6 +97,9 @@ type UpdateToastState = "available" | "downloading" | "installed" | "error"; type AppView = "management" | "repository"; + type PendingDiscard = + | { kind: "file"; file: GitFileStatus; staged: boolean } + | { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string }; interface RepoTab { path: string; @@ -110,6 +114,10 @@ const OPEN_REPOS_KEY = "gitlite.openRepos.v1"; const RECENT_REPOS_KEY = "gitlite.recentRepos.v1"; const AI_SETTINGS_KEY = "gitlite.aiSettings.v1"; + const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1"; + const COMMIT_PANEL_DEFAULT_HEIGHT = 220; + const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT; + const COMMIT_PANEL_MAX_HEIGHT = 640; // ── State ────────────────────────────────────────────────────────────────── @@ -157,6 +165,7 @@ let linePatchText = ""; let linePatchLoading = false; let linePatchError = ""; + let pendingDiscard: PendingDiscard | null = null; let globalSearchOpen = false; let lastSearchQuery = ""; let globalSearchResults: GitSearchHit[] = []; @@ -186,6 +195,10 @@ let updateCheckInFlight = false; let updateDownloadTotal = 0; let updateDownloadedBytes = 0; + let commitPanelHeight = loadCommitPanelHeight(); + let resizingCommitPanel = false; + let resizeStartY = 0; + let resizeStartHeight = 0; // ── 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) { recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40); persistRepoLists(); @@ -692,7 +755,7 @@ } function isCancellationMessage(message: string): boolean { - return message.toLowerCase().includes("abgebrochen"); + return message.toLowerCase().includes("cancelled"); } function cancelActiveFileHistoryLoad() { @@ -766,7 +829,7 @@ if (isBusy) return; try { const selected = await openDialog({ - title: "Repository folder auswaehlen", + title: "Select repository folder", directory: true, multiple: false, defaultPath: repoPath.trim() || activeRepoPath || undefined, @@ -975,7 +1038,7 @@ if (auth) { if (key) void credDelete(key).catch(() => {}); credDialogError = - "Zugangsdaten wurden abgelehnt oder sind abgelaufen. Bitte erneut anmelden."; + "Credentials were rejected or have expired. Please sign in again."; credDialogAction = action; credDialogKey = key; credDialogOpen = true; @@ -984,7 +1047,7 @@ errorMessage = message; } } else { - credDialogError = message || "Anmeldung fehlgeschlagen."; + credDialogError = message || "Sign-in failed."; } } @@ -1022,11 +1085,11 @@ if (errorMessage && !isAuthError(errorMessage) && isNonFastForwardPushError(errorMessage)) { errorMessage = ""; const shouldSync = window.confirm( - "Der Remote hat neuere Commits, deshalb wurde der Push abgelehnt.\n\nJetzt Pull/Merge ausfuehren und danach den Push erneut versuchen?", + "The remote has newer commits, so the push was rejected.\n\nRun Pull/Merge now and try pushing again afterwards?", ); if (!shouldSync) { - const message = "Push abgelehnt: Der Remote hat neuere Commits. Pull zuerst ausfuehren, dann erneut pushen."; + const message = "Push rejected: the remote has newer commits. Pull first, then push again."; if (fromStore) errorMessage = message; else credDialogError = message; return; @@ -1050,7 +1113,7 @@ if (statusHasConflicts(status)) { credDialogOpen = false; credDialogAction = null; - errorMessage = "Pull hat Merge-Konflikte erzeugt. Loese die Konflikte, committe den Merge und pushe danach erneut."; + errorMessage = "Pull produced merge conflicts. Resolve the conflicts, commit the merge, and then push again."; return; } @@ -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 () => { applyStatus(await restoreFiles(activeRepoPath, [file.path], staged)); await refreshExplorerFiles(activeRepoPath); @@ -1176,9 +1244,17 @@ } } - async function applyLinePatch(action: PatchApplyAction, patch: string) { - if (!activeRepoPath || !linePatchFile || isBusy) return; - const file = linePatchFile; + function isDiscardPatchAction(action: PatchApplyAction): boolean { + return action === "discard-staged" || action === "discard-unstaged"; + } + + async function runLinePatchAction( + action: PatchApplyAction, + patch: string, + file: GitFileStatus, + staged: boolean, + ) { + if (!activeRepoPath || isBusy) return; operation = patchOperationLabel(action, file); errorMessage = ""; linePatchError = ""; @@ -1188,7 +1264,7 @@ await refreshExplorerFiles(activeRepoPath); await refreshFileHistory(activeRepoPath); - const updatedPatch = await getFilePatch(activeRepoPath, file.path, linePatchStaged); + const updatedPatch = await getFilePatch(activeRepoPath, file.path, staged); if (updatedPatch.trim()) { linePatchText = updatedPatch; } else { @@ -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() { const paths = changedFiles.filter((f) => f.unstaged !== null).map((f) => f.path); if (paths.length === 0) return; @@ -1441,7 +1548,7 @@ } catch (error) { if (globalSearchId === searchId) { const message = errorToMessage(error); - globalSearchError = message.includes("abgebrochen") ? "Suche wurde abgebrochen." : message; + globalSearchError = message.includes("cancelled") ? "Search was cancelled." : message; } } finally { if (globalSearchId === searchId) { @@ -1454,7 +1561,7 @@ async function cancelGlobalSearch() { if (!globalSearchId) return; const searchId = globalSearchId; - globalSearchError = "Abbruch wird angefordert..."; + globalSearchError = "Requesting cancellation..."; try { await cancelCodeSearch(searchId); } catch (error) { @@ -1533,7 +1640,8 @@ // ── Event handlers ───────────────────────────────────────────────────────── function handleWindowKeydown(event: KeyboardEvent) { - if (event.key === "Escape" && compareDialogOpen) closeCompareDialog(); + if (event.key === "Escape" && pendingDiscard && !isBusy) closeDiscardConfirm(); + else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog(); else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null; else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null; else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false; @@ -1813,7 +1921,7 @@ -
+
+ + + {/if} +{#if pendingDiscard} + +{/if} + {#if globalSearchOpen}
{:else if provider === "openai"} @@ -230,7 +230,7 @@
{/if} diff --git a/src/lib/components/CommitPanel.svelte b/src/lib/components/CommitPanel.svelte index 7f60c53..e6c4777 100644 --- a/src/lib/components/CommitPanel.svelte +++ b/src/lib/components/CommitPanel.svelte @@ -58,7 +58,7 @@ ); -
+
Commit diff --git a/src/lib/components/CredentialDialog.svelte b/src/lib/components/CredentialDialog.svelte index fec7a3e..56f369c 100644 --- a/src/lib/components/CredentialDialog.svelte +++ b/src/lib/components/CredentialDialog.svelte @@ -44,10 +44,10 @@ (mode === "token" || username.trim().length > 0), ); let actionLabel = $derived(action === "push" ? "Push" : "Pull"); - let actionTitle = $derived(action === "push" ? "Push authentifizieren" : "Pull authentifizieren"); + let actionTitle = $derived(action === "push" ? "Authenticate push" : "Authenticate pull"); let actionHint = $derived(action === "push" - ? "Der Remote braucht Schreibrechte. Nutze ein Passwort oder einen Token mit passenden Repository-Rechten." - : "Der Remote braucht Zugriff auf das Repository. Nutze deine Git-Zugangsdaten oder einen Personal Access Token."); + ? "The remote needs write access. Use a password or a token with the appropriate repository permissions." + : "The remote needs access to the repository. Use your Git credentials or a personal access token."); function handleSubmit(e: SubmitEvent) { e.preventDefault(); @@ -66,7 +66,7 @@ role="presentation" onclick={(e) => { if (e.target === e.currentTarget) onCancel(); }} > -