mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-10 17:27:10 +00:00
* feat(web-shell): add git commit history browser Add a read-only Git Log dialog to the Web Shell, accessible via /log command or the History tab in the Changes dialog. Full-stack implementation across core, daemon, SDK, and web-shell: - core: fetchGitLog (paginated commit list) and fetchGitCommitDetail (message body + per-file numstat) with 12 integration tests - daemon: GET /workspace/git/log and /workspace/git/log/commit routes with bound + qualified dual registration - SDK: DaemonGitLog/DaemonGitCommitDetail types and client methods - web-shell: GitLogDialog with commit list, expandable details, SHA copy icon, Load more pagination, and Changes/History tab switching in both dialogs * fix(web-shell): address review feedback on git log browser - Critical: use first-parent diff for merge commits (diff-tree without -c or explicit parent outputs nothing for merges) - Remove dead embedded prop from GitDiffDialog and GitLogDialog - Replace span role=button with aria-hidden for copy icon (a11y) - Add loadMore error feedback instead of silent catch - Refresh relative timestamps every 60s (useState + interval) - Remove dead branch param from subtitle i18n call * fix(web-shell): address R2 review feedback on git log browser - Use bounded split in parseLogFields (first 7 separators) to prevent subject containing literal \x1f from shifting the parents field - Add SHA hex format validation at daemon route layer (400 for invalid) - Add rendering branch for detail.available === false (error message instead of empty content) * fix(web-shell): count renamed files in commit detail + test git-log route & dialog Follows the R2 review-feedback commit (which fixed the bounded parse, the non-hex SHA 400, and the unavailable-detail render). Remaining items: - Commit detail counts renamed files. diff-tree is plumbing and does not honour diff.renames, so a `git mv` split into a delete + add pair (or an empty-path entry) instead of one file — understating filesCount / linesAdded / linesRemoved. Run diff-tree with -M and give the inline numstat parser the same pending-rename state machine as parseGitNumstat, so a rename is one file keyed by its new path. Covered by a real-repo rename test, plus a merge-commit test that locks the first-parent diff. - Tests for the two previously-untested modules: the workspace-git-log route (list shape, pagination clamping, sha-required + non-hex 400, trust gating) and GitLogDialog (all five list state paths, load-more offset + error, detail expand + both failure branches incl. available:false, and the relative-time render). * fix(web-shell): address R3 review feedback on git log browser - Fix timeAgo '0y ago' for commits ~360-364 days old (Math.max(1, ...)) - Add .catch() to clipboard writeText to prevent unhandled rejection - Reset loadMoreError on initial re-fetch (daemon reconnect) - Preserve prev.available in loadMore merge instead of overwriting - Add ARIA tab semantics (role=tablist/tab, aria-selected) to both Changes and History tab bars * fix(web-shell): address R4 review feedback on git log browser - Fix vacuous limit-clamp test: seed 3 commits, verify limit=2 returns 2 + hasMore, limit=0 clamps to 1 - Add CSS var fallbacks for --subtle-bg and --success-bg (dialog portals outside App.module.css scope) - Extract GIT_DIALOG_SWITCH_DELAY_MS constant with doc comment - Make copy-SHA control keyboard accessible (tabIndex, onKeyDown, aria-label instead of aria-hidden) * fix(web-shell): escape apostrophe in worktree welcome string * fix(web-shell): address git history review feedback Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(web-shell): mock git diff content in app tests Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(core): harden git log metadata parsing Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: wenshao <wenshao@example.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
459 lines
14 KiB
TypeScript
459 lines
14 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk';
|
|
import type {
|
|
DaemonDiffHunk,
|
|
DaemonWorkspaceGitDiff,
|
|
DaemonWorkspaceGitDiffFile,
|
|
} from '@qwen-code/sdk/daemon';
|
|
import type { BundledLanguage, ThemedToken } from 'shiki';
|
|
import { useI18n } from '../../i18n';
|
|
import { useTheme, WebShellThemeId } from '../../themeContext';
|
|
import {
|
|
getCodeHighlighter,
|
|
isTooLargeToHighlight,
|
|
} from '../messages/codeHighlighter';
|
|
import { resolveFenceLanguage } from '../messages/Markdown';
|
|
import { languageForPath } from '../messages/ToolGroup';
|
|
import { sanitizeControlChars } from '../messages/toolFormatting';
|
|
import { DialogShell } from './DialogShell';
|
|
import styles from './GitDiffDialog.module.css';
|
|
|
|
type RowType = 'add' | 'del' | 'context' | 'meta';
|
|
|
|
interface DiffRow {
|
|
type: RowType;
|
|
oldNo: number | null;
|
|
newNo: number | null;
|
|
text: string;
|
|
tokens: ThemedToken[] | null;
|
|
}
|
|
|
|
const ROW_CLASS: Record<RowType, string> = {
|
|
add: styles.diffLineAdd,
|
|
del: styles.diffLineDel,
|
|
context: styles.diffLineContext,
|
|
meta: styles.diffLineMeta,
|
|
};
|
|
|
|
function shikiThemeFor(theme: ReturnType<typeof useTheme>): string {
|
|
return theme === WebShellThemeId.Light
|
|
? 'github-light-default'
|
|
: 'github-dark-default';
|
|
}
|
|
|
|
// Build the unified-diff rows for a file's hunks, highlighting each side
|
|
// (context+added / context+removed) as its own code block so multi-line tokens
|
|
// (a comment or string crossing an add/delete boundary) still tokenize
|
|
// correctly. Each rendered line then pulls its tokens from the matching side:
|
|
// `+` from the new side, `-` from the old side, context from either (identical).
|
|
async function buildRows(
|
|
hunks: DaemonDiffHunk[],
|
|
path: string,
|
|
theme: string,
|
|
): Promise<DiffRow[]> {
|
|
const { resolvedLang } = resolveFenceLanguage(languageForPath(path));
|
|
let highlighter: Awaited<ReturnType<typeof getCodeHighlighter>> | null = null;
|
|
if (resolvedLang !== 'text') {
|
|
try {
|
|
highlighter = await getCodeHighlighter(resolvedLang);
|
|
} catch {
|
|
highlighter = null;
|
|
}
|
|
}
|
|
|
|
const rows: DiffRow[] = [];
|
|
for (const hunk of hunks) {
|
|
const newSide: string[] = [];
|
|
const oldSide: string[] = [];
|
|
for (const line of hunk.lines) {
|
|
const prefix = line[0];
|
|
const body = line.slice(1);
|
|
if (prefix === '+') newSide.push(body);
|
|
else if (prefix === '-') oldSide.push(body);
|
|
else if (prefix === ' ') {
|
|
newSide.push(body);
|
|
oldSide.push(body);
|
|
}
|
|
}
|
|
const newCode = newSide.join('\n');
|
|
const oldCode = oldSide.join('\n');
|
|
let newTokens: ThemedToken[][] | null = null;
|
|
let oldTokens: ThemedToken[][] | null = null;
|
|
if (highlighter) {
|
|
// resolvedLang is a real Shiki language id here ('text' was filtered out
|
|
// before the highlighter was loaded).
|
|
const lang = resolvedLang as BundledLanguage;
|
|
// Highlight each side independently so a small side keeps its tokens even
|
|
// when the other side exceeds the size cap.
|
|
if (!isTooLargeToHighlight(newCode)) {
|
|
try {
|
|
newTokens = highlighter.codeToTokens(newCode, { lang, theme }).tokens;
|
|
} catch {
|
|
newTokens = null;
|
|
}
|
|
}
|
|
if (!isTooLargeToHighlight(oldCode)) {
|
|
try {
|
|
oldTokens = highlighter.codeToTokens(oldCode, { lang, theme }).tokens;
|
|
} catch {
|
|
oldTokens = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
let ni = 0;
|
|
let oi = 0;
|
|
let oldNo = hunk.oldStart;
|
|
let newNo = hunk.newStart;
|
|
for (const line of hunk.lines) {
|
|
const prefix = line[0];
|
|
const body = line.slice(1);
|
|
if (prefix === '+') {
|
|
rows.push({
|
|
type: 'add',
|
|
oldNo: null,
|
|
newNo,
|
|
text: body,
|
|
tokens: newTokens?.[ni] ?? null,
|
|
});
|
|
ni++;
|
|
newNo++;
|
|
} else if (prefix === '-') {
|
|
rows.push({
|
|
type: 'del',
|
|
oldNo,
|
|
newNo: null,
|
|
text: body,
|
|
tokens: oldTokens?.[oi] ?? null,
|
|
});
|
|
oi++;
|
|
oldNo++;
|
|
} else if (prefix === ' ') {
|
|
rows.push({
|
|
type: 'context',
|
|
oldNo,
|
|
newNo,
|
|
text: body,
|
|
tokens: newTokens?.[ni] ?? null,
|
|
});
|
|
ni++;
|
|
oi++;
|
|
oldNo++;
|
|
newNo++;
|
|
} else {
|
|
// e.g. "\ No newline at end of file" — a neutral marker, no line number.
|
|
rows.push({
|
|
type: 'meta',
|
|
oldNo: null,
|
|
newNo: null,
|
|
text: line,
|
|
tokens: null,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function renderContent(row: DiffRow): ReactNode {
|
|
if (!row.tokens || row.tokens.length === 0) return row.text;
|
|
return row.tokens.map((token, index) => (
|
|
<span key={index} style={token.color ? { color: token.color } : undefined}>
|
|
{token.content}
|
|
</span>
|
|
));
|
|
}
|
|
|
|
function DiffHunks({ hunks, path }: { hunks: DaemonDiffHunk[]; path: string }) {
|
|
const { t } = useI18n();
|
|
const theme = useTheme();
|
|
const shikiTheme = shikiThemeFor(theme);
|
|
const [rows, setRows] = useState<DiffRow[] | null>(null);
|
|
const [failed, setFailed] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setRows(null);
|
|
setFailed(false);
|
|
buildRows(hunks, path, shikiTheme)
|
|
.then((built) => {
|
|
if (!cancelled) setRows(built);
|
|
})
|
|
// Highlighter failures degrade to plain text inside buildRows; this
|
|
// catches the unexpected (e.g. malformed hunk lines), which would
|
|
// otherwise be an unhandled rejection leaving `rows` stuck at null with
|
|
// no feedback.
|
|
.catch(() => {
|
|
if (!cancelled) setFailed(true);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [hunks, path, shikiTheme]);
|
|
|
|
if (failed) {
|
|
return (
|
|
<div className={styles.filePlaceholder}>{t('gitDiff.fileError')}</div>
|
|
);
|
|
}
|
|
|
|
// null while the rows are first built and again while re-tokenizing after a
|
|
// theme switch; show a placeholder instead of an empty, jumpily-resized box.
|
|
if (rows === null) {
|
|
return <div className={styles.filePlaceholder}>{t('gitDiff.loading')}</div>;
|
|
}
|
|
|
|
return (
|
|
<div className={styles.diffLines}>
|
|
{(rows ?? []).map((row, index) => (
|
|
<div
|
|
key={index}
|
|
className={`${styles.diffLine} ${ROW_CLASS[row.type]}`}
|
|
>
|
|
<span className={styles.diffOldNo}>{row.oldNo ?? ''}</span>
|
|
<span className={styles.diffNewNo}>{row.newNo ?? ''}</span>
|
|
<span className={styles.diffMarker}>
|
|
{row.type === 'add'
|
|
? '+'
|
|
: row.type === 'del'
|
|
? '-'
|
|
: row.type === 'meta'
|
|
? ''
|
|
: ' '}
|
|
</span>
|
|
<code className={styles.diffContent}>{renderContent(row)}</code>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DiffFileRow({
|
|
workspaceCwd,
|
|
file,
|
|
}: {
|
|
workspaceCwd: string;
|
|
file: DaemonWorkspaceGitDiffFile;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const { client } = useWorkspace();
|
|
const [open, setOpen] = useState(false);
|
|
const [hunks, setHunks] = useState<DaemonDiffHunk[] | null>(null);
|
|
const [truncated, setTruncated] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState(false);
|
|
// Guard the in-flight fetch so closing the dialog before it resolves doesn't
|
|
// settle state on an unmounted row (matching DiffHunks / GitDiffDialog).
|
|
const cancelledRef = useRef(false);
|
|
useEffect(() => {
|
|
// Reset on mount: StrictMode replays mount→unmount→mount and the ref
|
|
// persists across the replay, so without this reset the flag would stick at
|
|
// true and suppress every post-fetch state update (row stuck on "Loading").
|
|
cancelledRef.current = false;
|
|
return () => {
|
|
cancelledRef.current = true;
|
|
};
|
|
}, []);
|
|
|
|
const toggle = () => {
|
|
const next = !open;
|
|
setOpen(next);
|
|
if (next && hunks === null && !loading && !file.isBinary) {
|
|
setLoading(true);
|
|
setError(false);
|
|
client
|
|
.workspaceByCwd(workspaceCwd)
|
|
// Pass the pre-rename path so a renamed file diffs old→new (rename
|
|
// detection) instead of showing the new path as fully added.
|
|
.workspaceGitDiffFile(file.path, file.oldPath)
|
|
.then((result) => {
|
|
if (cancelledRef.current) return;
|
|
setHunks(result.hunks);
|
|
setTruncated(result.truncated === true);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelledRef.current) setError(true);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelledRef.current) setLoading(false);
|
|
});
|
|
}
|
|
};
|
|
|
|
const displayName = sanitizeControlChars(file.path);
|
|
|
|
return (
|
|
<div className={styles.file}>
|
|
<button
|
|
type="button"
|
|
className={styles.fileHeader}
|
|
onClick={toggle}
|
|
aria-expanded={open}
|
|
aria-label={t(open ? 'gitDiff.collapse' : 'gitDiff.expand', {
|
|
path: file.oldPath
|
|
? `${sanitizeControlChars(file.oldPath)} → ${displayName}`
|
|
: displayName,
|
|
})}
|
|
>
|
|
<span className={styles.fileStats}>
|
|
{file.isBinary ? (
|
|
<span className={styles.fileBinary}>{t('gitDiff.binary')}</span>
|
|
) : (
|
|
<>
|
|
<span className={styles.statAdd}>+{file.added ?? 0}</span>
|
|
<span className={styles.statDel}>-{file.removed ?? 0}</span>
|
|
</>
|
|
)}
|
|
</span>
|
|
<span className={styles.filePath} title={displayName}>
|
|
{file.oldPath ? (
|
|
<>
|
|
<span className={styles.fileOldPath}>
|
|
{sanitizeControlChars(file.oldPath)}
|
|
</span>
|
|
{' → '}
|
|
</>
|
|
) : null}
|
|
{displayName}
|
|
</span>
|
|
{file.isUntracked && (
|
|
<span className={styles.fileTag}>{t('gitDiff.untracked')}</span>
|
|
)}
|
|
{file.isDeleted && (
|
|
<span className={styles.fileTag}>{t('gitDiff.deleted')}</span>
|
|
)}
|
|
</button>
|
|
{open && (
|
|
<div className={styles.fileBody}>
|
|
{file.isBinary ? (
|
|
<div className={styles.filePlaceholder}>{t('gitDiff.binary')}</div>
|
|
) : loading ? (
|
|
<div className={styles.filePlaceholder}>{t('gitDiff.loading')}</div>
|
|
) : error ? (
|
|
<div className={styles.filePlaceholder}>
|
|
{t('gitDiff.fileError')}
|
|
</div>
|
|
) : hunks && hunks.length > 0 ? (
|
|
<>
|
|
<DiffHunks hunks={hunks} path={file.path} />
|
|
{truncated && (
|
|
<div className={styles.filePlaceholder} role="note">
|
|
{t('gitDiff.truncated')}
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className={styles.filePlaceholder}>{t('gitDiff.noDiff')}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function GitDiffContent({
|
|
workspaceCwd,
|
|
onSubtitleChange,
|
|
}: {
|
|
workspaceCwd: string;
|
|
onSubtitleChange?: (subtitle: string | undefined) => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const { client } = useWorkspace();
|
|
const [diff, setDiff] = useState<DaemonWorkspaceGitDiff | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setError(false);
|
|
client
|
|
.workspaceByCwd(workspaceCwd)
|
|
.workspaceGitDiff()
|
|
.then((result) => {
|
|
if (!cancelled) setDiff(result);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setError(true);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [client, workspaceCwd]);
|
|
|
|
const subtitle =
|
|
diff && diff.available
|
|
? t('gitDiff.summary', {
|
|
count: diff.filesCount,
|
|
added: diff.linesAdded,
|
|
removed: diff.linesRemoved,
|
|
})
|
|
: undefined;
|
|
|
|
useEffect(() => {
|
|
onSubtitleChange?.(subtitle);
|
|
}, [onSubtitleChange, subtitle]);
|
|
|
|
let body: ReactNode;
|
|
if (loading) {
|
|
body = <div className={styles.placeholder}>{t('gitDiff.loading')}</div>;
|
|
} else if (error) {
|
|
body = <div className={styles.placeholder}>{t('gitDiff.error')}</div>;
|
|
} else if (!diff || !diff.available) {
|
|
body = <div className={styles.placeholder}>{t('gitDiff.unavailable')}</div>;
|
|
} else if (diff.files.length === 0) {
|
|
body = <div className={styles.placeholder}>{t('gitDiff.empty')}</div>;
|
|
} else {
|
|
body = (
|
|
<div className={styles.fileList}>
|
|
{diff.files.map((file) => (
|
|
<DiffFileRow
|
|
// Key by workspace + path so switching workspace remounts the row
|
|
// instead of reusing another workspace's hunks/open state for a
|
|
// path both workspaces share.
|
|
key={`${workspaceCwd}:${file.path}`}
|
|
workspaceCwd={workspaceCwd}
|
|
file={file}
|
|
/>
|
|
))}
|
|
{diff.hiddenCount > 0 && (
|
|
<div className={styles.hiddenNote}>
|
|
{t('gitDiff.hidden', { count: diff.hiddenCount })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return <div className={styles.content}>{body}</div>;
|
|
}
|
|
|
|
export function GitDiffDialog({
|
|
workspaceCwd,
|
|
onClose,
|
|
}: {
|
|
workspaceCwd: string;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
return (
|
|
<DialogShell
|
|
title={t('gitDiff.title')}
|
|
size="xl"
|
|
allowFullscreen
|
|
onClose={onClose}
|
|
>
|
|
<GitDiffContent workspaceCwd={workspaceCwd} />
|
|
</DialogShell>
|
|
);
|
|
}
|