Stage, unstage, and discard individual diff hunks
Add per-hunk staging, unstaging, and discarding to the Changes diff
view, so a single change region inside a file can be acted on in
isolation instead of forcing whole-file stage/revert. The change is
wired end-to-end across the web server, the shared UI runtime API
contract, and the VS Code extension, with Electron inheriting the web
path unchanged (it boots the server in-process).
Server
------
- New `applyHunk(directory, filePath, { patch, action })` in
packages/web/server/lib/git/service.js. It resolves the repository
context and validates the file path with the same helpers used by
stageFiles/unstageFiles (resolveGitFileContext +
validateRepositoryFilePaths), then writes the single-hunk patch to a
temporary file in the OS temp dir (never inside the repo, so it
cannot show up as an untracked file) and runs `git apply` with flags
chosen per action:
stage -> git apply --cached (working tree -> index)
unstage -> git apply --cached --reverse (index -> working tree)
discard -> git apply --reverse (revert in working tree)
A `git apply --check` runs first with the same flags, so a stale
hunk that no longer applies fails with a clear "Hunk no longer
applies - refresh and try again" message instead of leaving a
partial mutation. The patch's target path is parsed and must match
the requested file (with /dev/null tolerated for new/deleted files),
preventing a patch from silently targeting a different path. The
whole operation runs inside withGitIndexMutationQueue to avoid
racing with concurrent stage/unstage. The temp file is removed in a
finally block.
- New `POST /api/git/apply-hunk` route in routes.js, registered
alongside stage/unstage. Validates directory, path, non-empty patch,
and action before delegating.
- DOCUMENTATION.md updated with the new service entry.
Patch extraction
----------------
- packages/ui/src/lib/diff/patchFileDiff.ts gains
splitPatchIntoHunks(patch) and extractHunkPatch(patch, hunkIndex).
They keep the original file header (diff --git / index / --- / +++)
and emit exactly one @@ hunk per standalone patch, which is what
`git apply` expects. Each emitted patch is guaranteed to end with a
trailing newline (without it git apply reports "corrupt patch").
Runtime API contract
--------------------
- GitAPI (packages/ui/src/lib/api/types.ts) gains optional
stageGitHunk / unstageGitHunk / revertGitHunk, matching the
stageGitFiles? / unstageGitFiles? precedent so runtimes that do not
support it degrade gracefully.
- gitApi.ts delegates to the registered runtime git API, falling back
to gitApiHttp, exactly like the existing whole-file helpers.
- gitApiHttp.ts posts to /api/git/apply-hunk.
- Web runtime composes the three methods in packages/web/src/api/git.ts.
VS Code parity
--------------
- packages/vscode/src/gitService.ts adds applyGitHunk(), implemented
natively with the existing execGit helper + a temp patch file +
`git apply` (--cached / --cached --reverse / --reverse), mirroring
the server's --check-first safety and temp-file cleanup.
- bridge-git-runtime.ts handles the new api:git/apply-hunk bridge
message; webview/api/git.ts sends it. VS Code users get identical
stage/unstage/discard-hunk behavior.
UI
--
- New DiffHunkActions component renders a compact per-hunk strip
above each expanded file diff in the Changes view. Each hunk chip
shows its +additions / -deletions counts and offers:
working scope -> Stage + Discard
staged scope -> Unstage
Clicking extracts that hunk's standalone patch via
extractHunkPatch(patch, hunkIndex) and calls the runtime git API.
Because the chip index comes directly from fileDiff.hunks[] and the
patch is sliced in the same order, the hunk the user sees is always
the hunk that gets applied. While any action is in flight all buttons
disable to prevent conflicting concurrent mutations; the per-hunk
spinner reflects in-flight state.
- DiffView wires DiffHunkActions into InlineDiffViewer (text diffs
only; binary/image and full-file-content modes are excluded since
they have no patch). MultiFileDiffEntry passes directory/staged
through and handles onHunkApplied by bumping the diff reload nonce
(so the file's diff re-fetches and the affected hunk disappears)
and refreshing git status (so file counts and the staged/changed
scope update). Hunk actions are therefore available wherever the
default patch-context diff is shown.
i18n
----
- 10 new keys (diffView.hunk.*) added to all 9 locales (en, es, fr,
ko, pl, pt-BR, uk, zh-CN, zh-TW), including stage/unstage/discard
labels, tooltips with the hunk index, a stale-hunk error message,
and an unsupported-runtime fallback.
Tests
-----
- packages/ui/src/lib/diff/patchFileDiff.test.ts covers
splitHunks/extractHunkPatch: multi-hunk split, header preservation,
single-hunk and empty patches, out-of-range indices.
- service.test.js adds an applyHunk suite that builds real temp repos
with two separate hunks and verifies: staging one hunk leaves the
other unstaged, discarding reverts only the targeted hunk in the
working tree, unstaging removes only one hunk from the index, and a
retargeted patch (different file path) is rejected. Also covers
invalid-action / missing-hunk-header validation.
- packages/web/src/api/git.test.ts mock completed with the new methods
(and previously-missing exports that prevented the test from
loading) and asserts the three hunk methods are exposed.
- routes.test.js continues to pass under bun.
CHANGELOG updated under [Unreleased].
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { FileDiffMetadata } from '@pierre/diffs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { extractHunkPatch } from '@/lib/diff/patchFileDiff';
|
||||
|
||||
type HunkAction = 'stage' | 'unstage' | 'discard';
|
||||
|
||||
interface DiffHunkActionsProps {
|
||||
patch: string;
|
||||
fileDiff: FileDiffMetadata | undefined;
|
||||
directory: string;
|
||||
filePath: string;
|
||||
staged: boolean;
|
||||
onApplied: (action: HunkAction) => void;
|
||||
}
|
||||
|
||||
export const DiffHunkActions = React.memo<DiffHunkActionsProps>(({
|
||||
patch,
|
||||
fileDiff,
|
||||
directory,
|
||||
filePath,
|
||||
staged,
|
||||
onApplied,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { git } = useRuntimeAPIs();
|
||||
const [busyKey, setBusyKey] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const hunks = fileDiff?.hunks;
|
||||
if (!hunks || hunks.length === 0 || !patch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const run = async (hunkIndex: number, action: HunkAction) => {
|
||||
const hunkPatch = extractHunkPatch(patch, hunkIndex);
|
||||
if (!hunkPatch) {
|
||||
setError(t('diffView.hunk.unavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${hunkIndex}:${action}`;
|
||||
setBusyKey(key);
|
||||
setError(null);
|
||||
try {
|
||||
if (action === 'stage') {
|
||||
if (!git.stageGitHunk) throw new Error(t('diffView.hunk.unsupported'));
|
||||
await git.stageGitHunk(directory, filePath, hunkPatch);
|
||||
} else if (action === 'unstage') {
|
||||
if (!git.unstageGitHunk) throw new Error(t('diffView.hunk.unsupported'));
|
||||
await git.unstageGitHunk(directory, filePath, hunkPatch);
|
||||
} else {
|
||||
if (!git.revertGitHunk) throw new Error(t('diffView.hunk.unsupported'));
|
||||
await git.revertGitHunk(directory, filePath, hunkPatch);
|
||||
}
|
||||
onApplied(action);
|
||||
} catch (actionError) {
|
||||
setError(actionError instanceof Error ? actionError.message : String(actionError));
|
||||
} finally {
|
||||
setBusyKey((current) => (current === key ? null : current));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 border-b border-[var(--interactive-border)]/40 bg-[var(--surface-elevated)]/40 px-3 py-1.5">
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto">
|
||||
<span className="typography-micro shrink-0 text-muted-foreground uppercase">
|
||||
{t('diffView.hunk.label')}
|
||||
</span>
|
||||
{hunks.map((hunk, index) => {
|
||||
const additions = hunk.additionLines;
|
||||
const deletions = hunk.deletionLines;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex shrink-0 items-center gap-1 rounded-md border border-[var(--interactive-border)]/50 bg-background/60 px-1.5 py-0.5"
|
||||
>
|
||||
<span className="typography-micro font-semibold text-muted-foreground">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
{additions > 0 ? (
|
||||
<span className="typography-micro" style={{ color: 'var(--status-success)' }}>
|
||||
+{additions}
|
||||
</span>
|
||||
) : null}
|
||||
{deletions > 0 ? (
|
||||
<span className="typography-micro" style={{ color: 'var(--status-error)' }}>
|
||||
−{deletions}
|
||||
</span>
|
||||
) : null}
|
||||
{staged ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-5 gap-1 px-1.5"
|
||||
disabled={busyKey !== null}
|
||||
onClick={() => void run(index, 'unstage')}
|
||||
title={t('diffView.hunk.unstageTitle', { index: index + 1 })}
|
||||
>
|
||||
{busyKey === `${index}:unstage` ? (
|
||||
<Icon name="loader-4" className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Icon name="arrow-go-back" className="size-3" />
|
||||
)}
|
||||
{t('diffView.hunk.unstage')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-5 gap-1 px-1.5"
|
||||
disabled={busyKey !== null}
|
||||
onClick={() => void run(index, 'stage')}
|
||||
title={t('diffView.hunk.stageTitle', { index: index + 1 })}
|
||||
>
|
||||
{busyKey === `${index}:stage` ? (
|
||||
<Icon name="loader-4" className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Icon name="add" className="size-3" />
|
||||
)}
|
||||
{t('diffView.hunk.stage')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-5 gap-1 px-1.5 text-muted-foreground hover:text-[var(--status-error)]"
|
||||
disabled={busyKey !== null}
|
||||
onClick={() => void run(index, 'discard')}
|
||||
title={t('diffView.hunk.discardTitle', { index: index + 1 })}
|
||||
>
|
||||
{busyKey === `${index}:discard` ? (
|
||||
<Icon name="loader-4" className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Icon name="close" className="size-3" />
|
||||
)}
|
||||
{t('diffView.hunk.discard')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="flex items-center gap-1.5 typography-meta" style={{ color: 'var(--status-error)' }}>
|
||||
<Icon name="error-warning" className="size-3.5 shrink-0" />
|
||||
<span className={cn('min-w-0')}>{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { DiffViewToggle } from '@/components/chat/message/DiffViewToggle';
|
||||
import type { DiffViewMode } from '@/components/chat/message/types';
|
||||
import { PierreDiffViewer } from './PierreDiffViewer';
|
||||
import { DiffHunkActions } from './DiffHunkActions';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
@@ -481,51 +482,67 @@ const InlineImageDiffViewer = React.memo<InlineImageDiffViewerProps>(({
|
||||
});
|
||||
|
||||
interface InlineDiffViewerProps {
|
||||
filePath: string;
|
||||
diff: DiffData;
|
||||
renderSideBySide: boolean;
|
||||
wrapLines: boolean;
|
||||
filePath: string;
|
||||
diff: DiffData;
|
||||
renderSideBySide: boolean;
|
||||
wrapLines: boolean;
|
||||
directory: string;
|
||||
staged: boolean;
|
||||
onHunkApplied: (action: 'stage' | 'unstage' | 'discard') => void;
|
||||
}
|
||||
|
||||
const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
|
||||
filePath,
|
||||
diff,
|
||||
renderSideBySide,
|
||||
wrapLines,
|
||||
const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
|
||||
filePath,
|
||||
diff,
|
||||
renderSideBySide,
|
||||
wrapLines,
|
||||
directory,
|
||||
staged,
|
||||
onHunkApplied,
|
||||
}) => {
|
||||
const language = React.useMemo(
|
||||
() => getLanguageFromExtension(filePath) || 'text',
|
||||
[filePath]
|
||||
);
|
||||
const language = React.useMemo(
|
||||
() => getLanguageFromExtension(filePath) || 'text',
|
||||
[filePath]
|
||||
);
|
||||
|
||||
if (diff.isBinary) {
|
||||
return <BinaryDiffPlaceholder />;
|
||||
}
|
||||
if (diff.isBinary) {
|
||||
return <BinaryDiffPlaceholder />;
|
||||
}
|
||||
|
||||
if (isImageFile(filePath)) {
|
||||
return (
|
||||
if (isImageFile(filePath)) {
|
||||
return (
|
||||
<InlineImageDiffViewer
|
||||
filePath={filePath}
|
||||
diff={diff}
|
||||
renderSideBySide={renderSideBySide}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full" style={{ contain: 'layout' }}>
|
||||
<PierreDiffViewer
|
||||
original={diff.original}
|
||||
modified={diff.modified}
|
||||
fileDiff={diff.fileDiff}
|
||||
language={language}
|
||||
fileName={filePath}
|
||||
renderSideBySide={renderSideBySide}
|
||||
wrapLines={wrapLines}
|
||||
layout="inline"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full" style={{ contain: 'layout' }}>
|
||||
{diff.patch && diff.fileDiff ? (
|
||||
<DiffHunkActions
|
||||
patch={diff.patch}
|
||||
fileDiff={diff.fileDiff}
|
||||
directory={directory}
|
||||
filePath={filePath}
|
||||
staged={staged}
|
||||
onApplied={onHunkApplied}
|
||||
/>
|
||||
) : null}
|
||||
<PierreDiffViewer
|
||||
original={diff.original}
|
||||
modified={diff.modified}
|
||||
fileDiff={diff.fileDiff}
|
||||
language={language}
|
||||
fileName={filePath}
|
||||
renderSideBySide={renderSideBySide}
|
||||
wrapLines={wrapLines}
|
||||
layout="inline"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface MultiFileDiffEntryProps {
|
||||
@@ -573,6 +590,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
}, [directory, file.path])
|
||||
);
|
||||
const setDiff = useGitStore((state) => state.setDiff);
|
||||
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
||||
const setDiffFileLayout = useUIStore((state) => state.setDiffFileLayout);
|
||||
|
||||
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
|
||||
@@ -693,6 +711,13 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
handleSelect();
|
||||
}, [handleOpenChange, handleSelect, isExpanded]);
|
||||
|
||||
const handleHunkApplied = React.useCallback(() => {
|
||||
setDiffRetryNonce((nonce) => nonce + 1);
|
||||
if (directory) {
|
||||
void fetchStatus(directory, git);
|
||||
}
|
||||
}, [directory, fetchStatus, git]);
|
||||
|
||||
return (
|
||||
<div ref={setSectionRef} className="scroll-mt-9 border-b border-[var(--interactive-border)]/40 last:border-b-0">
|
||||
<div className="sticky top-0 z-10 border-b border-[var(--interactive-border)]/35 bg-[var(--surface-elevated)]/90 backdrop-blur-md supports-[backdrop-filter]:bg-[var(--surface-elevated)]/80">
|
||||
@@ -847,6 +872,9 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
diff={diffData}
|
||||
renderSideBySide={renderSideBySide}
|
||||
wrapLines={wrapLines}
|
||||
directory={directory}
|
||||
staged={staged}
|
||||
onHunkApplied={handleHunkApplied}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -473,6 +473,9 @@ export interface GitAPI {
|
||||
stageGitFiles?(directory: string, filePaths: string[]): Promise<void>;
|
||||
unstageGitFile(directory: string, filePath: string): Promise<void>;
|
||||
unstageGitFiles?(directory: string, filePaths: string[]): Promise<void>;
|
||||
stageGitHunk?(directory: string, filePath: string, patch: string): Promise<void>;
|
||||
unstageGitHunk?(directory: string, filePath: string, patch: string): Promise<void>;
|
||||
revertGitHunk?(directory: string, filePath: string, patch: string): Promise<void>;
|
||||
isLinkedWorktree(directory: string): Promise<boolean>;
|
||||
getGitBranches(directory: string): Promise<GitBranch>;
|
||||
deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { extractHunkPatch, splitPatchIntoHunks } from "./patchFileDiff";
|
||||
|
||||
const SAMPLE_PATCH = `diff --git a/foo.txt b/foo.txt
|
||||
index 1111111..2222222 100644
|
||||
--- a/foo.txt
|
||||
+++ b/foo.txt
|
||||
@@ -1,4 +1,5 @@
|
||||
line1
|
||||
+added-top
|
||||
line2
|
||||
line3
|
||||
line4
|
||||
@@ -10,3 +11,4 @@
|
||||
line10
|
||||
-deleted-mid
|
||||
line11
|
||||
+added-bottom
|
||||
`;
|
||||
|
||||
describe("splitPatchIntoHunks", () => {
|
||||
test("splits a multi-hunk patch into standalone per-hunk patches", () => {
|
||||
const hunks = splitPatchIntoHunks(SAMPLE_PATCH);
|
||||
expect(hunks.length).toBe(2);
|
||||
|
||||
expect(hunks[0]).toContain("diff --git a/foo.txt b/foo.txt");
|
||||
expect(hunks[0]).toContain("--- a/foo.txt");
|
||||
expect(hunks[0]).toContain("+++ b/foo.txt");
|
||||
expect(hunks[0]).toContain("@@ -1,4 +1,5 @@");
|
||||
expect(hunks[0]).toContain("+added-top");
|
||||
expect(hunks[0]).not.toContain("@@ -10,3 +11,4 @@");
|
||||
expect(hunks[0]).not.toContain("added-bottom");
|
||||
|
||||
expect(hunks[1]).toContain("@@ -10,3 +11,4 @@");
|
||||
expect(hunks[1]).toContain("-deleted-mid");
|
||||
expect(hunks[1]).toContain("+added-bottom");
|
||||
expect(hunks[1]).not.toContain("added-top");
|
||||
});
|
||||
|
||||
test("each hunk keeps the file header so it applies on its own", () => {
|
||||
const hunks = splitPatchIntoHunks(SAMPLE_PATCH);
|
||||
for (const hunk of hunks) {
|
||||
expect(hunk.startsWith("diff --git a/foo.txt b/foo.txt\n")).toBe(true);
|
||||
expect(hunk.match(/^--- a\/foo.txt$/m)).not.toBeNull();
|
||||
expect(hunk.match(/^\+\+\+ b\/foo.txt$/m)).not.toBeNull();
|
||||
expect(hunk.match(/^@@\s/m)).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("returns [] for an empty patch or a patch without hunks", () => {
|
||||
expect(splitPatchIntoHunks("")).toEqual([]);
|
||||
expect(splitPatchIntoHunks("diff --git a/foo b/foo\n--- a/foo\n+++ b/foo\n")).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles a single-hunk patch", () => {
|
||||
const single = `diff --git a/a b/a
|
||||
--- a/a
|
||||
+++ b/a
|
||||
@@ -1,1 +1,2 @@
|
||||
a
|
||||
+b
|
||||
`;
|
||||
const hunks = splitPatchIntoHunks(single);
|
||||
expect(hunks.length).toBe(1);
|
||||
expect(hunks[0]).toContain("+b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractHunkPatch", () => {
|
||||
test("returns the standalone patch for the requested index", () => {
|
||||
const second = extractHunkPatch(SAMPLE_PATCH, 1);
|
||||
expect(second).not.toBeNull();
|
||||
expect(second).toContain("@@ -10,3 +11,4 @@");
|
||||
expect(second).toContain("diff --git a/foo.txt b/foo.txt");
|
||||
});
|
||||
|
||||
test("returns null for out-of-range or invalid indices", () => {
|
||||
expect(extractHunkPatch(SAMPLE_PATCH, -1)).toBeNull();
|
||||
expect(extractHunkPatch(SAMPLE_PATCH, 2)).toBeNull();
|
||||
expect(extractHunkPatch(SAMPLE_PATCH, 1.5)).toBeNull();
|
||||
expect(extractHunkPatch("", 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -142,3 +142,54 @@ const joinPatchLines = (lines: Array<{ text: string; newline: boolean }>): strin
|
||||
|
||||
const emptyFileDiff = (file: string): FileDiffMetadata =>
|
||||
parseDiffFromFile({ name: file, contents: '' }, { name: file, contents: '' });
|
||||
|
||||
/**
|
||||
* Split a unified diff patch for a single file into standalone per-hunk patches.
|
||||
*
|
||||
* Each returned patch preserves the original file header (everything before the
|
||||
* first `@@` hunk header) plus exactly one hunk, producing a patch that can be
|
||||
* fed to `git apply` on its own.
|
||||
*
|
||||
* Returns an empty array when no hunk headers are present.
|
||||
*/
|
||||
export const splitPatchIntoHunks = (patch: string): string[] => {
|
||||
if (!patch) return [];
|
||||
|
||||
const lines = patch.split(/\r?\n/);
|
||||
const hunkHeaderRegex = /^@@\s/;
|
||||
const headerLines: string[] = [];
|
||||
let firstHunk = 0;
|
||||
while (firstHunk < lines.length && !hunkHeaderRegex.test(lines[firstHunk] ?? '')) {
|
||||
headerLines.push(lines[firstHunk]);
|
||||
firstHunk += 1;
|
||||
}
|
||||
|
||||
if (firstHunk >= lines.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hunks: string[][] = [];
|
||||
for (let index = firstHunk; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (hunkHeaderRegex.test(line ?? '')) {
|
||||
hunks.push([...headerLines, line]);
|
||||
} else if (hunks.length > 0) {
|
||||
hunks[hunks.length - 1].push(line ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
return hunks.map((hunkLines) => hunkLines.join('\n'))
|
||||
.filter((hunk) => hunk.trim().length > 0)
|
||||
.map((hunk) => (hunk.endsWith('\n') ? hunk : `${hunk}\n`));
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract a standalone patch for a single hunk by zero-based index.
|
||||
*
|
||||
* Returns `null` when the index is out of range or the patch has no hunks.
|
||||
*/
|
||||
export const extractHunkPatch = (patch: string, hunkIndex: number): string | null => {
|
||||
if (!Number.isInteger(hunkIndex) || hunkIndex < 0) return null;
|
||||
const hunks = splitPatchIntoHunks(patch);
|
||||
return hunks[hunkIndex] ?? null;
|
||||
};
|
||||
|
||||
@@ -168,6 +168,24 @@ export async function unstageGitFiles(directory: string, filePaths: string[]): P
|
||||
return gitHttp.unstageGitFiles(directory, filePaths);
|
||||
}
|
||||
|
||||
export async function stageGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime?.stageGitHunk) return runtime.stageGitHunk(directory, filePath, patch);
|
||||
return gitHttp.stageGitHunk(directory, filePath, patch);
|
||||
}
|
||||
|
||||
export async function unstageGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime?.unstageGitHunk) return runtime.unstageGitHunk(directory, filePath, patch);
|
||||
return gitHttp.unstageGitHunk(directory, filePath, patch);
|
||||
}
|
||||
|
||||
export async function revertGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime?.revertGitHunk) return runtime.revertGitHunk(directory, filePath, patch);
|
||||
return gitHttp.revertGitHunk(directory, filePath, patch);
|
||||
}
|
||||
|
||||
export async function isLinkedWorktree(directory: string): Promise<boolean> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.isLinkedWorktree(directory);
|
||||
|
||||
@@ -289,6 +289,43 @@ export async function unstageGitFiles(directory: string, filePaths: string[]): P
|
||||
}
|
||||
}
|
||||
|
||||
export async function stageGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
|
||||
await applyGitHunk(directory, filePath, patch, 'stage');
|
||||
}
|
||||
|
||||
export async function unstageGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
|
||||
await applyGitHunk(directory, filePath, patch, 'unstage');
|
||||
}
|
||||
|
||||
export async function revertGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
|
||||
await applyGitHunk(directory, filePath, patch, 'discard');
|
||||
}
|
||||
|
||||
async function applyGitHunk(
|
||||
directory: string,
|
||||
filePath: string,
|
||||
patch: string,
|
||||
action: 'stage' | 'unstage' | 'discard',
|
||||
): Promise<void> {
|
||||
if (!filePath) {
|
||||
throw new Error('path is required to apply a git hunk');
|
||||
}
|
||||
if (typeof patch !== 'string' || !patch.trim()) {
|
||||
throw new Error('patch is required to apply a git hunk');
|
||||
}
|
||||
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/apply-hunk`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: filePath, patch, action }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(message.error || 'Failed to apply git hunk');
|
||||
}
|
||||
}
|
||||
|
||||
export async function isLinkedWorktree(directory: string): Promise<boolean> {
|
||||
if (!directory) {
|
||||
return false;
|
||||
|
||||
@@ -1215,6 +1215,15 @@ export const dict = {
|
||||
'diffView.actions.enableLineWrap': 'Enable line wrap',
|
||||
'diffView.actions.openFileInEditorAtChange': 'Open this file in editor at change',
|
||||
'diffView.actions.openFileAtFirstChangedLine': 'Open this file at first changed line',
|
||||
'diffView.hunk.label': 'Hunks',
|
||||
'diffView.hunk.stage': 'Stage',
|
||||
'diffView.hunk.unstage': 'Unstage',
|
||||
'diffView.hunk.discard': 'Discard',
|
||||
'diffView.hunk.stageTitle': 'Stage hunk {index}',
|
||||
'diffView.hunk.unstageTitle': 'Unstage hunk {index}',
|
||||
'diffView.hunk.discardTitle': 'Discard hunk {index}',
|
||||
'diffView.hunk.unavailable': 'This hunk is no longer available. Refresh the diff and try again.',
|
||||
'diffView.hunk.unsupported': 'Staging individual hunks is not supported in this runtime.',
|
||||
'rightSidebar.contextNotesTodo.plan.defaultTitle': 'Plan',
|
||||
'rightSidebar.contextNotesTodo.empty.selectProject': 'Select a project to add notes and todos.',
|
||||
'rightSidebar.contextNotesTodo.notes.title': 'Quick notes - {project}',
|
||||
|
||||
@@ -1181,6 +1181,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
"diffView.actions.enableLineWrap": "Activar ajuste de línea",
|
||||
"diffView.actions.openFileInEditorAtChange": "Abrir este archivo en el editor en el cambio",
|
||||
"diffView.actions.openFileAtFirstChangedLine": "Abrir este archivo en la primera línea modificada",
|
||||
"diffView.hunk.label": "Fragmentos",
|
||||
"diffView.hunk.stage": "Preparar",
|
||||
"diffView.hunk.unstage": "Quitar",
|
||||
"diffView.hunk.discard": "Descartar",
|
||||
"diffView.hunk.stageTitle": "Preparar fragmento {index}",
|
||||
"diffView.hunk.unstageTitle": "Quitar fragmento {index} de la preparación",
|
||||
"diffView.hunk.discardTitle": "Descartar fragmento {index}",
|
||||
"diffView.hunk.unavailable": "Este fragmento ya no está disponible. Actualice el diff e inténtelo de nuevo.",
|
||||
"diffView.hunk.unsupported": "Preparar fragmentos individuales no es compatible en este entorno.",
|
||||
"rightSidebar.contextNotesTodo.plan.defaultTitle": "Plan",
|
||||
"rightSidebar.contextNotesTodo.empty.selectProject": "Selecciona un proyecto para añadir notas y tareas pendientes.",
|
||||
"rightSidebar.contextNotesTodo.notes.title": "Notas rápidas — {project}",
|
||||
|
||||
@@ -1088,6 +1088,15 @@ export const dict = {
|
||||
'diffView.actions.enableLineWrap': 'Activer le retour à la ligne',
|
||||
'diffView.actions.openFileInEditorAtChange': 'Ouvrez ce fichier dans l\'éditeur lors du changement',
|
||||
'diffView.actions.openFileAtFirstChangedLine': 'Ouvrez ce fichier à la première ligne modifiée',
|
||||
'diffView.hunk.label': 'Sections',
|
||||
'diffView.hunk.stage': 'Préparer',
|
||||
'diffView.hunk.unstage': 'Retirer',
|
||||
'diffView.hunk.discard': 'Annuler',
|
||||
'diffView.hunk.stageTitle': 'Préparer la section {index}',
|
||||
'diffView.hunk.unstageTitle': 'Retirer la section {index} de la préparation',
|
||||
'diffView.hunk.discardTitle': 'Annuler la section {index}',
|
||||
'diffView.hunk.unavailable': "Cette section n'est plus disponible. Actualisez le diff et réessayez.",
|
||||
'diffView.hunk.unsupported': "La préparation de sections individuelles n'est pas prise en charge dans cet environnement.",
|
||||
'rightSidebar.contextNotesTodo.plan.defaultTitle': 'Plan',
|
||||
'rightSidebar.contextNotesTodo.empty.selectProject': 'Sélectionnez un projet pour ajouter des notes et des tâches.',
|
||||
'rightSidebar.contextNotesTodo.notes.title': 'Notes rapides - {project}',
|
||||
|
||||
@@ -1218,6 +1218,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.actions.enableLineWrap': '줄 바꿈 켜기',
|
||||
'diffView.actions.openFileInEditorAtChange': '변경 위치에서 이 파일을 에디터로 열기',
|
||||
'diffView.actions.openFileAtFirstChangedLine': '첫 변경 줄에서 이 파일 열기',
|
||||
'diffView.hunk.label': '허크',
|
||||
'diffView.hunk.stage': '스테이지',
|
||||
'diffView.hunk.unstage': '스테이지 해제',
|
||||
'diffView.hunk.discard': '취소',
|
||||
'diffView.hunk.stageTitle': '허크 {index} 스테이지',
|
||||
'diffView.hunk.unstageTitle': '허크 {index} 스테이지 해제',
|
||||
'diffView.hunk.discardTitle': '허크 {index} 취소',
|
||||
'diffView.hunk.unavailable': '이 허크는 더 이상 사용할 수 없습니다. diff를 새로고침 후 다시 시도하세요.',
|
||||
'diffView.hunk.unsupported': '개별 허크 스테이징은 이 환경에서 지원되지 않습니다.',
|
||||
'rightSidebar.contextNotesTodo.plan.defaultTitle': '플랜',
|
||||
'rightSidebar.contextNotesTodo.empty.selectProject': '메모와 Todo를 추가할 프로젝트를 선택하세요.',
|
||||
'rightSidebar.contextNotesTodo.notes.title': '빠른 메모 - {project}',
|
||||
|
||||
@@ -1415,6 +1415,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.actions.loadFullFiles': 'Wczytaj pełne pliki',
|
||||
'diffView.actions.disableFullFiles': 'Nie wczytuj pełnych plików',
|
||||
'diffView.actions.openFileAtFirstChangedLine': 'Otwórz plik na pierwszej zmienionej linii',
|
||||
'diffView.hunk.label': 'Fragmenty',
|
||||
'diffView.hunk.stage': 'Przygotuj',
|
||||
'diffView.hunk.unstage': 'Cofnij',
|
||||
'diffView.hunk.discard': 'Odrzuć',
|
||||
'diffView.hunk.stageTitle': 'Przygotuj fragment {index}',
|
||||
'diffView.hunk.unstageTitle': 'Cofnij przygotowanie fragmentu {index}',
|
||||
'diffView.hunk.discardTitle': 'Odrzuć fragment {index}',
|
||||
'diffView.hunk.unavailable': 'Ten fragment nie jest już dostępny. Odśwież diff i spróbuj ponownie.',
|
||||
'diffView.hunk.unsupported': 'Przygotowywanie pojedynczych fragmentów nie jest obsługiwane w tym środowisku.',
|
||||
'diffView.actions.openFileInEditorAtChange': 'Otwórz plik w edytorze na zmianie',
|
||||
'diffView.actions.renderAnyway': 'Renderuj mimo to',
|
||||
'diffView.actions.retry': 'Ponów',
|
||||
|
||||
@@ -1181,6 +1181,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
"diffView.actions.enableLineWrap": "Ativar ajuste de linha",
|
||||
"diffView.actions.openFileInEditorAtChange": "Abrir este arquivo no editor nesta alteração",
|
||||
"diffView.actions.openFileAtFirstChangedLine": "Abrir este arquivo na primeira linha alterada",
|
||||
"diffView.hunk.label": "Trechos",
|
||||
"diffView.hunk.stage": "Preparar",
|
||||
"diffView.hunk.unstage": "Remover",
|
||||
"diffView.hunk.discard": "Descartar",
|
||||
"diffView.hunk.stageTitle": "Preparar trecho {index}",
|
||||
"diffView.hunk.unstageTitle": "Remover trecho {index} da preparação",
|
||||
"diffView.hunk.discardTitle": "Descartar trecho {index}",
|
||||
"diffView.hunk.unavailable": "Este trecho não está mais disponível. Atualize o diff e tente novamente.",
|
||||
"diffView.hunk.unsupported": "A preparação de trechos individuais não é suportada neste ambiente.",
|
||||
"rightSidebar.contextNotesTodo.plan.defaultTitle": "Plano",
|
||||
"rightSidebar.contextNotesTodo.empty.selectProject": "Selecione um projeto para adicionar notas e tarefas pendentes.",
|
||||
"rightSidebar.contextNotesTodo.notes.title": "Notas rápidas — {project}",
|
||||
|
||||
@@ -1181,6 +1181,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
"diffView.actions.enableLineWrap": "Увімкнути перенос рядків",
|
||||
"diffView.actions.openFileInEditorAtChange": "Відкрити цей файл у редакторі на зміні",
|
||||
"diffView.actions.openFileAtFirstChangedLine": "Відкрити цей файл у першому зміненому рядку",
|
||||
"diffView.hunk.label": "Шматки",
|
||||
"diffView.hunk.stage": "Додати",
|
||||
"diffView.hunk.unstage": "Прибрати",
|
||||
"diffView.hunk.discard": "Відкинути",
|
||||
"diffView.hunk.stageTitle": "Додати шматок {index} до індексу",
|
||||
"diffView.hunk.unstageTitle": "Прибрати шматок {index} з індексу",
|
||||
"diffView.hunk.discardTitle": "Відкинути шматок {index}",
|
||||
"diffView.hunk.unavailable": "Цей шматок більше недоступний. Оновіть diff і спробуйте знову.",
|
||||
"diffView.hunk.unsupported": "Додавання окремих шматків до індексу не підтримується в цьому середовищі.",
|
||||
"rightSidebar.contextNotesTodo.plan.defaultTitle": "План",
|
||||
"rightSidebar.contextNotesTodo.empty.selectProject": "Виберіть проєкт, щоб додати нотатки та завдання.",
|
||||
"rightSidebar.contextNotesTodo.notes.title": "Швидкі нотатки - {project}",
|
||||
|
||||
@@ -1181,6 +1181,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.actions.enableLineWrap': '开启自动换行',
|
||||
'diffView.actions.openFileInEditorAtChange': '在编辑器中打开此文件并定位变更',
|
||||
'diffView.actions.openFileAtFirstChangedLine': '在首个变更行打开此文件',
|
||||
'diffView.hunk.label': '代码块',
|
||||
'diffView.hunk.stage': '暂存',
|
||||
'diffView.hunk.unstage': '取消暂存',
|
||||
'diffView.hunk.discard': '放弃',
|
||||
'diffView.hunk.stageTitle': '暂存代码块 {index}',
|
||||
'diffView.hunk.unstageTitle': '取消暂存代码块 {index}',
|
||||
'diffView.hunk.discardTitle': '放弃代码块 {index}',
|
||||
'diffView.hunk.unavailable': '此代码块已不可用。请刷新差异后重试。',
|
||||
'diffView.hunk.unsupported': '此运行环境不支持暂存单个代码块。',
|
||||
'rightSidebar.contextNotesTodo.plan.defaultTitle': '计划',
|
||||
'rightSidebar.contextNotesTodo.empty.selectProject': '请选择一个项目以添加笔记和待办事项。',
|
||||
'rightSidebar.contextNotesTodo.notes.title': '快速笔记 - {project}',
|
||||
|
||||
@@ -1191,6 +1191,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.actions.enableLineWrap': '開啟自動換行',
|
||||
'diffView.actions.openFileInEditorAtChange': '在編輯器中開啟此檔案並定位變更',
|
||||
'diffView.actions.openFileAtFirstChangedLine': '在首個變更行開啟此檔案',
|
||||
'diffView.hunk.label': '程式碼區塊',
|
||||
'diffView.hunk.stage': '暫存',
|
||||
'diffView.hunk.unstage': '取消暫存',
|
||||
'diffView.hunk.discard': '捨棄',
|
||||
'diffView.hunk.stageTitle': '暫存程式碼區塊 {index}',
|
||||
'diffView.hunk.unstageTitle': '取消暫存程式碼區塊 {index}',
|
||||
'diffView.hunk.discardTitle': '捨棄程式碼區塊 {index}',
|
||||
'diffView.hunk.unavailable': '此程式碼區塊已不可用。請重新整理差異後重試。',
|
||||
'diffView.hunk.unsupported': '此執行環境不支援暫存個別程式碼區塊。',
|
||||
'rightSidebar.contextNotesTodo.plan.defaultTitle': '計畫',
|
||||
'rightSidebar.contextNotesTodo.empty.selectProject': '請選擇一個專案以新增筆記和待辦事項。',
|
||||
'rightSidebar.contextNotesTodo.notes.title': '快速筆記 - {project}',
|
||||
|
||||
Reference in New Issue
Block a user