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",
"version": "2026.7.16",
"version": "2026.7.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitty",
"version": "2026.7.16",
"version": "2026.7.17",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitty",
"version": "2026.7.16",
"version": "2026.7.17",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty",
"version": "2026.7.16",
"version": "2026.7.17",
"identifier": "com.gitty",
"build": {
"beforeDevCommand": "npm run dev",
+63 -20
View File
@@ -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 {
@@ -631,26 +632,32 @@
async function autoRefreshTick() {
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
const path = activeRepoPath;
autoRefreshInFlight = true;
try {
// 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;
applyStatus(nextStatus);
// 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;
await refreshBranchList(activeRepoPath, bundle.branches);
await refreshTags(activeRepoPath, bundle.tags);
await refreshStashes(activeRepoPath, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
await refreshBranchList(path, bundle.branches);
await refreshTags(path, bundle.tags);
await refreshStashes(path, bundle.stashes);
await refreshCommitHistory(path, bundle.commits);
await refreshExplorerFiles(path, bundle.files);
// File history reflects `git log`, which only changes when HEAD actually moves
// (new commit, checkout, merge, ...) — skip the reload otherwise so a plain
// working-tree/status change (staging, edits) doesn't keep re-fetching and
// flickering the currently viewed file's history.
if (lastFileHistoryHeadHash !== previousHeadHash) {
await refreshFileHistory(activeRepoPath);
if (lastFileHistoryHeadHash !== previousHeadHash && sameRepoPath(path, activeRepoPath)) {
await refreshFileHistory(path);
}
} catch { /* ignore transient errors */ } finally {
autoRefreshInFlight = false;
@@ -2582,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;
@@ -2737,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);
}
@@ -3744,7 +3786,8 @@
onSelectFile={selectFileFromStatus}
onStage={stageFile}
onUnstage={unstageFile}
onDiscard={discardFile}
onDiscard={discardFiles}
onDiscardMany={discardChanges}
onPatch={openLinePatch}
onStageAll={stageAllFiles}
onUnstageAll={unstageAllFiles}
@@ -3921,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
View File
@@ -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;
+27 -10
View File
@@ -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>
+42 -5
View File
@@ -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>