feat(desktop): add hunk-level review controls

This commit is contained in:
DragonnZhang 2026-04-25 11:11:30 +08:00
parent a8dfa18598
commit c3bc36fde1
13 changed files with 1071 additions and 89 deletions

View file

@ -69,6 +69,15 @@ async function main() {
await clickButton('Open Project');
await waitForText('desktop-e2e-workspace');
await waitForText('README.md');
await waitForText('Accept Hunk');
await clickButton('Accept Hunk');
await waitForText('Accepted');
await setFieldByAriaLabel(
'Review comment for README.md',
'Review note from E2E',
);
await clickButton('Add Comment');
await waitForText('Review note from E2E');
await waitForSelector('[data-testid="project-list"]');
await clickButton('New Thread');

View file

@ -36,6 +36,7 @@ import {
stageDesktopProjectChanges,
updateDesktopUserSettings,
type DesktopGitDiff,
type DesktopGitReviewTarget,
type DesktopProject,
type DesktopSessionSummary,
type DesktopTerminal,
@ -393,37 +394,63 @@ export function App() {
[activeProject],
);
const stageAllChanges = useCallback(async () => {
if (loadState.state !== 'ready' || !activeProject) {
return;
}
const stageReviewTarget = useCallback(
async (target: DesktopGitReviewTarget) => {
if (loadState.state !== 'ready' || !activeProject) {
return;
}
try {
const result = await stageDesktopProjectChanges(
loadState.status.serverInfo,
activeProject.id,
);
applyReviewMutation(result.status, result.diff);
} catch (error) {
setReviewError(getErrorMessage(error));
}
}, [activeProject, applyReviewMutation, loadState]);
try {
const result = await stageDesktopProjectChanges(
loadState.status.serverInfo,
activeProject.id,
target,
);
applyReviewMutation(result.status, result.diff);
} catch (error) {
setReviewError(getErrorMessage(error));
}
},
[activeProject, applyReviewMutation, loadState],
);
const revertAllChanges = useCallback(async () => {
if (loadState.state !== 'ready' || !activeProject) {
return;
}
const revertReviewTarget = useCallback(
async (target: DesktopGitReviewTarget) => {
if (loadState.state !== 'ready' || !activeProject) {
return;
}
try {
const result = await revertDesktopProjectChanges(
loadState.status.serverInfo,
activeProject.id,
);
applyReviewMutation(result.status, result.diff);
} catch (error) {
setReviewError(getErrorMessage(error));
}
}, [activeProject, applyReviewMutation, loadState]);
try {
const result = await revertDesktopProjectChanges(
loadState.status.serverInfo,
activeProject.id,
target,
);
applyReviewMutation(result.status, result.diff);
} catch (error) {
setReviewError(getErrorMessage(error));
}
},
[activeProject, applyReviewMutation, loadState],
);
const openReviewFile = useCallback(
async (filePath: string) => {
if (!activeProject) {
return;
}
try {
await window.qwenDesktop.openPath(
joinProjectFilePath(activeProject.path, filePath),
);
setReviewError(null);
} catch (error) {
setReviewError(getErrorMessage(error));
}
},
[activeProject],
);
const commitChanges = useCallback(async () => {
if (
@ -666,14 +693,15 @@ export function App() {
onModelChange={changeModel}
onPermissionResponse={respondToPermission}
onRefreshProjectGitStatus={refreshProjectGitStatus}
onRevertAllChanges={revertAllChanges}
onOpenReviewFile={openReviewFile}
onRevertReviewTarget={revertReviewTarget}
onRunTerminalCommand={runTerminalCommand}
onSaveSettings={saveSettings}
onSelectProject={selectProject}
onSelectSession={setActiveSessionId}
onSendMessage={sendMessage}
onSettingsDispatch={dispatchSettings}
onStageAllChanges={stageAllChanges}
onStageReviewTarget={stageReviewTarget}
onStopGeneration={stopGeneration}
onTerminalCommandChange={setTerminalCommand}
/>
@ -708,3 +736,12 @@ function isApprovalMode(value: string): value is DesktopApprovalMode {
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Desktop operation failed.';
}
function joinProjectFilePath(projectPath: string, filePath: string): string {
const separator = projectPath.includes('\\') ? '\\' : '/';
const base =
projectPath.endsWith('/') || projectPath.endsWith('\\')
? projectPath.slice(0, -1)
: projectPath;
return `${base}${separator}${filePath}`;
}

View file

@ -59,6 +59,19 @@ export type DesktopGitChangeStatus =
| 'untracked'
| 'unknown';
export type DesktopGitChangeSource = 'staged' | 'unstaged' | 'untracked';
export interface DesktopGitDiffHunk {
id: string;
source: DesktopGitChangeSource;
header: string;
oldStart: number;
oldLines: number;
newStart: number;
newLines: number;
lines: string[];
}
export interface DesktopGitChangedFile {
path: string;
status: DesktopGitChangeStatus;
@ -66,6 +79,7 @@ export interface DesktopGitChangedFile {
unstaged: boolean;
untracked: boolean;
diff: string;
hunks: DesktopGitDiffHunk[];
}
export interface DesktopGitDiff {
@ -88,6 +102,11 @@ export interface DesktopGitCommitMutation extends DesktopGitReviewMutation {
};
}
export type DesktopGitReviewTarget =
| { scope: 'all' }
| { scope: 'file'; filePath: string }
| { scope: 'hunk'; filePath: string; hunkId: string };
export type DesktopTerminalStatus = 'running' | 'exited' | 'failed' | 'killed';
export interface DesktopTerminal {
@ -249,12 +268,13 @@ export async function getDesktopProjectGitDiff(
export async function stageDesktopProjectChanges(
serverInfo: DesktopServerInfo,
projectId: string,
target: DesktopGitReviewTarget = { scope: 'all' },
): Promise<DesktopGitReviewMutation> {
return writeJson(
serverInfo,
`/api/projects/${encodeURIComponent(projectId)}/git/stage`,
'POST',
{ scope: 'all' },
target,
isGitReviewMutation,
);
}
@ -262,12 +282,13 @@ export async function stageDesktopProjectChanges(
export async function revertDesktopProjectChanges(
serverInfo: DesktopServerInfo,
projectId: string,
target: DesktopGitReviewTarget = { scope: 'all' },
): Promise<DesktopGitReviewMutation> {
return writeJson(
serverInfo,
`/api/projects/${encodeURIComponent(projectId)}/git/revert`,
'POST',
{ scope: 'all' },
target,
isGitReviewMutation,
);
}
@ -807,7 +828,28 @@ function isGitChangedFile(value: unknown): value is DesktopGitChangedFile {
typeof candidate.staged === 'boolean' &&
typeof candidate.unstaged === 'boolean' &&
typeof candidate.untracked === 'boolean' &&
typeof candidate.diff === 'string'
typeof candidate.diff === 'string' &&
Array.isArray(candidate.hunks) &&
candidate.hunks.every(isGitDiffHunk)
);
}
function isGitDiffHunk(value: unknown): value is DesktopGitDiffHunk {
if (!value || typeof value !== 'object') {
return false;
}
const candidate = value as Partial<DesktopGitDiffHunk>;
return (
typeof candidate.id === 'string' &&
isGitChangeSource(candidate.source) &&
typeof candidate.header === 'string' &&
typeof candidate.oldStart === 'number' &&
typeof candidate.oldLines === 'number' &&
typeof candidate.newStart === 'number' &&
typeof candidate.newLines === 'number' &&
Array.isArray(candidate.lines) &&
candidate.lines.every((line) => typeof line === 'string')
);
}
@ -823,6 +865,10 @@ function isGitChangeStatus(value: unknown): value is DesktopGitChangeStatus {
);
}
function isGitChangeSource(value: unknown): value is DesktopGitChangeSource {
return value === 'staged' || value === 'unstaged' || value === 'untracked';
}
function isDesktopTerminal(value: unknown): value is DesktopTerminal {
if (!value || typeof value !== 'object') {
return false;

View file

@ -4,8 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Dispatch } from 'react';
import type { DesktopGitDiff, DesktopProject } from '../../api/client.js';
import { useState, type Dispatch } from 'react';
import type {
DesktopGitChangedFile,
DesktopGitDiff,
DesktopGitDiffHunk,
DesktopGitReviewTarget,
DesktopProject,
} from '../../api/client.js';
import type { ChatState } from '../../stores/chatStore.js';
import type { ModelState } from '../../stores/modelStore.js';
import type {
@ -31,10 +37,11 @@ export function ReviewPanel({
onCommitMessageChange,
onModeChange,
onModelChange,
onRevertAll,
onOpenFile,
onRevertTarget,
onSaveSettings,
onSettingsDispatch,
onStageAll,
onStageTarget,
}: {
activeProject: DesktopProject | null;
activeSessionId: string | null;
@ -51,10 +58,11 @@ export function ReviewPanel({
onCommitMessageChange: (message: string) => void;
onModeChange: (mode: DesktopApprovalMode) => void;
onModelChange: (modelId: string) => void;
onRevertAll: () => void;
onOpenFile: (filePath: string) => void;
onRevertTarget: (target: DesktopGitReviewTarget) => void;
onSaveSettings: () => void;
onSettingsDispatch: Dispatch<SettingsAction>;
onStageAll: () => void;
onStageTarget: (target: DesktopGitReviewTarget) => void;
}) {
return (
<section
@ -72,8 +80,9 @@ export function ReviewPanel({
reviewError={reviewError}
onCommit={onCommit}
onCommitMessageChange={onCommitMessageChange}
onRevertAll={onRevertAll}
onStageAll={onStageAll}
onOpenFile={onOpenFile}
onRevertTarget={onRevertTarget}
onStageTarget={onStageTarget}
/>
<RuntimeDetails loadState={loadState} />
<SessionDetails
@ -99,8 +108,9 @@ function ReviewSummary({
gitDiff,
onCommit,
onCommitMessageChange,
onRevertAll,
onStageAll,
onOpenFile,
onRevertTarget,
onStageTarget,
project,
reviewError,
}: {
@ -108,11 +118,19 @@ function ReviewSummary({
gitDiff: DesktopGitDiff | null;
onCommit: () => void;
onCommitMessageChange: (message: string) => void;
onRevertAll: () => void;
onStageAll: () => void;
onOpenFile: (filePath: string) => void;
onRevertTarget: (target: DesktopGitReviewTarget) => void;
onStageTarget: (target: DesktopGitReviewTarget) => void;
project: DesktopProject | null;
reviewError: string | null;
}) {
const [commentDrafts, setCommentDrafts] = useState<Record<string, string>>(
{},
);
const [reviewComments, setReviewComments] = useState<
Record<string, string[]>
>({});
if (!project) {
return (
<div className="review-summary">
@ -164,7 +182,7 @@ function ReviewSummary({
className="secondary-button"
disabled={changedFiles.length === 0}
type="button"
onClick={onRevertAll}
onClick={() => onRevertTarget({ scope: 'all' })}
>
Revert All
</button>
@ -172,23 +190,46 @@ function ReviewSummary({
className="secondary-button"
disabled={changedFiles.length === 0}
type="button"
onClick={onStageAll}
onClick={() => onStageTarget({ scope: 'all' })}
>
Stage All
Accept All
</button>
</div>
<div className="changed-files">
{changedFiles.length === 0 ? (
<div className="empty-row">No changes</div>
) : (
changedFiles.map((file) => (
<details key={file.path} open={changedFiles.length === 1}>
<summary>
<span>{file.path}</span>
<small>{file.status}</small>
</summary>
<pre>{file.diff || 'No textual diff available.'}</pre>
</details>
changedFiles.map((file, index) => (
<ChangedFileReview
key={file.path}
commentDraft={commentDrafts[file.path] ?? ''}
comments={reviewComments[file.path] ?? []}
file={file}
isInitiallyOpen={changedFiles.length === 1 || index === 0}
onAddComment={() => {
const comment = (commentDrafts[file.path] ?? '').trim();
if (!comment) {
return;
}
setReviewComments((current) => ({
...current,
[file.path]: [...(current[file.path] ?? []), comment],
}));
setCommentDrafts((current) => ({
...current,
[file.path]: '',
}));
}}
onCommentDraftChange={(comment) =>
setCommentDrafts((current) => ({
...current,
[file.path]: comment,
}))
}
onOpenFile={onOpenFile}
onRevertTarget={onRevertTarget}
onStageTarget={onStageTarget}
/>
))
)}
</div>
@ -213,6 +254,163 @@ function ReviewSummary({
);
}
function ChangedFileReview({
commentDraft,
comments,
file,
isInitiallyOpen,
onAddComment,
onCommentDraftChange,
onOpenFile,
onRevertTarget,
onStageTarget,
}: {
commentDraft: string;
comments: string[];
file: DesktopGitChangedFile;
isInitiallyOpen: boolean;
onAddComment: () => void;
onCommentDraftChange: (comment: string) => void;
onOpenFile: (filePath: string) => void;
onRevertTarget: (target: DesktopGitReviewTarget) => void;
onStageTarget: (target: DesktopGitReviewTarget) => void;
}) {
const fileTarget = { scope: 'file' as const, filePath: file.path };
return (
<details data-testid={`changed-file-${file.path}`} open={isInitiallyOpen}>
<summary>
<span>{file.path}</span>
<small>
{file.status} · {file.hunks.length} hunk
{file.hunks.length === 1 ? '' : 's'}
</small>
</summary>
<div className="file-review-actions">
<button
className="secondary-button"
type="button"
onClick={() => onOpenFile(file.path)}
>
Open
</button>
<button
className="secondary-button"
type="button"
onClick={() => onRevertTarget(fileTarget)}
>
Revert File
</button>
<button
className="secondary-button"
type="button"
onClick={() => onStageTarget(fileTarget)}
>
Accept File
</button>
</div>
{file.hunks.length === 0 ? (
<pre>{file.diff || 'No textual diff available.'}</pre>
) : (
<div className="diff-hunks">
{file.hunks.map((hunk) => (
<DiffHunkReview
key={hunk.id}
file={file}
hunk={hunk}
onRevertTarget={onRevertTarget}
onStageTarget={onStageTarget}
/>
))}
</div>
)}
<div className="review-comment-box">
<label>
<span>Comment</span>
<textarea
aria-label={`Review comment for ${file.path}`}
placeholder="Add review note for this file"
rows={2}
value={commentDraft}
onChange={(event) => onCommentDraftChange(event.target.value)}
/>
</label>
<button
className="secondary-button"
disabled={commentDraft.trim().length === 0}
type="button"
onClick={onAddComment}
>
Add Comment
</button>
{comments.length > 0 ? (
<ul className="review-comments">
{comments.map((comment, index) => (
<li key={`${file.path}-${index}`}>{comment}</li>
))}
</ul>
) : null}
</div>
</details>
);
}
function DiffHunkReview({
file,
hunk,
onRevertTarget,
onStageTarget,
}: {
file: DesktopGitChangedFile;
hunk: DesktopGitDiffHunk;
onRevertTarget: (target: DesktopGitReviewTarget) => void;
onStageTarget: (target: DesktopGitReviewTarget) => void;
}) {
const hunkTarget = {
scope: 'hunk' as const,
filePath: file.path,
hunkId: hunk.id,
};
return (
<section className="diff-hunk" data-testid={`diff-hunk-${hunk.id}`}>
<div className="diff-hunk-header">
<span>{hunk.header}</span>
<small>{formatHunkSource(hunk.source)}</small>
</div>
<div className="diff-hunk-actions">
<button
className="secondary-button"
type="button"
onClick={() => onRevertTarget(hunkTarget)}
>
Revert Hunk
</button>
<button
className="secondary-button"
disabled={hunk.source === 'staged'}
type="button"
onClick={() => onStageTarget(hunkTarget)}
>
{hunk.source === 'staged' ? 'Accepted' : 'Accept Hunk'}
</button>
</div>
<pre>{hunk.lines.join('\n') || 'No textual hunk available.'}</pre>
</section>
);
}
function formatHunkSource(source: DesktopGitDiffHunk['source']): string {
if (source === 'staged') {
return 'Accepted';
}
if (source === 'untracked') {
return 'New file';
}
return 'Pending';
}
function RuntimeDetails({ loadState }: { loadState: LoadState }) {
if (loadState.state === 'loading') {
return <div className="runtime-row muted">Checking service</div>;

View file

@ -79,14 +79,15 @@ describe('WorkspacePage', () => {
onModelChange={vi.fn()}
onPermissionResponse={vi.fn()}
onRefreshProjectGitStatus={vi.fn()}
onRevertAllChanges={vi.fn()}
onOpenReviewFile={vi.fn()}
onRevertReviewTarget={vi.fn()}
onRunTerminalCommand={vi.fn()}
onSaveSettings={vi.fn()}
onSelectProject={vi.fn()}
onSelectSession={vi.fn()}
onSendMessage={(event) => event.preventDefault()}
onSettingsDispatch={vi.fn()}
onStageAllChanges={vi.fn()}
onStageReviewTarget={vi.fn()}
onStopGeneration={vi.fn()}
onTerminalCommandChange={vi.fn()}
/>,
@ -150,6 +151,18 @@ const gitDiff: DesktopGitDiff = {
unstaged: true,
untracked: false,
diff: '@@ -1 +1 @@\n-export const value = 1;\n+export const value = 2;',
hunks: [
{
id: 'hunk-1',
source: 'unstaged',
header: '@@ -1 +1 @@',
oldStart: 1,
oldLines: 1,
newStart: 1,
newLines: 1,
lines: ['-export const value = 1;', '+export const value = 2;'],
},
],
},
],
};

View file

@ -7,6 +7,7 @@
import type { Dispatch, FormEvent } from 'react';
import type {
DesktopGitDiff,
DesktopGitReviewTarget,
DesktopProject,
DesktopSessionSummary,
DesktopTerminal,
@ -57,14 +58,15 @@ export function WorkspacePage({
onModelChange,
onPermissionResponse,
onRefreshProjectGitStatus,
onRevertAllChanges,
onOpenReviewFile,
onRevertReviewTarget,
onRunTerminalCommand,
onSaveSettings,
onSelectProject,
onSelectSession,
onSendMessage,
onSettingsDispatch,
onStageAllChanges,
onStageReviewTarget,
onStopGeneration,
onTerminalCommandChange,
}: {
@ -99,14 +101,15 @@ export function WorkspacePage({
onModelChange: (modelId: string) => void;
onPermissionResponse: (requestId: string, optionId: string) => void;
onRefreshProjectGitStatus: () => void;
onRevertAllChanges: () => void;
onOpenReviewFile: (filePath: string) => void;
onRevertReviewTarget: (target: DesktopGitReviewTarget) => void;
onRunTerminalCommand: () => void;
onSaveSettings: () => void;
onSelectProject: (projectId: string) => void;
onSelectSession: (sessionId: string) => void;
onSendMessage: (event: FormEvent<HTMLFormElement>) => void;
onSettingsDispatch: Dispatch<SettingsAction>;
onStageAllChanges: () => void;
onStageReviewTarget: (target: DesktopGitReviewTarget) => void;
onStopGeneration: () => void;
onTerminalCommandChange: (command: string) => void;
}) {
@ -161,10 +164,11 @@ export function WorkspacePage({
onCommitMessageChange={onCommitMessageChange}
onModeChange={onModeChange}
onModelChange={onModelChange}
onRevertAll={onRevertAllChanges}
onOpenFile={onOpenReviewFile}
onRevertTarget={onRevertReviewTarget}
onSaveSettings={onSaveSettings}
onSettingsDispatch={onSettingsDispatch}
onStageAll={onStageAllChanges}
onStageTarget={onStageReviewTarget}
/>
</div>
<TerminalDrawer

View file

@ -623,7 +623,64 @@ input:focus {
text-transform: uppercase;
}
.changed-files pre {
.file-review-actions,
.diff-hunk-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
padding: 8px 10px;
border-top: 1px solid rgba(238, 240, 237, 0.08);
}
.diff-hunks {
display: grid;
gap: 8px;
padding: 8px;
border-top: 1px solid rgba(238, 240, 237, 0.08);
}
.diff-hunk {
border: 1px solid rgba(238, 240, 237, 0.08);
background: rgba(8, 10, 11, 0.42);
}
.diff-hunk-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 32px;
padding: 8px 10px;
color: #d8dcd6;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
monospace;
font-size: 11px;
}
.diff-hunk-header span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.diff-hunk-header small {
color: #96e6ba;
font-family:
ui-sans-serif,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
}
.changed-files pre,
.diff-hunk pre {
max-height: 260px;
margin: 0;
overflow: auto;
@ -638,6 +695,51 @@ input:focus {
white-space: pre-wrap;
}
.review-comment-box {
display: grid;
gap: 8px;
padding: 10px;
border-top: 1px solid rgba(238, 240, 237, 0.08);
}
.review-comment-box label {
display: grid;
gap: 6px;
}
.review-comment-box label span {
color: #9ca39b;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.review-comment-box textarea {
width: 100%;
resize: vertical;
border: 1px solid rgba(238, 240, 237, 0.13);
background: rgba(238, 240, 237, 0.035);
color: #eef0ed;
padding: 8px 10px;
line-height: 1.45;
}
.review-comments {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.review-comments li {
border-left: 2px solid rgba(231, 189, 115, 0.65);
padding-left: 8px;
color: #d8dcd6;
font-size: 12px;
line-height: 1.45;
}
.terminal-drawer {
display: grid;
gap: 10px;

View file

@ -264,6 +264,80 @@ describe('DesktopServer', () => {
).resolves.toBe('test commit');
});
it('returns hunk metadata and can stage or revert individual hunks', async () => {
const projectPath = await createMultiHunkGitProject();
const storePath = join(
await createTempDirectory('qwen-desktop-store-'),
'desktop-projects.json',
);
const server = await createTestServer(undefined, undefined, storePath);
const opened = await postJson(server, '/api/projects/open', {
path: projectPath,
});
const projectId = getProjectId(opened.body);
const diff = await getJson(
server,
`/api/projects/${encodeURIComponent(projectId)}/git/diff`,
{
Authorization: 'Bearer test-token',
},
);
const firstHunk = getChangedFileHunks(diff.body, 'tracked.txt')[0];
const staged = await postJson(
server,
`/api/projects/${encodeURIComponent(projectId)}/git/stage`,
{
scope: 'hunk',
filePath: 'tracked.txt',
hunkId: firstHunk?.id,
},
);
const remainingUnstagedHunk = getChangedFileHunks(
staged.body,
'tracked.txt',
).find((hunk) => hunk.source === 'unstaged');
const reverted = await postJson(
server,
`/api/projects/${encodeURIComponent(projectId)}/git/revert`,
{
scope: 'hunk',
filePath: 'tracked.txt',
hunkId: remainingUnstagedHunk?.id,
},
);
expect(diff.status).toBe(200);
expect(getChangedFileHunks(diff.body, 'tracked.txt')).toEqual([
expect.objectContaining({ source: 'unstaged' }),
expect.objectContaining({ source: 'unstaged' }),
]);
expect(staged.body).toMatchObject({
ok: true,
status: {
staged: 1,
modified: 1,
},
});
expect(getChangedFileHunks(staged.body, 'tracked.txt')).toEqual([
expect.objectContaining({ source: 'staged' }),
expect.objectContaining({ source: 'unstaged' }),
]);
expect(reverted.body).toMatchObject({
ok: true,
status: {
staged: 1,
modified: 0,
},
});
await expect(
readFile(join(projectPath, 'tracked.txt'), 'utf8'),
).resolves.toContain('line-01 changed');
await expect(
readFile(join(projectPath, 'tracked.txt'), 'utf8'),
).resolves.toContain('line-12');
});
it('can revert all project changes', async () => {
const projectPath = await createCommittedGitProject();
const storePath = join(
@ -957,6 +1031,36 @@ async function createCommittedGitProject(): Promise<string> {
return projectPath;
}
async function createMultiHunkGitProject(): Promise<string> {
const projectPath = await createTempDirectory('qwen-desktop-git-');
await runGit(projectPath, ['init']);
await runGit(projectPath, ['config', 'user.email', 'desktop@example.com']);
await runGit(projectPath, ['config', 'user.name', 'Desktop Test']);
await writeFile(
join(projectPath, 'tracked.txt'),
`${Array.from(
{ length: 12 },
(_, index) => `line-${String(index + 1).padStart(2, '0')}`,
).join('\n')}\n`,
'utf8',
);
await runGit(projectPath, ['add', '.']);
await runGit(projectPath, ['commit', '-m', 'initial']);
await writeFile(
join(projectPath, 'tracked.txt'),
`${[
'line-01 changed',
...Array.from(
{ length: 10 },
(_, index) => `line-${String(index + 2).padStart(2, '0')}`,
),
'line-12 changed',
].join('\n')}\n`,
'utf8',
);
return projectPath;
}
async function getJson(
server: DesktopServer,
path: string,
@ -1163,6 +1267,64 @@ function getTerminalId(message: unknown): string {
return message.terminal.id;
}
function getChangedFileHunks(
message: unknown,
filePath: string,
): Array<{ id: string; source: string }> {
if (!message || typeof message !== 'object') {
throw new Error('Expected Git diff files.');
}
let files: unknown;
if ('files' in message) {
files = message.files;
} else if (
'diff' in message &&
message.diff &&
typeof message.diff === 'object' &&
'files' in message.diff
) {
files = message.diff.files;
}
if (!Array.isArray(files)) {
throw new Error('Expected Git diff files.');
}
const file = files.find(
(candidate) =>
!!candidate &&
typeof candidate === 'object' &&
'path' in candidate &&
candidate.path === filePath,
);
if (!file || typeof file !== 'object' || !('hunks' in file)) {
throw new Error(`Expected Git diff hunks for ${filePath}.`);
}
const hunks = file.hunks;
if (!Array.isArray(hunks)) {
throw new Error(`Expected Git diff hunks for ${filePath}.`);
}
return hunks.map((hunk) => {
if (
!hunk ||
typeof hunk !== 'object' ||
!('id' in hunk) ||
typeof hunk.id !== 'string' ||
!('source' in hunk) ||
typeof hunk.source !== 'string'
) {
throw new Error(`Expected typed Git hunk for ${filePath}.`);
}
return {
id: hunk.id,
source: hunk.source,
};
});
}
async function waitForTerminal(
server: DesktopServer,
terminalId: string,

View file

@ -1034,7 +1034,19 @@ function parseGitTarget(body: Record<string, unknown>): DesktopGitTarget {
};
}
throw new DesktopHttpError(400, 'bad_request', 'scope must be all or file.');
if (scope === 'hunk') {
return {
scope,
filePath: getRequiredString(body, 'filePath'),
hunkId: getRequiredString(body, 'hunkId'),
};
}
throw new DesktopHttpError(
400,
'bad_request',
'scope must be all, file, or hunk.',
);
}
function getOptionalString(

View file

@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { execFile } from 'node:child_process';
import { execFile, spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { isAbsolute, normalize, relative, sep } from 'node:path';
import { DesktopHttpError } from '../http/errors.js';
@ -18,6 +19,19 @@ export type DesktopGitChangeStatus =
| 'untracked'
| 'unknown';
export type DesktopGitChangeSource = 'staged' | 'unstaged' | 'untracked';
export interface DesktopGitDiffHunk {
id: string;
source: DesktopGitChangeSource;
header: string;
oldStart: number;
oldLines: number;
newStart: number;
newLines: number;
lines: string[];
}
export interface DesktopGitChangedFile {
path: string;
status: DesktopGitChangeStatus;
@ -25,6 +39,7 @@ export interface DesktopGitChangedFile {
unstaged: boolean;
untracked: boolean;
diff: string;
hunks: DesktopGitDiffHunk[];
}
export interface DesktopGitDiff {
@ -40,8 +55,13 @@ export interface DesktopGitCommitResult {
}
export interface DesktopGitTarget {
scope: 'all' | 'file';
scope: 'all' | 'file' | 'hunk';
filePath?: string;
hunkId?: string;
}
interface ParsedGitDiffHunk extends DesktopGitDiffHunk {
patch: string;
}
export class DesktopGitReviewService {
@ -71,6 +91,20 @@ export class DesktopGitReviewService {
return;
}
if (target.scope === 'hunk') {
const hunk = await getSafeHunkPatch(projectPath, target);
if (hunk.source === 'staged') {
return;
}
await runGitWithInput(
projectPath,
['apply', '--cached', '--recount', '--whitespace=nowarn'],
hunk.patch,
);
return;
}
await runGit(projectPath, [
'add',
'--',
@ -86,6 +120,30 @@ export class DesktopGitReviewService {
return;
}
if (target.scope === 'hunk') {
const hunk = await getSafeHunkPatch(projectPath, target);
if (hunk.source === 'staged') {
await runGitWithInput(
projectPath,
[
'apply',
'--cached',
'--reverse',
'--recount',
'--whitespace=nowarn',
],
hunk.patch,
);
}
await runGitWithInput(
projectPath,
['apply', '--reverse', '--recount', '--whitespace=nowarn'],
hunk.patch,
);
return;
}
const filePath = getSafeRelativePath(projectPath, target);
await runGitIgnoringFailure(projectPath, [
'restore',
@ -167,9 +225,21 @@ async function describeChangedFile(
? createUntrackedFileDiff(projectPath, entry.path)
: Promise.resolve(''),
]);
const diff = [cachedDiff, worktreeDiff, untrackedDiff]
const diffParts: Array<{
source: DesktopGitChangeSource;
diff: string;
}> = [
{ source: 'staged', diff: cachedDiff },
{ source: untracked ? 'untracked' : 'unstaged', diff: worktreeDiff },
{ source: 'untracked', diff: untrackedDiff },
];
const diff = diffParts
.map((part) => part.diff)
.filter((part) => part.length > 0)
.join('\n');
const hunks = diffParts.flatMap((part) =>
parseDiffHunks(entry.path, part.source, part.diff),
);
return {
path: entry.path,
@ -178,6 +248,7 @@ async function describeChangedFile(
unstaged: entry.worktreeStatus !== ' ' && entry.worktreeStatus !== '?',
untracked,
diff,
hunks: hunks.map(({ patch: _patch, ...hunk }) => hunk),
};
}
@ -217,32 +288,191 @@ async function createUntrackedFileDiff(
});
try {
const raw = await readFile(`${projectPath}${sep}${safePath}`, 'utf8');
const additions = raw
.split(/\r?\n/u)
.map((line) => `+${line}`)
.join('\n');
const content = raw.endsWith('\n') ? raw.slice(0, -1) : raw;
const lines = content.length > 0 ? content.split(/\r?\n/u) : [];
const additions = lines.map((line) => `+${line}`).join('\n');
return [
`diff --git a/${safePath} b/${safePath}`,
'new file mode 100644',
'--- /dev/null',
`+++ b/${safePath}`,
'@@',
`@@ -0,0 +1,${lines.length} @@`,
additions,
].join('\n');
]
.filter((line) => line.length > 0)
.join('\n');
} catch {
return `diff unavailable for untracked file ${safePath}`;
}
}
async function getSafeHunkPatch(
projectPath: string,
target: DesktopGitTarget,
): Promise<ParsedGitDiffHunk> {
const filePath = getSafeRelativePath(projectPath, target);
const hunkId = getRequiredHunkId(target);
const statusEntries = await getPorcelainStatus(projectPath);
const entry = statusEntries.find((candidate) => candidate.path === filePath);
if (!entry) {
throw new DesktopHttpError(
404,
'git_hunk_not_found',
'The requested Git hunk is no longer available.',
);
}
const file = await describeChangedFileWithPatches(projectPath, entry);
const hunk = file.hunks.find((candidate) => candidate.id === hunkId);
if (!hunk) {
throw new DesktopHttpError(
404,
'git_hunk_not_found',
'The requested Git hunk is no longer available.',
);
}
return hunk;
}
async function describeChangedFileWithPatches(
projectPath: string,
entry: PorcelainStatusEntry,
): Promise<{
path: string;
hunks: ParsedGitDiffHunk[];
}> {
const untracked = entry.indexStatus === '?' && entry.worktreeStatus === '?';
const [cachedDiff, worktreeDiff, untrackedDiff] = await Promise.all([
untracked
? Promise.resolve('')
: runGit(projectPath, ['diff', '--cached', '--', entry.path]),
untracked
? Promise.resolve('')
: runGit(projectPath, ['diff', '--', entry.path]),
untracked
? createUntrackedFileDiff(projectPath, entry.path)
: Promise.resolve(''),
]);
return {
path: entry.path,
hunks: [
...parseDiffHunks(entry.path, 'staged', cachedDiff),
...parseDiffHunks(
entry.path,
untracked ? 'untracked' : 'unstaged',
worktreeDiff,
),
...parseDiffHunks(entry.path, 'untracked', untrackedDiff),
],
};
}
function parseDiffHunks(
filePath: string,
source: DesktopGitChangeSource,
diff: string,
): ParsedGitDiffHunk[] {
if (!diff.trim()) {
return [];
}
const normalizedDiff = diff.endsWith('\n') ? diff.slice(0, -1) : diff;
const lines = normalizedDiff.split('\n');
const headerLines: string[] = [];
const hunks: ParsedGitDiffHunk[] = [];
let current: string[] | null = null;
for (const line of lines) {
if (line.startsWith('@@ ')) {
if (current) {
pushParsedHunk(filePath, source, headerLines, current, hunks);
}
current = [line];
continue;
}
if (current) {
current.push(line);
} else if (line.length > 0) {
headerLines.push(line);
}
}
if (current) {
pushParsedHunk(filePath, source, headerLines, current, hunks);
}
return hunks;
}
function pushParsedHunk(
filePath: string,
source: DesktopGitChangeSource,
headerLines: string[],
hunkLines: string[],
hunks: ParsedGitDiffHunk[],
): void {
const header = hunkLines[0] ?? '';
const range = parseHunkRange(header);
if (!range) {
return;
}
const ordinal = hunks.length;
const patch = `${[...headerLines, ...hunkLines].join('\n')}\n`;
hunks.push({
id: createHunkId(filePath, source, ordinal, hunkLines),
source,
header,
...range,
lines: hunkLines.slice(1),
patch,
});
}
function parseHunkRange(
header: string,
): Omit<DesktopGitDiffHunk, 'id' | 'source' | 'header' | 'lines'> | null {
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/u.exec(header);
if (!match) {
return null;
}
return {
oldStart: Number(match[1]),
oldLines: match[2] === undefined ? 1 : Number(match[2]),
newStart: Number(match[3]),
newLines: match[4] === undefined ? 1 : Number(match[4]),
};
}
function createHunkId(
filePath: string,
source: DesktopGitChangeSource,
ordinal: number,
hunkLines: string[],
): string {
return createHash('sha256')
.update(`${source}\0${filePath}\0${ordinal}\0${hunkLines.join('\n')}`)
.digest('hex')
.slice(0, 16);
}
function getSafeRelativePath(
projectPath: string,
target: DesktopGitTarget,
): string {
if (target.scope !== 'file' || !target.filePath?.trim()) {
if (
(target.scope !== 'file' && target.scope !== 'hunk') ||
!target.filePath?.trim()
) {
throw new DesktopHttpError(
400,
'bad_request',
'filePath is required for file-scoped Git review operations.',
'filePath is required for file or hunk-scoped Git review operations.',
);
}
@ -274,6 +504,18 @@ function getSafeRelativePath(
return normalized;
}
function getRequiredHunkId(target: DesktopGitTarget): string {
if (target.scope !== 'hunk' || !target.hunkId?.trim()) {
throw new DesktopHttpError(
400,
'bad_request',
'hunkId is required for hunk-scoped Git review operations.',
);
}
return target.hunkId;
}
async function runGitIgnoringFailure(
cwd: string,
args: string[],
@ -307,3 +549,51 @@ function runGit(cwd: string, args: string[]): Promise<string> {
);
});
}
function runGitWithInput(
cwd: string,
args: string[],
input: string,
): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn('git', ['-C', cwd, ...args], {
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
child.kill();
}, 20_000);
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
});
child.on('error', (error) => {
clearTimeout(timeout);
reject(new DesktopHttpError(400, 'git_error', error.message));
});
child.on('close', (code) => {
clearTimeout(timeout);
if (code !== 0) {
reject(
new DesktopHttpError(
400,
'git_error',
timedOut
? 'Git review operation timed out.'
: stderr.trim() || `git exited with status ${code ?? 'unknown'}`,
),
);
return;
}
resolve(stdout);
});
child.stdin.end(input);
});
}