diff --git a/.qwen/e2e-tests/electron-desktop/diff-review-commit.md b/.qwen/e2e-tests/electron-desktop/diff-review-commit.md new file mode 100644 index 000000000..e118d8329 --- /dev/null +++ b/.qwen/e2e-tests/electron-desktop/diff-review-commit.md @@ -0,0 +1,63 @@ +# Electron Desktop E2E Record: Diff Review and Commit + +Date: 2026-04-25 + +## Slice + +Slice 12 basic diff review and commit. + +## User-Visible Scenario + +1. Launch the desktop app with a temporary HOME/QWEN_RUNTIME_DIR and fake ACP. +2. Open a temporary Git workspace with an initial commit. +3. Modify a tracked file and create an untracked file. +4. Verify the right Review panel lists changed files and textual diff content. +5. Stage all changes from the Review panel. +6. Enter a commit message and commit. +7. Verify the Review panel returns to a clean state. + +## Assertions + +- `GET /api/projects/:id/git/diff` returns modified and untracked files. +- The Review panel shows changed file count and diff text. +- `POST /api/projects/:id/git/stage` updates Git status from modified/untracked + to staged. +- `POST /api/projects/:id/git/commit` creates a commit and returns a clean + status. +- `POST /api/projects/:id/git/revert` cleans tracked and untracked changes when + explicitly invoked. +- Commit and Git errors are displayed in the review area. + +## Diagnostics on Failure + +- Save renderer screenshot. +- Save renderer console errors and failed network requests. +- Save Electron main stdout/stderr. +- Save `git -C status --porcelain=v1 --branch`. +- Save `git -C diff` and `git -C diff --cached`. +- Save DesktopServer responses for diff/stage/revert/commit routes. + +## Automated Coverage Added This Iteration + +The full Electron E2E harness is still pending. This iteration added +server-level coverage in `packages/desktop/src/server/index.test.ts`: + +- opens a registered project and reads `/git/diff`; +- verifies modified and untracked files are returned with diff text; +- stages all changes and verifies status counts; +- commits staged changes and verifies a clean status; +- reverts all changes and verifies the workspace returns to the initial file + content. + +## Execution Results + +- `npm run test --workspace=packages/desktop` passed: 8 files, 50 tests. +- `npm run typecheck --workspace=packages/desktop` passed. +- `npm run lint --workspace=packages/desktop` passed. +- `npm run build --workspace=packages/desktop` passed. + +## Remaining Risk + +Hunk-level accept/revert, inline comments, Open in Editor, and real Electron +renderer assertions are not complete yet. They remain required before the MVP +can be marked done. diff --git a/design/qwen-code-electron-desktop-implementation-plan.md b/design/qwen-code-electron-desktop-implementation-plan.md index 1d4ce0592..3c245a7a3 100644 --- a/design/qwen-code-electron-desktop-implementation-plan.md +++ b/design/qwen-code-electron-desktop-implementation-plan.md @@ -172,17 +172,36 @@ scope before a DONE marker can be created. ### Slice 12: Diff Review and Commit -- Status: pending +- Status: partial complete in iteration 7 - Goal: add Git diff/status review APIs and UI actions for accept/revert and commit. +- Files: + - `packages/desktop/src/server/services/gitReviewService.ts` + - `packages/desktop/src/server/index.ts` + - `packages/desktop/src/server/index.test.ts` + - `packages/desktop/src/server/services/projectService.ts` + - `packages/desktop/src/renderer/api/client.ts` + - `packages/desktop/src/renderer/App.tsx` + - `packages/desktop/src/renderer/styles.css` - Acceptance criteria: - Right Changes tab shows changed files and unified diff. - Stage/unstage/revert/commit routes are token protected and scoped to a registered project. - Commit errors are visible in the UI. +- Completed: + - Token-protected diff, stage, revert, and commit routes scoped to registered + projects. + - Basic right Review panel changed-file list, textual diff preview, Stage + All, Revert All, commit message input, and Commit action. + - Server tests for diff, stage, commit, revert, invalid project path, and Git + status metadata. +- Remaining: + - Hunk-level accept/revert, inline comments, Open in Editor, richer file tree, + and renderer E2E coverage. - E2E coverage: - - Temporary Git workspace with a fake file change, accept/stage, commit, and - error diagnostics. + - Record in `.qwen/e2e-tests/electron-desktop/diff-review-commit.md`. + - Later Electron E2E must use a temporary Git workspace with a fake file + change, accept/stage, commit, and error diagnostics. ### Slice 13: Scoped Terminal @@ -231,6 +250,10 @@ scope before a DONE marker can be created. - 2026-04-25: Keep the CDP switch opt-in through `QWEN_DESKTOP_CDP_PORT` and always pair it with `remote-debugging-address=127.0.0.1`; production remains closed unless the environment variable is set. +- 2026-04-25: Implement desktop Git review with `git` via `execFile` and + explicit relative path validation. This avoids broad shell execution and + keeps review operations scoped to projects registered through the desktop + project service. ## Verification Log @@ -253,6 +276,11 @@ scope before a DONE marker can be created. - `curl --fail --silent http://127.0.0.1:9339/json/list` passed and returned a `Qwen Code` page at `file:///Users/dragon/Documents/qwen-code/packages/desktop/dist/renderer/index.html`. +- 2026-04-25 Slice 12 basic diff review: + - `npm run test --workspace=packages/desktop` passed: 8 files, 50 tests. + - `npm run typecheck --workspace=packages/desktop` passed. + - `npm run lint --workspace=packages/desktop` passed. + - `npm run build --workspace=packages/desktop` passed. ## Self Review Notes @@ -269,6 +297,14 @@ scope before a DONE marker can be created. - The CDP smoke verified endpoint discovery but did not yet drive DOM, console, network, or screenshot assertions through MCP; that remains in the E2E harness slice. +- Slice 12 review operations resolve the project path from the registered + project id server-side; renderer cannot submit arbitrary cwd values. +- File-scoped Git operations reject absolute paths and parent-directory + traversal. The current UI exposes all-scope operations only; file/hunk UI is + still pending. +- Revert All uses `git restore` and `git clean -fd`, so it is intentionally + available only as an explicit user review action and remains scoped to the + active registered project. ## Remaining Work diff --git a/packages/desktop/src/renderer/App.tsx b/packages/desktop/src/renderer/App.tsx index 866ef6dce..3ce9c970a 100644 --- a/packages/desktop/src/renderer/App.tsx +++ b/packages/desktop/src/renderer/App.tsx @@ -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([]); const [activeSessionId, setActiveSessionId] = useState(null); const [sessionError, setSessionError] = useState(null); + const [gitDiff, setGitDiff] = useState(null); + const [reviewError, setReviewError] = useState(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() {

