diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 4b326fe..356ccff 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -753,6 +753,66 @@ pub fn diff_file_against_working_tree( }) } +#[tauri::command] +pub fn compare_file_to_head( + path: String, + commit: String, + file: String, +) -> Result { + let repo = resolve_repo(&path)?; + validate_files(std::slice::from_ref(&file))?; + let commit_hash = verify_commit(&repo, &commit)?; + let head_hash = verify_commit(&repo, "HEAD")?; + + let name_status = run_git_with_paths( + &repo, + &[ + "diff", + "--name-status", + "-M", + "-z", + commit_hash.as_str(), + head_hash.as_str(), + ], + std::slice::from_ref(&file), + )?; + let numstat = run_git_with_paths( + &repo, + &[ + "diff", + "--numstat", + "-M", + "-z", + commit_hash.as_str(), + head_hash.as_str(), + ], + std::slice::from_ref(&file), + )?; + let patch_output = run_git_with_paths( + &repo, + &[ + "diff", + "-M", + FULL_FILE_DIFF_CONTEXT, + commit_hash.as_str(), + head_hash.as_str(), + ], + std::slice::from_ref(&file), + )?; + + let files = parse_diff_files(&name_status, &numstat)?; + let patch = String::from_utf8_lossy(&patch_output).to_string(); + + Ok(GitCommitComparison { + from_short: short_hash(&commit_hash), + to_short: "HEAD".to_string(), + from_hash: commit_hash, + to_hash: head_hash, + files, + patch, + }) +} + #[tauri::command] pub fn read_conflict(path: String, file: String) -> Result { let repo = resolve_repo(&path)?; @@ -2497,6 +2557,35 @@ mod tests { assert!(comparison.patch.contains("working tree change")); } + #[test] + fn compare_file_to_head_reports_selected_file_against_current_commit() { + let repo = init_temp_repo("compare_file_to_head"); + commit_initial_file(&repo.path); + let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); + + fs::write(repo.path.join("old.txt"), "original\nsecond line\n") + .expect("tracked file should change"); + fs::write(repo.path.join("other.txt"), "other file\n") + .expect("other file should be written"); + run_git_test(&repo.path, ["add", "old.txt", "other.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "second"]); + let head_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); + + let comparison = compare_file_to_head( + repo.path.to_string_lossy().to_string(), + first_commit, + "old.txt".to_string(), + ) + .unwrap(); + + assert_eq!(comparison.to_hash, head_commit); + assert_eq!(comparison.to_short, "HEAD"); + assert_eq!(comparison.files.len(), 1); + assert_eq!(comparison.files[0].path, "old.txt"); + assert!(comparison.patch.contains("second line")); + assert!(!comparison.patch.contains("other file")); + } + #[test] fn read_and_resolve_conflict_round_trip() { let repo = init_temp_repo("resolve_conflict"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 003cdc5..c5e5f62 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,11 +3,12 @@ mod git; use git::{ - cancel_code_search, checkout_branch, commit, compare_commits, diff_file_against_working_tree, - get_status, list_branches, list_commits, list_file_history, list_repository_files, - merge_branch, open_repository, 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, + cancel_code_search, checkout_branch, commit, compare_commits, compare_file_to_head, + diff_file_against_working_tree, get_status, list_branches, list_commits, list_file_history, + list_repository_files, merge_branch, open_repository, 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() { @@ -32,6 +33,7 @@ fn main() { list_repository_files, list_file_history, compare_commits, + compare_file_to_head, diff_file_against_working_tree, search_code_introductions, cancel_code_search, diff --git a/src/App.svelte b/src/App.svelte index e981d80..a1ea7ca 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -22,6 +22,7 @@ compareCommits, cancelCodeSearch, diffFileAgainstWorkingTree, + compareFileToHead, getStatus, listBranches, listCommits, @@ -79,6 +80,7 @@ let comparison: GitCommitComparison | null = null; let compareDialogOpen = false; let selectedDiffPath = ""; + let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null; let globalSearchOpen = false; let globalSearchResults: GitSearchHit[] = []; let globalSearchBusy = false; @@ -211,6 +213,7 @@ comparison = null; compareDialogOpen = false; selectedDiffPath = ""; + pendingRestoreFile = null; } } @@ -242,7 +245,7 @@ selectedExplorerPath = ""; selectedExplorerKind = "file"; expandedExplorerPaths = new Set(); expandedCommitHashes = new Set(); fileHistory = []; compareFrom = ""; compareTo = ""; - comparison = null; compareDialogOpen = false; selectedDiffPath = ""; + comparison = null; compareDialogOpen = false; selectedDiffPath = ""; pendingRestoreFile = null; if (globalSearchBusy) void cancelGlobalSearch(); globalSearchResults = []; globalSearchOpen = false; globalSearchError = ""; resolveDialogOpen = false; conflictTarget = ""; conflict = null; @@ -433,15 +436,30 @@ }); } - async function restoreCommitFile(target: GitCommit, file: GitCommitFile) { - if (!activeRepoPath) return; + async function restoreCommitFile(target: GitCommit, file: GitCommitFile): Promise { + if (!activeRepoPath) return false; const confirmed = window.confirm(`Restore ${file.path} from ${target.short_hash}?\n\nThis changes the file in your working tree so you can review and commit it.`); - if (!confirmed) return; + if (!confirmed) return false; await runOperation(`Restoring ${file.path}`, async () => { applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, file.path)); await refreshExplorerFiles(activeRepoPath); await refreshFileHistory(activeRepoPath); }); + return !errorMessage; + } + + async function previewCommitFileFromHistory(target: GitCommit, file: GitCommitFile) { + if (!activeRepoPath) return; + await runOperation(`Diffing ${file.path}`, async () => { + const result = await compareFileToHead(activeRepoPath, target.hash, file.path); + const matchingFile = result.files.find((diffFile) => + diffFile.path === file.path || diffFile.old_path === file.old_path || diffFile.old_path === file.path, + ); + comparison = result; + selectedDiffPath = matchingFile?.path ?? result.files[0]?.path ?? file.path; + pendingRestoreFile = { commit: target, file }; + compareDialogOpen = true; + }); } // ── Explorer interaction ─────────────────────────────────────────────────── @@ -482,6 +500,7 @@ const result = await compareCommits(activeRepoPath, compareFrom, compareTo); comparison = result; selectedDiffPath = result.files[0]?.path ?? ""; + pendingRestoreFile = null; compareDialogOpen = true; }); } @@ -492,6 +511,7 @@ const result = await diffFileAgainstWorkingTree(activeRepoPath, historyCommit.hash, selectedExplorerPath); comparison = result; selectedDiffPath = result.files[0]?.path ?? selectedExplorerPath; + pendingRestoreFile = null; compareDialogOpen = true; }); } @@ -500,6 +520,17 @@ if (comparison) compareDialogOpen = true; } + function closeCompareDialog() { + compareDialogOpen = false; + pendingRestoreFile = null; + } + + async function restorePreviewedCommitFile() { + if (!pendingRestoreFile) return; + const restored = await restoreCommitFile(pendingRestoreFile.commit, pendingRestoreFile.file); + if (restored) closeCompareDialog(); + } + function selectDiffFile(file: GitDiffFile) { selectedDiffPath = file.path; } @@ -614,7 +645,7 @@ function submitRepo(event: SubmitEvent) { event.preventDefault(); void openRepo(); } function handleWindowKeydown(event: KeyboardEvent) { - if (event.key === "Escape" && compareDialogOpen) compareDialogOpen = false; + if (event.key === "Escape" && compareDialogOpen) closeCompareDialog(); else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog(); } @@ -796,7 +827,7 @@ {isBusy} {expandedCommitHashes} onRestoreCommit={restoreCommit} - onRestoreCommitFile={restoreCommitFile} + onPreviewCommitFile={previewCommitFileFromHistory} onToggleCommitFiles={(hash) => { const next = new Set(expandedCommitHashes); if (next.has(hash)) next.delete(hash); else next.add(hash); @@ -823,7 +854,9 @@ {comparison} {selectedDiffPath} {isBusy} - onClose={() => { compareDialogOpen = false; }} + restoreLabel={pendingRestoreFile ? "Restore file" : ""} + onClose={closeCompareDialog} + onRestore={restorePreviewedCommitFile} onSelectFile={selectDiffFile} /> {/if} diff --git a/src/app.css b/src/app.css index 6f18bc1..b77c3d1 100644 --- a/src/app.css +++ b/src/app.css @@ -817,6 +817,10 @@ } .dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); } + .dialog-header > div:first-child { min-width: 0; } + .dialog-header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex: 0 0 auto; min-width: 0; } + .compare-restore { max-width: 170px; min-width: 0; } + .compare-restore span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dialog-range { display: flex; align-items: center; gap: 8px; margin: 2px 0 0; color: var(--color-accent); font-size: 15px; } .dialog-title { margin: 2px 0 0; color: var(--color-ink); font-size: 15px; font-weight: 600; } @@ -1130,54 +1134,92 @@ .cred-card { display: flex; flex-direction: column; - width: min(420px, 100%); + width: min(500px, calc(100vw - 32px)); max-height: calc(100vh - 48px); - border: 1px solid var(--color-border); + border: 1px solid rgba(100, 108, 255, 0.36); border-radius: 14px; - background: var(--color-surface); - box-shadow: 0 28px 72px rgba(0, 0, 0, 0.65), 0 2px 12px rgba(0,0,0,0.35); + background: + linear-gradient(180deg, rgba(255,255,255,0.06), transparent 42%), + rgba(15, 16, 28, 0.96); + box-shadow: 0 32px 90px rgba(0, 0, 0, 0.68), 0 0 0 1px rgba(255,255,255,0.04) inset; overflow: hidden; + backdrop-filter: blur(18px); } .cred-hero { + display: grid; + gap: 12px; + padding: 20px 22px 18px; + border-bottom: 1px solid rgba(255,255,255,0.07); + background: + linear-gradient(135deg, rgba(100,108,255,0.24), rgba(189,52,254,0.18) 46%, rgba(65,209,255,0.09)), + var(--color-bar); + } + + .cred-hero-top { display: flex; align-items: center; gap: 14px; - padding: 20px 20px 20px 22px; - background: var(--color-bar); - border-bottom: 1px solid rgba(255,255,255,0.05); + min-width: 0; } .cred-hero-icon { display: flex; align-items: center; justify-content: center; - width: 48px; - height: 48px; + width: 50px; + height: 50px; flex-shrink: 0; border-radius: 12px; - background: rgba(90,140,248,0.2); - color: var(--color-primary); - border: 1px solid rgba(90,140,248,0.3); + border: 1px solid rgba(255,255,255,0.16); + color: #ffffff; + background: + linear-gradient(135deg, rgba(65,209,255,0.32), rgba(100,108,255,0.5) 46%, rgba(189,52,254,0.48)); + box-shadow: 0 14px 36px rgba(100,108,255,0.22), inset 0 1px 0 rgba(255,255,255,0.18); } .cred-hero-text { flex: 1; min-width: 0; } .cred-hero-label { margin: 0 0 2px; - color: var(--color-bar-muted); + color: #aeb8ff; font-size: 11px; - font-weight: 700; + font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; } .cred-hero-title { margin: 0; color: #ffffff; - font-size: 16px; - font-weight: 700; - line-height: 1.2; + font-size: 19px; + font-weight: 800; + line-height: 1.15; } + .cred-hero-copy { + max-width: 420px; + margin: 0; + color: #c4caea; + font-size: 13px; + line-height: 1.45; + } + + .cred-security-note { + display: inline-flex; + align-items: center; + justify-self: start; + gap: 7px; + min-height: 26px; + padding: 0 9px; + border: 1px solid rgba(65,209,255,0.18); + border-radius: 999px; + color: #bfefff; + background: rgba(65,209,255,0.08); + font-size: 11.5px; + font-weight: 700; + } + .cred-security-note svg { flex: 0 0 auto; color: #41d1ff; } + .cred-security-note span { min-width: 0; } + .cred-close { display: flex; align-items: center; @@ -1186,10 +1228,10 @@ height: 30px; flex-shrink: 0; padding: 0; - border: 1px solid rgba(255,255,255,0.1); - border-radius: 7px; - background: transparent; - color: var(--color-bar-muted); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 8px; + background: rgba(255,255,255,0.04); + color: #aeb8ff; cursor: pointer; transition: background 0.12s, color 0.12s; } @@ -1202,18 +1244,20 @@ .cred-body { display: flex; flex-direction: column; - gap: 16px; - padding: 20px; + gap: 14px; + padding: 18px 22px 20px; overflow: auto; + background: linear-gradient(180deg, rgba(255,255,255,0.025), transparent); } .cred-segment { - display: flex; - gap: 0; - padding: 3px; - border: 1px solid var(--color-border); - border-radius: 9px; - background: var(--color-surface-dim); + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 0.72fr); + gap: 4px; + padding: 4px; + border: 1px solid rgba(100,108,255,0.28); + border-radius: 10px; + background: rgba(8, 9, 18, 0.58); } .cred-seg-btn { @@ -1222,23 +1266,23 @@ align-items: center; justify-content: center; gap: 6px; - min-height: 32px; + min-height: 34px; padding: 0 12px; border: 1px solid transparent; - border-radius: 7px; + border-radius: 8px; background: transparent; color: var(--color-ink-dim); - font-size: 13px; - font-weight: 600; + font-size: 12.5px; + font-weight: 800; cursor: pointer; transition: background 0.14s, color 0.14s, border-color 0.14s; } .cred-seg-btn:hover:not(.active) { color: var(--color-ink-muted); } .cred-seg-btn.active { - background: var(--color-surface-raised); - border-color: var(--color-border-input); + background: linear-gradient(135deg, rgba(100,108,255,0.28), rgba(65,209,255,0.12)); + border-color: rgba(65,209,255,0.36); color: var(--color-ink); - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); + box-shadow: 0 10px 22px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255,255,255,0.06); } .cred-fields { display: flex; flex-direction: column; gap: 12px; } @@ -1247,7 +1291,7 @@ .cred-field-label { font-size: 12px; - font-weight: 700; + font-weight: 800; color: var(--color-ink-muted); letter-spacing: 0.03em; } @@ -1257,19 +1301,22 @@ display: flex; align-items: center; } - .cred-input :global(.cred-field-icon) { + .cred-input .cred-field-icon { position: absolute; left: 11px; color: var(--color-ink-faint); pointer-events: none; } .cred-input input { - height: 40px; + height: 42px; padding-left: 34px; padding-right: 40px; - border-radius: 8px; + border-radius: 9px; + border-color: rgba(65,209,255,0.22); + background: rgba(7, 8, 16, 0.7); font-size: 14px; } + .cred-input input::placeholder { color: rgba(168,177,216,0.48); } .cred-reveal { position: absolute; @@ -1293,10 +1340,10 @@ display: flex; align-items: flex-start; gap: 8px; - padding: 10px 12px; + padding: 10px 11px; border-radius: 8px; - border: 1px solid rgba(90,140,248,0.25); - background: rgba(90,140,248,0.08); + border: 1px solid rgba(65,209,255,0.22); + background: linear-gradient(135deg, rgba(65,209,255,0.08), rgba(100,108,255,0.07)); color: var(--color-ink-muted); font-size: 12px; line-height: 1.55; @@ -1330,8 +1377,8 @@ display: flex; align-items: center; justify-content: space-between; - gap: 12px; - padding-top: 4px; + gap: 14px; + padding-top: 8px; border-top: 1px solid var(--color-border-subtle); } @@ -1345,7 +1392,7 @@ .cred-save input[type="checkbox"] { width: auto; height: auto; cursor: pointer; } .cred-save span { font-size: 12.5px; color: var(--color-ink-dim); } - .cred-btns { display: flex; gap: 8px; } + .cred-btns { display: flex; gap: 8px; flex-shrink: 0; } .cred-cancel { min-height: 36px; @@ -1371,9 +1418,9 @@ gap: 6px; min-height: 36px; padding: 0 16px; - border: 1px solid var(--color-primary-dark); + border: 1px solid rgba(100,108,255,0.78); border-radius: 7px; - background: var(--color-primary); + background: linear-gradient(135deg, #646cff, #bd34fe); color: #ffffff; font-size: 13px; font-weight: 700; @@ -1381,8 +1428,8 @@ transition: background 0.12s, border-color 0.12s; } .cred-submit:hover:not(:disabled) { - background: var(--color-primary-dark); - border-color: #3a68e0; + background: linear-gradient(135deg, #747bff, #c966ff); + border-color: rgba(65,209,255,0.7); } .cred-submit:disabled { opacity: 0.45; cursor: not-allowed; } diff --git a/src/lib/components/CompareDialog.svelte b/src/lib/components/CompareDialog.svelte index d8100b4..effcb7b 100644 --- a/src/lib/components/CompareDialog.svelte +++ b/src/lib/components/CompareDialog.svelte @@ -1,5 +1,5 @@