diff --git a/src/App.svelte b/src/App.svelte index 123f22f..6dd6fdb 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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} void | Promise; @@ -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"); @@ -46,11 +52,22 @@

- This will reset the {sourceLabel} for the {scopeLabel.toLowerCase()} below. + This will reset the {sourceLabel} for the {scopeLabel} below.

- {targetPath} + {#if count > 1} +
    + {#each files.slice(0, 8) as file (`${file.old_path ?? ""}:${file.path}`)} +
  • {targetPath(file)}
  • + {/each} + {#if files.length > 8} +
  • +{files.length - 8} more
  • + {/if} +
+ {:else if count === 1} + {targetPath(files[0])} + {/if}

- 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.

diff --git a/src/lib/components/StatusPanel.svelte b/src/lib/components/StatusPanel.svelte index e037009..b9dfdfa 100644 --- a/src/lib/components/StatusPanel.svelte +++ b/src/lib/components/StatusPanel.svelte @@ -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 @@