refactor(status): Support batch and all changes discard functionality

The file discarding mechanism has been significantly refactored to improve handling of multi-file operations. Instead of processing discards on a per-file basis, the system now supports batch actions for selected files or reverting all tracked modifications simultaneously. This update centralizes complex discard logic into dedicated functions across the component suite.

- Updated state management and types to handle arrays of file changes.
- Added UI elements and handlers for discarding all staged/unstaged changes.
- Enhanced the confirmation dialog to display multiple discarded targets in a list view.
This commit is contained in:
Christoph Brandau
2026-07-10 11:36:15 +02:00
parent 44a5b776d7
commit 7ef1a2dac8
4 changed files with 134 additions and 26 deletions
+48 -11
View File
@@ -133,7 +133,8 @@
type AppView = "management" | "repository"; type AppView = "management" | "repository";
type CredentialAction = "push" | "pull" | "fetch" | "clone"; type CredentialAction = "push" | "pull" | "fetch" | "clone";
type PendingDiscard = type PendingDiscard =
| { kind: "file"; file: GitFileStatus; staged: boolean } | { kind: "file"; files: GitFileStatus[]; staged: boolean }
| { kind: "all-changes"; files: GitFileStatus[] }
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string }; | { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
interface RepoTab { interface RepoTab {
@@ -2588,26 +2589,59 @@
}); });
} }
function discardFile(file: GitFileStatus, staged: boolean) { function discardFiles(files: GitFileStatus[], staged: boolean) {
if (!activeRepoPath || isBusy) return; if (!activeRepoPath || isBusy || files.length === 0) return;
pendingDiscard = { kind: "file", file, staged }; pendingDiscard = { kind: "file", files, staged };
trackEvent("discard_confirm_opened", { trackEvent("discard_confirm_opened", {
kind: "file", kind: "file",
staged: staged ? 1 : 0, staged: staged ? 1 : 0,
files: files.length,
}); });
} }
async function runDiscardFile(file: GitFileStatus, staged: boolean) { function discardChanges(files: GitFileStatus[]) {
await runOperation(`Discarding ${file.path}`, async () => { if (!activeRepoPath || isBusy || files.length === 0) return;
applyStatus(await restoreFiles(activeRepoPath, [file.path], staged)); pendingDiscard = { kind: "all-changes", files };
trackEvent("discard_confirm_opened", {
kind: "all",
files: files.length,
});
}
async function runDiscardFiles(files: GitFileStatus[], staged: boolean) {
if (files.length === 0) return;
const paths = files.map((file) => file.path);
await runOperation(files.length === 1 ? `Discarding ${baseName(files[0].path)}` : `Discarding ${files.length} files`, async () => {
applyStatus(await restoreFiles(activeRepoPath, paths, staged));
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath); await refreshFileHistory(activeRepoPath);
trackEvent("file_discarded", { trackEvent("file_discarded", {
files: files.length,
staged: staged ? 1 : 0, staged: staged ? 1 : 0,
}); });
}); });
} }
// Discards both the staged and unstaged changes for each given file (used
// by "Discard all" and "Discard selected", which don't distinguish lanes).
async function runDiscardAllChanges(files: GitFileStatus[]) {
const stagedPaths = files.filter((file) => file.staged !== null).map((file) => file.path);
const unstagedPaths = files.filter((file) => file.unstaged !== null).map((file) => file.path);
if (stagedPaths.length === 0 && unstagedPaths.length === 0) return;
await runOperation(files.length === 1 ? `Discarding ${baseName(files[0].path)}` : `Discarding ${files.length} files`, async () => {
let nextStatus: GitStatus | null = null;
if (stagedPaths.length > 0) nextStatus = await restoreFiles(activeRepoPath, stagedPaths, true);
if (unstagedPaths.length > 0) nextStatus = await restoreFiles(activeRepoPath, unstagedPaths, false);
if (nextStatus) applyStatus(nextStatus);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("file_discarded", {
files: files.length,
staged: 2,
});
});
}
async function openLinePatch(file: GitFileStatus, staged: boolean) { async function openLinePatch(file: GitFileStatus, staged: boolean) {
if (!activeRepoPath) return; if (!activeRepoPath) return;
linePatchOpen = true; linePatchOpen = true;
@@ -2743,7 +2777,9 @@
if (!discard || !activeRepoPath || isBusy) return; if (!discard || !activeRepoPath || isBusy) return;
if (discard.kind === "file") { if (discard.kind === "file") {
await runDiscardFile(discard.file, discard.staged); await runDiscardFiles(discard.files, discard.staged);
} else if (discard.kind === "all-changes") {
await runDiscardAllChanges(discard.files);
} else { } else {
await runLinePatchAction(discard.action, discard.patch, discard.file, discard.staged); await runLinePatchAction(discard.action, discard.patch, discard.file, discard.staged);
} }
@@ -3750,7 +3786,8 @@
onSelectFile={selectFileFromStatus} onSelectFile={selectFileFromStatus}
onStage={stageFile} onStage={stageFile}
onUnstage={unstageFile} onUnstage={unstageFile}
onDiscard={discardFile} onDiscard={discardFiles}
onDiscardMany={discardChanges}
onPatch={openLinePatch} onPatch={openLinePatch}
onStageAll={stageAllFiles} onStageAll={stageAllFiles}
onUnstageAll={unstageAllFiles} onUnstageAll={unstageAllFiles}
@@ -3927,8 +3964,8 @@
{#if pendingDiscard} {#if pendingDiscard}
<DiscardConfirmDialog <DiscardConfirmDialog
file={pendingDiscard.file} files={pendingDiscard.kind === "hunk" ? [pendingDiscard.file] : pendingDiscard.files}
staged={pendingDiscard.staged} staged={pendingDiscard.kind === "all-changes" ? null : pendingDiscard.staged}
scope={pendingDiscard.kind === "hunk" ? "hunk" : "file"} scope={pendingDiscard.kind === "hunk" ? "hunk" : "file"}
{isBusy} {isBusy}
onConfirm={confirmDiscard} onConfirm={confirmDiscard}
+17
View File
@@ -3188,6 +3188,23 @@
align-items: center; align-items: center;
gap: 6px; gap: 6px;
} }
.discard-target-list {
display: grid;
gap: 4px;
max-height: 168px;
overflow: auto;
margin: 0;
padding: 0;
list-style: none;
}
.discard-target-list .discard-target {
max-height: none;
}
.discard-target-more {
padding: 2px 2px 0;
color: var(--color-ink-muted);
font-size: 12px;
}
.discard-warning-text { .discard-warning-text {
color: #ffb8bf; color: #ffb8bf;
font-weight: 650; font-weight: 650;
+27 -10
View File
@@ -3,8 +3,8 @@
import type { GitFileStatus } from "../types"; import type { GitFileStatus } from "../types";
interface Props { interface Props {
file: GitFileStatus; files: GitFileStatus[];
staged: boolean; staged: boolean | null;
scope: "file" | "hunk"; scope: "file" | "hunk";
isBusy: boolean; isBusy: boolean;
onConfirm: () => void | Promise<void>; onConfirm: () => void | Promise<void>;
@@ -12,7 +12,7 @@
} }
let { let {
file, files,
staged = false, staged = false,
scope = "file", scope = "file",
isBusy = false, isBusy = false,
@@ -20,10 +20,16 @@
onClose = () => {}, onClose = () => {},
}: Props = $props(); }: Props = $props();
let targetPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path); function targetPath(file: GitFileStatus): string {
let title = $derived(scope === "hunk" ? "Discard hunk?" : "Discard file changes?"); return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes"); }
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
let count = $derived(files.length);
let title = $derived(
scope === "hunk" ? "Discard hunk?" : count > 1 ? `Discard changes in ${count} files?` : "Discard file changes?"
);
let scopeLabel = $derived(scope === "hunk" ? "selected hunk" : count > 1 ? `${count} files` : "file");
let sourceLabel = $derived(staged === null ? "staged and unstaged changes" : staged ? "staged changes" : "unstaged changes");
</script> </script>
@@ -46,11 +52,22 @@
<div class="discard-confirm-copy"> <div class="discard-confirm-copy">
<p> <p>
This will reset the {sourceLabel} for the {scopeLabel.toLowerCase()} below. This will reset the {sourceLabel} for the {scopeLabel} below.
</p> </p>
<code class="discard-target" title={targetPath}>{targetPath}</code> {#if count > 1}
<ul class="discard-target-list">
{#each files.slice(0, 8) as file (`${file.old_path ?? ""}:${file.path}`)}
<li><code class="discard-target" title={targetPath(file)}>{targetPath(file)}</code></li>
{/each}
{#if files.length > 8}
<li class="discard-target-more">+{files.length - 8} more</li>
{/if}
</ul>
{:else if count === 1}
<code class="discard-target" title={targetPath(files[0])}>{targetPath(files[0])}</code>
{/if}
<p class="discard-warning-text"> <p class="discard-warning-text">
This cannot be undone. If the file only exists in your working tree, it can be deleted entirely. This cannot be undone. If a file only exists in your working tree, it can be deleted entirely.
</p> </p>
</div> </div>
</div> </div>
+42 -5
View File
@@ -15,7 +15,8 @@
onSelectFile: (file: GitFileStatus) => void; onSelectFile: (file: GitFileStatus) => void;
onStage: (files: GitFileStatus[]) => void; onStage: (files: GitFileStatus[]) => void;
onUnstage: (files: GitFileStatus[]) => void; onUnstage: (files: GitFileStatus[]) => void;
onDiscard: (file: GitFileStatus, staged: boolean) => void; onDiscard: (files: GitFileStatus[], staged: boolean) => void;
onDiscardMany: (files: GitFileStatus[]) => void;
onPatch: (file: GitFileStatus, staged: boolean) => void; onPatch: (file: GitFileStatus, staged: boolean) => void;
onStageAll: () => void; onStageAll: () => void;
onUnstageAll: () => void; onUnstageAll: () => void;
@@ -34,6 +35,7 @@
onStage = () => {}, onStage = () => {},
onUnstage = () => {}, onUnstage = () => {},
onDiscard = () => {}, onDiscard = () => {},
onDiscardMany = () => {},
onPatch = () => {}, onPatch = () => {},
onStageAll = () => {}, onStageAll = () => {},
onUnstageAll = () => {}, onUnstageAll = () => {},
@@ -125,6 +127,21 @@
onUnstage(targets); onUnstage(targets);
} }
// Discard mirrors the stage/unstage target selection: if the clicked row is
// part of the current multi-selection, the whole selection (filtered to the
// relevant lane) is discarded; otherwise just that one file.
function discardStagedFromFile(file: GitFileStatus) {
const targets = selectedUnstageTargets(file);
if (targets.length === 0) return;
onDiscard(targets, true);
}
function discardUnstagedFromFile(file: GitFileStatus) {
const targets = selectedStageTargets(file);
if (targets.length === 0) return;
onDiscard(targets, false);
}
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null)); let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null)); let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
let selectedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f))).length); let selectedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f))).length);
@@ -173,6 +190,16 @@
<Undo2 size={14} aria-hidden="true" /> <Undo2 size={14} aria-hidden="true" />
Unstage all Unstage all
</button> </button>
<button
class="btn-sm danger"
type="button"
onclick={() => onDiscardMany(changedFiles)}
disabled={isBusy || changedFiles.length === 0}
title="Discard all changes"
>
<RotateCcw size={14} aria-hidden="true" />
Discard all
</button>
{#if selectedCount > 1} {#if selectedCount > 1}
<span class="status-selection-count">{selectedCount} selected</span> <span class="status-selection-count">{selectedCount} selected</span>
<button <button
@@ -195,6 +222,16 @@
<Undo2 size={14} aria-hidden="true" /> <Undo2 size={14} aria-hidden="true" />
Unstage selected Unstage selected
</button> </button>
<button
class="btn-sm danger"
type="button"
onclick={() => onDiscardMany(selectedFiles())}
disabled={isBusy || selectedCount === 0}
title="Discard changes in selected files"
>
<RotateCcw size={14} aria-hidden="true" />
Discard selected
</button>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -241,9 +278,9 @@
<FileDiff size={14} aria-hidden="true" /> <FileDiff size={14} aria-hidden="true" />
Details Details
</button> </button>
<button class="btn-sm" type="button" onclick={() => onDiscard(file, true)} disabled={isBusy} title="Discard staged changes"> <button class="btn-sm" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={unstageTargets.length > 1 ? `Discard staged changes in ${unstageTargets.length} selected files` : "Discard staged changes"}>
<RotateCcw size={14} aria-hidden="true" /> <RotateCcw size={14} aria-hidden="true" />
Discard {unstageTargets.length > 1 ? `Discard ${unstageTargets.length}` : "Discard"}
</button> </button>
{:else} {:else}
<span class="quiet">No staged change</span> <span class="quiet">No staged change</span>
@@ -267,9 +304,9 @@
<FileDiff size={14} aria-hidden="true" /> <FileDiff size={14} aria-hidden="true" />
Details Details
</button> </button>
<button class="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes"> <button class="btn-sm" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={stageTargets.length > 1 ? `Discard unstaged changes in ${stageTargets.length} selected files` : "Discard unstaged changes"}>
<RotateCcw size={14} aria-hidden="true" /> <RotateCcw size={14} aria-hidden="true" />
Discard {stageTargets.length > 1 ? `Discard ${stageTargets.length}` : "Discard"}
</button> </button>
{:else} {:else}
<span class="quiet">No unstaged change</span> <span class="quiet">No unstaged change</span>