diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index cdd23e6..c062ebe 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -385,6 +385,59 @@ pub fn restore_files(path: String, files: Vec, staged: bool) -> Result Result { + let repo = resolve_repo(&path)?; + validate_files(std::slice::from_ref(&file))?; + + let base_args = if staged { + &[ + "diff", + "--cached", + "--no-ext-diff", + "--no-textconv", + "--unified=3", + ][..] + } else { + &["diff", "--no-ext-diff", "--no-textconv", "--unified=3"][..] + }; + let output = run_git_with_paths(&repo, base_args, &[file])?; + Ok(String::from_utf8_lossy(&output).to_string()) +} + +#[tauri::command] +pub fn apply_file_patch( + path: String, + file: String, + patch: String, + action: String, +) -> Result { + let repo = resolve_repo(&path)?; + validate_files(std::slice::from_ref(&file))?; + if patch.trim().is_empty() { + return Err("Kein Patch ausgewaehlt.".to_string()); + } + + let patch_path = write_temp_patch(&patch)?; + let result = match action.as_str() { + "stage" => check_apply_patch(&repo, &patch_path, &["--cached"]) + .and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached"])), + "unstage" => check_apply_patch(&repo, &patch_path, &["--cached", "--reverse"]) + .and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])), + "discard-unstaged" => check_apply_patch(&repo, &patch_path, &["--reverse"]) + .and_then(|_| run_apply_patch(&repo, &patch_path, &["--reverse"])), + "discard-staged" => check_apply_patch(&repo, &patch_path, &["--cached", "--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, &["--reverse"])), + _ => Err("Ungueltige Patch-Aktion.".to_string()), + }; + + let _ = std::fs::remove_file(&patch_path); + result?; + status_for_repo(&repo) +} + #[tauri::command] pub fn commit(path: String, message: String) -> Result { let repo = resolve_repo(&path)?; @@ -2184,6 +2237,45 @@ fn validate_files(files: &[String]) -> Result<(), String> { Ok(()) } +fn write_temp_patch(patch: &str) -> Result { + let counter = CANCELLABLE_GIT_OUTPUT_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "gitlite_patch_{}_{}.patch", + std::process::id(), + counter + )); + std::fs::write(&path, patch.as_bytes()) + .map_err(|err| format!("Patch-Datei konnte nicht geschrieben werden: {err}"))?; + Ok(path) +} + +fn check_apply_patch(repo: &Path, patch_path: &Path, options: &[&str]) -> Result<(), String> { + run_apply_patch_command(repo, patch_path, options, true) +} + +fn run_apply_patch(repo: &Path, patch_path: &Path, options: &[&str]) -> Result<(), String> { + run_apply_patch_command(repo, patch_path, options, false) +} + +fn run_apply_patch_command( + repo: &Path, + patch_path: &Path, + options: &[&str], + check_only: bool, +) -> Result<(), String> { + let mut args = Vec::with_capacity(options.len() + 5); + args.push(OsString::from("apply")); + if check_only { + args.push(OsString::from("--check")); + } + args.extend(options.iter().map(OsString::from)); + args.push(OsString::from("--recount")); + args.push(OsString::from("--whitespace=nowarn")); + args.push(patch_path.as_os_str().to_os_string()); + + run_git(repo, args).map(|_| ()) +} + #[cfg(unix)] fn write_askpass_script() -> Result { use std::os::unix::fs::PermissionsExt; @@ -3388,6 +3480,95 @@ mod tests { assert!(err.contains("existiert bereits")); } + #[test] + fn apply_file_patch_stages_and_discards_selected_changes() { + let repo = init_temp_repo("apply_file_patch"); + fs::write(repo.path.join("old.txt"), "one\ntwo\nthree\n") + .expect("initial file should be written"); + run_git_test(&repo.path, ["add", "old.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "init"]); + + fs::write(repo.path.join("old.txt"), "one\nTWO\nthree\nfour\n") + .expect("changed file should be written"); + + let selected_patch = "diff --git a/old.txt b/old.txt\n--- a/old.txt\n+++ b/old.txt\n@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n"; + let status = apply_file_patch( + repo.path.to_string_lossy().to_string(), + "old.txt".to_string(), + selected_patch.to_string(), + "stage".to_string(), + ) + .expect("selected line should stage"); + + assert_eq!(status.files[0].staged, Some(FileStatusKind::Modified)); + assert_eq!(status.files[0].unstaged, Some(FileStatusKind::Modified)); + assert_eq!( + git_output_test(&repo.path, ["show", ":old.txt"]), + "one\nTWO\nthree" + ); + assert_eq!( + fs::read_to_string(repo.path.join("old.txt")) + .expect("working tree should be readable") + .replace("\r\n", "\n"), + "one\nTWO\nthree\nfour\n" + ); + + let unstaged_patch = get_file_patch( + repo.path.to_string_lossy().to_string(), + "old.txt".to_string(), + false, + ) + .expect("unstaged patch should load"); + assert!(unstaged_patch.contains("+four")); + + let status = apply_file_patch( + repo.path.to_string_lossy().to_string(), + "old.txt".to_string(), + unstaged_patch, + "discard-unstaged".to_string(), + ) + .expect("unstaged line should discard"); + + assert_eq!(status.files[0].staged, Some(FileStatusKind::Modified)); + assert_eq!(status.files[0].unstaged, None); + assert_eq!( + fs::read_to_string(repo.path.join("old.txt")) + .expect("working tree should be readable") + .replace("\r\n", "\n"), + "one\nTWO\nthree\n" + ); + } + + #[test] + fn get_file_patch_splits_distant_changes_like_interactive_diff() { + let repo = init_temp_repo("file_patch_hunks"); + let original = (1..=30) + .map(|line| format!("line {line}\n")) + .collect::(); + fs::write(repo.path.join("old.txt"), original).expect("initial file should be written"); + run_git_test(&repo.path, ["add", "old.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "init"]); + + let changed = (1..=30) + .map(|line| match line { + 5 => "line five changed\n".to_string(), + 20 => "line twenty changed\n".to_string(), + _ => format!("line {line}\n"), + }) + .collect::(); + fs::write(repo.path.join("old.txt"), changed).expect("changed file should be written"); + + let patch = get_file_patch( + repo.path.to_string_lossy().to_string(), + "old.txt".to_string(), + false, + ) + .expect("patch should load"); + let hunk_count = patch.lines().filter(|line| line.starts_with("@@ ")).count(); + + assert_eq!(hunk_count, 2, "{patch}"); + } + #[test] fn restore_to_commit_resets_branch_to_selected_commit() { let repo = init_temp_repo("restore_to_commit"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a0ce927..4d09990 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,14 +3,13 @@ mod git; use git::{ - cancel_code_search, checkout_branch, commit, compare_commits, compare_file_to_head, - compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, - diff_file_against_working_tree, get_remote_url, get_status, list_branches, list_commits, - list_file_history, list_repository_files, merge_branch, open_repository, - open_repository_bundle, pull, push, - read_conflict, resolve_conflict, resolve_conflict_side, restore_file_from_commit, - restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files, - SearchCancellationState, + apply_file_patch, cancel_code_search, checkout_branch, commit, compare_commits, + compare_file_to_head, compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, + diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, list_branches, + list_commits, list_file_history, list_repository_files, merge_branch, open_repository, + open_repository_bundle, pull, push, read_conflict, resolve_conflict, resolve_conflict_side, + restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions, + stage_files, unstage_files, SearchCancellationState, }; fn main() { @@ -27,6 +26,8 @@ fn main() { stage_files, unstage_files, restore_files, + get_file_patch, + apply_file_patch, commit, pull, push, diff --git a/src/App.svelte b/src/App.svelte index 4248692..6f71d71 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -14,6 +14,7 @@ import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte"; import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte"; import HistoryPanel from "./lib/components/HistoryPanel.svelte"; + import LinePatchDialog from "./lib/components/LinePatchDialog.svelte"; import NewBranchDialog from "./lib/components/NewBranchDialog.svelte"; import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte"; import ResolveDialog from "./lib/components/ResolveDialog.svelte"; @@ -25,6 +26,7 @@ commit, compareCommits, cancelCodeSearch, + applyFilePatch, createBranch, diffFileAgainstWorkingTree, compareFileToParent, @@ -41,6 +43,7 @@ credLoad, credSave, credDelete, + getFilePatch, readConflict, resolveConflict, resolveConflictSide, @@ -65,6 +68,7 @@ GitRepositoryFile, GitSearchHit, GitStatus, + PatchApplyAction, PreparedResolution, StoredCredential, } from "./lib/types"; @@ -103,6 +107,12 @@ let selectedDiffPath = ""; let diffHighlightQuery = ""; let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null; + let linePatchOpen = false; + let linePatchFile: GitFileStatus | null = null; + let linePatchStaged = false; + let linePatchText = ""; + let linePatchLoading = false; + let linePatchError = ""; let globalSearchOpen = false; let lastSearchQuery = ""; let globalSearchResults: GitSearchHit[] = []; @@ -675,6 +685,77 @@ }); } + async function openLinePatch(file: GitFileStatus, staged: boolean) { + if (!activeRepoPath) return; + linePatchOpen = true; + linePatchFile = file; + linePatchStaged = staged; + linePatchText = ""; + linePatchError = ""; + linePatchLoading = true; + + try { + linePatchText = await getFilePatch(activeRepoPath, file.path, staged); + } catch (error) { + linePatchError = errorToMessage(error); + errorMessage = linePatchError; + } finally { + linePatchLoading = false; + } + } + + async function refreshLinePatch() { + if (!activeRepoPath || !linePatchFile) return; + await openLinePatch(linePatchFile, linePatchStaged); + } + + function closeLinePatch() { + if (isBusy) return; + linePatchOpen = false; + linePatchFile = null; + linePatchText = ""; + linePatchError = ""; + } + + function patchOperationLabel(action: PatchApplyAction, file: GitFileStatus): string { + switch (action) { + case "stage": + return `Staging hunk in ${file.path}`; + case "unstage": + return `Unstaging hunk in ${file.path}`; + default: + return `Discarding hunk in ${file.path}`; + } + } + + async function applyLinePatch(action: PatchApplyAction, patch: string) { + if (!activeRepoPath || !linePatchFile || isBusy) return; + const file = linePatchFile; + operation = patchOperationLabel(action, file); + errorMessage = ""; + linePatchError = ""; + + try { + applyStatus(await applyFilePatch(activeRepoPath, file.path, patch, action)); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + + const updatedPatch = await getFilePatch(activeRepoPath, file.path, linePatchStaged); + if (updatedPatch.trim()) { + linePatchText = updatedPatch; + } else { + linePatchOpen = false; + linePatchFile = null; + linePatchText = ""; + } + } catch (error) { + linePatchError = errorToMessage(error); + errorMessage = linePatchError; + } finally { + operation = ""; + } + } + async function stageAllFiles() { const paths = changedFiles.filter((f) => f.unstaged !== null).map((f) => f.path); if (paths.length === 0) return; @@ -1129,6 +1210,7 @@ onStage={stageFile} onUnstage={unstageFile} onDiscard={discardFile} + onPatch={openLinePatch} onStageAll={stageAllFiles} onUnstageAll={unstageAllFiles} /> @@ -1189,6 +1271,20 @@ /> {/if} +{#if linePatchOpen && linePatchFile} + +{/if} + {#if globalSearchOpen}
@@ -121,7 +128,12 @@
No local branches.
{:else} {#each localBranches as branch (branch.name)} -
+
checkoutOnDoubleClick(event, branch)} + title={branch.current ? "Current branch" : "Double-click to checkout"} + >