docs/design/2026-07-19-webshell-git-log.md
2026-07-16-webshell-git-status-diff.md 落地了 branch chip 增强(dirty /
ahead-behind / stash / detached / operation)和可视化 diff 查看器
(GitDiffDialog)。这两项解决了"工作区当前状态"的感知问题。
但用户还有另一个高频需求:查看提交历史。目前要看最近做了什么,只能在 chat
里让 agent 跑 git log --oneline,然后读一段纯文本。对于一个图形界面来说,
这是明显的体验缺口——提交历史是代码审查、回溯变更、理解项目演进的基础视图。
本期目标:新增一个只读的 Git Log 浏览器,以紧凑列表展示提交记录,点击展开
查看完整 message 和文件变更统计。复用现有 DialogShell / SDK / 路由模式,
不引入写操作。
/log 斜杠命令和 UI 入口,打开提交历史浏览器。core (gitDiff.ts 扩展)
├─ fetchGitLog(cwd, { limit, skip }) [新增] 提交列表
└─ fetchGitCommitDetail(cwd, sha) [新增] 单 commit 详情(message + numstat)
│
▼
daemon (serve)
├─ GET /workspace/git/log?limit=&skip= [新增]
├─ GET /workspace/git/log/commit?sha= [新增]
└─ qualified 版本 /workspaces/:workspace/git/log[/commit]
│
▼
SDK (DaemonClient + types)
├─ DaemonGitLogEntry / DaemonGitLog [新增]
├─ DaemonGitCommitDetail [新增]
├─ workspaceGitLog(limit?, skip?) [新增]
└─ workspaceGitCommitDetail(sha) [新增]
│
▼
Web Shell (client)
├─ GitDialog.tsx [新增] Changes / History 统一容器
├─ GitLogDialog.tsx (+ .module.css) [新增] 提交列表 + 展开详情
├─ App.tsx [扩展] /log 命令拦截 + dialog view 状态
└─ i18n.tsx [扩展] gitLog.* 文案
/log 或 Git chip 打开统一的 GitDialog。使用
DialogShell size="xl" allowFullscreen,在同一个 dialog 内切换 Changes / History:
┌─ History ───────────────────────── 50 commits ─ ✕ ┐
│ │
│ a1b2c3d feat(cli): add --json flag 2h ago │
│ wenshao │
│ │
│ e4f5g6h fix(core): handle null config 5h ago │
│ dev · HEAD -> main, v1.2.0 │
│ │
│ ▼ 789abcd refactor: simplify parser 1d ago │
│ ┌──────────────────────────────────────────────┐ │
│ │ Broke the monolithic parse() into smaller │ │
│ │ functions for readability. │ │
│ │ │ │
│ │ 3 files · +45 −12 │ │
│ │ +30 −8 src/parser.ts │ │
│ │ +10 −2 src/utils.ts │ │
│ │ +5 −2 test/parser.test.ts │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ c0ffee1 chore: bump deps 3d ago │
│ bot │
│ │
│ [ Load more ] │
└──────────────────────────────────────────────────────┘
交互:
⎇ 图标区分。// packages/core/src/utils/gitDiff.ts 新增
export interface GitLogEntry {
sha: string; // 完整 40 字符 SHA
shortSha: string; // 短 SHA(git 默认缩写)
authorName: string;
authorEmail: string;
authorDate: number; // unix timestamp(秒)
subject: string;
refs: string; // %D 输出,如 "HEAD -> main, origin/main, v1.2.0"
parents: string[]; // parent SHA 列表(length > 1 表示 merge commit)
}
export interface GitLogResult {
entries: GitLogEntry[];
hasMore: boolean; // 是否还有更多提交
}
export interface GitCommitFileStat {
path: string;
added: number; // 二进制文件为 0
removed: number;
isBinary: boolean;
}
export interface GitCommitDetail {
sha: string;
shortSha: string;
authorName: string;
authorEmail: string;
authorDate: number;
subject: string;
body: string; // 完整 message body(可能为空)
refs: string;
parents: string[];
files: GitCommitFileStat[];
filesCount: number;
linesAdded: number;
linesRemoved: number;
hiddenCount: number; // 超出 MAX_FILES 的文件数
}
// packages/sdk-typescript/src/daemon/types.ts 新增
export interface DaemonGitLogEntry {
sha: string;
shortSha: string;
authorName: string;
authorEmail: string;
authorDate: number;
subject: string;
refs?: string;
parents: string[];
}
export interface DaemonGitLog {
v: 1;
workspaceCwd: string;
available: boolean;
entries: DaemonGitLogEntry[];
hasMore: boolean;
}
export interface DaemonGitCommitFileStat {
path: string;
added: number;
removed: number;
isBinary: boolean;
}
export interface DaemonGitCommitDetail {
v: 1;
workspaceCwd: string;
available: boolean;
sha: string;
shortSha: string;
authorName: string;
authorEmail: string;
authorDate: number;
subject: string;
body: string;
refs?: string;
parents: string[];
files: DaemonGitCommitFileStat[];
filesCount: number;
linesAdded: number;
linesRemoved: number;
hiddenCount: number;
}
fetchGitLog 和 fetchGitCommitDetail放在 packages/core/src/utils/gitDiff.ts,复用已有的 runGit 内部函数和
上限常量。
fetchGitLog(cwd, { limit = 50, skip = 0 }): Promise<GitLogResult | null>git --no-optional-locks log -z
--format='%H%x00%h%x00%an%x00%ae%x00%at%x00%s%x00%D%x00%P'
-n <limit+1> --skip=<skip>
\x00(NUL)分隔字段,-z 用 NUL 终止每条记录。limit + 1 条来判断 hasMore,返回时截断到 limit。--no-optional-locks 避免写锁。null。{ entries: [], hasMore: false }。limit 上限 200,超出截断到 200。fetchGitCommitDetail(cwd, sha): Promise<GitCommitDetail | null>sha:必须匹配 /^[0-9a-f]{7,40}$/i(防止注入)。git --no-optional-locks log -1 -z
--format='%H%x00%h%x00%an%x00%ae%x00%at%x00%s%x00%D%x00%P%x00%b'
<sha>
git --no-optional-locks diff-tree --no-commit-id --numstat -r -z <sha>
--root。parseGitNumstat 的逻辑(或提取共享函数),受
MAX_FILES 上限约束。null。新增 packages/cli/src/serve/routes/workspace-git-log.ts,遵循
workspace-git-diff.ts 的双注册模式:
export function registerWorkspaceGitLogRoutes(app, deps: {
boundWorkspace: string;
sendBridgeError: SendBridgeError;
}): void {
app.get('/workspace/git/log', ...);
app.get('/workspace/git/log/commit', ...);
}
export function registerWorkspaceQualifiedGitLogRoutes(app, deps: {
workspaceRegistry: WorkspaceRegistry;
sendBridgeError: SendBridgeError;
}): void {
app.get('/workspaces/:workspace/git/log', ...);
app.get('/workspaces/:workspace/git/log/commit', ...);
}
GET /workspace/git/log?limit=50&skip=0:
limit(默认 50,max 200)和 skip(默认 0)查询参数。fetchGitLog(workspaceCwd, { limit, skip })。DaemonGitLog(v: 1,available 标记)。applyReadHeaders(res) + sendBridgeError 错误处理。GET /workspace/git/log/commit?sha=<sha>:
sha 格式。fetchGitCommitDetail(workspaceCwd, sha)。DaemonGitCommitDetail。qualified 路由复用 resolveWorkspaceRuntimeFromParam +
requireTrustedWorkspaceRuntime 信任校验。
在 server.ts 中注册(参照 diff 路由的注册位置)。
types.ts:新增上述 DaemonGitLogEntry / DaemonGitLog /
DaemonGitCommitFileStat / DaemonGitCommitDetail 类型,从
src/index.ts 和 src/daemon/index.ts 导出。
DaemonClient(主 client + workspace-qualified client)新增:
async workspaceGitLog(limit?: number, skip?: number): Promise<DaemonGitLog> {
const params = new URLSearchParams();
if (limit != null) params.set('limit', String(limit));
if (skip != null) params.set('skip', String(skip));
const qs = params.toString();
return await this.jsonRequest<DaemonGitLog>(
`/workspace/git/log${qs ? `?${qs}` : ''}`,
'GET /workspace/git/log',
{ mode: 'rest' },
);
}
async workspaceGitCommitDetail(sha: string): Promise<DaemonGitCommitDetail> {
return await this.jsonRequest<DaemonGitCommitDetail>(
`/workspace/git/log/commit?sha=${urlEncode(sha)}`,
'GET /workspace/git/log/commit',
{ mode: 'rest' },
);
}
新增 packages/web-shell/client/components/dialogs/GitLogDialog.tsx +
GitLogDialog.module.css。
Props(与 GitDiffDialog 对齐):
export function GitLogDialog({
workspaceCwd,
onClose,
}: {
workspaceCwd: string;
onClose: () => void;
});
数据获取:
client.workspaceByCwd(workspaceCwd).workspaceGitLog() 拉首页。workspaceGitLog(50, nextSkip),追加时按 SHA 去重。workspaceGitCommitDetail(sha) 按需拉详情。let cancelled,click 用
useRef)。渲染:
DialogShell title={t('gitLog.title')} size="xl" allowFullscreen。refs 字符串提取 HEAD -> branch、tag 等,渲染为小标签。parents.length > 1 时显示 merge 图标。<pre> 渲染,保留换行)+ 文件统计列表(复用
GitDiffDialog 的文件行样式语义:+N −M path,binary 标记)。timeAgo 工具函数(秒/分/时/天/周/月/年),不引入
外部库。CSS Module:
var(--border)、var(--muted-foreground)
等)。.commitRow、.commitSha、.commitSubject、.commitMeta、.commitRefs、
.commitDetail、.commitBody、.fileStat、.loadMore、.placeholder。gitDialog 状态:
{ workspaceCwd: string; view: 'diff' | 'log' } | undefined
/log 斜杠命令本地拦截(与 /diff 同模式),将 view 设为 log。diff view;GitDialog 内的 Changes / History tabs
直接切换 view,不关闭或重新打开 Radix dialog。<GitDialog>。dialogOpen 判断加入 gitDialog !== undefined。getLocalCommands 补 log 补全项。新增 gitLog.* 命名空间(en + zh-CN):
| Key | EN | zh-CN |
|---|---|---|
gitLog.title | History | 提交历史 |
gitLog.subtitle | (v) => \${v?.count} commits`` | (v) => \${v?.count} 条提交`` |
gitLog.loading | Loading history… | 加载历史中… |
gitLog.empty | No commits yet | 暂无提交 |
gitLog.unavailable | Git is not available for this workspace | 此工作区不可用 Git |
gitLog.error | Failed to load history | 加载历史失败 |
gitLog.loadMore | Load more | 加载更多 |
gitLog.loadingMore | Loading… | 加载中… |
gitLog.files | (v) => \${v?.count} files · +${v?.added} −${v?.removed}`` | (v) => \${v?.count} 个文件 · +${v?.added} −${v?.removed}`` |
gitLog.detailError | Failed to load commit details | 加载提交详情失败 |
gitLog.hidden | (v) => \${v?.count} more file(s) not shown`` | (v) => \还有 ${v?.count} 个文件未显示`` |
gitLog.copySha | (v) => \Copy commit ${v?.sha}`` | (v) => \复制提交 ${v?.sha}`` |
localCommand.logNoWorkspace | No workspace is available yet to show history for. | 当前还没有可用于查看历史的工作区。 |
/workspace/git/log 路由:SDK 调用会 404,前端显示
Failed to load history 错误占位。fetchGitLog 返回 null 或空列表,前端显示占位。/log 在非 Web Shell 客户端不存在(不影响 CLI / ACP)。fetchGitLog:
hasMore 判断正确;skip 偏移正确。null。limit 上限截断(>200 → 200)。fetchGitCommitDetail:
--root 生效,文件统计正确。null。GET /workspace/git/log:正确映射 core 结果到 wire format。GET /workspace/git/log/commit?sha=:sha 校验 + 映射。workspaceGitLog URL 拼接(有/无参数)。workspaceGitCommitDetail URL 拼接 + sha 编码。GitLogDialog:
/log 本地拦截(有/无 workspace)。/log:列表正确,展开详情正确,Load more 正确。git log 在超大仓库(100k+ commits)上 --skip 性能退化。
控制:--skip 是 O(skip) 的,但 50 条一页、用户手动翻页的场景下,
skip 值通常不会极大。如果后续需要深分页,可改为 --before=<timestamp>
游标。本期不做。%D(refs)在大量 tag/branch 时字符串很长。控制:UI 只显示
前 2-3 个 ref,其余折叠。/^[0-9a-f]{7,40}$/i,
daemon 层二次校验。| 步骤 | 内容 | 涉及包 |
|---|---|---|
| 1 | core:fetchGitLog + fetchGitCommitDetail + 单测 | core |
| 2 | daemon:workspace-git-log.ts 路由 + 注册 + 单测 | cli |
| 3 | SDK:类型 + client 方法 + 导出 | sdk-typescript |
| 4 | Web Shell:GitLogDialog + CSS + i18n + App 集成 + 单测 | web-shell |
| 5 | build + typecheck + lint + 全量单测验证 | all |