From f61cd4bfaa7ddf4fa3a5e65dc80d40bc6a9643c1 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Fri, 18 Sep 2026 08:32:35 +0000 Subject: [PATCH] feat(web-shell): draw the commit history as a lane graph, across all branches, with search (#12152) * feat(web-shell): draw the commit history as a lane graph, across all branches, with search The history tab listed HEAD's commits as a flat list: branch structure was invisible, other branches were unreachable without a checkout, and there was no way to find a commit by message, author, or hash. The daemon log route now takes `all=1` to walk HEAD plus every local branch, remote branch, and tag, and `search=` to keep only commits whose message, author, or hash prefix matches (case-insensitive fixed strings; git ANDs `--grep` with `--author`, so the two walks are unioned and paged by commit date). Every walk is `--date-order`, so a parent never precedes a child and the client can lay out lanes from `parents` alone. The history tab gains a search field, an "All branches" toggle, and a lane graph column: one lane per concurrent line of development, S-curves at forks and merges, hollow nodes for merge commits, and lanes drawn straight through an expanded commit's detail block. The branch chip offers a "History" action next to "View Changes". Part of #11941. * fix(web-shell): keep the History action visible under the branch picker search The picker hides its action list while the search box has text unless one of the known action labels matches the query; the new History entry was missing from that list, so typing part of its name hid it instead of finding it. --------- Co-authored-by: probe --- .../2026-09-18-web-shell-git-history-graph.md | 78 ++++++ ...09-18-web-shell-git-history-graph.zh-CN.md | 78 ++++++ .../serve/routes/workspace-git-log.test.ts | 8 + .../cli/src/serve/routes/workspace-git-log.ts | 15 +- packages/core/src/utils/gitDiff.test.ts | 93 +++++++ packages/core/src/utils/gitDiff.ts | 158 +++++++++-- .../sdk-typescript/src/daemon/DaemonClient.ts | 7 + packages/sdk-typescript/src/daemon/index.ts | 1 + packages/sdk-typescript/src/daemon/types.ts | 8 + packages/web-shell/client/App.tsx | 18 ++ .../components/BranchPickerPopover.test.tsx | 63 +++++ .../client/components/BranchPickerPopover.tsx | 26 +- .../client/components/ChatEditor.tsx | 4 + .../components/dialogs/GitDialog.test.tsx | 13 +- .../dialogs/GitLogDialog.module.css | 102 ++++++- .../components/dialogs/GitLogDialog.test.tsx | 183 ++++++++++++- .../components/dialogs/GitLogDialog.tsx | 251 ++++++++++++++++-- .../components/panels/EnvironmentPanel.tsx | 3 + packages/web-shell/client/i18n.tsx | 8 + .../client/utils/commitGraph.test.ts | 94 +++++++ .../web-shell/client/utils/commitGraph.ts | 108 ++++++++ 21 files changed, 1257 insertions(+), 62 deletions(-) create mode 100644 docs/design/2026-09-18-web-shell-git-history-graph.md create mode 100644 docs/design/2026-09-18-web-shell-git-history-graph.zh-CN.md create mode 100644 packages/web-shell/client/utils/commitGraph.test.ts create mode 100644 packages/web-shell/client/utils/commitGraph.ts diff --git a/docs/design/2026-09-18-web-shell-git-history-graph.md b/docs/design/2026-09-18-web-shell-git-history-graph.md new file mode 100644 index 00000000000..022d8f39f6b --- /dev/null +++ b/docs/design/2026-09-18-web-shell-git-history-graph.md @@ -0,0 +1,78 @@ +# Web Shell commit history: lane graph and search + +[English](2026-09-18-web-shell-git-history-graph.md) | [简体中文](2026-09-18-web-shell-git-history-graph.zh-CN.md) + +Status: implemented. Part of [#11941](https://github.com/QwenLM/qwen-code/issues/11941); the worktree manager from the same issue is a separate design. + +## Problem + +The Web Shell history tab lists the commits reachable from `HEAD` as a flat list. Merge commits carry a marker but the branch structure is invisible, commits on other branches are unreachable without a checkout, and there is no way to find a commit by message, author, or hash beyond scrolling. + +## Current state + +- `GET /workspaces/:workspace/git/log` accepts `limit`, `skip`, and a single `range`; `fetchGitLog` in `packages/core/src/utils/gitDiff.ts` rejects a `range` that starts with `-` or `..`, so multi-ref flags cannot be smuggled through it. +- The response already carries `parents` and `refs` for each entry. +- `GitLogContent` in `packages/web-shell/client/components/dialogs/GitLogDialog.tsx` renders the list and is embedded as the `log` tab of `GitDialog`. + +## Goals + +- Show the commit history as a lane graph: one column per concurrent line of development, curves where branches fork and merge. +- Let the user widen the walk from `HEAD` to every local branch, remote branch, and tag. +- Filter the history by message, author, or hash from the same tab. +- Open the history from the branch chip, next to the existing Changes and Commit actions. + +## Non-goals + +- No rendering of commits that the daemon walk does not return (no client-side graph reconstruction beyond the loaded pages). +- No new daemon route; the existing log route grows two optional query parameters. +- No changes to the commit detail endpoint or the diff tab. + +## Design + +### Daemon and core + +`fetchGitLog` takes two new options: + +- `all`: the walk covers `HEAD --branches --tags --remotes` instead of `HEAD`. Stash, notes, and replace refs stay excluded. +- `search`: a case-insensitive fixed-string filter. git ANDs `--grep` with `--author`, so message and author matches are two walks whose union is paged; a query of four to forty hex characters additionally resolves as a commit hash prefix through `git rev-parse --verify ^{commit}`. The union is ordered by author date, so a page boundary can shift by one entry when author and commit dates disagree; the client de-duplicates by sha. + +Every walk now passes `--date-order`, so a parent never precedes one of its children and the lane layout needs nothing beyond `parents`. The route reads `all=1` and `search=` (trimmed, capped at 200 characters). The SDK exposes them as a trailing `options` argument on `workspaceGitLog` in both the bound-workspace and workspace-qualified clients. + +### Lane layout + +`layoutCommitGraph` in `packages/web-shell/client/utils/commitGraph.ts` is a pure function over `{ sha, parents }[]` in walk order. Each lane waits for one commit: + +- A commit takes the lowest lane waiting for it, or a fresh lane when none does. Every other lane waiting for the same commit merges into that node. +- Its first parent continues the lane, unless another lane already waits for that parent, in which case the line joins that lane instead of running in parallel. +- Each further parent takes the lane already waiting for it or opens a new one. + +The layout is recomputed over the whole accumulated list after every page, so pagination never breaks a lane. Search results are not contiguous history, so the graph is hidden while a query is active. + +### Rendering + +Each row draws its own SVG stretched to the row height (`preserveAspectRatio="none"`): pass-through lanes as vertical lines, incoming and outgoing lane changes as S-curves, and the node as a positioned dot (hollow for merges). An expanded row draws the still-open lanes straight through its detail block so lines never break. Lane colours cycle through a fixed eight-colour palette; lanes beyond twenty share the last column so a busy all-branches view cannot crowd the subject out. + +### Toolbar and entry points + +The history tab gains a toolbar with a search field (debounced 300 ms) and an `All branches` toggle. Both re-fetch from offset zero. `BranchPickerPopover` gains an `onOpenLog` action rendered as `History` next to `View Changes`; `ChatEditor` and `EnvironmentPanel` thread it from `App`, which opens `GitDialog` on the `log` view with the session's worktree cwd when there is one. + +## Constraints + +- `range`, `all`, and `search` compose: `all` wins over `range`; `search` filters whatever walk the other two select. +- A hash-prefix hit is returned even when the commit is not reachable from the selected walk; the user asked for that commit explicitly. +- The graph column has a hard cap of twenty lanes. Repositories with hundreds of unmerged branch tips render the overflow in the last lane rather than widening the dialog. + +## Validation + +- `packages/core/src/utils/gitDiff.test.ts`: `all` walks a side branch and tag, an unsafe `range` is ignored, search unions message, author, and hash with fixed-string matching, and search pages with `hasMore`. +- `packages/cli/src/serve/routes/workspace-git-log.test.ts`: the route forwards `all` and `search`. +- `packages/web-shell/client/utils/commitGraph.test.ts`: linear history, merge lanes, lane reuse, and fresh lanes for unseen parents. +- `packages/web-shell/client/components/dialogs/GitLogDialog.test.tsx`: the toggle and search re-fetch with the right options, matches hide the graph, and a merge lays out in two columns with lanes continuing through an expanded row. +- `packages/web-shell/client/components/BranchPickerPopover.test.tsx`: the `History` entry calls back and closes. + +## Acceptance criteria + +- The history tab shows a node per commit and curves at every fork and merge, on the current branch by default. +- `All branches` lists commits from every branch and tag with their ref chips. +- Typing in the search field narrows the list to matching message, author, or hash and clears back to the full history when emptied. +- The branch chip offers `History` and opens the tab. diff --git a/docs/design/2026-09-18-web-shell-git-history-graph.zh-CN.md b/docs/design/2026-09-18-web-shell-git-history-graph.zh-CN.md new file mode 100644 index 00000000000..0134ccbdc44 --- /dev/null +++ b/docs/design/2026-09-18-web-shell-git-history-graph.zh-CN.md @@ -0,0 +1,78 @@ +# Web Shell 提交历史:泳道图与搜索 + +[English](2026-09-18-web-shell-git-history-graph.md) | [简体中文](2026-09-18-web-shell-git-history-graph.zh-CN.md) + +状态:已实现。属于 [#11941](https://github.com/QwenLM/qwen-code/issues/11941);同一 issue 中的 worktree 管理器是独立设计。 + +## 问题 + +Web Shell 的历史标签页把 `HEAD` 可达的提交列成一条扁平列表。合并提交有标记,但分支结构不可见;其它分支上的提交不切换检出就看不到;除了滚动之外,没有办法按提交信息、作者或哈希找到某个提交。 + +## 现状 + +- `GET /workspaces/:workspace/git/log` 接受 `limit`、`skip` 和单个 `range`;`packages/core/src/utils/gitDiff.ts` 中的 `fetchGitLog` 拒绝以 `-` 或 `..` 开头的 `range`,因此多 ref 参数无法借道传入。 +- 响应已为每条记录携带 `parents` 与 `refs`。 +- `packages/web-shell/client/components/dialogs/GitLogDialog.tsx` 中的 `GitLogContent` 渲染列表,并作为 `GitDialog` 的 `log` 标签页嵌入。 + +## 目标 + +- 把提交历史画成泳道图:每条并行的开发线一列,分叉与合并处用曲线连接。 +- 允许用户把遍历范围从 `HEAD` 扩大到所有本地分支、远程分支和标签。 +- 在同一标签页内按提交信息、作者或哈希过滤历史。 +- 在分支 chip 上、紧邻现有的「查看变更」和「提交」动作旁打开历史。 + +## 非目标 + +- 不渲染 daemon 遍历未返回的提交(客户端不会在已加载页面之外重建图)。 +- 不新增 daemon 路由;现有 log 路由只增加两个可选查询参数。 +- 不改动提交详情接口和 diff 标签页。 + +## 设计 + +### Daemon 与 core + +`fetchGitLog` 新增两个选项: + +- `all`:遍历 `HEAD --branches --tags --remotes` 而非仅 `HEAD`。stash、notes、replace ref 仍被排除。 +- `search`:大小写不敏感的固定字符串过滤。git 对 `--grep` 与 `--author` 取 AND,因此信息匹配与作者匹配是两次遍历,取并集后分页;4 到 40 位十六进制查询还会经 `git rev-parse --verify ^{commit}` 按提交哈希前缀解析。并集按作者日期排序,作者日期与提交日期不一致时页边界可能偏移一条;客户端按 sha 去重。 + +所有遍历现在都带 `--date-order`,父提交绝不早于其任何子提交出现,泳道布局只需要 `parents`。路由读取 `all=1` 与 `search=`(去空白、上限 200 字符)。SDK 在绑定工作区与限定工作区两个客户端的 `workspaceGitLog` 上以尾部 `options` 参数暴露它们。 + +### 泳道布局 + +`packages/web-shell/client/utils/commitGraph.ts` 中的 `layoutCommitGraph` 是对遍历顺序的 `{ sha, parents }[]` 的纯函数。每条泳道等待一个提交: + +- 提交占用等待它的最低泳道;没有则开一条新泳道。其它等待同一提交的泳道都汇入该节点。 +- 第一父提交延续该泳道;若已有别的泳道在等待这个父提交,则线条并入那条泳道而不是并行下去。 +- 其余每个父提交占用已在等待它的泳道,或新开一条。 + +每次翻页后都对累积的整份列表重新布局,分页不会打断泳道。搜索结果不是连续历史,查询激活期间隐藏图。 + +### 渲染 + +每一行绘制自己的 SVG 并拉伸到行高(`preserveAspectRatio="none"`):穿行泳道画成竖线,进入和离开的换道画成 S 形曲线,节点是定位的圆点(合并提交为空心)。展开的行在其详情块中把仍然打开的泳道直线画穿,线条不会断。泳道颜色循环使用固定的 8 色调色板;第 20 条之后的泳道共用最后一列,繁忙的全部分支视图不会把标题挤出去。 + +### 工具栏与入口 + +历史标签页增加一条工具栏:搜索框(300 ms 防抖)和「全部分支」开关。两者都从偏移 0 重新拉取。`BranchPickerPopover` 增加 `onOpenLog` 动作,渲染为「提交历史」,位于「查看变更」旁;`ChatEditor` 与 `EnvironmentPanel` 从 `App` 透传,`App` 以 `log` 视图打开 `GitDialog`,有会话 worktree 时带上其 cwd。 + +## 约束 + +- `range`、`all`、`search` 可组合:`all` 优先于 `range`;`search` 过滤前两者选定的遍历。 +- 哈希前缀命中即使不在所选遍历可达范围内也会返回;用户明确要的就是那个提交。 +- 图列硬上限 20 条泳道。有数百个未合并分支尖端的仓库会把溢出画在最后一条泳道里,而不是撑宽对话框。 + +## 验证 + +- `packages/core/src/utils/gitDiff.test.ts`:`all` 遍历到侧分支和标签,不安全的 `range` 被忽略,搜索对信息、作者、哈希取并集且按固定字符串匹配,搜索分页带 `hasMore`。 +- `packages/cli/src/serve/routes/workspace-git-log.test.ts`:路由透传 `all` 与 `search`。 +- `packages/web-shell/client/utils/commitGraph.test.ts`:线性历史、合并泳道、泳道复用、未见父提交的新泳道。 +- `packages/web-shell/client/components/dialogs/GitLogDialog.test.tsx`:开关与搜索以正确选项重新拉取,匹配结果隐藏图,合并布局为两列且泳道穿过展开行。 +- `packages/web-shell/client/components/BranchPickerPopover.test.tsx`:「提交历史」条目回调并关闭。 + +## 验收标准 + +- 历史标签页默认在当前分支上,每个提交一个节点,每处分叉与合并有曲线。 +- 「全部分支」列出所有分支和标签上的提交并带 ref 标签。 +- 在搜索框输入后列表收窄为信息、作者或哈希匹配的提交,清空后恢复完整历史。 +- 分支 chip 提供「提交历史」并打开该标签页。 diff --git a/packages/cli/src/serve/routes/workspace-git-log.test.ts b/packages/cli/src/serve/routes/workspace-git-log.test.ts index 2e99ffef8a6..5f55717553c 100644 --- a/packages/cli/src/serve/routes/workspace-git-log.test.ts +++ b/packages/cli/src/serve/routes/workspace-git-log.test.ts @@ -56,6 +56,7 @@ const ENTRY = { subject: 'do a thing', refs: 'HEAD -> main', parents: ['0000000000000000000000000000000000000000'], + commitDate: 1_700_000_000, }; describe('workspace Git log routes', () => { @@ -136,6 +137,7 @@ describe('workspace Git log routes', () => { expect(fetchGitLogMock).toHaveBeenCalledWith('/work/main', { limit: 200, skip: 30, + all: false, }); }); @@ -152,6 +154,7 @@ describe('workspace Git log routes', () => { expect(fetchGitLogMock).toHaveBeenCalledWith('/work/main', { limit: 50, skip: 0, + all: false, }); }); @@ -168,6 +171,7 @@ describe('workspace Git log routes', () => { expect(fetchGitLogMock).toHaveBeenCalledWith('/work/main', { limit: 1, skip: 0, + all: false, }); }); @@ -185,6 +189,7 @@ describe('workspace Git log routes', () => { limit: 50, skip: 0, range: 'main..HEAD', + all: false, }); }); @@ -200,6 +205,7 @@ describe('workspace Git log routes', () => { expect(fetchGitLogMock).toHaveBeenCalledWith('/work/main', { limit: 50, skip: 0, + all: false, }); fetchGitLogMock.mockClear(); @@ -207,6 +213,7 @@ describe('workspace Git log routes', () => { expect(fetchGitLogMock).toHaveBeenCalledWith('/work/main', { limit: 50, skip: 0, + all: false, }); }); @@ -344,6 +351,7 @@ describe('workspace Git log routes', () => { expect(fetchGitLogMock).toHaveBeenCalledWith('/work/secondary', { limit: 50, skip: 0, + all: false, }); }); diff --git a/packages/cli/src/serve/routes/workspace-git-log.ts b/packages/cli/src/serve/routes/workspace-git-log.ts index 99b0ebbe110..3496a5b5bb1 100644 --- a/packages/cli/src/serve/routes/workspace-git-log.ts +++ b/packages/cli/src/serve/routes/workspace-git-log.ts @@ -21,6 +21,8 @@ import { } from '../workspace-route-runtime.js'; import { applyReadHeaders } from './workspace-file-read.js'; +const MAX_SEARCH_CHARS = 200; + function buildLogList( workspaceCwd: string, result: GitLogResult | null, @@ -114,7 +116,18 @@ async function handleLogList( typeof rawRange === 'string' && rawRange.trim() ? rawRange.trim() : undefined; - const result = await fetchGitLog(workspaceCwd, { limit, skip, range }); + const rawSearch = req.query['search']; + const search = + typeof rawSearch === 'string' && rawSearch.trim() + ? rawSearch.trim().slice(0, MAX_SEARCH_CHARS) + : undefined; + const result = await fetchGitLog(workspaceCwd, { + limit, + skip, + range, + all: req.query['all'] === '1', + search, + }); res.status(200).json(buildLogList(workspaceCwd, result)); } catch (err) { sendBridgeError(res, err, { route }); diff --git a/packages/core/src/utils/gitDiff.test.ts b/packages/core/src/utils/gitDiff.test.ts index 09009c5f802..7c65854f771 100644 --- a/packages/core/src/utils/gitDiff.test.ts +++ b/packages/core/src/utils/gitDiff.test.ts @@ -2084,6 +2084,99 @@ describe('fetchGitLog', () => { expect(result!.entries[0].refs).toContain('HEAD'); expect(result!.entries[0].parents).toHaveLength(0); }); + it('walks every branch and tag with `all`, children before parents', async () => { + await fs.writeFile(path.join(repo, 'a.txt'), 'x\n'); + await git(repo, 'add', '.'); + await git(repo, 'commit', '-q', '-m', 'base'); + await git(repo, 'checkout', '-q', '-b', 'feature'); + await fs.writeFile(path.join(repo, 'f.txt'), 'f\n'); + await git(repo, 'add', '.'); + await git(repo, 'commit', '-q', '-m', 'feature work'); + await git(repo, 'tag', 'v1'); + await git(repo, 'checkout', '-q', 'main'); + + const headOnly = await fetchGitLog(repo); + expect(headOnly!.entries.map((e) => e.subject)).toEqual(['base']); + + const all = await fetchGitLog(repo, { all: true }); + expect(all!.entries.map((e) => e.subject)).toEqual([ + 'feature work', + 'base', + ]); + expect(all!.entries[0].refs).toContain('feature'); + expect(all!.entries[0].refs).toContain('v1'); + }, 15_000); + + it('ignores an unsafe range instead of passing it to git', async () => { + await fs.writeFile(path.join(repo, 'a.txt'), 'x\n'); + await git(repo, 'add', '.'); + await git(repo, 'commit', '-q', '-m', 'only'); + + const result = await fetchGitLog(repo, { range: '--output=/tmp/x' }); + expect(result!.entries.map((e) => e.subject)).toEqual(['only']); + }); + + it('searches message, author, and hash prefix as a union', async () => { + await fs.writeFile(path.join(repo, 'a.txt'), 'x\n'); + await git(repo, 'add', '.'); + await git(repo, 'commit', '-q', '-m', 'Add parser'); + await fs.writeFile(path.join(repo, 'b.txt'), 'y\n'); + await git(repo, 'add', '.'); + await git( + repo, + '-c', + 'user.name=Grace Hopper', + 'commit', + '-q', + '-m', + 'Fix typo', + ); + await fs.writeFile(path.join(repo, 'c.txt'), 'z\n'); + await git(repo, 'add', '.'); + await git(repo, 'commit', '-q', '-m', 'Unrelated [a.b]'); + const unfiltered = await fetchGitLog(repo); + const parserSha = unfiltered!.entries.find( + (e) => e.subject === 'Add parser', + )!.sha; + + const byMessage = await fetchGitLog(repo, { search: 'PARSER' }); + expect(byMessage!.entries.map((e) => e.subject)).toEqual(['Add parser']); + + const byAuthor = await fetchGitLog(repo, { search: 'hopper' }); + expect(byAuthor!.entries.map((e) => e.subject)).toEqual(['Fix typo']); + + const byHash = await fetchGitLog(repo, { search: parserSha.slice(0, 7) }); + expect(byHash!.entries.map((e) => e.sha)).toEqual([parserSha]); + + // Fixed-string matching: regex metacharacters are literal. + const literal = await fetchGitLog(repo, { search: '[a.b]' }); + expect(literal!.entries.map((e) => e.subject)).toEqual(['Unrelated [a.b]']); + + const none = await fetchGitLog(repo, { search: 'zzz-no-such' }); + expect(none).toEqual({ entries: [], hasMore: false }); + }, 20_000); + + it('pages search results newest-first with hasMore', async () => { + for (let i = 0; i < 4; i++) { + await fs.writeFile(path.join(repo, `f${i}.txt`), `${i}\n`); + await git(repo, 'add', '.'); + await git(repo, 'commit', '-q', '-m', `match ${i}`); + } + const page1 = await fetchGitLog(repo, { search: 'match', limit: 3 }); + expect(page1!.entries.map((e) => e.subject)).toEqual([ + 'match 3', + 'match 2', + 'match 1', + ]); + expect(page1!.hasMore).toBe(true); + const page2 = await fetchGitLog(repo, { + search: 'match', + limit: 3, + skip: 3, + }); + expect(page2!.entries.map((e) => e.subject)).toEqual(['match 0']); + expect(page2!.hasMore).toBe(false); + }, 15_000); }); describe('fetchGitCommitDetail', () => { diff --git a/packages/core/src/utils/gitDiff.ts b/packages/core/src/utils/gitDiff.ts index 698040be8e4..6596ac4ac88 100644 --- a/packages/core/src/utils/gitDiff.ts +++ b/packages/core/src/utils/gitDiff.ts @@ -1451,6 +1451,8 @@ export interface GitLogEntry { refs: string; /** Parent SHAs (length > 1 ⇒ merge commit). */ parents: string[]; + /** Committer timestamp in seconds; the key `--date-order` walks by. */ + commitDate: number; } export interface GitLogResult { @@ -1482,12 +1484,13 @@ export interface GitCommitDetail { hiddenCount: number; } -const LOG_FORMAT = '%H%x00%h%x00%an%x00%ae%x00%at%x00%s%x00%D%x00%P'; +const LOG_FORMAT = '%H%x00%h%x00%an%x00%ae%x00%at%x00%s%x00%D%x00%P%x00%ct'; +const LOG_FIELDS = 9; const LOG_DETAIL_FORMAT = '%H%x00%h%x00%an%x00%ae%x00%at%x00%s%x00%D%x00%P%x00%b'; function parseLogFields(parts: string[]): GitLogEntry | null { - if (parts.length !== 8) return null; + if (parts.length !== LOG_FIELDS) return null; return { sha: parts[0], shortSha: parts[1], @@ -1497,18 +1500,56 @@ function parseLogFields(parts: string[]): GitLogEntry | null { subject: parts[5], refs: parts[6], parents: parts[7] ? parts[7].split(' ').filter(Boolean) : [], + commitDate: parseInt(parts[8], 10) || 0, }; } +export interface GitLogOptions { + limit?: number; + skip?: number; + range?: string; + /** Walk HEAD plus every local branch, remote branch, and tag. */ + all?: boolean; + /** + * Keep only commits whose message, author, or hash matches. Message and + * author matching is a case-insensitive fixed-string search; a hex query + * of at least four characters also resolves as a commit hash prefix. + */ + search?: string; +} + +const LOG_ALL_REVISIONS = ['HEAD', '--branches', '--tags', '--remotes']; + +function isSafeLogRange(range: string): boolean { + return ( + !range.startsWith('-') && + !range.startsWith('..') && + /^[A-Za-z0-9_./~^-]+$/.test(range) + ); +} + +function parseLogRecords(stdout: string): GitLogEntry[] { + const fields = stdout.split('\0'); + if (fields.at(-1) === '') fields.pop(); + const entries: GitLogEntry[] = []; + for (let i = 0; i + LOG_FIELDS <= fields.length; i += LOG_FIELDS) { + const entry = parseLogFields(fields.slice(i, i + LOG_FIELDS)); + if (entry) entries.push(entry); + } + return entries; +} + /** * Fetch a page of commit log entries (newest first). * - * Returns `null` when not inside a git repo or when git fails. An empty - * repo (no commits) returns `{ entries: [], hasMore: false }`. + * The walk is `--date-order`, so a parent never precedes one of its children + * and a lane graph can be laid out from `parents` alone. Returns `null` when + * not inside a git repo or when git fails. An empty repo (no commits) + * returns `{ entries: [], hasMore: false }`. */ export async function fetchGitLog( cwd: string, - options?: { limit?: number; skip?: number; range?: string }, + options?: GitLogOptions, ): Promise { const gitRoot = findGitRoot(cwd); if (!gitRoot) return null; @@ -1518,6 +1559,15 @@ export async function fetchGitLog( MAX_LOG_LIMIT, ); const skip = Math.max(options?.skip ?? 0, 0); + const revisions = options?.all + ? LOG_ALL_REVISIONS + : options?.range && isSafeLogRange(options.range) + ? [options.range] + : []; + const search = options?.search?.trim(); + if (search) { + return searchGitLog(gitRoot, revisions, search, limit, skip); + } const stdout = await runGit( [ @@ -1525,15 +1575,12 @@ export async function fetchGitLog( 'log', '-z', `--format=${LOG_FORMAT}`, + '--date-order', '-n', String(limit + 1), ...(skip > 0 ? ['--skip', String(skip)] : []), - ...(options?.range && - !options.range.startsWith('-') && - !options.range.startsWith('..') && - /^[A-Za-z0-9_./~^-]+$/.test(options.range) - ? [options.range, '--'] - : []), + ...revisions, + '--', ], gitRoot, ); @@ -1545,18 +1592,87 @@ export async function fetchGitLog( return head === null ? { entries: [], hasMore: false } : null; } - const fields = stdout.split('\0'); - if (fields.at(-1) === '') fields.pop(); - const recordCount = Math.floor(fields.length / 8); - const hasMore = recordCount > limit; - const pageCount = hasMore ? limit : recordCount; + const entries = parseLogRecords(stdout); + return { entries: entries.slice(0, limit), hasMore: entries.length > limit }; +} - const entries: GitLogEntry[] = []; - for (let i = 0; i < pageCount; i++) { - const entry = parseLogFields(fields.slice(i * 8, i * 8 + 8)); - if (entry) entries.push(entry); +/** + * git ANDs `--grep` with `--author`, so the message and author matches are + * two walks whose union is paged here. Each walk is capped at the page window + * and the union is re-ordered by commit date, ties keeping each walk's own + * order, so a page boundary can shift by an entry where the walks interleave; + * callers de-duplicate by sha. + */ +async function searchGitLog( + gitRoot: string, + revisions: string[], + query: string, + limit: number, + skip: number, +): Promise { + const window = String(skip + limit + 1); + const walk = (filter: string) => + runGit( + [ + '--no-optional-locks', + 'log', + '-z', + `--format=${LOG_FORMAT}`, + '--date-order', + '-i', + '--fixed-strings', + filter, + '-n', + window, + ...revisions, + '--', + ], + gitRoot, + ); + const runs = [walk(`--grep=${query}`), walk(`--author=${query}`)]; + if (/^[0-9a-f]{4,40}$/i.test(query)) { + runs.push( + runGit( + [ + 'rev-parse', + '--verify', + '--quiet', + '--end-of-options', + `${query}^{commit}`, + ], + gitRoot, + ).then((sha) => + sha + ? runGit( + [ + '--no-optional-locks', + 'log', + '-z', + `--format=${LOG_FORMAT}`, + '--no-walk', + sha.trim(), + '--', + ], + gitRoot, + ) + : null, + ), + ); } - return { entries, hasMore }; + const bySha = new Map(); + for (const stdout of await Promise.all(runs)) { + for (const entry of parseLogRecords(stdout ?? '')) { + if (!bySha.has(entry.sha)) + bySha.set(entry.sha, { entry, rank: bySha.size }); + } + } + const entries = [...bySha.values()] + .sort((a, b) => b.entry.commitDate - a.entry.commitDate || a.rank - b.rank) + .map((item) => item.entry); + return { + entries: entries.slice(skip, skip + limit), + hasMore: entries.length > skip + limit, + }; } /** diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 5e97b2d8c5c..1be72eb553d 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -105,6 +105,7 @@ import type { DaemonWorkspaceGitDiff, DaemonWorkspaceGitDiffHunks, DaemonGitLog, + DaemonGitLogOptions, DaemonGitCommitDetail, DaemonGitBranchesResult, DaemonGitCheckoutResult, @@ -1499,11 +1500,14 @@ export class DaemonClient { limit?: number, skip?: number, range?: string, + options?: DaemonGitLogOptions, ): Promise { const params = new URLSearchParams(); if (limit != null) params.set('limit', String(limit)); if (skip != null) params.set('skip', String(skip)); if (range) params.set('range', range); + if (options?.all) params.set('all', '1'); + if (options?.search) params.set('search', options.search); const qs = params.toString(); return await this.jsonRequest( `/workspace/git/log${qs ? `?${qs}` : ''}`, @@ -6969,12 +6973,15 @@ export class WorkspaceDaemonClient { skip?: number, cwd?: string, range?: string, + options?: DaemonGitLogOptions, ): Promise { const params = new URLSearchParams(); if (limit != null) params.set('limit', String(limit)); if (skip != null) params.set('skip', String(skip)); if (cwd != null) params.set('cwd', cwd); if (range) params.set('range', range); + if (options?.all) params.set('all', '1'); + if (options?.search) params.set('search', options.search); const qs = params.toString(); return this.client.workspaceJsonRequest( this.workspaceSelector, diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 572e261a8cc..8b295fcd512 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -539,6 +539,7 @@ export type { DaemonDiffHunk, DaemonGitLogEntry, DaemonGitLog, + DaemonGitLogOptions, DaemonGitCommitFileStat, DaemonGitCommitDetail, DaemonGitBranchInfo, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 4fafd05b31c..3b7926312eb 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -328,6 +328,14 @@ export interface DaemonGitLogEntry { parents: string[]; } +/** Optional filters for `GET /workspace/git/log`. */ +export interface DaemonGitLogOptions { + /** Walk every local branch, remote branch, and tag instead of HEAD only. */ + all?: boolean; + /** Keep only commits whose message, author, or hash matches. */ + search?: string; +} + /** Response from `GET /workspace/git/log`. */ export interface DaemonGitLog { v: 1; diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 03c5717a8a8..72985b9b391 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -10489,6 +10489,14 @@ export function App({ view: 'commit', }); }, [gitDiffWorkspaceCwd, sessionWorktree?.path]); + const handleOpenLog = useCallback(() => { + if (!gitDiffWorkspaceCwd) return; + setGitDialog({ + workspaceCwd: gitDiffWorkspaceCwd, + gitCwd: sessionWorktree?.path, + view: 'log', + }); + }, [gitDiffWorkspaceCwd, sessionWorktree?.path]); const dialogOpen = showResumeDialog || showDeleteDialog || @@ -20299,6 +20307,11 @@ export function App({ ? handleOpenCommit : undefined } + onOpenLog={ + gitDiffWorkspaceCwd + ? handleOpenLog + : undefined + } chatWidthMode={chatWidthMode} showChatWidthToggle={!isChatEmptyState} chatWidthToggleMin={chatWidthToggleMin} @@ -20608,6 +20621,11 @@ export function App({ ? handleOpenCommit : undefined } + onOpenGitLog={ + workspaceContextActive && gitDiffWorkspaceCwd + ? handleOpenLog + : undefined + } onOpenAgent={openEnvironmentAgent} onOpenAgentWorkflow={ sessionAgentTraceSupported ? openAgentWorkflow : undefined diff --git a/packages/web-shell/client/components/BranchPickerPopover.test.tsx b/packages/web-shell/client/components/BranchPickerPopover.test.tsx index 249f018cd54..4efab3a308f 100644 --- a/packages/web-shell/client/components/BranchPickerPopover.test.tsx +++ b/packages/web-shell/client/components/BranchPickerPopover.test.tsx @@ -228,6 +228,7 @@ function mount( overrides: Partial<{ onOpenDiff: () => void; onOpenCommit: () => void; + onOpenLog: () => void; onOpenChange: (open: boolean) => void; onBranchChanged: () => void; open: boolean; @@ -250,6 +251,7 @@ function mount( onBranchChanged={overrides.onBranchChanged} onOpenDiff={overrides.onOpenDiff} onOpenCommit={overrides.onOpenCommit} + onOpenLog={overrides.onOpenLog} > @@ -350,6 +352,67 @@ describe('BranchPickerPopover actions', () => { expect(onOpenChange).toHaveBeenCalledWith(false); }); + it('keeps the actions visible while the search matches "History"', async () => { + workspaceGitBranches.mockResolvedValue({ + v: 1, + workspaceCwd: '/repo', + available: true, + local: [{ name: 'main', isHead: true }], + remote: [], + tags: [], + recent: [], + head: 'main', + detached: false, + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + mount({ onOpenLog: vi.fn() }); + await flush(); + + const search = document.body.querySelector( + 'input[type="text"], input:not([type])', + ) as HTMLInputElement; + await act(async () => { + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!; + setter.call(search, 'hist'); + search.dispatchEvent(new Event('input', { bubbles: true })); + }); + expect( + document.body.querySelector('[data-testid="branch-picker-history"]'), + ).not.toBeNull(); + }); + + it('wires "History" to onOpenLog and closes', async () => { + workspaceGitBranches.mockResolvedValue({ + v: 1, + workspaceCwd: '/repo', + available: true, + local: [{ name: 'main', isHead: true }], + remote: [], + tags: [], + recent: [], + head: 'main', + detached: false, + }); + const onOpenLog = vi.fn(); + const onOpenChange = vi.fn(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + mount({ onOpenLog, onOpenChange }); + await flush(); + + clickButton('History'); + + expect(onOpenLog).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + it('wires "Commit" to onOpenCommit and closes', async () => { workspaceGitBranches.mockResolvedValue({ v: 1, diff --git a/packages/web-shell/client/components/BranchPickerPopover.tsx b/packages/web-shell/client/components/BranchPickerPopover.tsx index da67e2c5f21..a574df9811b 100644 --- a/packages/web-shell/client/components/BranchPickerPopover.tsx +++ b/packages/web-shell/client/components/BranchPickerPopover.tsx @@ -22,6 +22,7 @@ import { GitBranchIcon, GitCommitIcon, GlobeIcon, + HistoryIcon, Loader2Icon, PlusIcon, SearchIcon, @@ -185,6 +186,8 @@ interface BranchPickerPopoverProps { onStatusRefreshed?: (status: DaemonWorkspaceGitStatus) => void; onOpenDiff?: () => void; onOpenCommit?: () => void; + /** Opens the commit history graph. */ + onOpenLog?: () => void; children: React.ReactNode; } @@ -413,6 +416,7 @@ export function BranchPickerPopover({ onStatusRefreshed, onOpenDiff, onOpenCommit, + onOpenLog, children, }: BranchPickerPopoverProps) { const { t } = useI18n(); @@ -1385,7 +1389,8 @@ export function BranchPickerPopover({ t('branchPicker.action.newBranch').toLowerCase().includes(q) || t('branchPicker.action.checkoutRef').toLowerCase().includes(q) || t('branchPicker.action.viewChanges').toLowerCase().includes(q) || - t('branchPicker.action.manageRemotes').toLowerCase().includes(q); + t('branchPicker.action.manageRemotes').toLowerCase().includes(q) || + t('branchPicker.action.history').toLowerCase().includes(q); useEffect(() => { if (!actionsVisible) { @@ -1569,6 +1574,25 @@ export function BranchPickerPopover({ )} + {onOpenLog && ( + + )}
diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 37ffeadbd62..1abcb179329 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -229,6 +229,8 @@ interface ChatEditorProps { onOpenGitDiff?: () => void; /** Opens the commit dialog. */ onOpenCommit?: () => void; + /** Opens the commit history graph. */ + onOpenLog?: () => void; /** Workspace name shown in the pane composer's `workspace` toolbar chip. */ workspaceName?: string; /** Full workspace cwd, used as the chip's tooltip. */ @@ -1590,6 +1592,7 @@ export const ChatEditor = memo( gitStatus, onOpenGitDiff, onOpenCommit, + onOpenLog, workspaceName, workspaceTitle, workspaceColor, @@ -3434,6 +3437,7 @@ export const ChatEditor = memo( status={gitStatus} onOpenDiff={onOpenGitDiff} onOpenCommit={onOpenCommit} + onOpenLog={onOpenLog} >
- ); - } else if (detail && !detail.available) { - detailBody = ( -
- {t('gitLog.detailError')} -
+ ); } } @@ -169,6 +293,9 @@ function CommitRow({ return (
+ {graph && ( + + )}
- {detailBody} + {detailBody !== undefined && ( +
+ {graph && } +
{detailBody}
+
+ )}
); } @@ -227,6 +359,9 @@ export function GitLogContent({ const [loadingMore, setLoadingMore] = useState(false); const [loadMoreError, setLoadMoreError] = useState(false); const [now, setNow] = useState(Date.now() / 1000); + const [allBranches, setAllBranches] = useState(false); + const [search, setSearch] = useState(''); + const [query, setQuery] = useState(''); const nextSkipRef = useRef(0); useEffect(() => { @@ -234,15 +369,31 @@ export function GitLogContent({ return () => clearInterval(id); }, []); + useEffect(() => { + const trimmed = search.trim(); + if (trimmed === query) return; + const id = setTimeout(() => setQuery(trimmed), SEARCH_DEBOUNCE_MS); + return () => clearTimeout(id); + }, [search, query]); + + const fetchPage = useCallback( + (skip: number) => + client + .workspaceByCwd(workspaceCwd) + .workspaceGitLog(PAGE_SIZE, skip, gitCwd, undefined, { + all: allBranches, + search: query || undefined, + }), + [client, workspaceCwd, gitCwd, allBranches, query], + ); + useEffect(() => { let cancelled = false; setLoading(true); setError(false); setLoadMoreError(false); nextSkipRef.current = 0; - client - .workspaceByCwd(workspaceCwd) - .workspaceGitLog(PAGE_SIZE, 0, gitCwd) + fetchPage(0) .then((result) => { if (!cancelled) { nextSkipRef.current = result.entries.length; @@ -258,14 +409,12 @@ export function GitLogContent({ return () => { cancelled = true; }; - }, [client, workspaceCwd, gitCwd]); + }, [fetchPage]); const loadMore = useCallback(() => { if (!log || loadingMore) return; setLoadingMore(true); - client - .workspaceByCwd(workspaceCwd) - .workspaceGitLog(PAGE_SIZE, nextSkipRef.current, gitCwd) + fetchPage(nextSkipRef.current) .then((result) => { nextSkipRef.current += result.entries.length; setLog((prev) => { @@ -287,7 +436,7 @@ export function GitLogContent({ .finally(() => { setLoadingMore(false); }); - }, [client, workspaceCwd, gitCwd, log, loadingMore]); + }, [fetchPage, log, loadingMore]); const subtitle = log?.available ? t('gitLog.subtitle', { count: log.entries.length }) @@ -297,6 +446,18 @@ export function GitLogContent({ onSubtitleChange?.(subtitle); }, [onSubtitleChange, subtitle]); + // Search results are not contiguous history, so lanes would mislead. + const graph = useMemo( + () => (log && !query ? layoutCommitGraph(log.entries) : undefined), + [log, query], + ); + const graphWidth = graph + ? Math.min( + graph.reduce((max, row) => Math.max(max, row.width), 1), + MAX_LANES, + ) * LANE_WIDTH + : 0; + let body: ReactNode; if (loading) { body =
{t('gitLog.loading')}
; @@ -305,18 +466,24 @@ export function GitLogContent({ } else if (!log || !log.available) { body =
{t('gitLog.unavailable')}
; } else if (log.entries.length === 0) { - body =
{t('gitLog.empty')}
; + body = ( +
+ {t(query ? 'gitLog.noMatches' : 'gitLog.empty')} +
+ ); } else { body = ( <>
- {log.entries.map((entry) => ( + {log.entries.map((entry, index) => ( ))}
@@ -340,7 +507,33 @@ export function GitLogContent({ ); } - return
{body}
; + return ( +
+
+ + +
+ {body} +
+ ); } export function GitLogDialog({ diff --git a/packages/web-shell/client/components/panels/EnvironmentPanel.tsx b/packages/web-shell/client/components/panels/EnvironmentPanel.tsx index c91d9992a8e..f61cf21f6cd 100644 --- a/packages/web-shell/client/components/panels/EnvironmentPanel.tsx +++ b/packages/web-shell/client/components/panels/EnvironmentPanel.tsx @@ -56,6 +56,7 @@ interface EnvironmentPanelProps { items?: readonly WebShellEnvironmentPanelItem[]; onOpenGitDiff?: () => void; onOpenGitCommit?: () => void; + onOpenGitLog?: () => void; onOpenAgent?: (task: DaemonSessionAgentTaskStatus) => void; onOpenAgentWorkflow?: () => void; onOpenTask: (task: DaemonSessionTaskWithWorkflowStatus) => void; @@ -162,6 +163,7 @@ export function EnvironmentPanel({ items = DEFAULT_ENVIRONMENT_PANEL_ITEMS, onOpenGitDiff, onOpenGitCommit, + onOpenGitLog, onOpenAgent, onOpenAgentWorkflow, onOpenTask, @@ -313,6 +315,7 @@ export function EnvironmentPanel({ status={gitStatus} onOpenDiff={onOpenGitDiff} onOpenCommit={onOpenGitCommit} + onOpenLog={onOpenGitLog} >