feat: add selective line restoration from historical commits
Add a new Tauri command to produce a diff between the working file and a historical commit (get_file_restore_patch) and support a new apply action ("restore-lines") that validates and applies only text-line changes for a single regular file.
Behavior changes and constraints:
- Fetch a filtered reverse diff for a file in a commit so UI can display selectable lines from an older revision.
- Applying "restore-lines" verifies the target is a regular file, rejects binary/metadata patches, and ensures the patch only modifies the selected file.
- Restored lines are applied to the working tree without staging other changes; the index is preserved.
- The operation rejects stale patches or patches targeting the wrong file.
UI wiring:
- Compare dialog gets a "Restore lines…" action for applicable modified files and opens the line-patch dialog in restore mode.
- Line-patch dialog gains a restore mode (restoreCommit) with adjusted UI/rendering to pair removed/added lines, helper text, and dedicated "Restore selected" / "Restore hunk" actions.
- App integration handles fetching the restore patch, applying selected lines, and refreshing views.
Tests:
- Add tests covering correct behavior (preserve unstaged/staged changes and index) and guard cases (stale/wrong-file patches).
This commit is contained in:
@@ -71,6 +71,7 @@
|
||||
cancelCodeSearch,
|
||||
cancelFileHistory,
|
||||
applyFilePatch,
|
||||
getFileRestorePatch,
|
||||
createBranch,
|
||||
createTag,
|
||||
deleteBranch,
|
||||
@@ -495,6 +496,8 @@
|
||||
let selectedDiffPath = "";
|
||||
let diffHighlightQuery = "";
|
||||
let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null;
|
||||
let linePatchRestoreCommit = "";
|
||||
let linePatchRestoreRepo = "";
|
||||
let linePatchOpen = false;
|
||||
let linePatchFile: GitFileStatus | null = null;
|
||||
let linePatchStaged = false;
|
||||
@@ -5019,6 +5022,8 @@
|
||||
|
||||
async function openLinePatch(file: GitFileStatus, staged: boolean) {
|
||||
if (!activeRepoPath) return;
|
||||
linePatchRestoreCommit = "";
|
||||
linePatchRestoreRepo = "";
|
||||
linePatchOpen = true;
|
||||
linePatchFile = file;
|
||||
linePatchStaged = staged;
|
||||
@@ -5039,13 +5044,59 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function openHistoricalLineRestore() {
|
||||
if (!activeRepoPath || !comparison || comparison.to_hash || isBusy) return;
|
||||
const file = comparison.files.find(item => item.path === selectedDiffPath);
|
||||
if (!file || file.status !== "modified" || file.old_path) return;
|
||||
linePatchRestoreCommit = comparison.from_hash;
|
||||
linePatchRestoreRepo = activeRepoPath;
|
||||
linePatchFile = { path: file.path, old_path: null, staged: null, unstaged: "modified" };
|
||||
linePatchStaged = false;
|
||||
linePatchText = "";
|
||||
linePatchError = "";
|
||||
compareDialogOpen = false;
|
||||
fileHistoryDialogOpen = false;
|
||||
globalSearchOpen = false;
|
||||
linePatchOpen = true;
|
||||
await refreshLinePatch();
|
||||
}
|
||||
|
||||
async function restoreSelectedLines(patch: string) {
|
||||
if (!linePatchFile || !linePatchRestoreCommit || isBusy) return;
|
||||
const file = linePatchFile.path;
|
||||
const repo = linePatchRestoreRepo;
|
||||
const commit = linePatchRestoreCommit;
|
||||
if (repo !== activeRepoPath) { linePatchError = "The active repository changed. Reopen the comparison."; return; }
|
||||
operation = appLanguage === "de" ? "Ausgewählte Zeilen wiederherstellen" : "Restoring selected lines";
|
||||
linePatchError = "";
|
||||
try {
|
||||
applyStatus(await applyFilePatch(repo, file, patch, "restore-lines"));
|
||||
linePatchText = await getFileRestorePatch(repo, commit, file);
|
||||
comparison = await diffFileAgainstWorkingTree(repo, commit, file);
|
||||
await refreshRepositoryViews(repo, { branches: false, commits: false });
|
||||
await refreshFileHistory(repo, file, true);
|
||||
} catch (error) { linePatchError = errorToMessage(error); }
|
||||
finally { operation = ""; }
|
||||
}
|
||||
|
||||
async function refreshLinePatch() {
|
||||
if (!activeRepoPath || !linePatchFile) return;
|
||||
if (linePatchRestoreCommit) {
|
||||
linePatchLoading = true;
|
||||
linePatchError = "";
|
||||
try { linePatchText = await getFileRestorePatch(linePatchRestoreRepo, linePatchRestoreCommit, linePatchFile.path); }
|
||||
catch (error) { linePatchError = errorToMessage(error); }
|
||||
finally { linePatchLoading = false; }
|
||||
return;
|
||||
}
|
||||
await openLinePatch(linePatchFile, linePatchStaged);
|
||||
}
|
||||
|
||||
function closeLinePatch() {
|
||||
if (isBusy) return;
|
||||
if (linePatchRestoreCommit) compareDialogOpen = !!comparison;
|
||||
linePatchRestoreCommit = "";
|
||||
linePatchRestoreRepo = "";
|
||||
linePatchOpen = false;
|
||||
linePatchFile = null;
|
||||
linePatchText = "";
|
||||
@@ -5132,6 +5183,7 @@
|
||||
}
|
||||
|
||||
async function applyLinePatch(action: PatchApplyAction, patch: string, scope: "hunk" | "lines") {
|
||||
if (action === "restore-lines") { await restoreSelectedLines(patch); return; }
|
||||
if (!activeRepoPath || !linePatchFile || isBusy) return;
|
||||
const file = linePatchFile;
|
||||
const staged = linePatchStaged;
|
||||
@@ -6681,6 +6733,7 @@
|
||||
<module.default
|
||||
file={linePatchFile}
|
||||
staged={linePatchStaged}
|
||||
restoreCommit={linePatchRestoreCommit}
|
||||
patch={linePatchText}
|
||||
{isBusy}
|
||||
isLoading={linePatchLoading}
|
||||
@@ -6947,6 +7000,7 @@
|
||||
restoreLabel={pendingRestoreFile ? (appLanguage === "de" ? "Datei wiederherstellen" : "Restore file") : ""}
|
||||
onClose={closeCompareDialog}
|
||||
onRestore={restorePreviewedCommitFile}
|
||||
onRestoreLines={openHistoricalLineRestore}
|
||||
onSelectFile={selectDiffFile}
|
||||
/>
|
||||
{/await}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
language?: "en" | "de";
|
||||
onClose: () => void;
|
||||
onRestore?: () => void;
|
||||
onRestoreLines?: () => void;
|
||||
onSelectFile: (file: GitDiffFile) => void;
|
||||
}
|
||||
|
||||
@@ -42,6 +43,7 @@
|
||||
language = "en",
|
||||
onClose = () => {},
|
||||
onRestore = undefined,
|
||||
onRestoreLines = undefined,
|
||||
onSelectFile = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
@@ -242,6 +244,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
{#if onRestoreLines && !comparison.to_hash && comparison.files.some(file => file.path === selectedDiffPath && file.status === "modified" && !file.old_path)}
|
||||
<button class="btn-secondary compare-restore" type="button" onclick={onRestoreLines} disabled={isBusy}>
|
||||
<RotateCcw size={15} aria-hidden="true" /><span>{isGerman ? "Zeilen wiederherstellen …" : "Restore lines…"}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if restoreLabel && onRestore}
|
||||
<button class="btn-secondary compare-restore" type="button" onclick={onRestore} disabled={isBusy} title={restoreLabel}>
|
||||
<RotateCcw size={15} aria-hidden="true" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ArrowDown, ArrowUp, Check, ExternalLink, FileDiff, LoaderCircle, Minus, Plus, RefreshCw, Trash2, X } from "@lucide/svelte";
|
||||
import { ArrowDown, ArrowUp, Check, ExternalLink, FileDiff, LoaderCircle, Minus, Plus, RefreshCw, RotateCcw, Trash2, X } from "@lucide/svelte";
|
||||
import type { GitFileStatus, PatchApplyAction } from "../types";
|
||||
|
||||
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
||||
@@ -36,6 +36,7 @@
|
||||
error: string;
|
||||
language?: "en" | "de";
|
||||
diffName?: string;
|
||||
restoreCommit?: string;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>;
|
||||
@@ -51,6 +52,7 @@
|
||||
error = "",
|
||||
language = "en",
|
||||
diffName = "diff tool",
|
||||
restoreCommit = "",
|
||||
onClose = () => {},
|
||||
onRefresh = () => {},
|
||||
onApply = () => {},
|
||||
@@ -66,7 +68,7 @@
|
||||
let lastSelectedLineId = $state("");
|
||||
|
||||
const t = (de: string, en: string) => isGerman ? de : en;
|
||||
let scopeLabel = $derived(staged ? t("Gestagte Änderungen", "Staged changes") : t("Nicht gestagte Änderungen", "Unstaged changes"));
|
||||
let scopeLabel = $derived(restoreCommit ? t(`Wiederherstellen aus ${restoreCommit.slice(0, 8)}`, `Restore from ${restoreCommit.slice(0, 8)}`) : staged ? t("Gestagte Änderungen", "Staged changes") : t("Nicht gestagte Änderungen", "Unstaged changes"));
|
||||
let activeHunk = $state(0);
|
||||
let hasTextPatch = $derived(!isLoading && !error && !!patch.trim() && !parsed.binary && parsed.hunks.length > 0);
|
||||
function clearSelection() { selectedLineIds = new Set(); lastSelectedLineId = ""; }
|
||||
@@ -226,7 +228,34 @@
|
||||
const output: string[] = [];
|
||||
let previousIncluded = false;
|
||||
|
||||
for (const line of hunk.lines) {
|
||||
if (restoreCommit) {
|
||||
// Pair replacement lines so restoring just one pair keeps its original position.
|
||||
const metadata = new Map<string, string>();
|
||||
hunk.lines.forEach((line, index) => {
|
||||
if (hunk.lines[index + 1]?.kind === "meta") metadata.set(line.id, hunk.lines[index + 1].text);
|
||||
});
|
||||
const emit = (line: PatchLine, prefix: string) => {
|
||||
output.push(prefix + line.text.slice(1));
|
||||
const marker = metadata.get(line.id);
|
||||
if (marker) output.push(marker);
|
||||
};
|
||||
for (let index = 0; index < hunk.lines.length;) {
|
||||
const line = hunk.lines[index];
|
||||
if (line.kind === "context") { emit(line, " "); index++; continue; }
|
||||
if (line.kind === "meta") { index++; continue; }
|
||||
const removed: PatchLine[] = [], added: PatchLine[] = [];
|
||||
while (index < hunk.lines.length && hunk.lines[index].kind !== "context") {
|
||||
const changed = hunk.lines[index++];
|
||||
if (changed.kind === "delete") removed.push(changed);
|
||||
if (changed.kind === "add") added.push(changed);
|
||||
}
|
||||
for (let offset = 0; offset < Math.max(removed.length, added.length); offset++) {
|
||||
const before = removed[offset], after = added[offset];
|
||||
if (before) emit(before, selectedLineIds.has(before.id) ? "-" : " ");
|
||||
if (after && selectedLineIds.has(after.id)) emit(after, "+");
|
||||
}
|
||||
}
|
||||
} else for (const line of hunk.lines) {
|
||||
if (line.kind === "context") {
|
||||
output.push(line.text);
|
||||
previousIncluded = true;
|
||||
@@ -296,16 +325,17 @@
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||
<div class="dialog line-patch-dialog" role="dialog" aria-modal="true" aria-label={t("Geänderte Zeilen", "Changed lines")}>
|
||||
<div class="dialog line-patch-dialog" class:restoring={!!restoreCommit} role="dialog" aria-modal="true" aria-label={t("Geänderte Zeilen", "Changed lines")}>
|
||||
<header class="dialog-header unified-dialog-header">
|
||||
<div class="patch-identity unified-dialog-heading"><span class="unified-dialog-icon" aria-hidden="true"><FileDiff size={23} aria-hidden="true" /></span><div class="unified-dialog-text"><p class="dialog-title" title={displayPath}>{displayPath}</p><span class="patch-scope">{scopeLabel}</span></div></div>
|
||||
<div class="dialog-header-actions">
|
||||
<button class="external-diff" type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={t(`In ${diffName} öffnen`, `Open in ${diffName}`)}><span>{t("Extern öffnen", "Open externally")}</span><ExternalLink size={14} /><span>·</span><span>{diffName}</span></button>
|
||||
{#if !restoreCommit}<button class="external-diff" type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={t(`In ${diffName} öffnen`, `Open in ${diffName}`)}><span>{t("Extern öffnen", "Open externally")}</span><ExternalLink size={14} /><span>·</span><span>{diffName}</span></button>{/if}
|
||||
<button class="icon-action" type="button" onclick={onRefresh} disabled={isBusy || isLoading} aria-label={t("Aktualisieren", "Refresh")} title={t("Aktualisieren", "Refresh")}><RefreshCw size={16} /></button>
|
||||
<button data-dialog-close class="icon-action" type="button" onclick={onClose} disabled={isBusy} aria-label={t("Schließen", "Close")}><X size={16} /></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if restoreCommit}<p class="restore-help">{t("Grün: aus der alten Version übernehmen. Rot: aus der aktuellen Datei entfernen. Für einen Zeilenaustausch beide Zeilen auswählen. Die Auswahl wird nicht gestagt.", "Green: take from the old version. Red: remove from the current file. Select both lines to replace a line. Changes remain unstaged.")}</p>{/if}
|
||||
<div class="line-patch-body">
|
||||
{#if isLoading}
|
||||
<div class="blank-state"><LoaderCircle class="spin" size={18} />{t("Änderungen werden geladen …", "Loading changes …")}</div>
|
||||
@@ -330,8 +360,10 @@
|
||||
</button>
|
||||
<strong>{t("Abschnitt", "Hunk")} {index + 1}</strong><code title={hunk.header}>{hunk.header}</code>
|
||||
<div class="line-patch-hunk-actions">
|
||||
{#if restoreCommit}<button class="line-patch-hunk-button stage" type="button" onclick={() => applyHunkAction("restore-lines", hunk)} disabled={isBusy}><RotateCcw size={14} />{t("Abschnitt wiederherstellen", "Restore hunk")}</button>{:else}
|
||||
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction(staged ? "discard-staged" : "discard-unstaged", hunk)} disabled={isBusy}><Trash2 size={14} />{t("Verwerfen", "Discard")}</button>
|
||||
<button class="line-patch-hunk-button {staged ? "unstage" : "stage"}" type="button" onclick={() => applyHunkAction(staged ? "unstage" : "stage", hunk)} disabled={isBusy}>{#if staged}<Minus size={14} />{:else}<Plus size={14} />{/if}{staged ? t("Abschnitt unstagen", "Unstage hunk") : t("Abschnitt stagen", "Stage hunk")}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="line-patch-lines">
|
||||
@@ -358,14 +390,16 @@
|
||||
{#if hasTextPatch}
|
||||
<footer class="patch-footer">
|
||||
<div class="selection-summary"><span class="selection-symbol" class:has-selection={selectedCount > 0}><Check size={13} /></span><strong aria-live="polite">{selectedCount} {t(selectedCount === 1 ? "Zeile ausgewählt" : "Zeilen ausgewählt", selectedCount === 1 ? "line selected" : "lines selected")}</strong><button class="clear-selection" type="button" onclick={clearSelection} disabled={isBusy || selectedCount === 0}>{t("Auswahl aufheben", "Clear selection")}</button></div>
|
||||
<div class="selection-actions"><button class="discard-selection" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy || selectedCount === 0}>{t("Auswahl verwerfen", "Discard selected")}</button><button class="stage-selection" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy || selectedCount === 0}>{selectedCount} {t(selectedCount === 1 ? "Zeile" : "Zeilen", selectedCount === 1 ? "line" : "lines")} {staged ? t("unstagen", "to unstage") : t("stagen", "to stage")}</button></div>
|
||||
<div class="selection-actions">{#if restoreCommit}<button class="stage-selection" type="button" onclick={() => applySelected("restore-lines")} disabled={isBusy || selectedCount === 0}><RotateCcw size={14} />{t("Auswahl wiederherstellen", "Restore selected")}</button>{:else}<button class="discard-selection" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy || selectedCount === 0}>{t("Auswahl verwerfen", "Discard selected")}</button><button class="stage-selection" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy || selectedCount === 0}>{selectedCount} {t(selectedCount === 1 ? "Zeile" : "Zeilen", selectedCount === 1 ? "line" : "lines")} {staged ? t("unstagen", "to unstage") : t("stagen", "to stage")}</button>{/if}</div>
|
||||
</footer>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.restore-help{margin:0;padding:10px 20px;border-bottom:1px solid var(--color-border);color:var(--color-ink-muted);font-size:12px;line-height:1.5;flex-shrink:0}
|
||||
.line-patch-dialog{width:min(1700px,100%);height:min(960px,100%);grid-template-rows:auto minmax(0,1fr) auto;font-size:13px}
|
||||
.line-patch-dialog.restoring{grid-template-rows:auto auto minmax(0,1fr) auto}
|
||||
.dialog-header{padding:12px 18px}.patch-identity{display:flex;align-items:center;gap:12px;min-width:0}.patch-identity>div{min-width:0}.patch-identity :global(svg){flex:none;color:var(--color-ink-muted)}.patch-identity .dialog-title{font-size:15px;line-height:1.4;margin:0;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.patch-scope{display:block;color:var(--color-ink-muted);font-size:12px;margin-top:2px}
|
||||
.dialog-header-actions{gap:8px}.dialog-header-actions .external-diff{display:flex;align-items:center;gap:8px;border:0;background:transparent;font-size:12px;color:var(--color-ink-muted);padding:5px 10px}.line-patch-dialog .icon-action{display:inline-flex;align-items:center;justify-content:center;flex:none;width:30px;min-width:30px;height:30px;min-height:30px;padding:0;border:1px solid var(--color-border);background:transparent;color:var(--color-ink-muted)}
|
||||
.patch-toolbar{display:flex;align-items:center;gap:20px;padding:8px 18px;min-height:46px;border-bottom:1px solid var(--color-border-subtle);background:var(--app-dialog-bg)}.patch-toolbar strong{font-size:12px;font-weight:600}.range-hint{color:var(--color-ink-faint);font-size:12px}.patch-summary{display:flex;align-items:center;gap:10px;margin-left:auto;color:var(--color-ink-muted);font-size:12px;white-space:nowrap}.patch-summary .add-count{color:var(--code-add-text)}.patch-summary .delete-count{color:var(--code-delete-text);margin-right:10px}
|
||||
|
||||
@@ -753,3 +753,7 @@ export function submoduleAction(path: string, modulePath: string, action: "updat
|
||||
export function checkoutSubmoduleRevision(path: string, modulePath: string, revision: string, kind: "tag" | "commit"): Promise<void> {
|
||||
return invoke("checkout_submodule_revision", { path, modulePath, revision, kind });
|
||||
}
|
||||
|
||||
export function getFileRestorePatch(path: string, commit: string, file: string): Promise<string> {
|
||||
return invoke("get_file_restore_patch", { path, commit, file });
|
||||
}
|
||||
|
||||
+1
-1
@@ -214,7 +214,7 @@ export interface GitFileStatus {
|
||||
unstaged: FileStatusKind | null;
|
||||
}
|
||||
|
||||
export type PatchApplyAction = "stage" | "unstage" | "discard-unstaged" | "discard-staged";
|
||||
export type PatchApplyAction = "restore-lines" | "stage" | "unstage" | "discard-unstaged" | "discard-staged";
|
||||
|
||||
export interface GitBranch {
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user