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 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
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user