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
+19 -2
View File
@@ -50,6 +50,13 @@
createOpen = false;
localOpen = true;
}
function checkoutOnDoubleClick(event: MouseEvent, branch: GitBranchInfo) {
if (branch.current || isBusy) return;
const target = event.target instanceof HTMLElement ? event.target : null;
if (target?.closest("button")) return;
onCheckout(branch);
}
</script>
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
@@ -121,7 +128,12 @@
<div class="branch-empty">No local branches.</div>
{:else}
{#each localBranches as branch (branch.name)}
<article class="branch-row" class:current={branch.current}>
<article
class="branch-row"
class:current={branch.current}
ondblclick={(event) => checkoutOnDoubleClick(event, branch)}
title={branch.current ? "Current branch" : "Double-click to checkout"}
>
<div class="branch-info">
<GitBranch size={16} aria-hidden="true" />
<div>
@@ -169,7 +181,12 @@
<div class="branch-empty">No remote branches.</div>
{:else}
{#each remoteBranches as branch (branch.name)}
<article class="branch-row" class:current={branch.current}>
<article
class="branch-row"
class:current={branch.current}
ondblclick={(event) => checkoutOnDoubleClick(event, branch)}
title={branch.current ? "Current branch" : "Double-click to checkout"}
>
<div class="branch-info">
<GitBranch size={16} aria-hidden="true" />
<div>
+189
View File
@@ -0,0 +1,189 @@
<script lang="ts">
import { LoaderCircle, X } from "@lucide/svelte";
import type { GitFileStatus, PatchApplyAction } from "../types";
type PatchLineKind = "context" | "add" | "delete" | "meta";
interface PatchLine {
id: string;
text: string;
kind: PatchLineKind;
}
interface PatchHunk {
id: string;
header: string;
lines: PatchLine[];
}
interface ParsedPatch {
headerLines: string[];
hunks: PatchHunk[];
binary: boolean;
}
interface Props {
file: GitFileStatus;
staged: boolean;
patch: string;
isBusy: boolean;
isLoading: boolean;
error: string;
onClose: () => void;
onRefresh: () => void | Promise<void>;
onApply: (action: PatchApplyAction, patch: string) => void | Promise<void>;
}
let {
file,
staged = false,
patch = "",
isBusy = false,
isLoading = false,
error = "",
onClose = () => {},
onRefresh = () => {},
onApply = () => {},
}: Props = $props();
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
$effect(() => {
parsed = parsePatch(patch);
});
function parsePatch(input: string): ParsedPatch {
const normalized = input.replace(/\r\n/g, "\n");
const lines = normalized.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
const headerLines: string[] = [];
const hunks: PatchHunk[] = [];
let current: PatchHunk | null = null;
for (const line of lines) {
if (line.startsWith("@@ ")) {
current = { id: `hunk-${hunks.length}`, header: line, lines: [] };
hunks.push(current);
continue;
}
if (!current) {
headerLines.push(line);
continue;
}
const kind = patchLineKind(line);
current.lines.push({
id: `${current.id}-line-${current.lines.length}`,
text: line,
kind,
});
}
return {
headerLines,
hunks,
binary: /(^|\n)(Binary files|GIT binary patch|literal \d+)/.test(normalized),
};
}
function patchLineKind(line: string): PatchLineKind {
if (line.startsWith("+") && !line.startsWith("+++")) return "add";
if (line.startsWith("-") && !line.startsWith("---")) return "delete";
if (line.startsWith(" ")) return "context";
return "meta";
}
function linePrefix(line: PatchLine): string {
if (line.kind === "add") return "+";
if (line.kind === "delete") return "-";
if (line.kind === "meta") return "\\";
return " ";
}
function lineBody(line: PatchLine): string {
if (line.kind === "meta") return line.text;
return line.text.slice(1);
}
function buildHunkPatch(hunk: PatchHunk): string {
return `${[...parsed.headerLines, hunk.header, ...hunk.lines.map((line) => line.text)].join("\n")}\n`;
}
async function applyHunkAction(action: PatchApplyAction, hunk: PatchHunk) {
if (isBusy || isLoading) return;
await onApply(action, buildHunkPatch(hunk));
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog line-patch-dialog" role="dialog" aria-modal="true" aria-label="Line patch">
<header class="dialog-header">
<div>
<span class="eyebrow">{scopeLabel}</span>
<p class="dialog-title" title={displayPath}>{displayPath}</p>
</div>
<div class="dialog-header-actions">
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>Refresh</button>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
</button>
</div>
</header>
<div class="line-patch-body">
{#if isLoading}
<div class="blank-state">
<LoaderCircle class="spin" size={18} aria-hidden="true" />
Loading patch...
</div>
{:else if error}
<div class="blank-state">{error}</div>
{:else if !patch.trim()}
<div class="blank-state">No line patch available for this file.</div>
{:else if parsed.binary || parsed.hunks.length === 0}
<div class="blank-state">This change cannot be split into text lines.</div>
{:else}
<div class="line-patch-scroll">
{#each parsed.hunks as hunk (hunk.id)}
<section class="line-patch-hunk">
<div class="line-patch-hunk-head">
<code>{hunk.header}</code>
<div class="line-patch-hunk-actions">
{#if staged}
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-staged", hunk)} disabled={isBusy}>
Discard Hunk
</button>
<button class="line-patch-hunk-button unstage" type="button" onclick={() => applyHunkAction("unstage", hunk)} disabled={isBusy}>
Unstage Hunk
</button>
{:else}
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-unstaged", hunk)} disabled={isBusy}>
Discard Hunk
</button>
<button class="line-patch-hunk-button stage" type="button" onclick={() => applyHunkAction("stage", hunk)} disabled={isBusy}>
Stage Hunk
</button>
{/if}
</div>
</div>
<div class="line-patch-lines">
{#each hunk.lines as line (line.id)}
<div class={`line-patch-row ${line.kind}`}>
<span class="line-patch-prefix">{linePrefix(line)}</span>
<code>{lineBody(line)}</code>
</div>
{/each}
</div>
</section>
{/each}
</div>
{/if}
</div>
</div>
</div>
+15 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Check, RotateCcw, Undo2 } from "@lucide/svelte";
import { Check, FileDiff, RotateCcw, Undo2 } from "@lucide/svelte";
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
interface Props {
@@ -12,6 +12,7 @@
onStage: (file: GitFileStatus) => void;
onUnstage: (file: GitFileStatus) => void;
onDiscard: (file: GitFileStatus, staged: boolean) => void;
onPatch: (file: GitFileStatus, staged: boolean) => void;
onStageAll: () => void;
onUnstageAll: () => void;
}
@@ -26,6 +27,7 @@
onStage = () => {},
onUnstage = () => {},
onDiscard = () => {},
onPatch = () => {},
onStageAll = () => {},
onUnstageAll = () => {},
}: Props = $props();
@@ -46,6 +48,10 @@
return file.old_path ? `${baseName(file.old_path)} -> ${baseName(file.path)}` : baseName(file.path);
}
function canPatch(kind: FileStatusKind | null): boolean {
return kind === "modified";
}
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
</script>
@@ -113,6 +119,10 @@
<Undo2 size={14} aria-hidden="true" />
Unstage
</button>
<button class="btn-sm" type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Stage, unstage, or discard selected lines">
<FileDiff size={14} aria-hidden="true" />
Lines
</button>
<button class="btn-sm" type="button" onclick={() => onDiscard(file, true)} disabled={isBusy} title="Discard staged changes">
<RotateCcw size={14} aria-hidden="true" />
Discard
@@ -134,6 +144,10 @@
<Check size={14} aria-hidden="true" />
Stage
</button>
<button class="btn-sm" type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Stage or discard selected lines">
<FileDiff size={14} aria-hidden="true" />
Lines
</button>
<button class="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes">
<RotateCcw size={14} aria-hidden="true" />
Discard
+14
View File
@@ -8,6 +8,7 @@ import type {
GitRepositoryFile,
GitSearchHit,
GitStatus,
PatchApplyAction,
RepositoryBundle,
StoredCredential,
} from "./types";
@@ -56,6 +57,19 @@ export function restoreFiles(
return invoke<GitStatus>("restore_files", { path, files, staged });
}
export function getFilePatch(path: string, file: string, staged: boolean): Promise<string> {
return invoke<string>("get_file_patch", { path, file, staged });
}
export function applyFilePatch(
path: string,
file: string,
patch: string,
action: PatchApplyAction,
): Promise<GitStatus> {
return invoke<GitStatus>("apply_file_patch", { path, file, patch, action });
}
export function commit(path: string, message: string): Promise<GitStatus> {
return invoke<GitStatus>("commit", { path, message });
}
+2
View File
@@ -24,6 +24,8 @@ export interface GitFileStatus {
unstaged: FileStatusKind | null;
}
export type PatchApplyAction = "stage" | "unstage" | "discard-unstaged" | "discard-staged";
export interface GitBranch {
name: string;
current: boolean;