From e18f8f1040524579fef5939ed3a38222566b5720 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 2 Jul 2026 12:17:57 +0200 Subject: [PATCH] add Loading Spinner when file History is loading Make Renamed instad of Del and untracked --- .claude/settings.local.json | 5 +- src-tauri/src/git.rs | 209 ++++++++++++++++++++- src/App.svelte | 34 +++- src/app.css | 76 ++++++++ src/lib/components/FileHistoryPanel.svelte | 21 +++ 5 files changed, 328 insertions(+), 17 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a499b36..9400d51 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -28,7 +28,10 @@ "Bash(xxd)", "Bash(python3 -)", "Bash(echo \"exit: $?\")", - "Bash(grep -n \"input,\\\\|select,\\\\|input {\\\\|select {\\\\|.repo-form input\\\\|input:focus\\\\|::placeholder\" src/app.css)" + "Bash(grep -n \"input,\\\\|select,\\\\|input {\\\\|select {\\\\|.repo-form input\\\\|input:focus\\\\|::placeholder\" src/app.css)", + "Bash(sudo -n true)", + "Bash(rustc --version)", + "Read(//mnt/c/Users/cbr/Desktop/src-tauri/src/**)" ] } } diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 87538a1..8d514e2 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -389,7 +389,23 @@ pub fn stage_files(path: String, files: Vec) -> Result = Vec::new(); + for file in &files { + match find_status(¤t_status.files, file) { + Some(entry) => { + if let Some(old_path) = &entry.old_path { + add_paths.push(old_path.clone()); + } + add_paths.push(entry.path.clone()); + } + None => add_paths.push(file.clone()), + } + } + run_git_with_paths(&repo, &["add"], &add_paths)?; } status_for_repo(&repo) @@ -1415,7 +1431,8 @@ fn status_for_repo(repo: &Path) -> Result { "--untracked-files=all", ], )?; - let (branch, files) = parse_status_output(&output)?; + let (branch, mut files) = parse_status_output(&output)?; + detect_worktree_renames(repo, &mut files); Ok(GitStatus { repo_path: repo.to_string_lossy().to_string(), @@ -1428,6 +1445,127 @@ fn status_for_repo(repo: &Path) -> Result { }) } +// `git status` only auto-detects renames between HEAD and the index (staged changes). +// A file renamed on disk but not yet `git add`ed shows up as a plain delete + untracked +// pair instead. We detect that case ourselves by comparing content hashes: if an unstaged +// deletion and an untracked file share the same blob hash (and the match is unambiguous), +// we merge them into a single unstaged "renamed" entry, mirroring how git reports staged +// renames. This is intentionally content-hash based (not similarity-based) so it never +// mutates the repository's real index. +const WORKTREE_RENAME_DETECTION_LIMIT: usize = 300; + +fn detect_worktree_renames(repo: &Path, files: &mut Vec) { + let deleted_paths: Vec = files + .iter() + .filter(|f| f.staged.is_none() && f.unstaged == Some(FileStatusKind::Deleted)) + .map(|f| f.path.clone()) + .collect(); + let untracked_paths: Vec = files + .iter() + .filter(|f| f.staged.is_none() && f.unstaged == Some(FileStatusKind::Untracked)) + .map(|f| f.path.clone()) + .collect(); + + if deleted_paths.is_empty() + || untracked_paths.is_empty() + || deleted_paths.len() > WORKTREE_RENAME_DETECTION_LIMIT + || untracked_paths.len() > WORKTREE_RENAME_DETECTION_LIMIT + { + return; + } + + let Ok(deleted_hashes) = index_blob_hashes(repo, &deleted_paths) else { + return; + }; + let Ok(untracked_hashes) = worktree_blob_hashes(repo, &untracked_paths) else { + return; + }; + + let mut hash_to_deleted: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for (path, hash) in &deleted_hashes { + hash_to_deleted.entry(hash).or_default().push(path); + } + let mut hash_to_untracked: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for (path, hash) in &untracked_hashes { + hash_to_untracked.entry(hash).or_default().push(path); + } + + let mut renames: Vec<(String, String)> = Vec::new(); + for (hash, olds) in &hash_to_deleted { + if olds.len() != 1 { + continue; + } + if let Some(news) = hash_to_untracked.get(hash) { + if news.len() == 1 { + renames.push((olds[0].to_string(), news[0].to_string())); + } + } + } + + for (old_path, new_path) in renames { + files.retain(|f| { + !(f.path == old_path && f.staged.is_none() && f.unstaged == Some(FileStatusKind::Deleted)) + && !(f.path == new_path + && f.staged.is_none() + && f.unstaged == Some(FileStatusKind::Untracked)) + }); + files.push(GitFileStatus { + path: new_path, + old_path: Some(old_path), + staged: None, + unstaged: Some(FileStatusKind::Renamed), + }); + } +} + +// Batched via `git ls-files -s -z` (one process for every deleted path) rather than one +// `git rev-parse` call per file, since this runs on every status refresh. +fn index_blob_hashes(repo: &Path, paths: &[String]) -> Result, String> { + let mut args: Vec = vec![ + OsString::from("ls-files"), + OsString::from("-s"), + OsString::from("-z"), + OsString::from("--"), + ]; + args.extend(paths.iter().map(OsString::from)); + let output = run_git(repo, args)?; + + let mut result = Vec::new(); + for entry in output.split(|byte| *byte == 0).filter(|e| !e.is_empty()) { + let text = String::from_utf8_lossy(entry); + let Some((meta, path)) = text.split_once('\t') else { + continue; + }; + let Some(hash) = meta.split_whitespace().nth(1) else { + continue; + }; + result.push((path.to_string(), hash.to_string())); + } + Ok(result) +} + +// Batched via `git hash-object --stdin-paths` (one process for every untracked path). +fn worktree_blob_hashes(repo: &Path, paths: &[String]) -> Result, String> { + let stdin_data = paths.join("\n"); + let output = run_git_with_stdin( + repo, + ["hash-object", "--stdin-paths"], + stdin_data.as_bytes(), + )?; + + let hashes: Vec = String::from_utf8_lossy(&output) + .lines() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()) + .collect(); + + Ok(paths + .iter() + .cloned() + .zip(hashes) + .collect()) +} + fn repository_files(repo: &Path) -> Result, String> { let status = status_for_repo(repo)?; repository_files_with_status(repo, &status) @@ -2207,13 +2345,22 @@ fn restore_worktree_files( for file in files { let status = find_status(statuses, file); - if matches!( - status.and_then(|entry| entry.unstaged), - Some(FileStatusKind::Untracked) - ) { - clean_paths.push(file.clone()); - } else { - restore_paths.push(file.clone()); + match status.and_then(|entry| entry.unstaged) { + Some(FileStatusKind::Untracked) => clean_paths.push(file.clone()), + // An unstaged rename (see `detect_worktree_renames`) has no index entry for the + // new path, so `git restore` can't act on it directly: restore the original + // content at the old path and drop the untracked new file instead. + Some(FileStatusKind::Renamed) => { + if let Some(entry) = status { + if let Some(old_path) = entry.old_path.clone() { + restore_paths.push(old_path); + } + clean_paths.push(entry.path.clone()); + } else { + restore_paths.push(file.clone()); + } + } + _ => restore_paths.push(file.clone()), } } @@ -2633,6 +2780,50 @@ where Err(format!("{context}: {details}")) } +fn run_git_with_stdin(repo: &Path, args: I, stdin_data: &[u8]) -> Result, String> +where + I: IntoIterator, + S: AsRef, +{ + use std::io::Write; + + let mut child = git_command() + .arg("-C") + .arg(repo) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {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}"))?; + } + + let output = child + .wait_with_output() + .map_err(|err| format!("Git-Ausgabe konnte nicht gelesen werden: {err}"))?; + + if output.status.success() { + return Ok(output.stdout); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let details = if !stderr.trim().is_empty() { + stderr.trim() + } else if !stdout.trim().is_empty() { + stdout.trim() + } else { + "unbekannter Fehler" + }; + + Err(format!("Git-Befehl fehlgeschlagen: {details}")) +} + fn parse_status_output(output: &[u8]) -> Result<(BranchInfo, Vec), String> { let entries: Vec<&[u8]> = output .split(|byte| *byte == 0) diff --git a/src/App.svelte b/src/App.svelte index 78d8827..4187cc6 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -117,6 +117,8 @@ let expandedExplorerPaths = new Set(); let expandedCommitHashes = new Set(); let fileHistory: GitCommit[] = []; + let fileHistoryLoading = false; + let fileHistoryRequestId = 0; let commitMessage = ""; let errorMessage = ""; let operation = ""; @@ -537,7 +539,9 @@ } async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath) { - fileHistory = file ? await listFileHistory(path, file, 100) : []; + const requestId = ++fileHistoryRequestId; + const history = file ? await listFileHistory(path, file, 100) : []; + if (requestId === fileHistoryRequestId) fileHistory = history; } // ── Repository operations ────────────────────────────────────────────────── @@ -1112,13 +1116,30 @@ return folders; } + // Loads history for a selected explorer node without blocking the rest of the UI + // (isBusy/runOperation would disable every button in the app while this awaits). + // A request id guards against a slower, stale request overwriting a newer selection. + async function loadSelectedFileHistory(path: string, repo = activeRepoPath) { + const requestId = ++fileHistoryRequestId; + fileHistoryLoading = true; + try { + const history = await listFileHistory(repo, path, 100); + if (requestId === fileHistoryRequestId) fileHistory = history; + } catch (error) { + if (requestId === fileHistoryRequestId) { + fileHistory = []; + errorMessage = errorToMessage(error); + } + } finally { + if (requestId === fileHistoryRequestId) fileHistoryLoading = false; + } + } + async function selectExplorerNode(node: ExplorerNode) { if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return; selectedExplorerPath = node.path; selectedExplorerKind = node.kind; - await runOperation(`Loading ${node.path} history`, async () => { - await refreshFileHistory(activeRepoPath, node.path); - }); + await loadSelectedFileHistory(node.path); } async function selectFileFromSearch(file: GitRepositoryFile) { @@ -1127,9 +1148,7 @@ selectedExplorerKind = "file"; expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]); - await runOperation(`Loading ${file.path} history`, async () => { - await refreshFileHistory(activeRepoPath, file.path); - }); + await loadSelectedFileHistory(file.path); } async function restoreSelectedFileFromCommit(target: GitCommit) { @@ -1642,6 +1661,7 @@ selectedExplorerLabel={selectedExplorerPath ? `${selectedExplorerKind === "folder" ? "Folder" : "File"} history` : "File history"} {hasRepository} {isBusy} + isLoading={fileHistoryLoading} onDiff={diffSelectedFileFromCommit} onRestore={restoreSelectedFileFromCommit} /> diff --git a/src/app.css b/src/app.css index 9f820ff..9abef6b 100644 --- a/src/app.css +++ b/src/app.css @@ -1371,6 +1371,82 @@ .file-history-actions .commit-action-buttons { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; justify-content: stretch; } .file-history-actions .commit-action-buttons button { min-width: 0; justify-content: center; padding-inline: 6px; } + /* --- File history loading (scoped, non-blocking) --- */ + + .file-history-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 140px; + padding: 20px; + } + + .file-history-loading-graph { width: 56px; height: 56px; overflow: visible; } + + .file-history-loading-graph .fhl-ring { + fill: none; + stroke: rgba(100, 108, 255, 0.22); + stroke-width: 3; + stroke-dasharray: 26 18; + transform-origin: 60px 60px; + animation: fhl-ring-spin 5s linear infinite; + } + + .file-history-loading-graph .fhl-trunk, + .file-history-loading-graph .fhl-branch { + fill: none; + stroke: url(#fhl-line); + stroke-width: 5; + stroke-linecap: round; + } + + .file-history-loading-graph .fhl-branch { + stroke-dasharray: 90; + stroke-dashoffset: 90; + animation: fhl-branch-draw 2.4s ease-in-out infinite; + } + + .file-history-loading-graph .fhl-node { + fill: var(--color-surface-alt); + stroke: url(#fhl-line); + stroke-width: 5; + animation: fhl-node-pulse 2.4s ease-in-out infinite; + } + .file-history-loading-graph .fhl-n1 { animation-delay: 0s; } + .file-history-loading-graph .fhl-n2 { animation-delay: 0.5s; } + .file-history-loading-graph .fhl-n3 { animation-delay: 1s; } + .file-history-loading-graph .fhl-n4 { animation-delay: 1.5s; } + + .file-history-loading-label { + color: var(--color-ink-faint); + font-size: 12.5px; + font-weight: 600; + letter-spacing: 0.01em; + } + + @keyframes fhl-ring-spin { to { transform: rotate(360deg); } } + @keyframes fhl-branch-draw { + 0% { stroke-dashoffset: 90; opacity: 0.35; } + 45% { stroke-dashoffset: 0; opacity: 1; } + 100% { stroke-dashoffset: 0; opacity: 1; } + } + @keyframes fhl-node-pulse { + 0%, 100% { fill: var(--color-surface-alt); filter: none; } + 50% { + fill: var(--color-primary); + filter: drop-shadow(0 0 6px rgba(100, 108, 255, 0.8)); + } + } + + @media (prefers-reduced-motion: reduce) { + .file-history-loading-graph .fhl-ring, + .file-history-loading-graph .fhl-branch, + .file-history-loading-graph .fhl-node { animation: none; } + .file-history-loading-graph .fhl-branch { stroke-dashoffset: 0; } + } + /* --- Git graph --- */ .graph-list { padding: 0; } diff --git a/src/lib/components/FileHistoryPanel.svelte b/src/lib/components/FileHistoryPanel.svelte index b46b3d6..a1cb468 100644 --- a/src/lib/components/FileHistoryPanel.svelte +++ b/src/lib/components/FileHistoryPanel.svelte @@ -8,6 +8,7 @@ selectedExplorerLabel: string; hasRepository: boolean; isBusy: boolean; + isLoading?: boolean; onDiff: (commit: GitCommit) => void; onRestore: (commit: GitCommit) => void; } @@ -18,6 +19,7 @@ selectedExplorerLabel = "File history", hasRepository = false, isBusy = false, + isLoading = false, onDiff = () => {}, onRestore = () => {}, }: Props = $props(); @@ -89,6 +91,25 @@
No repository loaded.
{:else if !selectedExplorerPath}
Select a file in Explorer.
+ {:else if isLoading} +
+ + Loading history… +
{:else if fileHistory.length === 0}
No history returned for this selection.
{:else} -- 2.54.0