Review

- + void; + onCommitMessageChange: (message: string) => void; + onRevertAll: () => void; + onStageAll: () => void; + project: DesktopProject | null; + reviewError: string | null; +}) { if (!project) { return (
@@ -837,6 +972,7 @@ function ReviewSummary({ project }: { project: DesktopProject | null }) { } const status = project.gitStatus; + const changedFiles = gitDiff?.files ?? []; return (
@@ -862,6 +998,10 @@ function ReviewSummary({ project }: { project: DesktopProject | null }) {
Untracked
{status.untracked}
+
+
Files
+
{changedFiles.length}
+
{status.error ? (
Git
@@ -869,6 +1009,56 @@ function ReviewSummary({ project }: { project: DesktopProject | null }) {
) : null} +
+ + +
+
+ {changedFiles.length === 0 ? ( +
No changes
+ ) : ( + changedFiles.map((file) => ( +
+ + {file.path} + {file.status} + +
{file.diff || 'No textual diff available.'}
+
+ )) + )} +
+
+ onCommitMessageChange(event.target.value)} + /> + +
+ {reviewError ?

{reviewError}

: null}
); } diff --git a/packages/desktop/src/renderer/api/client.ts b/packages/desktop/src/renderer/api/client.ts index 747b81e51..43036e7cc 100644 --- a/packages/desktop/src/renderer/api/client.ts +++ b/packages/desktop/src/renderer/api/client.ts @@ -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 { + return getJson( + serverInfo, + `/api/projects/${encodeURIComponent(projectId)}/git/diff`, + isGitDiff, + ); +} + +export async function stageDesktopProjectChanges( + serverInfo: DesktopServerInfo, + projectId: string, +): Promise { + return writeJson( + serverInfo, + `/api/projects/${encodeURIComponent(projectId)}/git/stage`, + 'POST', + { scope: 'all' }, + isGitReviewMutation, + ); +} + +export async function revertDesktopProjectChanges( + serverInfo: DesktopServerInfo, + projectId: string, +): Promise { + return writeJson( + serverInfo, + `/api/projects/${encodeURIComponent(projectId)}/git/revert`, + 'POST', + { scope: 'all' }, + isGitReviewMutation, + ); +} + +export async function commitDesktopProjectChanges( + serverInfo: DesktopServerInfo, + projectId: string, + message: string, +): Promise { + 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; + 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; + 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; + 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; + 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; diff --git a/packages/desktop/src/renderer/styles.css b/packages/desktop/src/renderer/styles.css index 2404b2c92..0f0df44c9 100644 --- a/packages/desktop/src/renderer/styles.css +++ b/packages/desktop/src/renderer/styles.css @@ -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; } diff --git a/packages/desktop/src/server/index.test.ts b/packages/desktop/src/server/index.test.ts index 1576011e1..ef5fa0f4c 100644 --- a/packages/desktop/src/server/index.test.ts +++ b/packages/desktop/src/server/index.test.ts @@ -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 { return join(dir, '.qwen', 'settings.json'); } +async function createCommittedGitProject(): Promise { + 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 { + return runGitOutput(cwd, args).then(() => undefined); +} + +function runGitOutput(cwd: string, args: string[]): Promise { 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()); }); }); } diff --git a/packages/desktop/src/server/index.ts b/packages/desktop/src/server/index.ts index 16c21def6..aa4417dd2 100644 --- a/packages/desktop/src/server/index.ts +++ b/packages/desktop/src/server/index.ts @@ -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 { + 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 { + 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 { + 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 { + 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): 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, key: string, diff --git a/packages/desktop/src/server/services/gitReviewService.ts b/packages/desktop/src/server/services/gitReviewService.ts new file mode 100644 index 000000000..665cbb2d0 --- /dev/null +++ b/packages/desktop/src/server/services/gitReviewService.ts @@ -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 { + 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 { + if (target.scope === 'all') { + await runGit(projectPath, ['add', '-A']); + return; + } + + await runGit(projectPath, [ + 'add', + '--', + getSafeRelativePath(projectPath, target), + ]); + } + + async revert(projectPath: string, target: DesktopGitTarget): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + }, + ); + }); +} diff --git a/packages/desktop/src/server/services/projectService.ts b/packages/desktop/src/server/services/projectService.ts index 95f62ade6..f23a0307e 100644 --- a/packages/desktop/src/server/services/projectService.ts +++ b/packages/desktop/src/server/services/projectService.ts @@ -93,6 +93,16 @@ export class DesktopProjectService { } async getProjectGitStatus(projectId: string): Promise { + const project = await this.getStoredProject(projectId); + return readGitStatus(project.path); + } + + async getProjectPath(projectId: string): Promise { + const project = await this.getStoredProject(projectId); + return project.path; + } + + private async getStoredProject(projectId: string): Promise { 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(