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:
+48
-11
@@ -133,7 +133,8 @@
|
||||
type AppView = "management" | "repository";
|
||||
type CredentialAction = "push" | "pull" | "fetch" | "clone";
|
||||
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 };
|
||||
|
||||
interface RepoTab {
|
||||
@@ -2588,26 +2589,59 @@
|
||||
});
|
||||
}
|
||||
|
||||
function discardFile(file: GitFileStatus, staged: boolean) {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
pendingDiscard = { kind: "file", file, staged };
|
||||
function discardFiles(files: GitFileStatus[], staged: boolean) {
|
||||
if (!activeRepoPath || isBusy || files.length === 0) return;
|
||||
pendingDiscard = { kind: "file", files, staged };
|
||||
trackEvent("discard_confirm_opened", {
|
||||
kind: "file",
|
||||
staged: staged ? 1 : 0,
|
||||
files: files.length,
|
||||
});
|
||||
}
|
||||
|
||||
async function runDiscardFile(file: GitFileStatus, staged: boolean) {
|
||||
await runOperation(`Discarding ${file.path}`, async () => {
|
||||
applyStatus(await restoreFiles(activeRepoPath, [file.path], staged));
|
||||
function discardChanges(files: GitFileStatus[]) {
|
||||
if (!activeRepoPath || isBusy || files.length === 0) return;
|
||||
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 refreshFileHistory(activeRepoPath);
|
||||
trackEvent("file_discarded", {
|
||||
files: files.length,
|
||||
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) {
|
||||
if (!activeRepoPath) return;
|
||||
linePatchOpen = true;
|
||||
@@ -2743,7 +2777,9 @@
|
||||
if (!discard || !activeRepoPath || isBusy) return;
|
||||
|
||||
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 {
|
||||
await runLinePatchAction(discard.action, discard.patch, discard.file, discard.staged);
|
||||
}
|
||||
@@ -3750,7 +3786,8 @@
|
||||
onSelectFile={selectFileFromStatus}
|
||||
onStage={stageFile}
|
||||
onUnstage={unstageFile}
|
||||
onDiscard={discardFile}
|
||||
onDiscard={discardFiles}
|
||||
onDiscardMany={discardChanges}
|
||||
onPatch={openLinePatch}
|
||||
onStageAll={stageAllFiles}
|
||||
onUnstageAll={unstageAllFiles}
|
||||
@@ -3927,8 +3964,8 @@
|
||||
|
||||
{#if pendingDiscard}
|
||||
<DiscardConfirmDialog
|
||||
file={pendingDiscard.file}
|
||||
staged={pendingDiscard.staged}
|
||||
files={pendingDiscard.kind === "hunk" ? [pendingDiscard.file] : pendingDiscard.files}
|
||||
staged={pendingDiscard.kind === "all-changes" ? null : pendingDiscard.staged}
|
||||
scope={pendingDiscard.kind === "hunk" ? "hunk" : "file"}
|
||||
{isBusy}
|
||||
onConfirm={confirmDiscard}
|
||||
|
||||
+17
@@ -3188,6 +3188,23 @@
|
||||
align-items: center;
|
||||
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 {
|
||||
color: #ffb8bf;
|
||||
font-weight: 650;
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import type { GitFileStatus } from "../types";
|
||||
|
||||
interface Props {
|
||||
file: GitFileStatus;
|
||||
staged: boolean;
|
||||
files: GitFileStatus[];
|
||||
staged: boolean | null;
|
||||
scope: "file" | "hunk";
|
||||
isBusy: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
@@ -12,7 +12,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
file,
|
||||
files,
|
||||
staged = false,
|
||||
scope = "file",
|
||||
isBusy = false,
|
||||
@@ -20,10 +20,16 @@
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let targetPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
||||
let title = $derived(scope === "hunk" ? "Discard hunk?" : "Discard file changes?");
|
||||
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
|
||||
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
|
||||
function targetPath(file: GitFileStatus): string {
|
||||
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -46,11 +52,22 @@
|
||||
|
||||
<div class="discard-confirm-copy">
|
||||
<p>
|
||||
This will reset the {sourceLabel} for the {scopeLabel.toLowerCase()} below.
|
||||
This will reset the {sourceLabel} for the {scopeLabel} below.
|
||||
</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">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
onSelectFile: (file: GitFileStatus) => void;
|
||||
onStage: (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;
|
||||
onStageAll: () => void;
|
||||
onUnstageAll: () => void;
|
||||
@@ -34,6 +35,7 @@
|
||||
onStage = () => {},
|
||||
onUnstage = () => {},
|
||||
onDiscard = () => {},
|
||||
onDiscardMany = () => {},
|
||||
onPatch = () => {},
|
||||
onStageAll = () => {},
|
||||
onUnstageAll = () => {},
|
||||
@@ -125,6 +127,21 @@
|
||||
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 hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
||||
let selectedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f))).length);
|
||||
@@ -173,6 +190,16 @@
|
||||
<Undo2 size={14} aria-hidden="true" />
|
||||
Unstage all
|
||||
</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}
|
||||
<span class="status-selection-count">{selectedCount} selected</span>
|
||||
<button
|
||||
@@ -195,6 +222,16 @@
|
||||
<Undo2 size={14} aria-hidden="true" />
|
||||
Unstage selected
|
||||
</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}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -241,9 +278,9 @@
|
||||
<FileDiff size={14} aria-hidden="true" />
|
||||
Details
|
||||
</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" />
|
||||
Discard
|
||||
{unstageTargets.length > 1 ? `Discard ${unstageTargets.length}` : "Discard"}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="quiet">No staged change</span>
|
||||
@@ -267,9 +304,9 @@
|
||||
<FileDiff size={14} aria-hidden="true" />
|
||||
Details
|
||||
</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" />
|
||||
Discard
|
||||
{stageTargets.length > 1 ? `Discard ${stageTargets.length}` : "Discard"}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="quiet">No unstaged change</span>
|
||||
|
||||
Reference in New Issue
Block a user