mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-04 22:51:08 +00:00
feat(desktop): add hunk-level review controls
This commit is contained in:
parent
a8dfa18598
commit
c3bc36fde1
13 changed files with 1071 additions and 89 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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;'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue