qwen-code/packages/web-shell/client/components/dialogs/GitLogDialog.tsx
Shaojin Wen 5c7c2ec0d0
fix(web-shell): enable Changes and History dialogs for worktree sessions (#7695)
* 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>
2026-07-25 12:47:02 +00:00

361 lines
9.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @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>
);
}