mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
feat(desktop): add diff review commit flow
This commit is contained in:
parent
82c6675d09
commit
39edf57e6d
9 changed files with 1144 additions and 8 deletions
|
|
@ -16,7 +16,9 @@ import {
|
|||
} from 'react';
|
||||
import {
|
||||
authenticateDesktop,
|
||||
commitDesktopProjectChanges,
|
||||
createDesktopSession,
|
||||
getDesktopProjectGitDiff,
|
||||
getDesktopProjectGitStatus,
|
||||
getDesktopSessionModeState,
|
||||
getDesktopSessionModelState,
|
||||
|
|
@ -25,10 +27,13 @@ import {
|
|||
listDesktopSessions,
|
||||
loadDesktopStatus,
|
||||
openDesktopProject,
|
||||
revertDesktopProjectChanges,
|
||||
setDesktopSessionMode,
|
||||
setDesktopSessionModel,
|
||||
stageDesktopProjectChanges,
|
||||
updateDesktopUserSettings,
|
||||
type DesktopConnectionStatus,
|
||||
type DesktopGitDiff,
|
||||
type DesktopProject,
|
||||
type DesktopSessionSummary,
|
||||
} from './api/client.js';
|
||||
|
|
@ -73,6 +78,9 @@ export function App() {
|
|||
const [sessions, setSessions] = useState<DesktopSessionSummary[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [sessionError, setSessionError] = useState<string | null>(null);
|
||||
const [gitDiff, setGitDiff] = useState<DesktopGitDiff | null>(null);
|
||||
const [reviewError, setReviewError] = useState<string | null>(null);
|
||||
const [commitMessage, setCommitMessage] = useState('');
|
||||
const [messageText, setMessageText] = useState('');
|
||||
const [chatState, dispatchChat] = useReducer(
|
||||
chatReducer,
|
||||
|
|
@ -341,6 +349,106 @@ export function App() {
|
|||
}
|
||||
}, [activeProject, loadState]);
|
||||
|
||||
const loadProjectReview = useCallback(async () => {
|
||||
if (loadState.state !== 'ready' || !activeProject) {
|
||||
setGitDiff(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const diff = await getDesktopProjectGitDiff(
|
||||
loadState.status.serverInfo,
|
||||
activeProject.id,
|
||||
);
|
||||
setGitDiff(diff);
|
||||
setReviewError(null);
|
||||
} catch (error) {
|
||||
setGitDiff(null);
|
||||
setReviewError(getErrorMessage(error));
|
||||
}
|
||||
}, [activeProject, loadState]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProjectReview();
|
||||
}, [loadProjectReview]);
|
||||
|
||||
const applyReviewMutation = useCallback(
|
||||
(status: DesktopProject['gitStatus'], diff: DesktopGitDiff) => {
|
||||
if (!activeProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProjects((current) =>
|
||||
current.map((project) =>
|
||||
project.id === activeProject.id
|
||||
? {
|
||||
...project,
|
||||
gitBranch: status.branch,
|
||||
gitStatus: status,
|
||||
}
|
||||
: project,
|
||||
),
|
||||
);
|
||||
setGitDiff(diff);
|
||||
setReviewError(null);
|
||||
},
|
||||
[activeProject],
|
||||
);
|
||||
|
||||
const stageAllChanges = useCallback(async () => {
|
||||
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]);
|
||||
|
||||
const revertAllChanges = useCallback(async () => {
|
||||
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]);
|
||||
|
||||
const commitChanges = useCallback(async () => {
|
||||
if (
|
||||
loadState.state !== 'ready' ||
|
||||
!activeProject ||
|
||||
commitMessage.trim().length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await commitDesktopProjectChanges(
|
||||
loadState.status.serverInfo,
|
||||
activeProject.id,
|
||||
commitMessage,
|
||||
);
|
||||
applyReviewMutation(result.status, result.diff);
|
||||
setCommitMessage('');
|
||||
} catch (error) {
|
||||
setReviewError(getErrorMessage(error));
|
||||
}
|
||||
}, [activeProject, applyReviewMutation, commitMessage, loadState]);
|
||||
|
||||
const saveSettings = useCallback(async () => {
|
||||
if (loadState.state !== 'ready') {
|
||||
return;
|
||||
|
|
@ -583,7 +691,16 @@ export function App() {
|
|||
<div className="panel-header">
|
||||
<h3>Review</h3>
|
||||
</div>
|
||||
<ReviewSummary project={activeProject} />
|
||||
<ReviewSummary
|
||||
commitMessage={commitMessage}
|
||||
gitDiff={gitDiff}
|
||||
project={activeProject}
|
||||
reviewError={reviewError}
|
||||
onCommit={commitChanges}
|
||||
onCommitMessageChange={setCommitMessage}
|
||||
onRevertAll={revertAllChanges}
|
||||
onStageAll={stageAllChanges}
|
||||
/>
|
||||
<RuntimeDetails loadState={loadState} />
|
||||
<SessionDetails
|
||||
activeSessionId={activeSessionId}
|
||||
|
|
@ -827,7 +944,25 @@ function PermissionPrompts({
|
|||
);
|
||||
}
|
||||
|
||||
function ReviewSummary({ project }: { project: DesktopProject | null }) {
|
||||
function ReviewSummary({
|
||||
commitMessage,
|
||||
gitDiff,
|
||||
onCommit,
|
||||
onCommitMessageChange,
|
||||
onRevertAll,
|
||||
onStageAll,
|
||||
project,
|
||||
reviewError,
|
||||
}: {
|
||||
commitMessage: string;
|
||||
gitDiff: DesktopGitDiff | null;
|
||||
onCommit: () => void;
|
||||
onCommitMessageChange: (message: string) => void;
|
||||
onRevertAll: () => void;
|
||||
onStageAll: () => void;
|
||||
project: DesktopProject | null;
|
||||
reviewError: string | null;
|
||||
}) {
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="review-summary">
|
||||
|
|
@ -837,6 +972,7 @@ function ReviewSummary({ project }: { project: DesktopProject | null }) {
|
|||
}
|
||||
|
||||
const status = project.gitStatus;
|
||||
const changedFiles = gitDiff?.files ?? [];
|
||||
return (
|
||||
<div className="review-summary">
|
||||
<div className="review-tabs" aria-label="Review sections">
|
||||
|
|
@ -862,6 +998,10 @@ function ReviewSummary({ project }: { project: DesktopProject | null }) {
|
|||
<dt>Untracked</dt>
|
||||
<dd>{status.untracked}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Files</dt>
|
||||
<dd>{changedFiles.length}</dd>
|
||||
</div>
|
||||
{status.error ? (
|
||||
<div>
|
||||
<dt>Git</dt>
|
||||
|
|
@ -869,6 +1009,56 @@ function ReviewSummary({ project }: { project: DesktopProject | null }) {
|
|||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
<div className="review-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={changedFiles.length === 0}
|
||||
type="button"
|
||||
onClick={onRevertAll}
|
||||
>
|
||||
Revert All
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={changedFiles.length === 0}
|
||||
type="button"
|
||||
onClick={onStageAll}
|
||||
>
|
||||
Stage 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>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="commit-box">
|
||||
<input
|
||||
aria-label="Commit message"
|
||||
placeholder="Commit message"
|
||||
value={commitMessage}
|
||||
onChange={(event) => onCommitMessageChange(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={commitMessage.trim().length === 0}
|
||||
type="button"
|
||||
onClick={onCommit}
|
||||
>
|
||||
Commit
|
||||
</button>
|
||||
</div>
|
||||
{reviewError ? <p className="error-text">{reviewError}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,44 @@ export interface DesktopProjectList {
|
|||
projects: DesktopProject[];
|
||||
}
|
||||
|
||||
export type DesktopGitChangeStatus =
|
||||
| 'added'
|
||||
| 'copied'
|
||||
| 'deleted'
|
||||
| 'modified'
|
||||
| 'renamed'
|
||||
| 'untracked'
|
||||
| 'unknown';
|
||||
|
||||
export interface DesktopGitChangedFile {
|
||||
path: string;
|
||||
status: DesktopGitChangeStatus;
|
||||
staged: boolean;
|
||||
unstaged: boolean;
|
||||
untracked: boolean;
|
||||
diff: string;
|
||||
}
|
||||
|
||||
export interface DesktopGitDiff {
|
||||
ok: true;
|
||||
files: DesktopGitChangedFile[];
|
||||
diff: string;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface DesktopGitReviewMutation {
|
||||
ok: true;
|
||||
status: DesktopGitStatus;
|
||||
diff: DesktopGitDiff;
|
||||
}
|
||||
|
||||
export interface DesktopGitCommitMutation extends DesktopGitReviewMutation {
|
||||
commit: {
|
||||
commit: string;
|
||||
summary: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DesktopRuntime {
|
||||
ok: true;
|
||||
desktop: {
|
||||
|
|
@ -182,6 +220,57 @@ export async function getDesktopProjectGitStatus(
|
|||
return response.status;
|
||||
}
|
||||
|
||||
export async function getDesktopProjectGitDiff(
|
||||
serverInfo: DesktopServerInfo,
|
||||
projectId: string,
|
||||
): Promise<DesktopGitDiff> {
|
||||
return getJson(
|
||||
serverInfo,
|
||||
`/api/projects/${encodeURIComponent(projectId)}/git/diff`,
|
||||
isGitDiff,
|
||||
);
|
||||
}
|
||||
|
||||
export async function stageDesktopProjectChanges(
|
||||
serverInfo: DesktopServerInfo,
|
||||
projectId: string,
|
||||
): Promise<DesktopGitReviewMutation> {
|
||||
return writeJson(
|
||||
serverInfo,
|
||||
`/api/projects/${encodeURIComponent(projectId)}/git/stage`,
|
||||
'POST',
|
||||
{ scope: 'all' },
|
||||
isGitReviewMutation,
|
||||
);
|
||||
}
|
||||
|
||||
export async function revertDesktopProjectChanges(
|
||||
serverInfo: DesktopServerInfo,
|
||||
projectId: string,
|
||||
): Promise<DesktopGitReviewMutation> {
|
||||
return writeJson(
|
||||
serverInfo,
|
||||
`/api/projects/${encodeURIComponent(projectId)}/git/revert`,
|
||||
'POST',
|
||||
{ scope: 'all' },
|
||||
isGitReviewMutation,
|
||||
);
|
||||
}
|
||||
|
||||
export async function commitDesktopProjectChanges(
|
||||
serverInfo: DesktopServerInfo,
|
||||
projectId: string,
|
||||
message: string,
|
||||
): Promise<DesktopGitCommitMutation> {
|
||||
return writeJson(
|
||||
serverInfo,
|
||||
`/api/projects/${encodeURIComponent(projectId)}/git/commit`,
|
||||
'POST',
|
||||
{ message },
|
||||
isGitCommitMutation,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createDesktopSession(
|
||||
serverInfo: DesktopServerInfo,
|
||||
cwd: string,
|
||||
|
|
@ -476,6 +565,52 @@ function isGitStatusResponse(
|
|||
return candidate.ok === true && isGitStatus(candidate.status);
|
||||
}
|
||||
|
||||
function isGitDiff(value: unknown): value is DesktopGitDiff {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<DesktopGitDiff>;
|
||||
return (
|
||||
candidate.ok === true &&
|
||||
Array.isArray(candidate.files) &&
|
||||
candidate.files.every(isGitChangedFile) &&
|
||||
typeof candidate.diff === 'string' &&
|
||||
typeof candidate.generatedAt === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isGitReviewMutation(
|
||||
value: unknown,
|
||||
): value is DesktopGitReviewMutation {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<DesktopGitReviewMutation>;
|
||||
return (
|
||||
candidate.ok === true &&
|
||||
isGitStatus(candidate.status) &&
|
||||
isGitDiff(candidate.diff)
|
||||
);
|
||||
}
|
||||
|
||||
function isGitCommitMutation(
|
||||
value: unknown,
|
||||
): value is DesktopGitCommitMutation {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<DesktopGitCommitMutation>;
|
||||
return (
|
||||
isGitReviewMutation(value) &&
|
||||
!!candidate.commit &&
|
||||
typeof candidate.commit.commit === 'string' &&
|
||||
typeof candidate.commit.summary === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isCreateSessionResponse(
|
||||
value: unknown,
|
||||
): value is { ok: true; session: DesktopSessionSummary } {
|
||||
|
|
@ -593,6 +728,34 @@ function isGitStatus(value: unknown): value is DesktopGitStatus {
|
|||
);
|
||||
}
|
||||
|
||||
function isGitChangedFile(value: unknown): value is DesktopGitChangedFile {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<DesktopGitChangedFile>;
|
||||
return (
|
||||
typeof candidate.path === 'string' &&
|
||||
isGitChangeStatus(candidate.status) &&
|
||||
typeof candidate.staged === 'boolean' &&
|
||||
typeof candidate.unstaged === 'boolean' &&
|
||||
typeof candidate.untracked === 'boolean' &&
|
||||
typeof candidate.diff === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isGitChangeStatus(value: unknown): value is DesktopGitChangeStatus {
|
||||
return (
|
||||
value === 'added' ||
|
||||
value === 'copied' ||
|
||||
value === 'deleted' ||
|
||||
value === 'modified' ||
|
||||
value === 'renamed' ||
|
||||
value === 'untracked' ||
|
||||
value === 'unknown'
|
||||
);
|
||||
}
|
||||
|
||||
function isModelState(value: unknown): value is DesktopSessionModelState {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -569,6 +569,75 @@ input:focus {
|
|||
border-left: 1px solid rgba(238, 240, 237, 0.1);
|
||||
}
|
||||
|
||||
.review-actions,
|
||||
.commit-box {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.review-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.commit-box input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.changed-files {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.changed-files details {
|
||||
border: 1px solid rgba(238, 240, 237, 0.1);
|
||||
background: rgba(238, 240, 237, 0.03);
|
||||
}
|
||||
|
||||
.changed-files summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.changed-files summary span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #eef0ed;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.changed-files summary small {
|
||||
color: #9ca39b;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.changed-files pre {
|
||||
max-height: 260px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
border-top: 1px solid rgba(238, 240, 237, 0.08);
|
||||
color: #d8dcd6;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||
monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #858c84;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,113 @@ describe('DesktopServer', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('returns project diffs and can stage and commit changes', async () => {
|
||||
const projectPath = await createCommittedGitProject();
|
||||
const storePath = join(
|
||||
await createTempDirectory('qwen-desktop-store-'),
|
||||
'desktop-projects.json',
|
||||
);
|
||||
await writeFile(join(projectPath, 'tracked.txt'), 'changed\n', 'utf8');
|
||||
await writeFile(join(projectPath, 'new.txt'), 'new file\n', 'utf8');
|
||||
|
||||
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 staged = await postJson(
|
||||
server,
|
||||
`/api/projects/${encodeURIComponent(projectId)}/git/stage`,
|
||||
{ scope: 'all' },
|
||||
);
|
||||
const committed = await postJson(
|
||||
server,
|
||||
`/api/projects/${encodeURIComponent(projectId)}/git/commit`,
|
||||
{ message: 'test commit' },
|
||||
);
|
||||
|
||||
expect(diff.status).toBe(200);
|
||||
expect(diff.body).toMatchObject({
|
||||
ok: true,
|
||||
files: expect.arrayContaining([
|
||||
expect.objectContaining({ path: 'tracked.txt', status: 'modified' }),
|
||||
expect.objectContaining({ path: 'new.txt', status: 'untracked' }),
|
||||
]),
|
||||
});
|
||||
expect(JSON.stringify(diff.body)).toContain('+changed');
|
||||
expect(staged.body).toMatchObject({
|
||||
ok: true,
|
||||
status: {
|
||||
staged: 2,
|
||||
modified: 0,
|
||||
untracked: 0,
|
||||
},
|
||||
});
|
||||
expect(committed.body).toMatchObject({
|
||||
ok: true,
|
||||
commit: {
|
||||
commit: expect.any(String),
|
||||
},
|
||||
status: {
|
||||
clean: true,
|
||||
staged: 0,
|
||||
modified: 0,
|
||||
untracked: 0,
|
||||
},
|
||||
diff: {
|
||||
files: [],
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
runGitOutput(projectPath, ['log', '-1', '--pretty=%s']),
|
||||
).resolves.toBe('test commit');
|
||||
});
|
||||
|
||||
it('can revert all project changes', async () => {
|
||||
const projectPath = await createCommittedGitProject();
|
||||
const storePath = join(
|
||||
await createTempDirectory('qwen-desktop-store-'),
|
||||
'desktop-projects.json',
|
||||
);
|
||||
await writeFile(join(projectPath, 'tracked.txt'), 'changed\n', 'utf8');
|
||||
await writeFile(join(projectPath, 'new.txt'), 'new file\n', 'utf8');
|
||||
|
||||
const server = await createTestServer(undefined, undefined, storePath);
|
||||
const opened = await postJson(server, '/api/projects/open', {
|
||||
path: projectPath,
|
||||
});
|
||||
const projectId = getProjectId(opened.body);
|
||||
const reverted = await postJson(
|
||||
server,
|
||||
`/api/projects/${encodeURIComponent(projectId)}/git/revert`,
|
||||
{ scope: 'all' },
|
||||
);
|
||||
|
||||
expect(reverted.status).toBe(200);
|
||||
expect(reverted.body).toMatchObject({
|
||||
ok: true,
|
||||
status: {
|
||||
clean: true,
|
||||
staged: 0,
|
||||
modified: 0,
|
||||
untracked: 0,
|
||||
},
|
||||
diff: {
|
||||
files: [],
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
readFile(join(projectPath, 'tracked.txt'), 'utf8'),
|
||||
).resolves.toBe('initial\n');
|
||||
});
|
||||
|
||||
it('reads and writes user settings without returning API key secrets', async () => {
|
||||
const settingsPath = await createTempSettingsPath();
|
||||
const server = await createTestServer(undefined, settingsPath);
|
||||
|
|
@ -773,6 +880,17 @@ async function createTempSettingsPath(): Promise<string> {
|
|||
return join(dir, '.qwen', 'settings.json');
|
||||
}
|
||||
|
||||
async function createCommittedGitProject(): 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'), 'initial\n', 'utf8');
|
||||
await runGit(projectPath, ['add', '.']);
|
||||
await runGit(projectPath, ['commit', '-m', 'initial']);
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
async function getJson(
|
||||
server: DesktopServer,
|
||||
path: string,
|
||||
|
|
@ -980,14 +1098,18 @@ function getAvailableModes(message: unknown): unknown[] {
|
|||
}
|
||||
|
||||
function runGit(cwd: string, args: string[]): Promise<void> {
|
||||
return runGitOutput(cwd, args).then(() => undefined);
|
||||
}
|
||||
|
||||
function runGitOutput(cwd: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile('git', args, { cwd }, (error, _stdout, stderr) => {
|
||||
execFile('git', args, { cwd }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(stderr.trim() || error.message));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
resolve(stdout.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ import {
|
|||
import { AcpEventRouter } from './acp/AcpEventRouter.js';
|
||||
import { PermissionBridge } from './acp/permissionBridge.js';
|
||||
import { isDesktopHttpError, DesktopHttpError } from './http/errors.js';
|
||||
import {
|
||||
DesktopGitReviewService,
|
||||
type DesktopGitTarget,
|
||||
} from './services/gitReviewService.js';
|
||||
import { DesktopProjectService } from './services/projectService.js';
|
||||
import { getRuntimeInfo } from './services/runtimeService.js';
|
||||
import {
|
||||
|
|
@ -35,6 +39,7 @@ interface HandlerContext {
|
|||
token: string;
|
||||
startedAt: number;
|
||||
now: () => Date;
|
||||
gitReviewService: DesktopGitReviewService;
|
||||
projectService: DesktopProjectService;
|
||||
sessionService: DesktopSessionService;
|
||||
settingsService: DesktopSettingsService;
|
||||
|
|
@ -51,6 +56,7 @@ export async function startDesktopServer(
|
|||
storePath: options.projectStorePath,
|
||||
now,
|
||||
});
|
||||
const gitReviewService = new DesktopGitReviewService(now);
|
||||
const sessionService = new DesktopSessionService(options.acpClient);
|
||||
const settingsService = new DesktopSettingsService(options.settingsPath);
|
||||
const socketHubRef: { current: SessionSocketHub | null } = { current: null };
|
||||
|
|
@ -83,6 +89,7 @@ export async function startDesktopServer(
|
|||
token,
|
||||
startedAt,
|
||||
now,
|
||||
gitReviewService,
|
||||
projectService,
|
||||
sessionService,
|
||||
settingsService,
|
||||
|
|
@ -226,6 +233,66 @@ async function handleRequest(
|
|||
return;
|
||||
}
|
||||
|
||||
const projectGitDiffMatch = matchSessionRoute(
|
||||
requestUrl.pathname,
|
||||
/^\/api\/projects\/([^/]+)\/git\/diff$/u,
|
||||
);
|
||||
if (projectGitDiffMatch) {
|
||||
await handleProjectGitDiffRoute(
|
||||
request,
|
||||
response,
|
||||
origin,
|
||||
context,
|
||||
projectGitDiffMatch,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const projectGitStageMatch = matchSessionRoute(
|
||||
requestUrl.pathname,
|
||||
/^\/api\/projects\/([^/]+)\/git\/stage$/u,
|
||||
);
|
||||
if (projectGitStageMatch) {
|
||||
await handleProjectGitStageRoute(
|
||||
request,
|
||||
response,
|
||||
origin,
|
||||
context,
|
||||
projectGitStageMatch,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const projectGitRevertMatch = matchSessionRoute(
|
||||
requestUrl.pathname,
|
||||
/^\/api\/projects\/([^/]+)\/git\/revert$/u,
|
||||
);
|
||||
if (projectGitRevertMatch) {
|
||||
await handleProjectGitRevertRoute(
|
||||
request,
|
||||
response,
|
||||
origin,
|
||||
context,
|
||||
projectGitRevertMatch,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const projectGitCommitMatch = matchSessionRoute(
|
||||
requestUrl.pathname,
|
||||
/^\/api\/projects\/([^/]+)\/git\/commit$/u,
|
||||
);
|
||||
if (projectGitCommitMatch) {
|
||||
await handleProjectGitCommitRoute(
|
||||
request,
|
||||
response,
|
||||
origin,
|
||||
context,
|
||||
projectGitCommitMatch,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestUrl.pathname === '/api/settings/user') {
|
||||
await handleUserSettingsRoute(request, response, origin, context);
|
||||
return;
|
||||
|
|
@ -395,6 +462,97 @@ async function handleProjectGitStatusRoute(
|
|||
sendMethodNotAllowed(response, origin);
|
||||
}
|
||||
|
||||
async function handleProjectGitDiffRoute(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
origin: string | undefined,
|
||||
context: HandlerContext,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
if (request.method === 'GET') {
|
||||
const projectPath = await context.projectService.getProjectPath(projectId);
|
||||
sendJson(
|
||||
response,
|
||||
origin,
|
||||
200,
|
||||
await context.gitReviewService.getDiff(projectPath),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
sendMethodNotAllowed(response, origin);
|
||||
}
|
||||
|
||||
async function handleProjectGitStageRoute(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
origin: string | undefined,
|
||||
context: HandlerContext,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
if (request.method === 'POST') {
|
||||
const body = await readObjectBody(request);
|
||||
const target = parseGitTarget(body);
|
||||
const projectPath = await context.projectService.getProjectPath(projectId);
|
||||
await context.gitReviewService.stage(projectPath, target);
|
||||
sendJson(response, origin, 200, {
|
||||
ok: true,
|
||||
status: await context.projectService.getProjectGitStatus(projectId),
|
||||
diff: await context.gitReviewService.getDiff(projectPath),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendMethodNotAllowed(response, origin);
|
||||
}
|
||||
|
||||
async function handleProjectGitRevertRoute(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
origin: string | undefined,
|
||||
context: HandlerContext,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
if (request.method === 'POST') {
|
||||
const body = await readObjectBody(request);
|
||||
const target = parseGitTarget(body);
|
||||
const projectPath = await context.projectService.getProjectPath(projectId);
|
||||
await context.gitReviewService.revert(projectPath, target);
|
||||
sendJson(response, origin, 200, {
|
||||
ok: true,
|
||||
status: await context.projectService.getProjectGitStatus(projectId),
|
||||
diff: await context.gitReviewService.getDiff(projectPath),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendMethodNotAllowed(response, origin);
|
||||
}
|
||||
|
||||
async function handleProjectGitCommitRoute(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
origin: string | undefined,
|
||||
context: HandlerContext,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
if (request.method === 'POST') {
|
||||
const body = await readObjectBody(request);
|
||||
const message = getRequiredString(body, 'message');
|
||||
const projectPath = await context.projectService.getProjectPath(projectId);
|
||||
const commit = await context.gitReviewService.commit(projectPath, message);
|
||||
sendJson(response, origin, 200, {
|
||||
ok: true,
|
||||
commit,
|
||||
status: await context.projectService.getProjectGitStatus(projectId),
|
||||
diff: await context.gitReviewService.getDiff(projectPath),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendMethodNotAllowed(response, origin);
|
||||
}
|
||||
|
||||
async function handleSessionsRoute(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
|
|
@ -766,6 +924,22 @@ function parseUserSettingsUpdate(
|
|||
};
|
||||
}
|
||||
|
||||
function parseGitTarget(body: Record<string, unknown>): DesktopGitTarget {
|
||||
const scope = body['scope'] ?? 'all';
|
||||
if (scope === 'all') {
|
||||
return { scope };
|
||||
}
|
||||
|
||||
if (scope === 'file') {
|
||||
return {
|
||||
scope,
|
||||
filePath: getRequiredString(body, 'filePath'),
|
||||
};
|
||||
}
|
||||
|
||||
throw new DesktopHttpError(400, 'bad_request', 'scope must be all or file.');
|
||||
}
|
||||
|
||||
function getOptionalString(
|
||||
body: Record<string, unknown>,
|
||||
key: string,
|
||||
|
|
|
|||
309
packages/desktop/src/server/services/gitReviewService.ts
Normal file
309
packages/desktop/src/server/services/gitReviewService.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { isAbsolute, normalize, relative, sep } from 'node:path';
|
||||
import { DesktopHttpError } from '../http/errors.js';
|
||||
|
||||
export type DesktopGitChangeStatus =
|
||||
| 'added'
|
||||
| 'copied'
|
||||
| 'deleted'
|
||||
| 'modified'
|
||||
| 'renamed'
|
||||
| 'untracked'
|
||||
| 'unknown';
|
||||
|
||||
export interface DesktopGitChangedFile {
|
||||
path: string;
|
||||
status: DesktopGitChangeStatus;
|
||||
staged: boolean;
|
||||
unstaged: boolean;
|
||||
untracked: boolean;
|
||||
diff: string;
|
||||
}
|
||||
|
||||
export interface DesktopGitDiff {
|
||||
ok: true;
|
||||
files: DesktopGitChangedFile[];
|
||||
diff: string;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface DesktopGitCommitResult {
|
||||
commit: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface DesktopGitTarget {
|
||||
scope: 'all' | 'file';
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export class DesktopGitReviewService {
|
||||
constructor(private readonly now: () => Date = () => new Date()) {}
|
||||
|
||||
async getDiff(projectPath: string): Promise<DesktopGitDiff> {
|
||||
const statusEntries = await getPorcelainStatus(projectPath);
|
||||
const files = await Promise.all(
|
||||
statusEntries.map((entry) => describeChangedFile(projectPath, entry)),
|
||||
);
|
||||
const diff = files
|
||||
.map((file) => file.diff)
|
||||
.filter((fileDiff) => fileDiff.length > 0)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
files,
|
||||
diff,
|
||||
generatedAt: this.now().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async stage(projectPath: string, target: DesktopGitTarget): Promise<void> {
|
||||
if (target.scope === 'all') {
|
||||
await runGit(projectPath, ['add', '-A']);
|
||||
return;
|
||||
}
|
||||
|
||||
await runGit(projectPath, [
|
||||
'add',
|
||||
'--',
|
||||
getSafeRelativePath(projectPath, target),
|
||||
]);
|
||||
}
|
||||
|
||||
async revert(projectPath: string, target: DesktopGitTarget): Promise<void> {
|
||||
if (target.scope === 'all') {
|
||||
await runGitIgnoringFailure(projectPath, ['restore', '--staged', '.']);
|
||||
await runGitIgnoringFailure(projectPath, ['restore', '.']);
|
||||
await runGit(projectPath, ['clean', '-fd']);
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = getSafeRelativePath(projectPath, target);
|
||||
await runGitIgnoringFailure(projectPath, [
|
||||
'restore',
|
||||
'--staged',
|
||||
'--',
|
||||
filePath,
|
||||
]);
|
||||
await runGitIgnoringFailure(projectPath, ['restore', '--', filePath]);
|
||||
await runGit(projectPath, ['clean', '-fd', '--', filePath]);
|
||||
}
|
||||
|
||||
async commit(
|
||||
projectPath: string,
|
||||
message: string,
|
||||
): Promise<DesktopGitCommitResult> {
|
||||
const trimmedMessage = message.trim();
|
||||
if (!trimmedMessage) {
|
||||
throw new DesktopHttpError(
|
||||
400,
|
||||
'bad_request',
|
||||
'Commit message must be a non-empty string.',
|
||||
);
|
||||
}
|
||||
|
||||
const summary = await runGit(projectPath, ['commit', '-m', trimmedMessage]);
|
||||
const commit = (await runGit(projectPath, ['rev-parse', 'HEAD'])).trim();
|
||||
return { commit, summary };
|
||||
}
|
||||
}
|
||||
|
||||
interface PorcelainStatusEntry {
|
||||
path: string;
|
||||
indexStatus: string;
|
||||
worktreeStatus: string;
|
||||
}
|
||||
|
||||
async function getPorcelainStatus(
|
||||
projectPath: string,
|
||||
): Promise<PorcelainStatusEntry[]> {
|
||||
const stdout = await runGit(projectPath, ['status', '--porcelain=v1', '-z']);
|
||||
const records = stdout.split('\0').filter((record) => record.length > 0);
|
||||
const entries: PorcelainStatusEntry[] = [];
|
||||
|
||||
for (let index = 0; index < records.length; index += 1) {
|
||||
const record = records[index] ?? '';
|
||||
const indexStatus = record[0] ?? ' ';
|
||||
const worktreeStatus = record[1] ?? ' ';
|
||||
const filePath = record.slice(3);
|
||||
entries.push({
|
||||
path: filePath,
|
||||
indexStatus,
|
||||
worktreeStatus,
|
||||
});
|
||||
|
||||
if (
|
||||
(indexStatus === 'R' || indexStatus === 'C') &&
|
||||
records[index + 1] !== undefined
|
||||
) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function describeChangedFile(
|
||||
projectPath: string,
|
||||
entry: PorcelainStatusEntry,
|
||||
): Promise<DesktopGitChangedFile> {
|
||||
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(''),
|
||||
]);
|
||||
const diff = [cachedDiff, worktreeDiff, untrackedDiff]
|
||||
.filter((part) => part.length > 0)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
path: entry.path,
|
||||
status: getChangeStatus(entry),
|
||||
staged: entry.indexStatus !== ' ' && entry.indexStatus !== '?',
|
||||
unstaged: entry.worktreeStatus !== ' ' && entry.worktreeStatus !== '?',
|
||||
untracked,
|
||||
diff,
|
||||
};
|
||||
}
|
||||
|
||||
function getChangeStatus(entry: PorcelainStatusEntry): DesktopGitChangeStatus {
|
||||
if (entry.indexStatus === '?' && entry.worktreeStatus === '?') {
|
||||
return 'untracked';
|
||||
}
|
||||
|
||||
const status =
|
||||
entry.worktreeStatus !== ' ' ? entry.worktreeStatus : entry.indexStatus;
|
||||
if (status === 'A') {
|
||||
return 'added';
|
||||
}
|
||||
if (status === 'C') {
|
||||
return 'copied';
|
||||
}
|
||||
if (status === 'D') {
|
||||
return 'deleted';
|
||||
}
|
||||
if (status === 'M' || status === 'T') {
|
||||
return 'modified';
|
||||
}
|
||||
if (status === 'R') {
|
||||
return 'renamed';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
async function createUntrackedFileDiff(
|
||||
projectPath: string,
|
||||
relativePath: string,
|
||||
): Promise<string> {
|
||||
const safePath = getSafeRelativePath(projectPath, {
|
||||
scope: 'file',
|
||||
filePath: relativePath,
|
||||
});
|
||||
try {
|
||||
const raw = await readFile(`${projectPath}${sep}${safePath}`, 'utf8');
|
||||
const additions = raw
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => `+${line}`)
|
||||
.join('\n');
|
||||
return [
|
||||
`diff --git a/${safePath} b/${safePath}`,
|
||||
'new file mode 100644',
|
||||
'--- /dev/null',
|
||||
`+++ b/${safePath}`,
|
||||
'@@',
|
||||
additions,
|
||||
].join('\n');
|
||||
} catch {
|
||||
return `diff unavailable for untracked file ${safePath}`;
|
||||
}
|
||||
}
|
||||
|
||||
function getSafeRelativePath(
|
||||
projectPath: string,
|
||||
target: DesktopGitTarget,
|
||||
): string {
|
||||
if (target.scope !== 'file' || !target.filePath?.trim()) {
|
||||
throw new DesktopHttpError(
|
||||
400,
|
||||
'bad_request',
|
||||
'filePath is required for file-scoped Git review operations.',
|
||||
);
|
||||
}
|
||||
|
||||
const normalized = normalize(target.filePath);
|
||||
if (
|
||||
isAbsolute(normalized) ||
|
||||
normalized === '..' ||
|
||||
normalized.startsWith(`..${sep}`)
|
||||
) {
|
||||
throw new DesktopHttpError(
|
||||
400,
|
||||
'bad_request',
|
||||
'filePath must stay inside the project.',
|
||||
);
|
||||
}
|
||||
|
||||
const relativePath = relative(
|
||||
projectPath,
|
||||
`${projectPath}${sep}${normalized}`,
|
||||
);
|
||||
if (relativePath === '..' || relativePath.startsWith(`..${sep}`)) {
|
||||
throw new DesktopHttpError(
|
||||
400,
|
||||
'bad_request',
|
||||
'filePath must stay inside the project.',
|
||||
);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function runGitIgnoringFailure(
|
||||
cwd: string,
|
||||
args: string[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
await runGit(cwd, args);
|
||||
} catch {
|
||||
// Reverting untracked or unstaged paths can legitimately fail. The
|
||||
// following clean/restore step decides the final result.
|
||||
}
|
||||
}
|
||||
|
||||
function runGit(cwd: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
'git',
|
||||
['-C', cwd, ...args],
|
||||
{
|
||||
maxBuffer: 1024 * 1024 * 8,
|
||||
timeout: 20_000,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const message = stderr.trim() || error.message;
|
||||
reject(new DesktopHttpError(400, 'git_error', message));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(stdout);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -93,6 +93,16 @@ export class DesktopProjectService {
|
|||
}
|
||||
|
||||
async getProjectGitStatus(projectId: string): Promise<DesktopGitStatus> {
|
||||
const project = await this.getStoredProject(projectId);
|
||||
return readGitStatus(project.path);
|
||||
}
|
||||
|
||||
async getProjectPath(projectId: string): Promise<string> {
|
||||
const project = await this.getStoredProject(projectId);
|
||||
return project.path;
|
||||
}
|
||||
|
||||
private async getStoredProject(projectId: string): Promise<StoredProject> {
|
||||
const store = await this.readStore();
|
||||
const project = store.projects.find((entry) => entry.id === projectId);
|
||||
if (!project) {
|
||||
|
|
@ -103,7 +113,7 @@ export class DesktopProjectService {
|
|||
);
|
||||
}
|
||||
|
||||
return readGitStatus(project.path);
|
||||
return project;
|
||||
}
|
||||
|
||||
private async describeStoredProject(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue