Compare commits

..
3 Commits
Author SHA1 Message Date
Christoph Brandau 7ef1a2dac8 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.
2026-07-10 11:36:15 +02:00
Christoph Brandau 44a5b776d7 fix(refresh): improve auto-refresh robustness against repo switches
The automatic data refreshing mechanism has been updated to handle asynchronous state changes more reliably. It now incorporates checks to determine if the active repository path has changed during background operations. This prevents stale or incorrect data from being displayed if a user navigates away from or switches repositories while a refresh cycle is in progress.

- Added path validation checks after status fetching and bundle opening
- Ensured all subsequent refresh calls use the validated current repository path
2026-07-10 11:15:47 +02:00
Christoph 55b4c9fc50 Update version to 2026.7.17 2026-07-09 20:58:50 +02:00
7 changed files with 153 additions and 39 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "gitty", "name": "gitty",
"version": "2026.7.16", "version": "2026.7.17",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gitty", "name": "gitty",
"version": "2026.7.16", "version": "2026.7.17",
"dependencies": { "dependencies": {
"@lucide/svelte": "^1.21.0", "@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gitty", "name": "gitty",
"version": "2026.7.16", "version": "2026.7.17",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty", "productName": "Gitty",
"version": "2026.7.16", "version": "2026.7.17",
"identifier": "com.gitty", "identifier": "com.gitty",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+63 -20
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 {
@@ -631,26 +632,32 @@
async function autoRefreshTick() { async function autoRefreshTick() {
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return; if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
const path = activeRepoPath;
autoRefreshInFlight = true; autoRefreshInFlight = true;
try { try {
// Cheap fast path: only fetch status; skip the heavy reload if nothing changed. // Cheap fast path: only fetch status; skip the heavy reload if nothing changed.
const nextStatus = await getStatus(activeRepoPath); const nextStatus = await getStatus(path);
// The user may have switched repos (or closed this one) while the status
// call was in flight — applying a stale result would flash/overwrite the
// now-active repo's name and data with this one's.
if (!sameRepoPath(path, activeRepoPath)) return;
if (statusFingerprint(nextStatus) === lastStatusFingerprint) return; if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
applyStatus(nextStatus); applyStatus(nextStatus);
// Something changed — reload branches, commits and files in one bundled call. // Something changed — reload branches, commits and files in one bundled call.
const bundle = await openRepositoryBundle(activeRepoPath, 100); const bundle = await openRepositoryBundle(path, 100);
if (!sameRepoPath(path, activeRepoPath)) return;
const previousHeadHash = lastFileHistoryHeadHash; const previousHeadHash = lastFileHistoryHeadHash;
await refreshBranchList(activeRepoPath, bundle.branches); await refreshBranchList(path, bundle.branches);
await refreshTags(activeRepoPath, bundle.tags); await refreshTags(path, bundle.tags);
await refreshStashes(activeRepoPath, bundle.stashes); await refreshStashes(path, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits); await refreshCommitHistory(path, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files); await refreshExplorerFiles(path, bundle.files);
// File history reflects `git log`, which only changes when HEAD actually moves // File history reflects `git log`, which only changes when HEAD actually moves
// (new commit, checkout, merge, ...) — skip the reload otherwise so a plain // (new commit, checkout, merge, ...) — skip the reload otherwise so a plain
// working-tree/status change (staging, edits) doesn't keep re-fetching and // working-tree/status change (staging, edits) doesn't keep re-fetching and
// flickering the currently viewed file's history. // flickering the currently viewed file's history.
if (lastFileHistoryHeadHash !== previousHeadHash) { if (lastFileHistoryHeadHash !== previousHeadHash && sameRepoPath(path, activeRepoPath)) {
await refreshFileHistory(activeRepoPath); await refreshFileHistory(path);
} }
} catch { /* ignore transient errors */ } finally { } catch { /* ignore transient errors */ } finally {
autoRefreshInFlight = false; autoRefreshInFlight = false;
@@ -2582,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;
@@ -2737,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);
} }
@@ -3744,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}
@@ -3921,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>