diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fe88da5..37534f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to this project will be documented in this file. - Sessions: selected rows now highlight across the full sidebar gutter. - Comments: inline file/diff comment drafts now stay in place when focus changes. - Git/Diff: redesigned the Changes diff view with faster multi-file rendering, expandable hunk separators, a full-file loading toggle, compact responsive controls, and a unified changed/staged context panel workflow. +- Git/Diff: individual diff hunks can now be staged, unstaged, or discarded directly from the Changes view via `git apply`. - GitHub: GitHub settings can now use credentials from the `gh` CLI when available (thanks to @tomzx). - Settings/MCP: importing MCP snippets from OpenCode config works again (thanks to @youzini). - Usage: added Cursor plan as a usage-tracking provider.. diff --git a/packages/ui/src/components/views/DiffHunkActions.tsx b/packages/ui/src/components/views/DiffHunkActions.tsx new file mode 100644 index 00000000..12388e40 --- /dev/null +++ b/packages/ui/src/components/views/DiffHunkActions.tsx @@ -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(({ + patch, + fileDiff, + directory, + filePath, + staged, + onApplied, +}) => { + const { t } = useI18n(); + const { git } = useRuntimeAPIs(); + const [busyKey, setBusyKey] = React.useState(null); + const [error, setError] = React.useState(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 ( +
+
+ + {t('diffView.hunk.label')} + + {hunks.map((hunk, index) => { + const additions = hunk.additionLines; + const deletions = hunk.deletionLines; + return ( +
+ + {String(index + 1).padStart(2, '0')} + + {additions > 0 ? ( + + +{additions} + + ) : null} + {deletions > 0 ? ( + + −{deletions} + + ) : null} + {staged ? ( + + ) : ( + <> + + + + )} +
+ ); + })} +
+ {error ? ( +
+ + {error} +
+ ) : null} +
+ ); +}); diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 89dbe2b1..0ac1dae4 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -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(({ }); 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(({ - filePath, - diff, - renderSideBySide, - wrapLines, +const InlineDiffViewer = React.memo(({ + 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 ; - } + if (diff.isBinary) { + return ; + } - if (isImageFile(filePath)) { - return ( + if (isImageFile(filePath)) { + return ( - ); - } - - return ( -
- -
); + } + + return ( +
+ {diff.patch && diff.fileDiff ? ( + + ) : null} + +
+ ); }); interface MultiFileDiffEntryProps { @@ -573,6 +590,7 @@ const MultiFileDiffEntry = React.memo(({ }, [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(({ handleSelect(); }, [handleOpenChange, handleSelect, isExpanded]); + const handleHunkApplied = React.useCallback(() => { + setDiffRetryNonce((nonce) => nonce + 1); + if (directory) { + void fetchStatus(directory, git); + } + }, [directory, fetchStatus, git]); + return (
@@ -847,6 +872,9 @@ const MultiFileDiffEntry = React.memo(({ diff={diffData} renderSideBySide={renderSideBySide} wrapLines={wrapLines} + directory={directory} + staged={staged} + onHunkApplied={handleHunkApplied} /> ) : null}
diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 1351a913..7a648776 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -473,6 +473,9 @@ export interface GitAPI { stageGitFiles?(directory: string, filePaths: string[]): Promise; unstageGitFile(directory: string, filePath: string): Promise; unstageGitFiles?(directory: string, filePaths: string[]): Promise; + stageGitHunk?(directory: string, filePath: string, patch: string): Promise; + unstageGitHunk?(directory: string, filePath: string, patch: string): Promise; + revertGitHunk?(directory: string, filePath: string, patch: string): Promise; isLinkedWorktree(directory: string): Promise; getGitBranches(directory: string): Promise; deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>; diff --git a/packages/ui/src/lib/diff/patchFileDiff.test.ts b/packages/ui/src/lib/diff/patchFileDiff.test.ts new file mode 100644 index 00000000..4a3ebea8 --- /dev/null +++ b/packages/ui/src/lib/diff/patchFileDiff.test.ts @@ -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(); + }); +}); diff --git a/packages/ui/src/lib/diff/patchFileDiff.ts b/packages/ui/src/lib/diff/patchFileDiff.ts index 85cb3c6f..263ec1c7 100644 --- a/packages/ui/src/lib/diff/patchFileDiff.ts +++ b/packages/ui/src/lib/diff/patchFileDiff.ts @@ -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; +}; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index c584767d..e2c36895 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -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 { + 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 { + 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 { + 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 { const runtime = getRuntimeGit(); if (runtime) return runtime.isLinkedWorktree(directory); diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index f4a5a5fb..adfa6830 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -289,6 +289,43 @@ export async function unstageGitFiles(directory: string, filePaths: string[]): P } } +export async function stageGitHunk(directory: string, filePath: string, patch: string): Promise { + await applyGitHunk(directory, filePath, patch, 'stage'); +} + +export async function unstageGitHunk(directory: string, filePath: string, patch: string): Promise { + await applyGitHunk(directory, filePath, patch, 'unstage'); +} + +export async function revertGitHunk(directory: string, filePath: string, patch: string): Promise { + await applyGitHunk(directory, filePath, patch, 'discard'); +} + +async function applyGitHunk( + directory: string, + filePath: string, + patch: string, + action: 'stage' | 'unstage' | 'discard', +): Promise { + 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 { if (!directory) { return false; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index ca48034d..78f8ed1a 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -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}', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 3f0dfc38..931e3ef0 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1181,6 +1181,15 @@ export const dict: Record = { "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}", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 92bfe923..64221666 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -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}', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 7b41e83f..07b33e96 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1218,6 +1218,15 @@ export const dict: Record = { '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}', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 8ba97414..91abcbfa 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1415,6 +1415,15 @@ export const dict: Record = { '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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index b86430ca..db35d4f9 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1181,6 +1181,15 @@ export const dict: Record = { "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}", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index c6ab56b1..c0bec51b 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1181,6 +1181,15 @@ export const dict: Record = { "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}", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 25968a68..f02b0d90 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1181,6 +1181,15 @@ export const dict: Record = { '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}', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index d54125fa..ddf0c830 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1191,6 +1191,15 @@ export const dict: Record = { '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}', diff --git a/packages/vscode/src/bridge-git-runtime.ts b/packages/vscode/src/bridge-git-runtime.ts index e02cd5d4..9cba677a 100644 --- a/packages/vscode/src/bridge-git-runtime.ts +++ b/packages/vscode/src/bridge-git-runtime.ts @@ -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; diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index af887a09..8ef2c0c2 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -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 { + 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 { diff --git a/packages/vscode/webview/api/git.ts b/packages/vscode/webview/api/git.ts index 0a4479ba..4ade53d5 100644 --- a/packages/vscode/webview/api/git.ts +++ b/packages/vscode/webview/api/git.ts @@ -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 => { + await sendBridgeMessage('api:git/apply-hunk', { directory, path: filePath, patch, action: 'stage' }); + }, + + unstageGitHunk: async (directory: string, filePath: string, patch: string): Promise => { + await sendBridgeMessage('api:git/apply-hunk', { directory, path: filePath, patch, action: 'unstage' }); + }, + + revertGitHunk: async (directory: string, filePath: string, patch: string): Promise => { + await sendBridgeMessage('api:git/apply-hunk', { directory, path: filePath, patch, action: 'discard' }); + }, + isLinkedWorktree: async (directory: string): Promise => { return sendBridgeMessage('api:git/worktree-type', { directory }); }, diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 31d3be6d..4fb14dda 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -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). diff --git a/packages/web/server/lib/git/routes.js b/packages/web/server/lib/git/routes.js index c6b19796..25fd5bba 100644 --- a/packages/web/server/lib/git/routes.js +++ b/packages/web/server/lib/git/routes.js @@ -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 { diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index eba6ff29..fe2899a4 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -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) { diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index e1ce0e6c..e38e100b 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -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 // --------------------------------------------------------------------------- diff --git a/packages/web/src/api/git.test.ts b/packages/web/src/api/git.test.ts index ae41a475..8fa3bee9 100644 --- a/packages/web/src/api/git.test.ts +++ b/packages/web/src/api/git.test.ts @@ -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'); }); }); diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts index 76fc85ee..41d57577 100644 --- a/packages/web/src/api/git.ts +++ b/packages/web/src/api/git.ts @@ -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'],