diff --git a/app/src/main/ipc/git.ts b/app/src/main/ipc/git.ts index aff01b0..6946b78 100644 --- a/app/src/main/ipc/git.ts +++ b/app/src/main/ipc/git.ts @@ -9,12 +9,14 @@ import { getWorktreeStatus, stageFiles, unstageFiles, + stageHunk, + unstageHunk, createCommit, checkoutWorktree, resetWorktreeBranch, } from '@sproutgit/git/staging'; import { fetchWorktree, pullWorktree, pushWorktreeBranch, getWorktreePushStatus } from '@sproutgit/git/remote'; -import { getDiffFiles, getDiffContent, getWorkingDiff } from '@sproutgit/git/diff'; +import { getDiffFiles, getDiffContent, getWorkingDiff, getUnstagedFileDiff, getStagedDiff } from '@sproutgit/git/diff'; import { createStash, listStashes, applyStash, popStash, dropStash } from '@sproutgit/git/stash'; import { cherryPickCommit } from '@sproutgit/git/cherry-pick'; import { handle } from './handle.js'; @@ -119,6 +121,26 @@ export function registerGitHandlers(configDb: ConfigDb): void { return unstageFiles(args.worktreePath, args.paths); }); + handle(IPC.GIT_STAGE_HUNK, async (_e, args: { + worktreePath: string; + filePath: string; + hunkIndex: number; + lineIndices?: number[] | null; + }) => { + assertWorkingTreePath(args.worktreePath); + return stageHunk(args.worktreePath, args.filePath, args.hunkIndex, args.lineIndices); + }); + + handle(IPC.GIT_UNSTAGE_HUNK, async (_e, args: { + worktreePath: string; + filePath: string; + hunkIndex: number; + lineIndices?: number[] | null; + }) => { + assertWorkingTreePath(args.worktreePath); + return unstageHunk(args.worktreePath, args.filePath, args.hunkIndex, args.lineIndices); + }); + handle(IPC.GIT_COMMIT, async (_e, args: { worktreePath: string; message: string }) => { assertWorkingTreePath(args.worktreePath); return createCommit(args.worktreePath, args.message); @@ -194,6 +216,18 @@ export function registerGitHandlers(configDb: ConfigDb): void { return result.diff; }); + handle(IPC.GIT_UNSTAGED_FILE_DIFF, async (_e, args: { worktreePath: string; file: string }) => { + if (!args.worktreePath || !existsSync(args.worktreePath)) return ''; + assertWorkingTreePath(args.worktreePath); + return getUnstagedFileDiff(args.worktreePath, args.file); + }); + + handle(IPC.GIT_STAGED_FILE_DIFF, async (_e, args: { worktreePath: string; file?: string }) => { + if (!args.worktreePath || !existsSync(args.worktreePath)) return ''; + assertWorkingTreePath(args.worktreePath); + return getStagedDiff(args.worktreePath, args.file ?? null); + }); + // ── stash ───────────────────────────────────────────────────────────────── handle(IPC.GIT_STASH_CREATE, async (_e, args: { worktreePath: string; message?: string }) => { assertWorkingTreePath(args.worktreePath); diff --git a/app/src/preload/index.ts b/app/src/preload/index.ts index d5725e1..12d0fd5 100644 --- a/app/src/preload/index.ts +++ b/app/src/preload/index.ts @@ -168,6 +168,12 @@ const api = { unstageFiles: (worktreePath: string, paths: string[]): Promise => invoke(IPC.GIT_UNSTAGE, { worktreePath, paths }), + stageHunk: (worktreePath: string, filePath: string, hunkIndex: number, lineIndices?: number[] | null): Promise => + invoke(IPC.GIT_STAGE_HUNK, lineIndices ? { worktreePath, filePath, hunkIndex, lineIndices } : { worktreePath, filePath, hunkIndex }), + + unstageHunk: (worktreePath: string, filePath: string, hunkIndex: number, lineIndices?: number[] | null): Promise => + invoke(IPC.GIT_UNSTAGE_HUNK, lineIndices ? { worktreePath, filePath, hunkIndex, lineIndices } : { worktreePath, filePath, hunkIndex }), + createCommit: (worktreePath: string, message: string): Promise => invoke(IPC.GIT_COMMIT, { worktreePath, message }), @@ -200,6 +206,12 @@ const api = { getWorkingDiff: (worktreePath: string, file?: string): Promise => invoke(IPC.GIT_WORKING_DIFF, file ? { worktreePath, file } : { worktreePath }), + getUnstagedFileDiff: (worktreePath: string, file: string): Promise => + invoke(IPC.GIT_UNSTAGED_FILE_DIFF, { worktreePath, file }), + + getStagedFileDiff: (worktreePath: string, file?: string): Promise => + invoke(IPC.GIT_STAGED_FILE_DIFF, file ? { worktreePath, file } : { worktreePath }), + // ── Stash ──────────────────────────────────────────────────────────────── createStash: (worktreePath: string, message?: string): Promise => invoke(IPC.GIT_STASH_CREATE, message ? { worktreePath, message } : { worktreePath }), diff --git a/app/src/renderer/routes/workspace.tsx b/app/src/renderer/routes/workspace.tsx index 049f2ba..cc4b707 100644 --- a/app/src/renderer/routes/workspace.tsx +++ b/app/src/renderer/routes/workspace.tsx @@ -705,11 +705,13 @@ function WorkspaceInner() { getStatus={(p) => api.getStatus(p)} stageFiles={(p, paths) => api.stageFiles(p, paths)} unstageFiles={(p, paths) => api.unstageFiles(p, paths)} + stageHunk={(p, filePath, hunkIndex, lineIndices) => api.stageHunk(p, filePath, hunkIndex, lineIndices)} + unstageHunk={(p, filePath, hunkIndex, lineIndices) => api.unstageHunk(p, filePath, hunkIndex, lineIndices)} createCommit={(p, msg) => api.createCommit(p, msg)} getDiff={(p, staged, file) => staged - ? api.getDiffContent(p, "HEAD", file) - : api.getWorkingDiff(p, file) + ? api.getStagedFileDiff(p, file) + : api.getUnstagedFileDiff(p, file ?? "") } generateCommitMessage={async (p) => { const settings = diff --git a/e2e/specs/commit-workflow.spec.ts b/e2e/specs/commit-workflow.spec.ts index 24e98ef..1816652 100644 --- a/e2e/specs/commit-workflow.spec.ts +++ b/e2e/specs/commit-workflow.spec.ts @@ -63,4 +63,57 @@ describe('commit workflow', () => { await assertNoErrors(); }); + + it('stages and unstages a single hunk, leaving the file\'s other hunk untouched', async () => { + const assertNoErrors = monitorErrors(); + + const defaultBranch = execSync('git symbolic-ref --short HEAD', { cwd: testRepo }).toString().trim(); + + // Commit a baseline multi-line tracked file before opening the workspace, + // so the two edits below land far enough apart to produce two separate + // diff hunks under git's default 3-line context. + const baseline = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`); + writeFileSync(join(testRepo, 'notes.txt'), baseline.join('\n') + '\n'); + execSync('git add notes.txt', { cwd: testRepo }); + execSync('git commit -m "add notes.txt"', { cwd: testRepo }); + + await gotoHash(`/workspace?path=${encodeURIComponent(testRepo)}`); + await expect($('//*[contains(@class,"sg-tab") and contains(.,"Graph")]')).toBeDisplayed(); + await expect($(`[data-testid="worktree-item"][data-branch="${defaultBranch}"]`)).toBeDisplayed(); + + const worktreePath = join(testRepo, '.sproutgit', 'worktrees', defaultBranch); + const modified = [...baseline]; + modified[1] = 'LINE TWO'; + modified[15] = 'LINE SIXTEEN'; + writeFileSync(join(worktreePath, 'notes.txt'), modified.join('\n') + '\n'); + + await $('//*[contains(@class,"sg-tab") and contains(.,"Changes")]').click(); + await expect($('//*[contains(@class,"sg-file-row") and contains(.,"notes.txt")]')).toBeDisplayed(); + await $('//*[contains(@class,"sg-file-row") and contains(.,"notes.txt")]').click(); + + // Two separate hunks should render for the two far-apart edits. + await expect($$('[data-testid="diff-hunk"]')).toBeElementsArrayOfSize(2); + + // Stage only the first hunk. + await $('[data-testid="stage-hunk-btn"][data-hunk-index="0"]').click(); + + // The diff view refreshes in place — only the second hunk should remain unstaged. + await expect($$('[data-testid="diff-hunk"]')).toBeElementsArrayOfSize(1); + await expect($('//*[contains(@class,"sg-diff-add") and contains(.,"LINE SIXTEEN")]')).toBeDisplayed(); + + // The file now has both staged and unstaged changes. + await expect($('[data-testid="staging-staged-file-row"][data-path="notes.txt"]')).toBeDisplayed(); + await expect($('[data-testid="staging-unstaged-file-row"][data-path="notes.txt"]')).toBeDisplayed(); + + // The staged pane shows exactly the hunk that was staged. + await $('[data-testid="staging-staged-file-row"][data-path="notes.txt"]').click(); + await expect($$('[data-testid="diff-hunk"]')).toBeElementsArrayOfSize(1); + await expect($('//*[contains(@class,"sg-diff-add") and contains(.,"LINE TWO")]')).toBeDisplayed(); + + // Unstage that hunk back — the file should return to fully unstaged. + await $('[data-testid="unstage-hunk-btn"][data-hunk-index="0"]').click(); + await expect($('[data-testid="staging-staged-file-row"][data-path="notes.txt"]')).not.toBeDisplayed(); + + await assertNoErrors(); + }); }); diff --git a/packages/git/src/__tests__/git.test.ts b/packages/git/src/__tests__/git.test.ts index 2233837..45bff3f 100644 --- a/packages/git/src/__tests__/git.test.ts +++ b/packages/git/src/__tests__/git.test.ts @@ -142,6 +142,38 @@ describe('staging', () => { }); }); +describe('getWorktreeStatus', () => { + let repoPath: string; + + beforeAll(() => { + repoPath = initTestRepo(); + return () => rmSync(repoPath, { recursive: true, force: true }); + }); + + it('reports the correct path when the only change is an unstaged modification to a tracked file', async () => { + // Regression test: `gitForPath()` configures simple-git with + // `trimmed: true`, which runs a whole-string `.trim()` on git's stdout. + // When the *only* status line is an unstaged-only change (" M file", + // i.e. the porcelain output's very first character is a space), that + // leading space used to be indistinguishable from incidental whitespace + // and got stripped along with it — corrupting the parsed path (losing + // its first character) and misreading the index/worktree columns. + writeFileSync(join(repoPath, 'README.md'), '# Test Repo\nmodified\n'); + + const result = await getWorktreeStatus(repoPath); + expect(result.files).toHaveLength(1); + expect(result.files[0]).toMatchObject({ + path: 'README.md', + staged: false, + indexStatus: ' ', + workTreeStatus: 'M', + }); + + // Restore for any tests that run after this one in the same file. + execSync('git checkout -- README.md', { cwd: repoPath }); + }); +}); + describe('getStagedDiff', () => { let repoPath: string; diff --git a/packages/git/src/__tests__/staging-hunks.test.ts b/packages/git/src/__tests__/staging-hunks.test.ts new file mode 100644 index 0000000..5a0b958 --- /dev/null +++ b/packages/git/src/__tests__/staging-hunks.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { execSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync, realpathSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { getWorktreeStatus, stageHunk, unstageHunk } from '../staging.js'; +import { getUnstagedFileDiff, getStagedDiff } from '../diff.js'; + +/** Creates a test repo with `initialContent` committed as file.txt. */ +function initTestRepo(initialContent: string): string { + const dir = realpathSync.native(mkdtempSync(join(tmpdir(), 'sg-git-hunk-test-'))); + execSync('git init', { cwd: dir, stdio: 'ignore' }); + execSync('git config user.email "test@sproutgit.test"', { cwd: dir, stdio: 'ignore' }); + execSync('git config user.name "SproutGit Test"', { cwd: dir, stdio: 'ignore' }); + writeFileSync(join(dir, 'file.txt'), initialContent); + execSync('git add file.txt', { cwd: dir, stdio: 'ignore' }); + execSync('git commit -m "initial commit"', { cwd: dir, stdio: 'ignore' }); + return dir; +} + +function readIndexedFile(dir: string): string { + return execSync('git show :file.txt', { cwd: dir }).toString(); +} + +// 30 lines so two single-line edits far enough apart (line 2 and line 25) land +// in separate hunks under the default --unified=3 context. +const THIRTY_LINES = Array.from({ length: 30 }, (_, i) => String(i + 1)); + +describe('stageHunk / unstageHunk', () => { + let repoPath: string; + + beforeEach(() => { + repoPath = initTestRepo(THIRTY_LINES.join('\n') + '\n'); + return () => rmSync(repoPath, { recursive: true, force: true }); + }); + + it('stages only the requested hunk, leaving the other one unstaged', async () => { + const modified = [...THIRTY_LINES]; + modified[1] = 'TWO'; + modified[24] = 'TWENTYFIVE'; + writeFileSync(join(repoPath, 'file.txt'), modified.join('\n') + '\n'); + + const raw = await getUnstagedFileDiff(repoPath, 'file.txt'); + expect(raw.match(/^@@/gm) ?? []).toHaveLength(2); + + await stageHunk(repoPath, 'file.txt', 0); + + const status = await getWorktreeStatus(repoPath); + const entry = status.files.find(f => f.path === 'file.txt'); + expect(entry?.indexStatus).not.toBe(' '); // something staged + expect(entry?.workTreeStatus).not.toBe(' '); // still something unstaged + + const stagedDiff = await getStagedDiff(repoPath, 'file.txt'); + expect(stagedDiff).toContain('+TWO'); + expect(stagedDiff).not.toContain('+TWENTYFIVE'); + + const remainingUnstaged = await getUnstagedFileDiff(repoPath, 'file.txt'); + expect(remainingUnstaged).toContain('+TWENTYFIVE'); + expect(remainingUnstaged).not.toContain('+TWO'); + }); + + it('stages only the selected lines within a hunk', async () => { + // Two single-line edits close enough to land in one hunk, but far enough + // apart (surrounded by unchanged context) that git emits them as + // independent del/add pairs rather than a merged del-block/add-block — + // i.e. hunk.lines is [ctx..., del(two), add(TWO), ctx..., del(six), add(SIX), ctx...]. + const original = ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']; + const localRepo = initTestRepo(original.join('\n') + '\n'); + try { + const modified = [...original]; + modified[1] = 'TWO'; + modified[5] = 'SIX'; + writeFileSync(join(localRepo, 'file.txt'), modified.join('\n') + '\n'); + + const raw = await getUnstagedFileDiff(localRepo, 'file.txt'); + expect(raw.match(/^@@/gm) ?? []).toHaveLength(1); // single hunk containing both edits + const allLines = raw.split('\n').filter(l => l !== ''); + const bodyLines = allLines.slice(allLines.findIndex(l => l.startsWith('@@')) + 1); + expect(bodyLines).toEqual([ + ' one', '-two', '+TWO', ' three', ' four', ' five', '-six', '+SIX', ' seven', ' eight', ' nine', + ]); + // Indices into hunk.lines: 0=one(ctx) 1=two(del) 2=TWO(add) 3=three(ctx) 4=four(ctx) 5=five(ctx) 6=six(del) 7=SIX(add) ... + await stageHunk(localRepo, 'file.txt', 0, [1, 2]); + + const stagedDiff = await getStagedDiff(localRepo, 'file.txt'); + expect(stagedDiff).toContain('+TWO'); + expect(stagedDiff).not.toContain('+SIX'); + + const remaining = await getUnstagedFileDiff(localRepo, 'file.txt'); + expect(remaining).toContain('+SIX'); + expect(remaining).not.toContain('+TWO'); + + // The index should now read "TWO" in place of "two", with "six" untouched. + const indexed = readIndexedFile(localRepo); + expect(indexed).toContain('TWO'); + expect(indexed).toContain('\nsix\n'); + } finally { + rmSync(localRepo, { recursive: true, force: true }); + } + }); + + it('unstages only the requested hunk, leaving the other one staged', async () => { + const modified = [...THIRTY_LINES]; + modified[1] = 'TWO'; + modified[24] = 'TWENTYFIVE'; + writeFileSync(join(repoPath, 'file.txt'), modified.join('\n') + '\n'); + execSync('git add file.txt', { cwd: repoPath, stdio: 'ignore' }); + + const stagedRaw = await getStagedDiff(repoPath, 'file.txt'); + expect(stagedRaw.match(/^@@/gm) ?? []).toHaveLength(2); + + await unstageHunk(repoPath, 'file.txt', 0); + + const stagedAfter = await getStagedDiff(repoPath, 'file.txt'); + expect(stagedAfter).not.toContain('+TWO'); + expect(stagedAfter).toContain('+TWENTYFIVE'); + + const unstagedAfter = await getUnstagedFileDiff(repoPath, 'file.txt'); + expect(unstagedAfter).toContain('+TWO'); + expect(unstagedAfter).not.toContain('+TWENTYFIVE'); + }); + + it('round-trips a file with no trailing newline', async () => { + const noNewlineRepo = initTestRepo('one\ntwo'); + try { + writeFileSync(join(noNewlineRepo, 'file.txt'), 'one\nTWO'); + await stageHunk(noNewlineRepo, 'file.txt', 0); + const content = readIndexedFile(noNewlineRepo); + expect(content).toBe('one\nTWO'); + } finally { + rmSync(noNewlineRepo, { recursive: true, force: true }); + } + }); + + it('leaves the working tree file content untouched after staging a hunk', async () => { + const modified = [...THIRTY_LINES]; + modified[1] = 'TWO'; + modified[24] = 'TWENTYFIVE'; + const modifiedContent = modified.join('\n') + '\n'; + writeFileSync(join(repoPath, 'file.txt'), modifiedContent); + + await stageHunk(repoPath, 'file.txt', 0); + + // Working tree content must be untouched by a --cached apply. + const workingContent = execSync('cat file.txt', { cwd: repoPath }).toString(); + expect(workingContent).toBe(modifiedContent); + }); +}); diff --git a/packages/git/src/diff.ts b/packages/git/src/diff.ts index 7cd888e..91ac695 100644 --- a/packages/git/src/diff.ts +++ b/packages/git/src/diff.ts @@ -53,12 +53,28 @@ export async function getDiffContent( } /** - * Returns the raw unified diff of currently staged changes (index vs HEAD). - * Used as the input fed to commit-message generators. + * Returns the raw unified diff of currently staged changes (index vs HEAD), + * optionally scoped to a single file. Used both as the input fed to + * commit-message generators (whole-repo) and as the source diff for + * unstaging a specific hunk (single file). */ -export async function getStagedDiff(worktreePath: string): Promise { +export async function getStagedDiff(worktreePath: string, filePath?: string | null): Promise { const git = gitForPath(worktreePath); - return git.raw(['diff', '--cached', '--unified=3']); + const args = ['diff', '--cached', '--unified=3']; + if (filePath) args.push('--', filePath); + return git.raw(args); +} + +/** + * Returns the unified diff of the working tree against the index (i.e. only + * genuinely unstaged changes, excluding anything already staged) for a + * single file. This is the exact diff `git apply --cached` needs as input + * when staging a hunk — unlike `getWorkingDiff`, which diffs against HEAD + * and so mixes in already-staged hunks. + */ +export async function getUnstagedFileDiff(worktreePath: string, filePath: string): Promise { + const git = gitForPath(worktreePath); + return git.raw(['diff', '--unified=3', '--', filePath]); } /** diff --git a/packages/git/src/index.ts b/packages/git/src/index.ts index 603da0f..42de7ca 100644 --- a/packages/git/src/index.ts +++ b/packages/git/src/index.ts @@ -13,6 +13,8 @@ export { getWorktreeStatus, stageFiles, unstageFiles, + stageHunk, + unstageHunk, createCommit, checkoutWorktree, resetWorktreeBranch, @@ -24,7 +26,7 @@ export { getWorktreePushStatus, getRemoteUrl, } from './remote.js'; -export { getDiffFiles, getDiffContent, getWorkingDiff, getStagedDiff } from './diff.js'; +export { getDiffFiles, getDiffContent, getWorkingDiff, getStagedDiff, getUnstagedFileDiff } from './diff.js'; export { initBareRepo, cloneBareRepo } from './init.js'; export { convertToBareWithWorktree, diff --git a/packages/git/src/staging.ts b/packages/git/src/staging.ts index dec12d2..a3668e8 100644 --- a/packages/git/src/staging.ts +++ b/packages/git/src/staging.ts @@ -1,9 +1,15 @@ +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { type WorktreeStatusResult, type StatusFileEntry, type CheckoutResult, + parseFileDiff, + buildHunkPatch, } from '@sproutgit/types'; import { gitForPath } from './client.js'; +import { getUnstagedFileDiff, getStagedDiff } from './diff.js'; /** * Returns the working-tree + index status for a worktree. @@ -93,6 +99,58 @@ export async function resetWorktreeBranch( await git.raw(['reset', `--${mode}`, targetRef]); } +/** + * Stages a single hunk (or a subset of its added/removed lines) from the + * working tree into the index, by building a patch from the current + * worktree-vs-index diff for `filePath` and applying it with + * `git apply --cached`. + */ +export async function stageHunk( + worktreePath: string, + filePath: string, + hunkIndex: number, + lineIndices?: readonly number[] | null +): Promise { + const raw = await getUnstagedFileDiff(worktreePath, filePath); + const fileDiff = parseFileDiff(raw); + if (!fileDiff) throw new Error(`No unstaged diff found for "${filePath}"`); + const patch = buildHunkPatch(fileDiff, hunkIndex, lineIndices); + await applyPatch(worktreePath, patch, false); +} + +/** + * Unstages a single hunk (or a subset of its lines) from the index back to + * the working tree, by building a patch from the current index-vs-HEAD diff + * for `filePath` and applying it with `git apply --cached --reverse`. + */ +export async function unstageHunk( + worktreePath: string, + filePath: string, + hunkIndex: number, + lineIndices?: readonly number[] | null +): Promise { + const raw = await getStagedDiff(worktreePath, filePath); + const fileDiff = parseFileDiff(raw); + if (!fileDiff) throw new Error(`No staged diff found for "${filePath}"`); + const patch = buildHunkPatch(fileDiff, hunkIndex, lineIndices); + await applyPatch(worktreePath, patch, true); +} + +async function applyPatch(worktreePath: string, patch: string, reverse: boolean): Promise { + const git = gitForPath(worktreePath); + const dir = await mkdtemp(join(tmpdir(), 'sproutgit-patch-')); + const patchPath = join(dir, 'hunk.patch'); + try { + await writeFile(patchPath, patch, 'utf8'); + const args = ['apply', '--cached', '--recount']; + if (reverse) args.push('--reverse'); + args.push(patchPath); + await git.raw(args); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + // ─── Internal helpers ───────────────────────────────────────────────────────── function parsePorcelainStatus(raw: string): StatusFileEntry[] { diff --git a/packages/types/src/__tests__/diff-hunks.test.ts b/packages/types/src/__tests__/diff-hunks.test.ts new file mode 100644 index 0000000..977b74f --- /dev/null +++ b/packages/types/src/__tests__/diff-hunks.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect } from 'vitest'; +import { parseFileDiff, buildHunkPatch } from '../diff-hunks.js'; + +const SINGLE_HUNK_DIFF = [ + 'diff --git a/foo.txt b/foo.txt', + 'index 1111111..2222222 100644', + '--- a/foo.txt', + '+++ b/foo.txt', + '@@ -1,3 +1,4 @@', + ' one', + '+two', + ' three', + '-four', + '+four modified', + '', +].join('\n'); + +const TWO_HUNK_DIFF = [ + 'diff --git a/bar.txt b/bar.txt', + 'index 3333333..4444444 100644', + '--- a/bar.txt', + '+++ b/bar.txt', + '@@ -1,2 +1,3 @@', + ' alpha', + '+beta', + ' gamma', + '@@ -10,2 +11,2 @@', + '-delta', + '+delta modified', + ' epsilon', + '', +].join('\n'); + +describe('parseFileDiff', () => { + it('returns null for empty input', () => { + expect(parseFileDiff('')).toBeNull(); + expect(parseFileDiff(' \n ')).toBeNull(); + }); + + it('parses file header lines verbatim', () => { + const parsed = parseFileDiff(SINGLE_HUNK_DIFF); + expect(parsed?.headerLines).toEqual([ + 'diff --git a/foo.txt b/foo.txt', + 'index 1111111..2222222 100644', + '--- a/foo.txt', + '+++ b/foo.txt', + ]); + expect(parsed?.oldPath).toBe('foo.txt'); + expect(parsed?.newPath).toBe('foo.txt'); + }); + + it('parses a single hunk into context/add/del lines', () => { + const parsed = parseFileDiff(SINGLE_HUNK_DIFF); + expect(parsed?.hunks).toHaveLength(1); + const hunk = parsed?.hunks[0]; + expect(hunk?.oldStart).toBe(1); + expect(hunk?.oldLines).toBe(3); + expect(hunk?.newStart).toBe(1); + expect(hunk?.newLines).toBe(4); + expect(hunk?.lines).toEqual([ + { kind: 'context', content: 'one' }, + { kind: 'add', content: 'two' }, + { kind: 'context', content: 'three' }, + { kind: 'del', content: 'four' }, + { kind: 'add', content: 'four modified' }, + ]); + }); + + it('parses multiple hunks in one file diff', () => { + const parsed = parseFileDiff(TWO_HUNK_DIFF); + expect(parsed?.hunks).toHaveLength(2); + expect(parsed?.hunks[0]?.lines.map(l => l.kind)).toEqual(['context', 'add', 'context']); + expect(parsed?.hunks[1]?.lines.map(l => l.kind)).toEqual(['del', 'add', 'context']); + expect(parsed?.hunks[1]?.oldStart).toBe(10); + expect(parsed?.hunks[1]?.newStart).toBe(11); + }); + + it('recognizes a deleted file (--- a/path, +++ /dev/null)', () => { + const diff = [ + 'diff --git a/gone.txt b/gone.txt', + 'deleted file mode 100644', + 'index 1111111..0000000 100644', + '--- a/gone.txt', + '+++ /dev/null', + '@@ -1,1 +0,0 @@', + '-bye', + '', + ].join('\n'); + const parsed = parseFileDiff(diff); + expect(parsed?.oldPath).toBe('gone.txt'); + expect(parsed?.newPath).toBeNull(); + }); + + it('attaches noNewlineAtEof to the preceding line', () => { + const diff = [ + 'diff --git a/no-eol.txt b/no-eol.txt', + 'index 1111111..2222222 100644', + '--- a/no-eol.txt', + '+++ b/no-eol.txt', + '@@ -1,1 +1,1 @@', + '-old', + '\\ No newline at end of file', + '+new', + '\\ No newline at end of file', + '', + ].join('\n'); + const parsed = parseFileDiff(diff); + const lines = parsed?.hunks[0]?.lines; + expect(lines?.[0]).toMatchObject({ kind: 'del', content: 'old', noNewlineAtEof: true }); + expect(lines?.[1]).toMatchObject({ kind: 'add', content: 'new', noNewlineAtEof: true }); + }); + + it('does not misparse an added line whose content starts with "@@"', () => { + const diff = [ + 'diff --git a/weird.txt b/weird.txt', + 'index 1111111..2222222 100644', + '--- a/weird.txt', + '+++ b/weird.txt', + '@@ -1,1 +1,2 @@', + ' keep', + '+@@ looks like a header @@', + '', + ].join('\n'); + const parsed = parseFileDiff(diff); + expect(parsed?.hunks).toHaveLength(1); + expect(parsed?.hunks[0]?.lines).toEqual([ + { kind: 'context', content: 'keep' }, + { kind: 'add', content: '@@ looks like a header @@' }, + ]); + }); +}); + +describe('buildHunkPatch', () => { + it('throws for an out-of-range hunk index', () => { + const parsed = parseFileDiff(SINGLE_HUNK_DIFF)!; + expect(() => buildHunkPatch(parsed, 5)).toThrow(/out of range/); + }); + + it('builds a full-hunk patch reusing the original file headers', () => { + const parsed = parseFileDiff(SINGLE_HUNK_DIFF)!; + const patch = buildHunkPatch(parsed, 0); + expect(patch).toBe([ + 'diff --git a/foo.txt b/foo.txt', + 'index 1111111..2222222 100644', + '--- a/foo.txt', + '+++ b/foo.txt', + '@@ -1,3 +1,4 @@', + ' one', + '+two', + ' three', + '-four', + '+four modified', + '', + ].join('\n')); + }); + + it('builds a patch for a specific hunk when the file has several', () => { + const parsed = parseFileDiff(TWO_HUNK_DIFF)!; + const patch = buildHunkPatch(parsed, 1); + expect(patch).toContain('@@ -10,2 +11,2 @@'); + expect(patch).toContain('-delta'); + expect(patch).not.toContain('+beta'); + }); + + it('drops a deselected add line entirely and recounts the header', () => { + const parsed = parseFileDiff(SINGLE_HUNK_DIFF)!; + // hunk.lines: [context one(0), add two(1), context three(2), del four(3), add four-modified(4)] + // Select only the del (index 3) and the second add (index 4); drop the first add (index 1). + const patch = buildHunkPatch(parsed, 0, [3, 4]); + const lines = patch.split('\n'); + expect(lines).toContain(' one'); + expect(lines).not.toContain('+two'); + expect(lines).toContain(' three'); + expect(lines).toContain('-four'); + expect(lines).toContain('+four modified'); + // old: one, three, four (deleted) = 3; new: one, three, four modified = 3 + expect(lines).toContain('@@ -1,3 +1,3 @@'); + }); + + it('keeps a deselected del line as context and recounts the header', () => { + const parsed = parseFileDiff(SINGLE_HUNK_DIFF)!; + // Select only the first add (index 1); deselect the del (index 3) and the second add (index 4). + const patch = buildHunkPatch(parsed, 0, [1]); + const lines = patch.split('\n'); + expect(lines).toContain(' one'); + expect(lines).toContain('+two'); + expect(lines).toContain(' three'); + expect(lines).toContain(' four'); // kept as context, not deleted + expect(lines).not.toContain('-four'); + expect(lines).not.toContain('+four modified'); + // old: one, three, four = 3; new: one, two, three, four = 4 + expect(lines).toContain('@@ -1,3 +1,4 @@'); + }); + + it('selecting nothing keeps only context lines', () => { + const parsed = parseFileDiff(SINGLE_HUNK_DIFF)!; + const patch = buildHunkPatch(parsed, 0, []); + const lines = patch.split('\n'); + const body = lines.slice(lines.indexOf('@@ -1,3 +1,3 @@') + 1); + expect(body).toContain(' one'); + expect(body).toContain(' three'); + expect(body).toContain(' four'); // del kept as context + expect(body.some(l => l.startsWith('+') || l.startsWith('-'))).toBe(false); + }); +}); diff --git a/packages/types/src/diff-hunks.ts b/packages/types/src/diff-hunks.ts new file mode 100644 index 0000000..f9c1966 --- /dev/null +++ b/packages/types/src/diff-hunks.ts @@ -0,0 +1,172 @@ +/** + * Pure parsing and patch-construction for the unified diff of a single file. + * Shared between the renderer (rendering per-hunk / per-line staging controls) + * and the git package (building the `git apply --cached` patch for hunk/line + * staging) — same sharing rationale as `validateBranchName` in validation.ts. + */ + +export type DiffLineKind = 'context' | 'add' | 'del'; + +export type DiffLine = { + kind: DiffLineKind; + /** Line content without the leading ' ' / '+' / '-' marker. */ + content: string; + /** Set when immediately followed by a "\ No newline at end of file" marker. */ + noNewlineAtEof?: boolean; +}; + +export type DiffHunk = { + /** Original "@@ -a,b +c,d @@" header line, verbatim. */ + header: string; + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: DiffLine[]; +}; + +export type ParsedFileDiff = { + oldPath: string | null; + newPath: string | null; + /** Lines from "diff --git ..." through "+++ ..." inclusive, reused verbatim when rebuilding a patch. */ + headerLines: string[]; + hunks: DiffHunk[]; +}; + +const HUNK_HEADER_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; +const NO_NEWLINE_MARKER = '\\ No newline at end of file'; + +/** Parses the unified diff for a single file (as produced by `git diff -- `). */ +export function parseFileDiff(raw: string): ParsedFileDiff | null { + if (!raw.trim()) return null; + + const lines = raw.split('\n'); + // Drop the single trailing empty element produced by the diff's final newline. + if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + + let i = 0; + const headerLines: string[] = []; + let oldPath: string | null = null; + let newPath: string | null = null; + + while (i < lines.length && !HUNK_HEADER_RE.test(lines[i] ?? '')) { + const line = lines[i] ?? ''; + if (line.startsWith('--- ')) oldPath = normalizeDiffPath(line.slice(4)); + if (line.startsWith('+++ ')) newPath = normalizeDiffPath(line.slice(4)); + headerLines.push(line); + i++; + } + + if (headerLines.length === 0) return null; + + const hunks: DiffHunk[] = []; + while (i < lines.length) { + const headerLine = lines[i] ?? ''; + const m = HUNK_HEADER_RE.exec(headerLine); + if (!m) break; + + const oldStart = Number(m[1]); + const oldLines = m[2] !== undefined ? Number(m[2]) : 1; + const newStart = Number(m[3]); + const newLines = m[4] !== undefined ? Number(m[4]) : 1; + i++; + + const hunkLines: DiffLine[] = []; + while (i < lines.length) { + const line = lines[i] ?? ''; + if (HUNK_HEADER_RE.test(line) || line.startsWith('diff --git')) break; + + if (line === NO_NEWLINE_MARKER) { + const last = hunkLines[hunkLines.length - 1]; + if (last) last.noNewlineAtEof = true; + i++; + continue; + } + + const marker = line.length > 0 ? line[0] : ' '; + const content = line.length > 0 ? line.slice(1) : ''; + if (marker === '+') hunkLines.push({ kind: 'add', content }); + else if (marker === '-') hunkLines.push({ kind: 'del', content }); + else hunkLines.push({ kind: 'context', content }); + i++; + } + + hunks.push({ header: headerLine, oldStart, oldLines, newStart, newLines, lines: hunkLines }); + } + + return { oldPath, newPath, headerLines, hunks }; +} + +function normalizeDiffPath(pathPart: string): string | null { + if (pathPart === '/dev/null') return null; + return pathPart.replace(/^[ab]\//, ''); +} + +/** + * Builds a standalone patch containing exactly one hunk from `fileDiff`, + * suitable for `git apply --cached` (stage) or `git apply --cached --reverse` + * (unstage). + * + * When `selectedLineIndices` is omitted, the whole hunk is included. When + * provided, it's the set of `hunk.lines` indices (add/del lines only) the + * user wants applied — a deselected 'add' line is dropped entirely (as if + * never added) and a deselected 'del' line is kept as context (as if never + * removed). This mirrors the algorithm `git add -p` uses for per-line + * staging, and works symmetrically for unstaging since that's just applying + * the index-vs-HEAD diff in reverse. + * + * Old/new start offsets are kept verbatim from the source hunk — git only + * uses them as a position hint and matches on context, so they don't need + * to be recomputed when lines are dropped or converted to context. + */ +export function buildHunkPatch( + fileDiff: ParsedFileDiff, + hunkIndex: number, + selectedLineIndices?: readonly number[] | null +): string { + const hunk = fileDiff.hunks[hunkIndex]; + if (!hunk) { + throw new Error(`Hunk index ${hunkIndex} is out of range (file has ${fileDiff.hunks.length} hunk(s))`); + } + + const selected = selectedLineIndices ? new Set(selectedLineIndices) : null; + + const body: string[] = []; + let oldCount = 0; + let newCount = 0; + + hunk.lines.forEach((line, idx) => { + const isSelected = selected === null || selected.has(idx); + + if (line.kind === 'context') { + body.push(` ${line.content}`); + oldCount++; + newCount++; + if (line.noNewlineAtEof) body.push(NO_NEWLINE_MARKER); + return; + } + + if (line.kind === 'add') { + if (!isSelected) return; // dropped: never added + body.push(`+${line.content}`); + newCount++; + if (line.noNewlineAtEof) body.push(NO_NEWLINE_MARKER); + return; + } + + // 'del' + if (isSelected) { + body.push(`-${line.content}`); + oldCount++; + } else { + body.push(` ${line.content}`); // kept: never removed + oldCount++; + newCount++; + } + if (line.noNewlineAtEof) body.push(NO_NEWLINE_MARKER); + }); + + const newHunkHeader = `@@ -${hunk.oldStart},${oldCount} +${hunk.newStart},${newCount} @@`; + + return [...fileDiff.headerLines, newHunkHeader, ...body, ''].join('\n'); +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ab464ff..d42feec 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -15,6 +15,7 @@ export * from './files.js'; export * from './mcp.js'; export * from './errors.js'; export * from './project-idea.js'; +export * from './diff-hunks.js'; export * from './ai-providers.js'; export * from './attention.js'; export * from './notifications.js'; diff --git a/packages/types/src/ipc.ts b/packages/types/src/ipc.ts index b393b0f..7d07919 100644 --- a/packages/types/src/ipc.ts +++ b/packages/types/src/ipc.ts @@ -12,6 +12,8 @@ export const IPC = { GIT_STATUS: 'git:status', GIT_STAGE: 'git:stage', GIT_UNSTAGE: 'git:unstage', + GIT_STAGE_HUNK: 'git:stageHunk', + GIT_UNSTAGE_HUNK: 'git:unstageHunk', GIT_COMMIT: 'git:commit', GIT_CHECKOUT: 'git:checkout', GIT_RESET: 'git:reset', @@ -22,6 +24,8 @@ export const IPC = { GIT_DIFF_FILES: 'git:diffFiles', GIT_DIFF_CONTENT: 'git:diffContent', GIT_WORKING_DIFF: 'git:workingDiff', + GIT_UNSTAGED_FILE_DIFF: 'git:unstagedFileDiff', + GIT_STAGED_FILE_DIFF: 'git:stagedFileDiff', GIT_STASH_CREATE: 'git:stashCreate', GIT_STASH_LIST: 'git:stashList', GIT_STASH_APPLY: 'git:stashApply', @@ -292,6 +296,8 @@ export type IpcMap = { 'git:status': { args: [worktreePath: string]; result: WorktreeStatusResult }; 'git:stage': { args: [args: { worktreePath: string; paths: string[] }]; result: void }; 'git:unstage': { args: [args: { worktreePath: string; paths: string[] }]; result: void }; + 'git:stageHunk': { args: [args: { worktreePath: string; filePath: string; hunkIndex: number; lineIndices?: number[] | null }]; result: void }; + 'git:unstageHunk': { args: [args: { worktreePath: string; filePath: string; hunkIndex: number; lineIndices?: number[] | null }]; result: void }; 'git:commit': { args: [args: { worktreePath: string; message: string }]; result: void }; 'git:checkout': { args: [args: { worktreePath: string; targetRef: string }]; result: void }; 'git:reset': { args: [args: { worktreePath: string; targetRef: string; mode: 'soft'|'mixed'|'hard' }]; result: void }; @@ -302,6 +308,8 @@ export type IpcMap = { 'git:diffFiles': { args: [args: { repoPath: string; range: string }]; result: DiffFileEntry[] }; 'git:diffContent': { args: [args: { repoPath: string; range: string; file?: string }]; result: string }; 'git:workingDiff': { args: [args: { worktreePath: string; file?: string }]; result: string }; + 'git:unstagedFileDiff': { args: [args: { worktreePath: string; file: string }]; result: string }; + 'git:stagedFileDiff': { args: [args: { worktreePath: string; file?: string }]; result: string }; 'git:stashCreate': { args: [args: { worktreePath: string; message?: string }]; result: void }; 'git:stashList': { args: [worktreePath: string]; result: StashListResult }; 'git:stashApply': { args: [args: { worktreePath: string; ref: string }]; result: void }; diff --git a/packages/ui/src/components/StagingPanel.tsx b/packages/ui/src/components/StagingPanel.tsx index 57dd581..3073845 100644 --- a/packages/ui/src/components/StagingPanel.tsx +++ b/packages/ui/src/components/StagingPanel.tsx @@ -14,7 +14,7 @@ import yamlLang from 'highlight.js/lib/languages/yaml'; import sqlLang from 'highlight.js/lib/languages/sql'; import pythonLang from 'highlight.js/lib/languages/python'; import goLang from 'highlight.js/lib/languages/go'; -import { type StatusFileEntry, type WorktreeStatusResult, type CommitMessageGenerateResult, type StashListResult } from '@sproutgit/types'; +import { type StatusFileEntry, type WorktreeStatusResult, type CommitMessageGenerateResult, type StashListResult, parseFileDiff } from '@sproutgit/types'; import { Spinner } from './Spinner.js'; import { ConfirmDialog } from './ConfirmDialog.js'; import { StashPanel } from './StashPanel.js'; @@ -33,12 +33,6 @@ hljs.registerLanguage('sql', sqlLang); hljs.registerLanguage('python', pythonLang); hljs.registerLanguage('go', goLang); -// Unified diff file headers are always "--- a/path" / "+++ b/path" (or /dev/null), -// i.e. the marker followed by a space — unlike an added/removed line whose content -// happens to start with "++"/"--", which has no space right after the marker. -const OLD_FILE_HEADER = /^--- /; -const NEW_FILE_HEADER = /^\+\+\+ /; - // ─── Props ──────────────────────────────────────────────────────────────────── type Props = { @@ -48,6 +42,9 @@ type Props = { getStatus: (worktreePath: string) => Promise; stageFiles: (worktreePath: string, paths: string[]) => Promise; unstageFiles: (worktreePath: string, paths: string[]) => Promise; + /** Stages a single hunk (or, when `lineIndices` is given, only those add/del lines within it). */ + stageHunk: (worktreePath: string, filePath: string, hunkIndex: number, lineIndices?: number[]) => Promise; + unstageHunk: (worktreePath: string, filePath: string, hunkIndex: number, lineIndices?: number[]) => Promise; createCommit: (worktreePath: string, message: string) => Promise; getDiff: (worktreePath: string, staged: boolean, file?: string) => Promise; onCommit: () => void; @@ -87,6 +84,8 @@ export function StagingPanel({ getStatus, stageFiles: stageFilesFn, unstageFiles: unstageFilesFn, + stageHunk: stageHunkFn, + unstageHunk: unstageHunkFn, createCommit: createCommitFn, getDiff, onCommit, @@ -154,6 +153,26 @@ export function StagingPanel({ onError: (err) => onToast?.(`Failed to unstage all: ${String(err)}`, 'error'), }); + const stageHunkMutation = useMutation({ + mutationFn: (args: { hunkIndex: number; lineIndices?: number[] }) => + stageHunkFn(worktreePath, diffFile!, args.hunkIndex, args.lineIndices), + onSuccess: () => { + invalidateStatus(); + void loadDiff(diffFile, diffStaged); + }, + onError: (err) => onToast?.(`Failed to stage hunk: ${String(err)}`, 'error'), + }); + + const unstageHunkMutation = useMutation({ + mutationFn: (args: { hunkIndex: number; lineIndices?: number[] }) => + unstageHunkFn(worktreePath, diffFile!, args.hunkIndex, args.lineIndices), + onSuccess: () => { + invalidateStatus(); + void loadDiff(diffFile, diffStaged); + }, + onError: (err) => onToast?.(`Failed to unstage hunk: ${String(err)}`, 'error'), + }); + const commitMutation = useMutation({ mutationFn: (message: string) => createCommitFn(worktreePath, message), onSuccess: () => { @@ -177,6 +196,8 @@ export function StagingPanel({ const [diffError, setDiffError] = useState(null); const [generating, setGenerating] = useState(false); const [confirmOverwrite, setConfirmOverwrite] = useState(false); + /** Per-hunk set of *deselected* add/del line indices (indices into that hunk's `lines` array). */ + const [deselectedLines, setDeselectedLines] = useState>>(new Map()); const commitError = commitTouched ? validateCommitMessage(commitMessage) : null; @@ -212,6 +233,7 @@ export function StagingPanel({ setDiffStaged(staged); setDiffLoading(true); setDiffError(null); + setDeselectedLines(new Map()); try { const content = await getDiff(worktreePath, staged, file ?? undefined); setDiffContent(content); @@ -236,25 +258,120 @@ export function StagingPanel({ // ─── Diff rendering ────────────────────────────────────────────────────────── - function renderDiff(raw: string): string { - if (!raw.trim()) return 'No changes'; + function toggleLineSelection(hunkIndex: number, lineIdx: number) { + setDeselectedLines(prev => { + const next = new Map(prev); + const set = new Set(next.get(hunkIndex) ?? []); + if (set.has(lineIdx)) set.delete(lineIdx); else set.add(lineIdx); + next.set(hunkIndex, set); + return next; + }); + } + + /** Renders the currently loaded diff as per-hunk sections with stage/unstage controls. */ + function renderDiffHunks(raw: string) { + if (!raw.trim()) { + return No changes; + } + const parsed = parseFileDiff(raw); + if (!parsed || parsed.hunks.length === 0) { + // Binary diffs, mode-only changes, and renames without content changes + // have no hunks to stage — show the raw header lines rather than + // claiming there's nothing here, which would hide a real diff. + const lines = (parsed?.headerLines ?? raw.split('\n')).filter(Boolean); + return lines.map((line, idx) =>
{line}
); + } const lang = languageForPath(diffFile); - const lines = raw.split('\n'); - return lines.map(line => { - if (line.startsWith('+') && !NEW_FILE_HEADER.test(line)) { - return `
+${highlightCode(line.slice(1), lang)}
`; - } else if (line.startsWith('-') && !OLD_FILE_HEADER.test(line)) { - return `
-${highlightCode(line.slice(1), lang)}
`; - } else if (line.startsWith('@@')) { - return `
${escapeHtml(line)}
`; - } else if (line.startsWith('diff ') || line.startsWith('index ') || OLD_FILE_HEADER.test(line) || NEW_FILE_HEADER.test(line)) { - return `
${escapeHtml(line)}
`; + + // Disabling only the specific hunk's own button lets a second click land + // on a different hunk while the first mutation is still in flight — + // React Query's `variables` only tracks the latest call, so the two + // requests can race and leave the staged/unstaged panes out of sync with + // their spinners. Disable every stage (or unstage) button while any + // mutation of that kind is pending; the spinner still only shows on the + // hunk actually being mutated. + const anyStagePending = stageHunkMutation.isPending; + const anyUnstagePending = unstageHunkMutation.isPending; + + return parsed.hunks.map((hunk, hunkIndex) => { + const deselected = deselectedLines.get(hunkIndex) ?? new Set(); + const selectableIdx: number[] = []; + hunk.lines.forEach((l, idx) => { if (l.kind !== 'context') selectableIdx.push(idx); }); + const selectedIdx = selectableIdx.filter(idx => !deselected.has(idx)); + const isPartial = selectedIdx.length > 0 && selectedIdx.length < selectableIdx.length; + const nothingSelected = selectedIdx.length === 0; + + const isThisStagePending = anyStagePending && stageHunkMutation.variables?.hunkIndex === hunkIndex; + const isThisUnstagePending = anyUnstagePending && unstageHunkMutation.variables?.hunkIndex === hunkIndex; + + function runStage() { + stageHunkMutation.mutate(isPartial ? { hunkIndex, lineIndices: selectedIdx } : { hunkIndex }); } - if (line.startsWith(' ')) { - return `
${highlightCode(line.slice(1), lang)}
`; + function runUnstage() { + unstageHunkMutation.mutate(isPartial ? { hunkIndex, lineIndices: selectedIdx } : { hunkIndex }); } - return `
${highlightCode(line, lang)}
`; - }).join(''); + + return ( +
+
+ {hunk.header} +
+ {diffStaged ? ( + + ) : ( + + )} +
+
+ {hunk.lines.map((line, idx) => { + if (line.kind === 'context') { + return ( +
+ + +
+ ); + } + const isAdd = line.kind === 'add'; + return ( +
+ toggleLineSelection(hunkIndex, idx)} + aria-label={`${isAdd ? 'Added' : 'Removed'} line in hunk ${hunkIndex + 1}: ${line.content}`} + /> + +
+ ); + })} +
+ ); + }); } // ─── Resize state ───────────────────────────────────────────────────────── @@ -506,10 +623,9 @@ export function StagingPanel({ {diffFile} ) : diffContent ? ( -
+          
+ {renderDiffHunks(diffContent)} +
) : (
Select a file to view its diff diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index 5802479..ea48f2a 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -210,10 +210,11 @@ code, pre, kbd, samp { .sg-diff { margin: 0; overflow-x: auto; font-size: 12px; font-family: var(--sg-font-code); line-height: 1.5; } .sg-diff-add { display: block; background: #dff3e4; color: #0f5132; } .sg-diff-del { display: block; background: #fbe4e8; color: #8a1f35; } -.sg-diff-hunk { display: block; color: var(--sg-text-faint); background: var(--sg-surface-raised); } +.sg-diff-hunk { display: flex; align-items: center; justify-content: space-between; gap: 8px; position: sticky; top: 0; z-index: 1; color: var(--sg-text-faint); background: var(--sg-surface-raised); } .sg-diff-ctx { display: block; color: var(--sg-text-dim); } .sg-diff-meta { display: block; color: var(--sg-text-faint); background: var(--sg-surface-raised); } .sg-diff-empty { display: block; color: var(--sg-text-faint); padding: 8px; } +.sg-diff-line-checkbox { cursor: pointer; } /* Diff-aware token colors preserve contrast over add/del backgrounds. */ .sg-diff-add .hljs-keyword, .sg-diff-add .hljs-selector-tag, .sg-diff-add .hljs-built_in { color: #0d683a; }