mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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=<text>` 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 <probe@local>
This commit is contained in:
1 parent
907667bbd6
commit
f61cd4bfaa
21 files changed
+1257
-62
No files matched your search
@@ -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 <query>^{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=<text>` (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.
|
||||
@@ -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 <query>^{commit}` 按提交哈希前缀解析。并集按作者日期排序,作者日期与提交日期不一致时页边界可能偏移一条;客户端按 sha 去重。
|
||||
|
||||
所有遍历现在都带 `--date-order`,父提交绝不早于其任何子提交出现,泳道布局只需要 `parents`。路由读取 `all=1` 与 `search=<text>`(去空白、上限 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 提供「提交历史」并打开该标签页。
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<GitLogResult | null> {
|
||||
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<GitLogResult> {
|
||||
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<string, { entry: GitLogEntry; rank: number }>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<DaemonGitLog> {
|
||||
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<DaemonGitLog>(
|
||||
`/workspace/git/log${qs ? `?${qs}` : ''}`,
|
||||
@@ -6969,12 +6973,15 @@ export class WorkspaceDaemonClient {
|
||||
skip?: number,
|
||||
cwd?: string,
|
||||
range?: string,
|
||||
options?: DaemonGitLogOptions,
|
||||
): Promise<DaemonGitLog> {
|
||||
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<DaemonGitLog>(
|
||||
this.workspaceSelector,
|
||||
|
||||
@@ -539,6 +539,7 @@ export type {
|
||||
DaemonDiffHunk,
|
||||
DaemonGitLogEntry,
|
||||
DaemonGitLog,
|
||||
DaemonGitLogOptions,
|
||||
DaemonGitCommitFileStat,
|
||||
DaemonGitCommitDetail,
|
||||
DaemonGitBranchInfo,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<button type="button">trigger</button>
|
||||
</BranchPickerPopover>
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{onOpenLog && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionItem}
|
||||
onClick={() => {
|
||||
onOpenLog();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
data-testid="branch-picker-history"
|
||||
>
|
||||
<HistoryIcon
|
||||
size={14}
|
||||
className={styles.actionIcon}
|
||||
/>
|
||||
<span className={styles.actionLabel}>
|
||||
{t('branchPicker.action.history')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className={styles.separator} />
|
||||
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -181,7 +181,10 @@ describe('GitDialog', () => {
|
||||
).toHaveLength(1);
|
||||
expect(historyTab?.getAttribute('aria-selected')).toBe('true');
|
||||
expect(panel?.getAttribute('aria-labelledby')).toBe('git-dialog-tab-log');
|
||||
expect(workspaceGitLog).toHaveBeenCalledWith(50, 0, undefined);
|
||||
expect(workspaceGitLog).toHaveBeenCalledWith(50, 0, undefined, undefined, {
|
||||
all: false,
|
||||
search: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('supports arrow-key tab navigation', async () => {
|
||||
@@ -249,7 +252,13 @@ describe('GitDialog', () => {
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(workspaceGitLog).toHaveBeenCalledWith(50, 0, '/worktrees/feature-x');
|
||||
expect(workspaceGitLog).toHaveBeenCalledWith(
|
||||
50,
|
||||
0,
|
||||
'/worktrees/feature-x',
|
||||
undefined,
|
||||
{ all: false, search: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the pull requests tab only when the daemon advertises the capability', async () => {
|
||||
|
||||
@@ -117,12 +117,19 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.commitDetail {
|
||||
padding: 8px 10px 12px;
|
||||
.detailRow {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--subtle-bg, rgba(128, 128, 128, 0.06));
|
||||
}
|
||||
|
||||
.commitDetail {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 8px 10px 12px;
|
||||
}
|
||||
|
||||
.commitBody {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
@@ -209,3 +216,94 @@
|
||||
.loadMore:hover {
|
||||
background: var(--subtle-bg, rgba(128, 128, 128, 0.06));
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.search:focus-within {
|
||||
border-color: var(--agent-blue-500, var(--muted-foreground));
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 5px 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toggle:hover {
|
||||
background: var(--subtle-bg, rgba(128, 128, 128, 0.06));
|
||||
}
|
||||
|
||||
.toggleOn {
|
||||
color: inherit;
|
||||
border-color: var(--agent-blue-500, var(--muted-foreground));
|
||||
background: var(--subtle-bg, rgba(128, 128, 128, 0.06));
|
||||
}
|
||||
|
||||
.graph {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* Absolute so the stretched row, not the viewBox ratio, sets the height. */
|
||||
.graphSvg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.graphNode {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
margin-left: -4.5px;
|
||||
border: 2px solid;
|
||||
border-radius: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.graphNodeMerge {
|
||||
background: var(--background, #fff);
|
||||
}
|
||||
@@ -79,6 +79,15 @@ function entry(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string): void {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
'value',
|
||||
)!.set!;
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function logPayload(entries: unknown[], hasMore = false, available = true) {
|
||||
return { v: 1 as const, workspaceCwd: '/repo', available, entries, hasMore };
|
||||
}
|
||||
@@ -91,7 +100,10 @@ describe('GitLogDialog', () => {
|
||||
mount();
|
||||
await flush();
|
||||
|
||||
expect(workspaceGitLog).toHaveBeenCalledWith(50, 0, undefined);
|
||||
expect(workspaceGitLog).toHaveBeenCalledWith(50, 0, undefined, undefined, {
|
||||
all: false,
|
||||
search: undefined,
|
||||
});
|
||||
expect(document.body.textContent).toContain('first change');
|
||||
expect(document.body.textContent).toContain('Ada');
|
||||
expect(document.body.textContent).toContain('2 minutes ago');
|
||||
@@ -153,7 +165,14 @@ describe('GitLogDialog', () => {
|
||||
await flush();
|
||||
|
||||
// Second call uses the accumulated offset (1 entry already loaded).
|
||||
expect(workspaceGitLog).toHaveBeenNthCalledWith(2, 50, 1, undefined);
|
||||
expect(workspaceGitLog).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
50,
|
||||
1,
|
||||
undefined,
|
||||
undefined,
|
||||
{ all: false, search: undefined },
|
||||
);
|
||||
expect(document.body.textContent).toContain('newest');
|
||||
expect(document.body.textContent).toContain('older');
|
||||
});
|
||||
@@ -182,8 +201,22 @@ describe('GitLogDialog', () => {
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(workspaceGitLog).toHaveBeenNthCalledWith(2, 50, 1, undefined);
|
||||
expect(workspaceGitLog).toHaveBeenNthCalledWith(3, 50, 3, undefined);
|
||||
expect(workspaceGitLog).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
50,
|
||||
1,
|
||||
undefined,
|
||||
undefined,
|
||||
{ all: false, search: undefined },
|
||||
);
|
||||
expect(workspaceGitLog).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
50,
|
||||
3,
|
||||
undefined,
|
||||
undefined,
|
||||
{ all: false, search: undefined },
|
||||
);
|
||||
expect(document.body.textContent?.match(/duplicate/g)).toHaveLength(1);
|
||||
expect(document.body.textContent).toContain('older');
|
||||
});
|
||||
@@ -336,7 +369,13 @@ describe('GitLogDialog', () => {
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(workspaceGitLog).toHaveBeenCalledWith(50, 0, '/worktrees/wt');
|
||||
expect(workspaceGitLog).toHaveBeenCalledWith(
|
||||
50,
|
||||
0,
|
||||
'/worktrees/wt',
|
||||
undefined,
|
||||
{ all: false, search: undefined },
|
||||
);
|
||||
|
||||
const loadMore = Array.from(document.body.querySelectorAll('button')).find(
|
||||
(b) => b.textContent === 'Load more',
|
||||
@@ -347,7 +386,14 @@ describe('GitLogDialog', () => {
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(workspaceGitLog).toHaveBeenNthCalledWith(2, 50, 1, '/worktrees/wt');
|
||||
expect(workspaceGitLog).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
50,
|
||||
1,
|
||||
'/worktrees/wt',
|
||||
undefined,
|
||||
{ all: false, search: undefined },
|
||||
);
|
||||
|
||||
const row = document.body.querySelector(
|
||||
'button[aria-expanded="false"]',
|
||||
@@ -363,4 +409,129 @@ describe('GitLogDialog', () => {
|
||||
'/worktrees/wt',
|
||||
);
|
||||
});
|
||||
|
||||
it('refetches every branch when the All branches toggle is pressed', async () => {
|
||||
workspaceGitLog
|
||||
.mockResolvedValueOnce(logPayload([entry({ subject: 'head only' })]))
|
||||
.mockResolvedValueOnce(logPayload([entry({ subject: 'other branch' })]));
|
||||
mount();
|
||||
await flush();
|
||||
|
||||
const toggle = document.body.querySelector(
|
||||
'[data-testid="git-log-all-branches"]',
|
||||
) as HTMLButtonElement;
|
||||
expect(toggle.textContent).toBe('All branches');
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('false');
|
||||
await act(async () => {
|
||||
toggle.click();
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(workspaceGitLog).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
50,
|
||||
0,
|
||||
undefined,
|
||||
undefined,
|
||||
{ all: true, search: undefined },
|
||||
);
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(document.body.textContent).toContain('other branch');
|
||||
expect(document.body.textContent).not.toContain('head only');
|
||||
});
|
||||
|
||||
it('searches after the query settles and hides the lane graph for matches', async () => {
|
||||
workspaceGitLog
|
||||
.mockResolvedValueOnce(logPayload([entry({ subject: 'unrelated' })]))
|
||||
.mockResolvedValueOnce(logPayload([entry({ subject: 'fix: typo' })]));
|
||||
mount();
|
||||
await flush();
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="commit-graph"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
const input = document.body.querySelector(
|
||||
'input[type="search"]',
|
||||
) as HTMLInputElement;
|
||||
await act(async () => {
|
||||
setInputValue(input, ' typo ');
|
||||
});
|
||||
// Still debouncing: no second fetch yet.
|
||||
expect(workspaceGitLog).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(workspaceGitLog).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
50,
|
||||
0,
|
||||
undefined,
|
||||
undefined,
|
||||
{ all: false, search: 'typo' },
|
||||
);
|
||||
expect(document.body.textContent).toContain('fix: typo');
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="commit-graph"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the no-match placeholder for an empty search result', async () => {
|
||||
workspaceGitLog
|
||||
.mockResolvedValueOnce(logPayload([entry()]))
|
||||
.mockResolvedValueOnce(logPayload([]));
|
||||
mount();
|
||||
await flush();
|
||||
const input = document.body.querySelector(
|
||||
'input[type="search"]',
|
||||
) as HTMLInputElement;
|
||||
await act(async () => {
|
||||
setInputValue(input, 'nothing');
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
});
|
||||
await flush();
|
||||
expect(document.body.textContent).toContain('No commits match');
|
||||
});
|
||||
|
||||
it('lays merge history out in lanes: the side branch takes a second column', async () => {
|
||||
const base = entry({ subject: 'base', parents: [] });
|
||||
const side = entry({ subject: 'side', parents: [base.sha] });
|
||||
const mainline = entry({ subject: 'mainline', parents: [base.sha] });
|
||||
const merge = entry({
|
||||
subject: 'merge',
|
||||
parents: [mainline.sha, side.sha],
|
||||
});
|
||||
workspaceGitLog.mockResolvedValue(
|
||||
logPayload([merge, side, mainline, base]),
|
||||
);
|
||||
mount();
|
||||
await flush();
|
||||
|
||||
const columns = Array.from(
|
||||
document.body.querySelectorAll('[data-testid="commit-graph"]'),
|
||||
).map((el) => el.getAttribute('data-column'));
|
||||
expect(columns).toEqual(['0', '1', '0', '0']);
|
||||
|
||||
// Lanes continue straight through an expanded row's detail block.
|
||||
workspaceGitCommitDetail.mockResolvedValue({
|
||||
...side,
|
||||
available: true,
|
||||
body: '',
|
||||
files: [],
|
||||
filesCount: 0,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
hiddenCount: 0,
|
||||
});
|
||||
const rows = document.body.querySelectorAll('button[aria-expanded]');
|
||||
await act(async () => {
|
||||
(rows[1] as HTMLButtonElement).click();
|
||||
});
|
||||
await flush();
|
||||
// The side row keeps both lanes (its own and the mainline) open below it.
|
||||
expect(
|
||||
document.body.querySelectorAll('[data-testid="commit-graph-tail"] path'),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -7,11 +7,12 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react';
|
||||
import { CheckIcon, CopyIcon, SearchIcon } from 'lucide-react';
|
||||
import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk';
|
||||
import type {
|
||||
DaemonGitLog,
|
||||
@@ -25,10 +26,139 @@ import {
|
||||
} from '../../utils/clipboard';
|
||||
import { useCopiedFlash } from '../../hooks/useCopiedFlash';
|
||||
import { timeAgo } from '../../utils/timeAgo';
|
||||
import {
|
||||
layoutCommitGraph,
|
||||
type CommitGraphRow,
|
||||
} from '../../utils/commitGraph';
|
||||
import { DialogShell } from './DialogShell';
|
||||
import styles from './GitLogDialog.module.css';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
const LANE_WIDTH = 12;
|
||||
/** Lanes past this share the last column so a busy graph cannot crowd out the subject. */
|
||||
const MAX_LANES = 20;
|
||||
const LANE_COLORS = [
|
||||
'#2f81f7',
|
||||
'#e3742f',
|
||||
'#3fb950',
|
||||
'#a371f7',
|
||||
'#f778ba',
|
||||
'#39c5cf',
|
||||
'#d29922',
|
||||
'#8b949e',
|
||||
];
|
||||
|
||||
function laneX(column: number): number {
|
||||
return Math.min(column, MAX_LANES - 1) * LANE_WIDTH + LANE_WIDTH / 2;
|
||||
}
|
||||
|
||||
function laneColor(color: number): string {
|
||||
return LANE_COLORS[color % LANE_COLORS.length];
|
||||
}
|
||||
|
||||
/** Node and connecting curves for one commit row; stretched to the row height. */
|
||||
function CommitGraphCell({
|
||||
row,
|
||||
width,
|
||||
isMerge,
|
||||
}: {
|
||||
row: CommitGraphRow;
|
||||
width: number;
|
||||
isMerge: boolean;
|
||||
}) {
|
||||
const x = laneX(row.column);
|
||||
const paths: ReactNode[] = [];
|
||||
row.through.forEach((line) => {
|
||||
const lx = laneX(line.column);
|
||||
paths.push(
|
||||
<path
|
||||
key={`t${line.column}`}
|
||||
d={`M${lx},0 V100`}
|
||||
stroke={laneColor(line.color)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
row.incoming.forEach((line) => {
|
||||
const lx = laneX(line.column);
|
||||
paths.push(
|
||||
<path
|
||||
key={`i${line.column}`}
|
||||
d={lx === x ? `M${x},0 V50` : `M${lx},0 C${lx},50 ${x},0 ${x},50`}
|
||||
stroke={laneColor(line.color)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
row.outgoing.forEach((line) => {
|
||||
const lx = laneX(line.column);
|
||||
paths.push(
|
||||
<path
|
||||
key={`o${line.column}`}
|
||||
d={lx === x ? `M${x},50 V100` : `M${x},50 C${x},100 ${lx},50 ${lx},100`}
|
||||
stroke={laneColor(line.color)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const color = laneColor(row.color);
|
||||
return (
|
||||
<span
|
||||
className={styles.graph}
|
||||
style={{ width }}
|
||||
aria-hidden="true"
|
||||
data-testid="commit-graph"
|
||||
data-column={row.column}
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 0 ${width} 100`}
|
||||
preserveAspectRatio="none"
|
||||
className={styles.graphSvg}
|
||||
>
|
||||
{paths}
|
||||
</svg>
|
||||
<span
|
||||
className={`${styles.graphNode}${isMerge ? ` ${styles.graphNodeMerge}` : ''}`}
|
||||
style={{
|
||||
left: x,
|
||||
borderColor: color,
|
||||
background: isMerge ? undefined : color,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Lanes that continue below a row, drawn straight through its expanded detail. */
|
||||
function CommitGraphTail({
|
||||
row,
|
||||
width,
|
||||
}: {
|
||||
row: CommitGraphRow;
|
||||
width: number;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={styles.graph}
|
||||
style={{ width }}
|
||||
aria-hidden="true"
|
||||
data-testid="commit-graph-tail"
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 0 ${width} 100`}
|
||||
preserveAspectRatio="none"
|
||||
className={styles.graphSvg}
|
||||
>
|
||||
{row.after.map((line) => (
|
||||
<path
|
||||
key={line.column}
|
||||
d={`M${laneX(line.column)},0 V100`}
|
||||
stroke={laneColor(line.color)}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function parseRefs(refs: string): { label: string; isHead: boolean }[] {
|
||||
if (!refs) return [];
|
||||
@@ -49,11 +179,15 @@ function CommitRow({
|
||||
workspaceCwd,
|
||||
gitCwd,
|
||||
now,
|
||||
graph,
|
||||
graphWidth,
|
||||
}: {
|
||||
entry: DaemonGitLogEntry;
|
||||
workspaceCwd: string;
|
||||
gitCwd?: string;
|
||||
now: number;
|
||||
graph?: CommitGraphRow;
|
||||
graphWidth: number;
|
||||
}) {
|
||||
const { client } = useWorkspace();
|
||||
const { language, t } = useI18n();
|
||||
@@ -110,19 +244,15 @@ function CommitRow({
|
||||
if (open) {
|
||||
if (loading) {
|
||||
detailBody = (
|
||||
<div className={styles.commitDetail}>
|
||||
<span className={styles.fileBinary}>{t('gitLog.loading')}</span>
|
||||
</div>
|
||||
<span className={styles.fileBinary}>{t('gitLog.loading')}</span>
|
||||
);
|
||||
} else if (error) {
|
||||
} else if (error || (detail && !detail.available)) {
|
||||
detailBody = (
|
||||
<div className={styles.commitDetail}>
|
||||
<span className={styles.detailError}>{t('gitLog.detailError')}</span>
|
||||
</div>
|
||||
<span className={styles.detailError}>{t('gitLog.detailError')}</span>
|
||||
);
|
||||
} else if (detail && detail.available) {
|
||||
} else if (detail) {
|
||||
detailBody = (
|
||||
<div className={styles.commitDetail}>
|
||||
<>
|
||||
{detail.body && (
|
||||
<pre className={styles.commitBody}>{detail.body}</pre>
|
||||
)}
|
||||
@@ -155,13 +285,7 @@ function CommitRow({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (detail && !detail.available) {
|
||||
detailBody = (
|
||||
<div className={styles.commitDetail}>
|
||||
<span className={styles.detailError}>{t('gitLog.detailError')}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -169,6 +293,9 @@ function CommitRow({
|
||||
return (
|
||||
<div className={styles.commitRow}>
|
||||
<div className={styles.commitHeader}>
|
||||
{graph && (
|
||||
<CommitGraphCell row={graph} width={graphWidth} isMerge={isMerge} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.commitToggle}
|
||||
@@ -205,7 +332,12 @@ function CommitRow({
|
||||
{copied ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
{detailBody}
|
||||
{detailBody !== undefined && (
|
||||
<div className={styles.detailRow}>
|
||||
{graph && <CommitGraphTail row={graph} width={graphWidth} />}
|
||||
<div className={styles.commitDetail}>{detailBody}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 = <div className={styles.placeholder}>{t('gitLog.loading')}</div>;
|
||||
@@ -305,18 +466,24 @@ export function GitLogContent({
|
||||
} 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>;
|
||||
body = (
|
||||
<div className={styles.placeholder}>
|
||||
{t(query ? 'gitLog.noMatches' : 'gitLog.empty')}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<>
|
||||
<div className={styles.commitList}>
|
||||
{log.entries.map((entry) => (
|
||||
{log.entries.map((entry, index) => (
|
||||
<CommitRow
|
||||
key={entry.sha}
|
||||
entry={entry}
|
||||
workspaceCwd={workspaceCwd}
|
||||
gitCwd={gitCwd}
|
||||
now={now}
|
||||
graph={graph?.[index]}
|
||||
graphWidth={graphWidth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -340,7 +507,33 @@ export function GitLogContent({
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={styles.content}>{body}</div>;
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.toolbar}>
|
||||
<label className={styles.search}>
|
||||
<SearchIcon size={13} />
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
type="search"
|
||||
placeholder={t('gitLog.search')}
|
||||
aria-label={t('gitLog.search')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.toggle}${allBranches ? ` ${styles.toggleOn}` : ''}`}
|
||||
aria-pressed={allBranches}
|
||||
data-testid="git-log-all-branches"
|
||||
onClick={() => setAllBranches((v) => !v)}
|
||||
>
|
||||
{t('gitLog.allBranches')}
|
||||
</button>
|
||||
</div>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitLogDialog({
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -51,6 +51,7 @@ const EN: Messages = {
|
||||
'branchPicker.action.newBranch': 'New Branch…',
|
||||
'branchPicker.action.checkoutRef': 'Checkout Tag or Revision…',
|
||||
'branchPicker.action.viewChanges': 'View Changes',
|
||||
'branchPicker.action.history': 'History',
|
||||
'branchPicker.newBranchPlaceholder': 'Branch name',
|
||||
'branchPicker.invalidBranchName':
|
||||
'Invalid branch name — avoid spaces, ~, ^, :, ? * and leading -',
|
||||
@@ -191,6 +192,9 @@ const EN: Messages = {
|
||||
'gitLog.detailError': 'Failed to load commit details',
|
||||
'gitLog.hidden': (v) => `${v?.count ?? 0} more file(s) not shown`,
|
||||
'gitLog.copySha': (v) => `Copy commit ${v?.sha ?? ''}`,
|
||||
'gitLog.allBranches': 'All branches',
|
||||
'gitLog.search': 'Search message, author, or hash',
|
||||
'gitLog.noMatches': 'No commits match',
|
||||
'githubPrs.title': 'Pull requests',
|
||||
'githubPrs.subtitle': (v) => `${v?.count ?? 0} open`,
|
||||
'githubPrs.loading': 'Loading pull requests…',
|
||||
@@ -3908,6 +3912,7 @@ const ZH: Messages = {
|
||||
'branchPicker.action.newBranch': '新建分支…',
|
||||
'branchPicker.action.checkoutRef': '检出标签或修订…',
|
||||
'branchPicker.action.viewChanges': '查看变更',
|
||||
'branchPicker.action.history': '提交历史',
|
||||
'branchPicker.newBranchPlaceholder': '分支名称',
|
||||
'branchPicker.invalidBranchName':
|
||||
'无效的分支名称 — 避免空格、~、^、:、? * 以及以 - 开头',
|
||||
@@ -4040,6 +4045,9 @@ const ZH: Messages = {
|
||||
'gitLog.detailError': '加载提交详情失败',
|
||||
'gitLog.hidden': (v) => `还有 ${v?.count ?? 0} 个文件未显示`,
|
||||
'gitLog.copySha': (v) => `复制提交 ${v?.sha ?? ''}`,
|
||||
'gitLog.allBranches': '全部分支',
|
||||
'gitLog.search': '搜索提交信息、作者或哈希',
|
||||
'gitLog.noMatches': '没有匹配的提交',
|
||||
'githubPrs.title': '拉取请求',
|
||||
'githubPrs.subtitle': (v) => `${v?.count ?? 0} 个开放`,
|
||||
'githubPrs.loading': '加载拉取请求中…',
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { layoutCommitGraph } from './commitGraph';
|
||||
|
||||
const c = (sha: string, ...parents: string[]) => ({ sha, parents });
|
||||
|
||||
describe('layoutCommitGraph', () => {
|
||||
it('keeps a linear history in one lane with one colour', () => {
|
||||
const rows = layoutCommitGraph([c('c', 'b'), c('b', 'a'), c('a')]);
|
||||
expect(rows.map((r) => r.column)).toEqual([0, 0, 0]);
|
||||
expect(rows.map((r) => r.color)).toEqual([0, 0, 0]);
|
||||
expect(rows[0].incoming).toEqual([]);
|
||||
expect(rows[0].outgoing).toEqual([{ column: 0, color: 0 }]);
|
||||
expect(rows[1].incoming).toEqual([{ column: 0, color: 0 }]);
|
||||
expect(rows[2].outgoing).toEqual([]);
|
||||
expect(rows[2].after).toEqual([]);
|
||||
expect(rows.every((r) => r.width === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('opens a second lane for a merge and closes it at the fork point', () => {
|
||||
// m merges f into main; both descend from base.
|
||||
const rows = layoutCommitGraph([
|
||||
c('m', 'main1', 'f'),
|
||||
c('f', 'base'),
|
||||
c('main1', 'base'),
|
||||
c('base'),
|
||||
]);
|
||||
const [merge, feature, main1, base] = rows;
|
||||
expect(merge.column).toBe(0);
|
||||
expect(merge.outgoing).toEqual([
|
||||
{ column: 0, color: 0 },
|
||||
{ column: 1, color: 1 },
|
||||
]);
|
||||
expect(merge.width).toBe(2);
|
||||
expect(feature.column).toBe(1);
|
||||
expect(feature.color).toBe(1);
|
||||
expect(feature.through).toEqual([{ column: 0, color: 0 }]);
|
||||
expect(feature.outgoing).toEqual([{ column: 1, color: 1 }]);
|
||||
expect(main1.column).toBe(0);
|
||||
expect(main1.through).toEqual([{ column: 1, color: 1 }]);
|
||||
expect(main1.width).toBe(2);
|
||||
// Both lanes wait for base; they converge on the lowest one at its node.
|
||||
expect(base.column).toBe(0);
|
||||
expect(base.incoming).toEqual([
|
||||
{ column: 0, color: 0 },
|
||||
{ column: 1, color: 1 },
|
||||
]);
|
||||
expect(base.after).toEqual([]);
|
||||
});
|
||||
|
||||
it('merges every lane waiting for a commit into its node', () => {
|
||||
// Two branch tips share the same parent.
|
||||
const rows = layoutCommitGraph([c('x', 'p'), c('y', 'p'), c('p')]);
|
||||
expect(rows[1].column).toBe(1);
|
||||
expect(rows[1].outgoing).toEqual([{ column: 1, color: 1 }]);
|
||||
expect(rows[2].column).toBe(0);
|
||||
expect(rows[2].incoming).toEqual([
|
||||
{ column: 0, color: 0 },
|
||||
{ column: 1, color: 1 },
|
||||
]);
|
||||
expect(rows[2].through).toEqual([]);
|
||||
});
|
||||
|
||||
it('reuses freed lanes before opening new ones', () => {
|
||||
const rows = layoutCommitGraph([
|
||||
c('a', 'b'),
|
||||
c('t1', 'old'),
|
||||
c('b', 'c'),
|
||||
c('t2', 'old2'),
|
||||
c('c'),
|
||||
]);
|
||||
expect(rows.map((r) => r.column)).toEqual([0, 1, 0, 2, 0]);
|
||||
// c has no parents: lane 0 closes and the trailing lanes stay open.
|
||||
expect(rows[4].after).toEqual([
|
||||
{ column: 1, color: 1 },
|
||||
{ column: 2, color: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives an unseen parent of a merge a fresh lane and colour', () => {
|
||||
const rows = layoutCommitGraph([c('m', 'a', 'b'), c('a'), c('b')]);
|
||||
expect(rows[0].outgoing).toEqual([
|
||||
{ column: 0, color: 0 },
|
||||
{ column: 1, color: 1 },
|
||||
]);
|
||||
expect(rows[2].column).toBe(1);
|
||||
expect(rows[2].color).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/** A line segment drawn across one row, addressed by lane index. */
|
||||
export interface CommitGraphLine {
|
||||
column: number;
|
||||
color: number;
|
||||
}
|
||||
|
||||
export interface CommitGraphRow {
|
||||
/** Lane holding this commit's node. */
|
||||
column: number;
|
||||
/** Colour index of the node and of the lane it continues. */
|
||||
color: number;
|
||||
/** Lanes entering the node from the row above (own lane and merged children). */
|
||||
incoming: CommitGraphLine[];
|
||||
/** Lanes leaving the node toward the row below (one per parent). */
|
||||
outgoing: CommitGraphLine[];
|
||||
/** Lanes that cross this row without touching the node. */
|
||||
through: CommitGraphLine[];
|
||||
/** Lanes still open below this row; drawn straight through expanded rows. */
|
||||
after: CommitGraphLine[];
|
||||
/** Highest lane index touched on this row plus one. */
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface Lane {
|
||||
sha: string;
|
||||
color: number;
|
||||
}
|
||||
|
||||
function firstFree(lanes: Array<Lane | null>): number {
|
||||
const free = lanes.indexOf(null);
|
||||
return free === -1 ? lanes.length : free;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out commits (newest first, parents never before children) into lanes.
|
||||
*
|
||||
* Each lane waits for one commit. A commit takes the lowest lane waiting for
|
||||
* it, or a fresh lane when none is; every other lane waiting for it merges in
|
||||
* at its node, so a mainline never jogs sideways to meet a fork. Its first
|
||||
* parent continues the lane; further parents each get the lane already
|
||||
* waiting for them or a new one.
|
||||
*/
|
||||
export function layoutCommitGraph(
|
||||
entries: ReadonlyArray<{ sha: string; parents: string[] }>,
|
||||
): CommitGraphRow[] {
|
||||
const lanes: Array<Lane | null> = [];
|
||||
let nextColor = 0;
|
||||
const rows: CommitGraphRow[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const found = lanes.findIndex((lane) => lane?.sha === entry.sha);
|
||||
const column = found === -1 ? firstFree(lanes) : found;
|
||||
const color = found === -1 ? nextColor++ : lanes[found]!.color;
|
||||
|
||||
const incoming: CommitGraphLine[] = [];
|
||||
const through: CommitGraphLine[] = [];
|
||||
lanes.forEach((lane, index) => {
|
||||
if (!lane) return;
|
||||
if (lane.sha === entry.sha) {
|
||||
incoming.push({ column: index, color: lane.color });
|
||||
lanes[index] = null;
|
||||
} else {
|
||||
through.push({ column: index, color: lane.color });
|
||||
}
|
||||
});
|
||||
|
||||
const outgoing: CommitGraphLine[] = [];
|
||||
entry.parents.forEach((parent, parentIndex) => {
|
||||
if (parentIndex === 0) {
|
||||
lanes[column] = { sha: parent, color };
|
||||
outgoing.push({ column, color });
|
||||
return;
|
||||
}
|
||||
const existing = lanes.findIndex((lane) => lane?.sha === parent);
|
||||
if (existing !== -1) {
|
||||
outgoing.push({ column: existing, color: lanes[existing]!.color });
|
||||
} else {
|
||||
const fresh = firstFree(lanes);
|
||||
const freshColor = nextColor++;
|
||||
lanes[fresh] = { sha: parent, color: freshColor };
|
||||
outgoing.push({ column: fresh, color: freshColor });
|
||||
}
|
||||
});
|
||||
|
||||
while (lanes.length > 0 && lanes[lanes.length - 1] === null) lanes.pop();
|
||||
|
||||
const after: CommitGraphLine[] = [];
|
||||
lanes.forEach((lane, index) => {
|
||||
if (lane) after.push({ column: index, color: lane.color });
|
||||
});
|
||||
const width =
|
||||
1 +
|
||||
Math.max(
|
||||
column,
|
||||
...incoming.map((l) => l.column),
|
||||
...outgoing.map((l) => l.column),
|
||||
...through.map((l) => l.column),
|
||||
);
|
||||
rows.push({ column, color, incoming, outgoing, through, after, width });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
Reference in new issue
Block a user