Implement interactive hunk staging and discarding

Introduce a new dialog that allows users to view file changes as individual hunks and selectively stage, unstage, or discard them. This provides more granular control over modifications, similar to `git add -p`.

Enhance branch panel usability by allowing double-click to checkout a branch.
This commit is contained in:
Christoph Brandau
2026-07-01 14:47:36 +02:00
parent 0bbd5ce1e8
commit 5752243e6e
9 changed files with 636 additions and 11 deletions
+96
View File
@@ -14,6 +14,7 @@
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
import LinePatchDialog from "./lib/components/LinePatchDialog.svelte";
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
@@ -25,6 +26,7 @@
commit,
compareCommits,
cancelCodeSearch,
applyFilePatch,
createBranch,
diffFileAgainstWorkingTree,
compareFileToParent,
@@ -41,6 +43,7 @@
credLoad,
credSave,
credDelete,
getFilePatch,
readConflict,
resolveConflict,
resolveConflictSide,
@@ -65,6 +68,7 @@
GitRepositoryFile,
GitSearchHit,
GitStatus,
PatchApplyAction,
PreparedResolution,
StoredCredential,
} from "./lib/types";
@@ -103,6 +107,12 @@
let selectedDiffPath = "";
let diffHighlightQuery = "";
let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null;
let linePatchOpen = false;
let linePatchFile: GitFileStatus | null = null;
let linePatchStaged = false;
let linePatchText = "";
let linePatchLoading = false;
let linePatchError = "";
let globalSearchOpen = false;
let lastSearchQuery = "";
let globalSearchResults: GitSearchHit[] = [];
@@ -675,6 +685,77 @@
});
}
async function openLinePatch(file: GitFileStatus, staged: boolean) {
if (!activeRepoPath) return;
linePatchOpen = true;
linePatchFile = file;
linePatchStaged = staged;
linePatchText = "";
linePatchError = "";
linePatchLoading = true;
try {
linePatchText = await getFilePatch(activeRepoPath, file.path, staged);
} catch (error) {
linePatchError = errorToMessage(error);
errorMessage = linePatchError;
} finally {
linePatchLoading = false;
}
}
async function refreshLinePatch() {
if (!activeRepoPath || !linePatchFile) return;
await openLinePatch(linePatchFile, linePatchStaged);
}
function closeLinePatch() {
if (isBusy) return;
linePatchOpen = false;
linePatchFile = null;
linePatchText = "";
linePatchError = "";
}
function patchOperationLabel(action: PatchApplyAction, file: GitFileStatus): string {
switch (action) {
case "stage":
return `Staging hunk in ${file.path}`;
case "unstage":
return `Unstaging hunk in ${file.path}`;
default:
return `Discarding hunk in ${file.path}`;
}
}
async function applyLinePatch(action: PatchApplyAction, patch: string) {
if (!activeRepoPath || !linePatchFile || isBusy) return;
const file = linePatchFile;
operation = patchOperationLabel(action, file);
errorMessage = "";
linePatchError = "";
try {
applyStatus(await applyFilePatch(activeRepoPath, file.path, patch, action));
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
const updatedPatch = await getFilePatch(activeRepoPath, file.path, linePatchStaged);
if (updatedPatch.trim()) {
linePatchText = updatedPatch;
} else {
linePatchOpen = false;
linePatchFile = null;
linePatchText = "";
}
} catch (error) {
linePatchError = errorToMessage(error);
errorMessage = linePatchError;
} finally {
operation = "";
}
}
async function stageAllFiles() {
const paths = changedFiles.filter((f) => f.unstaged !== null).map((f) => f.path);
if (paths.length === 0) return;
@@ -1129,6 +1210,7 @@
onStage={stageFile}
onUnstage={unstageFile}
onDiscard={discardFile}
onPatch={openLinePatch}
onStageAll={stageAllFiles}
onUnstageAll={unstageAllFiles}
/>
@@ -1189,6 +1271,20 @@
/>
{/if}
{#if linePatchOpen && linePatchFile}
<LinePatchDialog
file={linePatchFile}
staged={linePatchStaged}
patch={linePatchText}
{isBusy}
isLoading={linePatchLoading}
error={linePatchError}
onClose={closeLinePatch}
onRefresh={refreshLinePatch}
onApply={applyLinePatch}
/>
{/if}
{#if globalSearchOpen}
<GlobalSearchDialog
{hasRepository}