Merge remote-tracking branch 'origin/main'

This commit is contained in:
Bohdan Triapitsyn
2026-06-14 14:52:37 +03:00
26 changed files with 811 additions and 34 deletions
@@ -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>
);
});
+62 -34
View File
@@ -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";
@@ -389,51 +390,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 {
@@ -481,6 +498,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);
@@ -601,6 +619,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">
@@ -755,6 +780,9 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
diff={diffData}
renderSideBySide={renderSideBySide}
wrapLines={wrapLines}
directory={directory}
staged={staged}
onHunkApplied={handleHunkApplied}
/>
) : null}
</div>
+3
View File
@@ -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();
});
});
+51
View File
@@ -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;
};
+18
View File
@@ -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);
+37
View File
@@ -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;
+9
View File
@@ -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}',
+9
View File
@@ -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}",
+9
View File
@@ -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}',
+9
View File
@@ -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}',
+9
View File
@@ -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}",
+9
View File
@@ -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}',
+17
View File
@@ -251,6 +251,23 @@ export async function handleStandardGitBridgeMessage(message: BridgeMessageInput
return { id, type, success: true, data: { success: true } };
}
case 'api:git/apply-hunk': {
const { directory, path: filePath, patch, action } = (payload || {}) as {
directory?: string;
path?: string;
patch?: string;
action?: 'stage' | 'unstage' | 'discard';
};
if (!directory || !filePath || typeof patch !== 'string' || !patch.trim()) {
return { id, type, success: false, error: 'Directory, path, and patch are required' };
}
if (action !== 'stage' && action !== 'unstage' && action !== 'discard') {
return { id, type, success: false, error: 'action must be stage, unstage, or discard' };
}
await gitService.applyGitHunk(directory, filePath, patch, action);
return { id, type, success: true, data: { success: true } };
}
case 'api:git/commit': {
const { directory, message, addAll, files, stageFiles } = (payload || {}) as {
directory?: string;
+52
View File
@@ -2321,6 +2321,58 @@ export async function unstageGitFiles(directory: string, filePaths: string[]): P
}
}
const HUNK_ACTION_ARGS: Record<'stage' | 'unstage' | 'discard', string[]> = {
stage: ['--cached'],
unstage: ['--cached', '--reverse'],
discard: ['--reverse'],
};
/**
* Apply a single-hunk patch to stage, unstage, or discard it.
* The patch is written to a temp file and applied with `git apply`.
*/
export async function applyGitHunk(
directory: string,
filePath: string,
patch: string,
action: 'stage' | 'unstage' | 'discard',
): Promise<void> {
if (!filePath) {
throw new Error('path is required');
}
if (typeof patch !== 'string' || !patch.trim()) {
throw new Error('patch is required');
}
if (!/^@@\s/m.test(patch)) {
throw new Error('patch does not contain a hunk header');
}
const flags = HUNK_ACTION_ARGS[action];
const tmpDir = os.tmpdir();
const tmpPath = path.join(tmpDir, `openchamber-hunk-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
try {
await fs.promises.writeFile(tmpPath, patch, 'utf8');
const check = await execGit(['apply', ...flags, '--check', tmpPath], directory);
if (check.exitCode !== 0) {
const detail = (check.stderr || '').trim();
throw new Error(
detail
? `Hunk no longer applies — refresh and try again.\n${detail}`
: 'Hunk no longer applies — refresh and try again.'
);
}
const apply = await execGit(['apply', ...flags, tmpPath], directory);
if (apply.exitCode !== 0) {
throw new Error(apply.stderr || 'Failed to apply git hunk');
}
} finally {
await fs.promises.rm(tmpPath, { force: true }).catch(() => {});
}
}
// ============== Commit Operations ==============
export interface GitCommitResult {
+12
View File
@@ -87,6 +87,18 @@ export const createVSCodeGitAPI = (): GitAPI => ({
await sendBridgeMessage('api:git/unstage', { directory, paths: filePaths });
},
stageGitHunk: async (directory: string, filePath: string, patch: string): Promise<void> => {
await sendBridgeMessage('api:git/apply-hunk', { directory, path: filePath, patch, action: 'stage' });
},
unstageGitHunk: async (directory: string, filePath: string, patch: string): Promise<void> => {
await sendBridgeMessage('api:git/apply-hunk', { directory, path: filePath, patch, action: 'unstage' });
},
revertGitHunk: async (directory: string, filePath: string, patch: string): Promise<void> => {
await sendBridgeMessage('api:git/apply-hunk', { directory, path: filePath, patch, action: 'discard' });
},
isLinkedWorktree: async (directory: string): Promise<boolean> => {
return sendBridgeMessage<boolean>('api:git/worktree-type', { directory });
},
@@ -33,6 +33,7 @@ The following functions are exported and used by the web server:
- `revertFile(directory, filePath, options)`: Revert a file. Default scope `all` discards staged and working-tree changes; scope `working` discards only unstaged/working-tree changes.
- `stageFile(directory, filePath)`: Add one file path to the index.
- `unstageFile(directory, filePath)`: Remove one file path from the index while preserving working-tree content.
- `applyHunk(directory, filePath, options)`: Apply a single-hunk patch via `git apply`. `options.action` is `stage` (`git apply --cached`), `unstage` (`git apply --cached --reverse`), or `discard` (`git apply --reverse` in the working tree). The patch is written to a temp file; a `--check` runs first so a stale hunk fails with a clear "refresh and try again" error instead of a partial mutation. The patch target path must match the requested file.
### Branch Operations
- `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches).
+27
View File
@@ -436,6 +436,33 @@ export function registerGitRoutes(app) {
}
});
app.post('/api/git/apply-hunk', async (req, res) => {
const { applyHunk } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { path: filePath, patch, action } = req.body || {};
if (!filePath || typeof filePath !== 'string') {
return res.status(400).json({ error: 'path parameter is required' });
}
if (typeof patch !== 'string' || !patch.trim()) {
return res.status(400).json({ error: 'patch is required' });
}
if (action !== 'stage' && action !== 'unstage' && action !== 'discard') {
return res.status(400).json({ error: 'action must be stage, unstage, or discard' });
}
await applyHunk(directory, filePath, { patch, action });
res.json({ success: true });
} catch (error) {
console.error('Failed to apply git hunk:', error);
res.status(500).json({ error: error.message || 'Failed to apply git hunk' });
}
});
app.post('/api/git/pull', async (req, res) => {
const { pull } = await getGitLibraries();
try {
+69
View File
@@ -2555,6 +2555,75 @@ export async function revertFile(directory, filePath, options = {}) {
});
}
const HUNK_ACTION_FLAGS = {
stage: ['--cached'],
unstage: ['--cached', '--reverse'],
discard: ['--reverse'],
};
const extractPatchTargetPath = (patch) => {
const matches = [...patch.matchAll(/^(?:-{3}|\+{3})\s+(?:[ab]\/)?([^\s\t]+)/gm)];
const realTargets = matches
.map((match) => match[1])
.filter((value) => value && value !== '/dev/null');
return realTargets[0] || null;
};
const writeTempPatchFile = async (patch) => {
const tmpDir = os.tmpdir();
const tmpPath = path.join(tmpDir, `openchamber-hunk-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
await fsp.writeFile(tmpPath, patch, 'utf8');
return tmpPath;
};
export async function applyHunk(directory, filePath, options = {}) {
const action = options?.action;
if (!action || !HUNK_ACTION_FLAGS[action]) {
throw new Error('Invalid hunk action');
}
const patch = typeof options?.patch === 'string' ? options.patch : '';
if (!patch.trim()) {
throw new Error('patch is required to apply a hunk');
}
if (!/^@@\s/m.test(patch)) {
throw new Error('patch does not contain a hunk header');
}
return withGitIndexMutationQueue(directory, async () => {
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
validateRepositoryFilePaths(repoRoot, [fileContext.repoPath]);
const targetPath = extractPatchTargetPath(patch);
if (targetPath && targetPath !== fileContext.repoPath && targetPath !== filePath) {
throw new Error('patch target path does not match the requested file');
}
const flags = HUNK_ACTION_FLAGS[action];
let tmpPath = null;
try {
tmpPath = await writeTempPatchFile(patch);
try {
await git.raw(['apply', ...flags, '--check', tmpPath]);
} catch (checkError) {
const text = parseGitErrorText(checkError);
throw new Error(
text
? `Hunk no longer applies — refresh and try again.\n${text}`
: 'Hunk no longer applies — refresh and try again.'
);
}
await git.raw(['apply', ...flags, tmpPath]);
} finally {
if (tmpPath) {
await fsp.rm(tmpPath, { force: true }).catch(() => {});
}
}
});
}
export async function collectDiffs(directory, files = []) {
const results = [];
for (const filePath of files) {
+120
View File
@@ -17,6 +17,8 @@ import {
revertCommit,
stageFiles,
unstageFiles,
applyHunk,
getDiff,
} from './service.js';
// ---------------------------------------------------------------------------
@@ -122,6 +124,124 @@ describe('git index path validation', () => {
});
});
// ---------------------------------------------------------------------------
// applyHunk (per-hunk stage / unstage / discard)
// ---------------------------------------------------------------------------
/** Minimal unified-diff splitter: returns standalone per-hunk patches. */
const splitHunks = (patch) => {
const lines = patch.split(/\r?\n/);
const headerEnd = lines.findIndex((line) => /^@@\s/.test(line));
if (headerEnd === -1) return [];
const header = lines.slice(0, headerEnd);
const hunks = [];
for (let i = headerEnd; i < lines.length; i += 1) {
const line = lines[i];
if (/^@@\s/.test(line)) hunks.push([...header, line]);
else if (hunks.length > 0) hunks[hunks.length - 1].push(line);
}
return hunks.map((hunk) => hunk.join('\n'))
.filter((hunk) => hunk.trim().length > 0)
.map((hunk) => (hunk.endsWith('\n') ? hunk : `${hunk}\n`));
};
const writeFile = (repo, name, contents) =>
fs.promises.writeFile(path.join(repo, name), contents, 'utf8');
// Build a 20-line file so changes on line 1 and line 20 stay in separate hunks
// (default 3-line diff context would merge closer edits into one hunk).
const makeFile = (first, last) =>
[first, ...Array.from({ length: 18 }, (_, i) => `line${i + 2}`), last].join('\n') + '\n';
const ORIGINAL_FILE = makeFile('line1', 'line20');
const EDITED_FILE = makeFile('TOP', 'BOTTOM');
const readWorking = (repo) => fs.promises.readFile(path.join(repo, 'file.txt'), 'utf8').then((c) => c.replace(/\r\n/g, '\n'));
const readStaged = async (git) => (await git.raw(['show', ':file.txt'])).replace(/\r\n/g, '\n');
describe('applyHunk', () => {
it('rejects an invalid action or a patch without a hunk header', async () => {
const { tmpDir } = await createTempRepo();
await expect(applyHunk(tmpDir, 'file.txt', { patch: '@@ -1 +1 @@\n a\n', action: 'bogus' })).rejects.toThrow(
'Invalid hunk action'
);
await expect(applyHunk(tmpDir, 'file.txt', { patch: 'no hunk here', action: 'stage' })).rejects.toThrow(
'hunk header'
);
});
it('stages a single hunk while leaving the rest unstaged', async () => {
if (!canRunGit()) return;
const { tmpDir, git } = await createTempRepo();
await writeFile(tmpDir, 'file.txt', ORIGINAL_FILE);
await git.add('file.txt');
await git.commit('Initial');
await writeFile(tmpDir, 'file.txt', EDITED_FILE);
const diff = await getDiff(tmpDir, { path: 'file.txt' });
const hunks = splitHunks(diff);
expect(hunks.length).toBe(2);
await applyHunk(tmpDir, 'file.txt', { patch: hunks[0], action: 'stage' });
expect(await readStaged(git)).toBe(makeFile('TOP', 'line20'));
expect(await readWorking(tmpDir)).toBe(EDITED_FILE);
});
it('discards a single hunk from the working tree', async () => {
if (!canRunGit()) return;
const { tmpDir, git } = await createTempRepo();
await writeFile(tmpDir, 'file.txt', ORIGINAL_FILE);
await git.add('file.txt');
await git.commit('Initial');
await writeFile(tmpDir, 'file.txt', EDITED_FILE);
const diff = await getDiff(tmpDir, { path: 'file.txt' });
const hunks = splitHunks(diff);
expect(hunks.length).toBe(2);
await applyHunk(tmpDir, 'file.txt', { patch: hunks[1], action: 'discard' });
expect(await readWorking(tmpDir)).toBe(makeFile('TOP', 'line20'));
});
it('unstages a single hunk from the index', async () => {
if (!canRunGit()) return;
const { tmpDir, git } = await createTempRepo();
await writeFile(tmpDir, 'file.txt', ORIGINAL_FILE);
await git.add('file.txt');
await git.commit('Initial');
await writeFile(tmpDir, 'file.txt', EDITED_FILE);
await git.add('file.txt');
const stagedDiff = await getDiff(tmpDir, { path: 'file.txt', staged: true });
const hunks = splitHunks(stagedDiff);
expect(hunks.length).toBe(2);
await applyHunk(tmpDir, 'file.txt', { patch: hunks[0], action: 'unstage' });
// Only the first hunk (line1 -> TOP) was reverted in the index;
// the second hunk (BOTTOM) stays staged.
expect(await readStaged(git)).toBe(makeFile('line1', 'BOTTOM'));
});
it('rejects a patch whose target path does not match the requested file', async () => {
if (!canRunGit()) return;
const { tmpDir, git } = await createTempRepo();
await writeFile(tmpDir, 'file.txt', ORIGINAL_FILE);
await git.add('file.txt');
await git.commit('Initial');
await writeFile(tmpDir, 'file.txt', makeFile('CHANGED', 'line20'));
const diff = await getDiff(tmpDir, { path: 'file.txt' });
const [hunk] = splitHunks(diff);
const retargeted = hunk.replace(/file\.txt/g, 'other.txt');
await expect(applyHunk(tmpDir, 'file.txt', { patch: retargeted, action: 'stage' })).rejects.toThrow(
'patch target path does not match'
);
});
});
// ---------------------------------------------------------------------------
// getStatus
// ---------------------------------------------------------------------------
+16
View File
@@ -10,6 +10,9 @@ vi.mock('@openchamber/ui/lib/gitApiHttp', () => ({
stageGitFiles: vi.fn(),
unstageGitFile: vi.fn(),
unstageGitFiles: vi.fn(),
stageGitHunk: vi.fn(),
unstageGitHunk: vi.fn(),
revertGitHunk: vi.fn(),
isLinkedWorktree: vi.fn(),
getGitBranches: vi.fn(),
deleteGitBranch: vi.fn(),
@@ -55,6 +58,16 @@ vi.mock('@openchamber/ui/lib/gitApiHttp', () => ({
stash: vi.fn(),
stashPop: vi.fn(),
getConflictDetails: vi.fn(),
checkoutCommit: vi.fn(),
cherryPick: vi.fn(),
revertCommit: vi.fn(),
resetToCommit: vi.fn(),
getCommitFileDiff: vi.fn(),
previewGitWorktree: vi.fn(),
getGitWorktreeBootstrapStatus: vi.fn(),
discoverGitCredentials: vi.fn(),
getGlobalGitIdentity: vi.fn(),
getRemoteUrl: vi.fn(),
}));
describe('createWebGitAPI', () => {
@@ -64,5 +77,8 @@ describe('createWebGitAPI', () => {
expect(typeof api.stageGitFiles).toBe('function');
expect(typeof api.unstageGitFiles).toBe('function');
expect(typeof api.stageGitHunk).toBe('function');
expect(typeof api.unstageGitHunk).toBe('function');
expect(typeof api.revertGitHunk).toBe('function');
});
});
+3
View File
@@ -15,6 +15,9 @@ export const createWebGitAPI = (): GitAPI => ({
stageGitFiles: gitApiHttp.stageGitFiles,
unstageGitFile: gitApiHttp.unstageGitFile,
unstageGitFiles: gitApiHttp.unstageGitFiles,
stageGitHunk: gitApiHttp.stageGitHunk,
unstageGitHunk: gitApiHttp.unstageGitHunk,
revertGitHunk: gitApiHttp.revertGitHunk,
isLinkedWorktree: gitApiHttp.isLinkedWorktree,
getGitBranches: gitApiHttp.getGitBranches,
deleteGitBranch: gitApiHttp.deleteGitBranch as GitAPI['deleteGitBranch'],