mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-04 05:40:58 +00:00
* fix(web-shell): enable Changes and History dialogs for worktree sessions * fix(web-shell): add containment and gitCwd forwarding tests, fix SDK nits (#7695) * fix(cli): use realpathSync in resolveContainedCwd test assertions (#7695) On macOS, os.tmpdir() returns /var/folders/... (a symlink to /private/var/folders/...), but resolveContainedCwd resolves paths via fs.realpathSync. The test assertions compared against the raw os.tmpdir()-based paths, causing two tests to fail on macOS. Wrap the expected values in fs.realpathSync to match the function's actual return value on all platforms. * test(web-shell): cover gitCwd forwarding through log pagination (#7695) * test(sdk): cover cwd query encoding in workspace git client methods (#7695) * fix(sdk): bump browser bundle size limit for worktree cwd params --------- Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
361 lines
9.9 KiB
TypeScript
361 lines
9.9 KiB
TypeScript
/**
|
||
* @license
|
||
* Copyright 2026 Qwen Team
|
||
* SPDX-License-Identifier: Apache-2.0
|
||
*/
|
||
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
type ReactNode,
|
||
} from 'react';
|
||
import { CheckIcon, CopyIcon } from 'lucide-react';
|
||
import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk';
|
||
import type {
|
||
DaemonGitLog,
|
||
DaemonGitLogEntry,
|
||
DaemonGitCommitDetail,
|
||
} from '@qwen-code/sdk/daemon';
|
||
import { useI18n } from '../../i18n';
|
||
import { timeAgo } from '../../utils/timeAgo';
|
||
import { DialogShell } from './DialogShell';
|
||
import styles from './GitLogDialog.module.css';
|
||
|
||
const PAGE_SIZE = 50;
|
||
|
||
function parseRefs(refs: string): { label: string; isHead: boolean }[] {
|
||
if (!refs) return [];
|
||
return refs
|
||
.split(',')
|
||
.map((r) => r.trim())
|
||
.filter(Boolean)
|
||
.slice(0, 3)
|
||
.map((r) => {
|
||
const isHead = r.startsWith('HEAD ->');
|
||
const label = isHead ? r.replace('HEAD -> ', '') : r;
|
||
return { label, isHead };
|
||
});
|
||
}
|
||
|
||
function CommitRow({
|
||
entry,
|
||
workspaceCwd,
|
||
gitCwd,
|
||
now,
|
||
}: {
|
||
entry: DaemonGitLogEntry;
|
||
workspaceCwd: string;
|
||
gitCwd?: string;
|
||
now: number;
|
||
}) {
|
||
const { client } = useWorkspace();
|
||
const { language, t } = useI18n();
|
||
const [open, setOpen] = useState(false);
|
||
const [detail, setDetail] = useState<DaemonGitCommitDetail | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState(false);
|
||
const [copied, setCopied] = useState(false);
|
||
const cancelledRef = useRef(false);
|
||
|
||
const copySha = () => {
|
||
void navigator.clipboard
|
||
.writeText(entry.sha)
|
||
.then(() => {
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 1500);
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
|
||
useEffect(() => {
|
||
cancelledRef.current = false;
|
||
return () => {
|
||
cancelledRef.current = true;
|
||
};
|
||
}, []);
|
||
|
||
const toggle = () => {
|
||
const next = !open;
|
||
setOpen(next);
|
||
if (next && detail === null && !loading) {
|
||
setLoading(true);
|
||
setError(false);
|
||
client
|
||
.workspaceByCwd(workspaceCwd)
|
||
.workspaceGitCommitDetail(entry.sha, gitCwd)
|
||
.then((result) => {
|
||
if (cancelledRef.current) return;
|
||
setDetail(result);
|
||
})
|
||
.catch(() => {
|
||
if (cancelledRef.current) return;
|
||
setError(true);
|
||
})
|
||
.finally(() => {
|
||
if (cancelledRef.current) return;
|
||
setLoading(false);
|
||
});
|
||
}
|
||
};
|
||
|
||
const refs = parseRefs(entry.refs ?? '');
|
||
const isMerge = entry.parents.length > 1;
|
||
|
||
let detailBody: ReactNode;
|
||
if (open) {
|
||
if (loading) {
|
||
detailBody = (
|
||
<div className={styles.commitDetail}>
|
||
<span className={styles.fileBinary}>{t('gitLog.loading')}</span>
|
||
</div>
|
||
);
|
||
} else if (error) {
|
||
detailBody = (
|
||
<div className={styles.commitDetail}>
|
||
<span className={styles.detailError}>{t('gitLog.detailError')}</span>
|
||
</div>
|
||
);
|
||
} else if (detail && detail.available) {
|
||
detailBody = (
|
||
<div className={styles.commitDetail}>
|
||
{detail.body && (
|
||
<pre className={styles.commitBody}>{detail.body}</pre>
|
||
)}
|
||
{detail.files && (
|
||
<div className={styles.fileStats}>
|
||
<div className={styles.fileStatHeader}>
|
||
{t('gitLog.files', {
|
||
count: detail.filesCount ?? 0,
|
||
added: detail.linesAdded ?? 0,
|
||
removed: detail.linesRemoved ?? 0,
|
||
})}
|
||
</div>
|
||
{detail.files.map((f) => (
|
||
<div key={f.path} className={styles.fileStatRow}>
|
||
{f.isBinary ? (
|
||
<span className={styles.fileBinary}>~</span>
|
||
) : (
|
||
<span className={styles.statNums}>
|
||
<span className={styles.statAdd}>+{f.added}</span>
|
||
<span className={styles.statDel}>−{f.removed}</span>
|
||
</span>
|
||
)}
|
||
<span className={styles.fileStatPath}>{f.path}</span>
|
||
</div>
|
||
))}
|
||
{(detail.hiddenCount ?? 0) > 0 && (
|
||
<div className={styles.hiddenNote}>
|
||
{t('gitLog.hidden', { count: detail.hiddenCount ?? 0 })}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
} else if (detail && !detail.available) {
|
||
detailBody = (
|
||
<div className={styles.commitDetail}>
|
||
<span className={styles.detailError}>{t('gitLog.detailError')}</span>
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className={styles.commitRow}>
|
||
<div className={styles.commitHeader}>
|
||
<button
|
||
type="button"
|
||
className={styles.commitToggle}
|
||
onClick={toggle}
|
||
aria-expanded={open}
|
||
>
|
||
{isMerge && <span className={styles.mergeIcon}>⎇</span>}
|
||
<span className={styles.commitSha} title={entry.sha}>
|
||
{entry.shortSha}
|
||
</span>
|
||
<span className={styles.commitSubject}>{entry.subject}</span>
|
||
{refs.length > 0 && (
|
||
<span className={styles.commitRefs}>
|
||
{refs.map((r) => (
|
||
<span
|
||
key={r.label}
|
||
className={`${styles.refTag}${r.isHead ? ` ${styles.refHead}` : ''}`}
|
||
>
|
||
{r.label}
|
||
</span>
|
||
))}
|
||
</span>
|
||
)}
|
||
<span className={styles.commitMeta}>
|
||
{entry.authorName} · {timeAgo(entry.authorDate, now, language)}
|
||
</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={styles.copyBtn}
|
||
onClick={copySha}
|
||
aria-label={t('gitLog.copySha', { sha: entry.shortSha })}
|
||
>
|
||
{copied ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
|
||
</button>
|
||
</div>
|
||
{detailBody}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function GitLogContent({
|
||
workspaceCwd,
|
||
gitCwd,
|
||
onSubtitleChange,
|
||
}: {
|
||
workspaceCwd: string;
|
||
gitCwd?: string;
|
||
onSubtitleChange?: (subtitle: string | undefined) => void;
|
||
}) {
|
||
const { client } = useWorkspace();
|
||
const { t } = useI18n();
|
||
const [log, setLog] = useState<DaemonGitLog | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState(false);
|
||
const [loadingMore, setLoadingMore] = useState(false);
|
||
const [loadMoreError, setLoadMoreError] = useState(false);
|
||
const [now, setNow] = useState(Date.now() / 1000);
|
||
const nextSkipRef = useRef(0);
|
||
|
||
useEffect(() => {
|
||
const id = setInterval(() => setNow(Date.now() / 1000), 60_000);
|
||
return () => clearInterval(id);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setLoading(true);
|
||
setError(false);
|
||
setLoadMoreError(false);
|
||
nextSkipRef.current = 0;
|
||
client
|
||
.workspaceByCwd(workspaceCwd)
|
||
.workspaceGitLog(PAGE_SIZE, 0, gitCwd)
|
||
.then((result) => {
|
||
if (!cancelled) {
|
||
nextSkipRef.current = result.entries.length;
|
||
setLog(result);
|
||
}
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setError(true);
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setLoading(false);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [client, workspaceCwd, gitCwd]);
|
||
|
||
const loadMore = useCallback(() => {
|
||
if (!log || loadingMore) return;
|
||
setLoadingMore(true);
|
||
client
|
||
.workspaceByCwd(workspaceCwd)
|
||
.workspaceGitLog(PAGE_SIZE, nextSkipRef.current, gitCwd)
|
||
.then((result) => {
|
||
nextSkipRef.current += result.entries.length;
|
||
setLog((prev) => {
|
||
if (!prev) return result;
|
||
const existing = new Set(prev.entries.map((entry) => entry.sha));
|
||
return {
|
||
...prev,
|
||
entries: [
|
||
...prev.entries,
|
||
...result.entries.filter((entry) => !existing.has(entry.sha)),
|
||
],
|
||
hasMore: result.hasMore,
|
||
};
|
||
});
|
||
})
|
||
.catch(() => {
|
||
setLoadMoreError(true);
|
||
})
|
||
.finally(() => {
|
||
setLoadingMore(false);
|
||
});
|
||
}, [client, workspaceCwd, gitCwd, log, loadingMore]);
|
||
|
||
const subtitle = log?.available
|
||
? t('gitLog.subtitle', { count: log.entries.length })
|
||
: undefined;
|
||
|
||
useEffect(() => {
|
||
onSubtitleChange?.(subtitle);
|
||
}, [onSubtitleChange, subtitle]);
|
||
|
||
let body: ReactNode;
|
||
if (loading) {
|
||
body = <div className={styles.placeholder}>{t('gitLog.loading')}</div>;
|
||
} else if (error) {
|
||
body = <div className={styles.placeholder}>{t('gitLog.error')}</div>;
|
||
} else if (!log || !log.available) {
|
||
body = <div className={styles.placeholder}>{t('gitLog.unavailable')}</div>;
|
||
} else if (log.entries.length === 0) {
|
||
body = <div className={styles.placeholder}>{t('gitLog.empty')}</div>;
|
||
} else {
|
||
body = (
|
||
<>
|
||
<div className={styles.commitList}>
|
||
{log.entries.map((entry) => (
|
||
<CommitRow
|
||
key={entry.sha}
|
||
entry={entry}
|
||
workspaceCwd={workspaceCwd}
|
||
gitCwd={gitCwd}
|
||
now={now}
|
||
/>
|
||
))}
|
||
</div>
|
||
{loadMoreError && (
|
||
<div className={styles.placeholder}>{t('gitLog.error')}</div>
|
||
)}
|
||
{log.hasMore && (
|
||
<button
|
||
type="button"
|
||
className={styles.loadMore}
|
||
onClick={() => {
|
||
setLoadMoreError(false);
|
||
loadMore();
|
||
}}
|
||
disabled={loadingMore}
|
||
>
|
||
{loadingMore ? t('gitLog.loadingMore') : t('gitLog.loadMore')}
|
||
</button>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
return <div className={styles.content}>{body}</div>;
|
||
}
|
||
|
||
export function GitLogDialog({
|
||
workspaceCwd,
|
||
onClose,
|
||
}: {
|
||
workspaceCwd: string;
|
||
onClose: () => void;
|
||
}) {
|
||
const { t } = useI18n();
|
||
return (
|
||
<DialogShell
|
||
title={t('gitLog.title')}
|
||
size="xl"
|
||
allowFullscreen
|
||
onClose={onClose}
|
||
>
|
||
<GitLogContent workspaceCwd={workspaceCwd} />
|
||
</DialogShell>
|
||
);
|
||
}
|