/** * @license * Copyright 2026 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ import { useCallback, useEffect, useRef, useState, type KeyboardEvent, } from 'react'; import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; import type { DaemonWorkspaceGitDiffFile } from '@qwen-code/sdk/daemon'; import { ChevronDownIcon, EyeIcon, Loader2Icon, PencilIcon, SearchIcon, } from 'lucide-react'; import { useI18n } from '../../i18n'; import { Markdown } from '../messages/Markdown'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; import { DialogShell } from './DialogShell'; import { GitDiffContent } from './GitDiffDialog'; import { GitLogContent } from './GitLogDialog'; import { GitHubPrsContent } from './GitHubPrsDialog'; import styles from './GitDialog.module.css'; export type GitDialogView = 'diff' | 'log' | 'prs' | 'commit'; const GITHUB_PRS_FEATURE = 'workspace_github_prs'; const TITLE_KEYS: Record = { diff: 'gitDiff.title', log: 'gitLog.title', prs: 'githubPrs.title', commit: 'gitCommit.title', }; /** Tabs visible in the tab bar — commit is a mode, not a regular tab. */ const TAB_VIEWS: Exclude[] = ['diff', 'log', 'prs']; const MAX_SUMMARY_CHARS = 3500; export function GitDialog({ workspaceCwd, gitCwd, initialView, sessionId, resolveSessionForWorkspace, onClose, }: { workspaceCwd: string; gitCwd?: string; initialView: GitDialogView; sessionId?: string; resolveSessionForWorkspace?: ( cwd: string, forceCreate?: boolean, ) => Promise; onClose: () => void; }) { const { t } = useI18n(); const { client, capabilities } = useWorkspace(); const prsSupported = capabilities?.features?.includes(GITHUB_PRS_FEATURE) === true; const tabViews = prsSupported ? TAB_VIEWS : TAB_VIEWS.filter((v) => v !== 'prs'); const [view, setView] = useState(initialView); const [subtitle, setSubtitle] = useState(); const [commitMsg, setCommitMsg] = useState(''); const [commitBusy, setCommitBusy] = useState<'commit' | 'push' | null>(null); const [generating, setGenerating] = useState(false); const [genFailed, setGenFailed] = useState(false); const [prFormOpen, setPrFormOpen] = useState(false); const [prTitle, setPrTitle] = useState(''); const [prBody, setPrBody] = useState(''); const [prBase, setPrBase] = useState(''); const [prBusy, setPrBusy] = useState(false); const [prGenerating, setPrGenerating] = useState(false); const [prPreview, setPrPreview] = useState(false); const [prBranches, setPrBranches] = useState<{ local: string[]; remotes: [string, string[]][]; } | null>(null); const [prBranchesError, setPrBranchesError] = useState(false); const prHeadInfoRef = useRef<{ hasUpstream: boolean } | null>(null); const [prStatus, setPrStatus] = useState<{ msg: string; type: 'error' | 'success'; url?: string; } | null>(null); const [commitStatus, setCommitStatus] = useState<{ msg: string; type: 'error' | 'success'; } | null>(null); const textareaRef = useRef(null); const resolveSessionRef = useRef(resolveSessionForWorkspace); resolveSessionRef.current = resolveSessionForWorkspace; const sessionIdRef = useRef(sessionId); sessionIdRef.current = sessionId; // btwSession with automatic retry: if the resolved session is stale // (daemon restarted, session evicted), force-create a new one and retry. const btwWithRetry = useCallback( async (sid: string, prompt: string, signal: AbortSignal) => { try { return await client.btwSession(sid, prompt, { signal }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (/no session/i.test(msg) && resolveSessionRef.current) { // Session is stale — force-create via the workspace resolver. // The resolver's listWorkspaceSessions path returned a dead id; // calling createSession directly guarantees a live session. const fresh = await resolveSessionRef.current( gitCwd ?? workspaceCwd, true, ); if (fresh && fresh !== sid && !signal.aborted) { sessionIdRef.current = fresh; return client.btwSession(fresh, prompt, { signal }); } } throw err; } }, [client, workspaceCwd, gitCwd], ); const btwWithRetryRef = useRef(btwWithRetry); btwWithRetryRef.current = btwWithRetry; const startedInCommit = useRef(initialView === 'commit').current; const isCommit = view === 'commit'; const effectiveTab = isCommit ? 'diff' : view; // Auto-generate commit message via session side-query when commit view opens. useEffect(() => { if (!isCommit) return; const abort = new AbortController(); setGenerating(true); setGenFailed(false); setCommitMsg(''); const resolveSession = resolveSessionRef.current ? resolveSessionRef.current(gitCwd ?? workspaceCwd) : sessionIdRef.current ? Promise.resolve(sessionIdRef.current) : Promise.resolve(undefined); resolveSession .then((sid) => { if (abort.signal.aborted) return; if (!sid) { setGenFailed(true); setGenerating(false); return; } const ws = client.workspaceByCwd(workspaceCwd); return ws.workspaceGitDiff(gitCwd).then((diff) => { if (abort.signal.aborted) return; if (diff.files.length === 0) { setGenerating(false); return; } const fileLines = diff.files.map((f: DaemonWorkspaceGitDiffFile) => { const status = f.isUntracked ? 'new' : f.isDeleted ? 'deleted' : 'modified'; const counts = f.isBinary ? ' (binary)' : ` (+${f.added ?? 0} -${f.removed ?? 0})`; const rename = f.oldPath ? ` (renamed from ${f.oldPath})` : ''; return `${status}: ${f.path}${counts}${rename}`; }); let fileSummary = fileLines.join('\n'); if (fileSummary.length > MAX_SUMMARY_CHARS) { let truncated = ''; let included = 0; for (const line of fileLines) { if (truncated.length + line.length + 1 > MAX_SUMMARY_CHARS) break; truncated += (truncated ? '\n' : '') + line; included++; } fileSummary = truncated + `\n(…and ${fileLines.length - included} more files)`; } const prompt = `Generate a concise git commit message for these changes. ` + `Reply with ONLY the commit message text, no quotes, no explanation.\n\n${fileSummary}`; return btwWithRetryRef .current(sid, prompt, abort.signal) .then((result) => { if (abort.signal.aborted) return; if (result.answer) setCommitMsg(result.answer.trim()); }); }); }) .catch(() => { if (!abort.signal.aborted) setGenFailed(true); }) .finally(() => { if (!abort.signal.aborted) setGenerating(false); }); return () => { abort.abort(); }; }, [isCommit, client, workspaceCwd, gitCwd]); // Clamp if the PR tab vanishes mid-session. const clampedTab = tabViews.includes( effectiveTab as (typeof tabViews)[number], ) ? effectiveTab : 'diff'; const selectView = useCallback((next: GitDialogView) => { setSubtitle(undefined); setView(next); }, []); const selectAndFocus = (next: GitDialogView) => { selectView(next); document.getElementById(`git-dialog-tab-${next}`)?.focus(); }; const onTabKeyDown = (event: KeyboardEvent) => { if (isCommit) { // Commit is the rightmost tab. Arrow keys and Home move into the regular // tabs so keyboard users aren't trapped on the commit tab; End stays put // because commit is already the last tab. if (event.key === 'ArrowLeft') { event.preventDefault(); selectAndFocus(tabViews[tabViews.length - 1]); } else if (event.key === 'ArrowRight' || event.key === 'Home') { event.preventDefault(); selectAndFocus(tabViews[0]); } else if (event.key === 'End') { event.preventDefault(); } return; } const index = tabViews.indexOf(clampedTab as (typeof tabViews)[number]); if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); const delta = event.key === 'ArrowRight' ? 1 : -1; selectAndFocus( tabViews[(index + delta + tabViews.length) % tabViews.length], ); return; } if (event.key === 'Home') { event.preventDefault(); selectAndFocus(tabViews[0]); return; } if (event.key === 'End') { event.preventDefault(); selectAndFocus(tabViews[tabViews.length - 1]); } }; const doCommit = useCallback( async (andPush: boolean) => { if (commitBusy || prBusy || !commitMsg.trim()) return; setCommitBusy(andPush ? 'push' : 'commit'); setCommitStatus(null); try { const ws = client.workspaceByCwd(workspaceCwd); const result = await ws.workspaceGitCommit( commitMsg.trim(), { all: true }, gitCwd, ); if (andPush) { try { await ws.workspaceGitPush({ setUpstream: true }, gitCwd); setCommitMsg(''); setCommitStatus({ msg: t('gitCommit.commitPushSuccess', { sha: result.sha }), type: 'success', }); } catch (pushErr) { setCommitStatus({ msg: t('gitCommit.commitSuccessPushFailed', { sha: result.sha, error: pushErr instanceof Error ? pushErr.message : String(pushErr), }), type: 'error', }); } } else { setCommitMsg(''); setCommitStatus({ msg: t('gitCommit.commitSuccess', { sha: result.sha }), type: 'success', }); } } catch (err) { setCommitStatus({ msg: err instanceof Error ? err.message : String(err), type: 'error', }); } finally { setCommitBusy(null); } }, [client, workspaceCwd, gitCwd, commitMsg, commitBusy, prBusy, t], ); const loadPrBranches = useCallback(() => { const ws = client.workspaceByCwd(workspaceCwd); setPrBranchesError(false); ws.workspaceGitBranches(gitCwd) .then((branches) => { const local = branches.local.map((b) => b.name); const remoteMap = new Map(); for (const b of branches.remote) { const slash = b.name.indexOf('/'); const remote = slash > 0 ? b.name.slice(0, slash) : 'other'; let list = remoteMap.get(remote); if (!list) { list = []; remoteMap.set(remote, list); } list.push(b.name); } setPrBranches({ local, remotes: Array.from(remoteMap.entries()) }); const head = branches.local.find((b) => b.isHead); prHeadInfoRef.current = head ? { hasUpstream: Boolean(head.upstream) } : null; }) .catch(() => { setPrBranches(null); setPrBranchesError(true); prHeadInfoRef.current = null; }); }, [client, workspaceCwd, gitCwd]); // Auto-fill PR form when it opens. useEffect(() => { if (!prFormOpen || !isCommit) return; const ws = client.workspaceByCwd(workspaceCwd); // Fetch branch list for the target-branch dropdown (independent). loadPrBranches(); setPrStatus(null); setPrGenerating(true); const abort = new AbortController(); // Resolve base branch, session, then generate — all chained. const baseBranchPromise = ws .workspaceGitHubDefaultBranch() .then((r) => r.branch) .catch(() => 'origin/main'); const resolveSession = resolveSessionRef.current ? resolveSessionRef.current(gitCwd ?? workspaceCwd) : sessionIdRef.current ? Promise.resolve(sessionIdRef.current) : Promise.resolve(undefined); Promise.all([baseBranchPromise, resolveSession]) .then(([base, sid]) => { if (abort.signal.aborted) return; setPrBase(base); if (!sid) { ws.workspaceGit() .then((git) => { if (!abort.signal.aborted && git.branch && !git.detached) setPrTitle(git.branch); }) .catch(() => {}) .finally(() => { if (!abort.signal.aborted) setPrGenerating(false); }); return; } return Promise.all([ ws.workspaceGitLog(50, 0, gitCwd, `${base}..HEAD`), ws.workspaceGitDiff(gitCwd), ]).then(([log, diff]) => { if (abort.signal.aborted) return; if (!log.available) { // The base ref didn't resolve locally (e.g. a stale or missing // default branch) — surface it instead of generating from an // empty range and emitting a misleading "(no commits)". setPrStatus({ msg: t('gitCommit.prLogUnavailable'), type: 'error', }); return; } // Build context from branch commits + uncommitted changes. const commitSummary = log.entries.length > 0 ? log.entries .map((e) => `- ${e.shortSha} ${e.subject}`) .join('\n') : '(no commits ahead of base)'; const uncommittedLines = diff.files.map( (f: DaemonWorkspaceGitDiffFile) => { const status = f.isUntracked ? 'new' : f.isDeleted ? 'deleted' : 'modified'; return `${status}: ${f.path}`; }, ); let uncommitted = uncommittedLines.join('\n'); if (uncommitted.length > MAX_SUMMARY_CHARS) { let truncated = ''; let included = 0; for (const line of uncommittedLines) { if (truncated.length + line.length + 1 > MAX_SUMMARY_CHARS) break; truncated += (truncated ? '\n' : '') + line; included++; } uncommitted = truncated + `\n(…and ${uncommittedLines.length - included} more files)`; } const prompt = `Generate a GitHub pull request title and body for the changes on this branch compared to ${base}. ` + `Use the commit history and conversation context to understand what was done and why.\n\n` + `Commits on this branch (not in ${base}):\n${commitSummary}\n\n` + `${uncommitted ? `Uncommitted changes:\n${uncommitted}\n\n` : ''}` + `Follow these rules strictly:\n` + `1. Title: conventional commit format, e.g. "feat(web-shell): add branch picker". Under 70 chars.\n` + `2. Body must follow this exact template structure (fill in each section):\n\n` + `## What this PR does\n` + `\n\n` + `## Why it is needed\n` + `\n\n` + `## Reviewer Test Plan\n` + `### How to verify\n` + `\n` + `### Evidence (Before & After)\n` + `N/A\n` + `### Tested on\n` + `| OS | Status |\n` + `| :--------: | :----: |\n` + `| macOS | ⚠️ |\n` + `| Windows | ⚠️ |\n` + `| Linux | ⚠️ |\n\n` + `## Risk & Scope\n` + `- Main risk or tradeoff: \n` + `- Not validated / out of scope: \n` + `- Breaking changes / migration notes: none\n\n` + `
\n中文说明\n\n` + `\n\n` + `
\n\n` + `3. Do NOT hard-wrap paragraphs — write each paragraph as one long line.\n` + `4. Reply with the title on the first line, then a blank line, then the body. No extra explanation.`; return btwWithRetryRef .current(sid, prompt, abort.signal) .then((result) => { if (abort.signal.aborted) return; if (result.answer) { const lines = result.answer.trim().split('\n'); setPrTitle(lines[0] ?? ''); setPrBody(lines.slice(1).join('\n').trim()); } else { console.warn('[GitDialog] btwSession returned null answer'); } }); }); }) .catch((err) => { if (!abort.signal.aborted) { console.error('[GitDialog] PR generation failed:', err); setPrStatus({ msg: err instanceof Error ? err.message : String(err), type: 'error', }); ws.workspaceGit() .then((git) => { if (!abort.signal.aborted && git.branch && !git.detached) setPrTitle(git.branch); }) .catch(() => {}); } }) .finally(() => { if (!abort.signal.aborted) setPrGenerating(false); }); return () => abort.abort(); }, [prFormOpen, isCommit, client, workspaceCwd, gitCwd, t, loadPrBranches]); const doCreatePr = useCallback(async () => { if (!prTitle.trim() || prGenerating || prBusy) return; setPrBusy(true); setPrStatus(null); if (prHeadInfoRef.current && !prHeadInfoRef.current.hasUpstream) { setPrStatus({ msg: t('gitCommit.prPushFirst'), type: 'error' }); setPrBusy(false); return; } try { const ws = client.workspaceByCwd(workspaceCwd); // Strip a known remote prefix for `gh pr create --base` // (origin/main → main), but leave local branch names — which may // themselves contain "/" — untouched. const trimmedBase = prBase.trim(); const remotePrefix = (prBranches?.remotes ?? []) .map(([name]) => name) .sort((a, b) => b.length - a.length) .find((name) => trimmedBase.startsWith(`${name}/`)); const baseBranch = remotePrefix ? trimmedBase.slice(remotePrefix.length + 1) : trimmedBase; const result = await ws.workspaceGitHubCreatePullRequest( { title: prTitle.trim(), body: prBody.trim() || undefined, base: baseBranch || undefined, }, gitCwd, ); setPrStatus({ msg: t('gitCommit.prCreated', { number: result.number ?? '' }), type: 'success', url: result.url, }); setPrFormOpen(false); } catch (err) { setPrStatus({ msg: err instanceof Error ? err.message : String(err), type: 'error', }); } finally { setPrBusy(false); } }, [ client, workspaceCwd, gitCwd, prTitle, prBody, prBase, prBranches, prGenerating, prBusy, t, ]); return (
{tabViews.map((name) => ( ))} {startedInCommit && ( )}
{clampedTab === 'diff' || isCommit ? ( ) : clampedTab === 'log' ? ( ) : ( )}
{isCommit && (