From 0e6d79b3048599e5703d99a9d9bc6f53173d6130 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Tue, 30 Jun 2026 00:02:34 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(LogGraph):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=87=AA=E8=AE=A1=E7=AE=97=20DAG=20lane=20=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E5=BC=95=E6=93=8E=E5=8F=8A=E8=A7=A3=E6=9E=90/=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E7=BA=AF=E9=80=BB=E8=BE=91;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - graph-types/color/layout:单遍增量 lane 状态机(nil-slot 复用保证分支直顺、沿首父链继承色 + 相邻 lane 异色),覆盖 octopus 合并 / 多 root / 收敛 / 截断 dangling 边; - log-line:git log NUL/RS 全字段解析(%H %P %an %ae %aI %s); - log-query:--topo-order 保拓扑序 + author/grep/path 服务端翻译(pathspec 置尾); - log-filter:下沉 LogFilter 单一事实源 + toClientFilter 抽取客户端维度; - 单测 29 例覆盖各拓扑场景、解析与查询构造。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- src/engine/log/graph-color.ts | 54 +++++++++ src/engine/log/graph-layout.ts | 163 ++++++++++++++++++++++++++++ src/engine/log/graph-types.ts | 54 +++++++++ src/engine/log/log-filter.ts | 28 +++++ src/engine/log/log-line.ts | 62 +++++++++++ src/engine/log/log-query.ts | 48 ++++++++ tests/unit/log-graph-layout.test.ts | 115 ++++++++++++++++++++ tests/unit/log-line.test.ts | 65 +++++++++++ tests/unit/log-query.test.ts | 50 +++++++++ 9 files changed, 639 insertions(+) create mode 100644 src/engine/log/graph-color.ts create mode 100644 src/engine/log/graph-layout.ts create mode 100644 src/engine/log/graph-types.ts create mode 100644 src/engine/log/log-line.ts create mode 100644 src/engine/log/log-query.ts create mode 100644 tests/unit/log-graph-layout.test.ts create mode 100644 tests/unit/log-line.test.ts create mode 100644 tests/unit/log-query.test.ts diff --git a/src/engine/log/graph-color.ts b/src/engine/log/graph-color.ts new file mode 100644 index 0000000..dd0a7c6 --- /dev/null +++ b/src/engine/log/graph-color.ts @@ -0,0 +1,54 @@ +/** + * 提交图 lane 着色(纯逻辑,零 vscode 依赖)。 + * + * 设计目标:复刻 IDEA 的「同一分支首父链同色、分叉点向次分支复用、相邻 lane 色相区分」语义。 + * 引擎只产出稳定的调色板索引(`colorIdx`),渲染器(webview)按主题将其映射为实际颜色 + * (优先 `--vscode-charts-*` 主题令牌,缺失时回退到 {@link DEFAULT_LANE_PALETTE}), + * 从而实现深浅 / 高对比自适应。调色板 hex 作为单一事实源由 engine 导出,供 renderer 复用。 + */ + +/** + * 默认 lane 调色板(GitHub-dark 风高饱和色,深浅主题均清晰可辨,相邻 lane 可区分)。 + * 渲染器按 `colorIdx % DEFAULT_LANE_PALETTE.length` 取色。 + */ +export const DEFAULT_LANE_PALETTE: readonly string[] = [ + '#f85149', '#58a6ff', '#3fb950', '#d29922', '#bc8cff', '#ff7b72', '#56d4dd', '#ffa657', +]; + +/** 调色板大小。 */ +export const LANE_PALETTE_SIZE = DEFAULT_LANE_PALETTE.length; + +/** + * 为新开 / 复用的 lane 槽选取一个与左右邻槽色相区分的调色板索引。 + * 无法完全区分时回退到按槽位轮转(最坏情况偶有同色,但不崩溃)。 + */ +export function pickDistinctColor(slotColors: ReadonlyArray, slot: number): number { + const neighbor = new Set(); + for (const n of [slotColors[slot - 1], slotColors[slot + 1]]) { + if (typeof n === 'number') { + neighbor.add(n); + } + } + for (let i = 0; i < LANE_PALETTE_SIZE; i++) { + if (!neighbor.has(i)) { + return i; + } + } + return slot % LANE_PALETTE_SIZE; +} + +/** + * 解析本 commit 节点的调色板索引: + * - `hit >= 0`(闭合既有下行边):沿用该槽色(首父链继承); + * - 否则若槽位有旧色(复用 nil 空洞):继承旧色(分叉点颜色复用,IDEA 语义); + * - 否则(全新追加槽):{@link pickDistinctColor} 另起新色。 + */ +export function resolveNodeColor(slotColors: ReadonlyArray, col: number, hit: number): number { + if (hit >= 0 && typeof slotColors[hit] === 'number') { + return slotColors[hit] as number; + } + if (typeof slotColors[col] === 'number') { + return slotColors[col] as number; + } + return pickDistinctColor(slotColors, col); +} diff --git a/src/engine/log/graph-layout.ts b/src/engine/log/graph-layout.ts new file mode 100644 index 0000000..e61976d --- /dev/null +++ b/src/engine/log/graph-layout.ts @@ -0,0 +1,163 @@ +/** + * 提交图 DAG lane 布局算法(纯逻辑,零 vscode 依赖)。 + * + * 单遍、自顶向底(newest-first)增量算法:维护一张活跃 lane 表 `lanes: (parentHash|null)[]` + * —— 槽值 = 该列「待下行抵达的父 hash」(一条尚未闭合的下行边),或 `null`(可复用的空洞)。 + * 「移除」= 置 `null`(不清色,保留分叉复用语义);「插入」= 复用首个 `null` 槽或追加, + * 这两种操作都不引起其他 lane 的左右位移,故分支「直顺」(参考 gitamine [1])。 + * + * 每个 commit 处理为:定位等待它的 lane(闭合其下行边)→ 着色 → 为其父开放下行边 + * (首父直行复用同槽;次父 fan-out 开新槽或收敛入既有槽)→ 导出本行的入边 / 出边 / 贯穿边。 + * + * [1] P. Vigier, "Commit graph drawing algorithms," pvigier's blog, 2019. + */ + +import type { GraphCommit, GraphEdge, GraphLayoutRow } from './graph-types'; +import { pickDistinctColor, resolveNodeColor } from './graph-color'; + +/** 返回数组中首个 `null` 槽的索引;无则 -1。 */ +function firstNull(arr: ReadonlyArray): number { + for (let i = 0; i < arr.length; i++) { + if (arr[i] === null) { + return i; + } + } + return -1; +} + +/** + * 计算提交图布局。`commits` 须按 `--topo-order`(newest-first)排序。 + * 复杂度 O(n·L),L = 瞬时并发 lane 数(现实 <10),1000 commit < 5ms。 + */ +export function computeGraphLayout(commits: readonly GraphCommit[]): readonly GraphLayoutRow[] { + // 加载窗口内的 hash 集合:O(n) 预扫,O(1) 查询「parent 是否在窗口」(截断边界判定)。 + const windowSet = new Set(); + for (const c of commits) { + windowSet.add(c.hash); + } + + const lanes: (string | null)[] = []; // 活跃 lane 表:槽值 = 待达父 hash 或 null 空洞 + const laneColor: (number | null)[] = []; // 每槽调色板索引(nil 槽保留旧色,分叉复用) + const rows: GraphLayoutRow[] = []; + + for (const c of commits) { + // 本行处理前的 lane 快照(入边 / 贯穿边的来源)。 + const prevLanes = lanes.slice(); + const prevColor = laneColor.slice(); + + // 步骤 A:定位等待 c.hash 的 lane(闭合其下行边)。 + const hit = prevLanes.indexOf(c.hash); + let col: number; + if (hit >= 0) { + col = hit; // 复用既有 lane + } else { + // 新分支尖:复用首个 nil 槽或追加(不引起位移)。 + const ni = firstNull(lanes); + col = ni >= 0 ? ni : lanes.length; + if (col === lanes.length) { + lanes.push(null); + laneColor.push(null); + } + } + + const nodeColor = resolveNodeColor(laneColor, col, hit); + const isMerge = c.parents.length > 1; + + // 步骤 B:入边 —— 所有 prevLanes 中等待 c.hash 的槽,其下行边抵达 node(收敛)。 + const incoming: GraphEdge[] = []; + for (let k = 0; k < prevLanes.length; k++) { + if (prevLanes[k] === c.hash) { + incoming.push({ + fromCol: k, + toCol: col, + colorIdx: prevColor[k] ?? nodeColor, + kind: k === col ? 'straight' : 'merge-in', + }); + } + } + + // 步骤 C:闭合 —— node 已抵达,释放所有等待 c.hash 的槽(置 nil,保留旧色)。 + for (let k = 0; k < lanes.length; k++) { + if (lanes[k] === c.hash) { + lanes[k] = null; + } + } + + // 步骤 D:出边 —— 为 c 的父开放下行边。 + const outgoing: GraphEdge[] = []; + let hasDanglingParent = false; + if (c.parents.length > 0) { + const p0 = c.parents[0]; + if (windowSet.has(p0)) { + lanes[col] = p0; // 首父直行:复用同槽,色沿首父链继承。 + laneColor[col] = nodeColor; + outgoing.push({ fromCol: col, toCol: col, colorIdx: nodeColor, kind: 'straight' }); + } else { + hasDanglingParent = true; // 截断边界:首父不在窗口,边悬空终止。 + outgoing.push({ fromCol: col, toCol: col, colorIdx: nodeColor, kind: 'dangling' }); + } + // 次父 / octopus 父:fan-out 开新槽,或收敛入既有槽。 + for (let i = 1; i < c.parents.length; i++) { + const pi = c.parents[i]; + if (!windowSet.has(pi)) { + hasDanglingParent = true; + outgoing.push({ fromCol: col, toCol: col, colorIdx: nodeColor, kind: 'dangling' }); + continue; + } + const existing = lanes.indexOf(pi); + if (existing >= 0 && existing !== col) { + // 收敛(CONVERGENCE):pi 已被另一 lane 等待 → 连接,不开新槽。 + outgoing.push({ fromCol: col, toCol: existing, colorIdx: laneColor[existing] ?? nodeColor, kind: 'merge-in' }); + } else { + const ni = firstNull(lanes); + const slot = ni >= 0 ? ni : lanes.length; + if (slot === lanes.length) { + lanes.push(null); + laneColor.push(null); + } + lanes[slot] = pi; + laneColor[slot] = pickDistinctColor(laneColor, slot); + outgoing.push({ fromCol: col, toCol: slot, colorIdx: laneColor[slot], kind: 'merge-in' }); + } + } + } + + // 步骤 E:贯穿边 —— prevLanes 中未被 node 消费的活跃 lane,全高竖线穿过本行。 + const passThrough: GraphEdge[] = []; + for (let k = 0; k < prevLanes.length; k++) { + if (prevLanes[k] !== null && prevLanes[k] !== c.hash) { + passThrough.push({ fromCol: k, toCol: k, colorIdx: prevColor[k] ?? 0, kind: 'straight' }); + } + } + + rows.push({ + hash: c.hash, + node: { col, colorIdx: nodeColor }, + incoming, + outgoing, + passThrough, + isMerge, + hasDanglingParent, + }); + } + + return rows; +} + +/** 布局用到的最大瞬时并发 lane 数(= 最大列号 + 1),供渲染器预算图列总宽。 */ +export function maxLanes(layout: readonly GraphLayoutRow[]): number { + let m = 0; + for (const row of layout) { + m = Math.max(m, row.node.col); + for (const e of row.incoming) { + m = Math.max(m, e.fromCol, e.toCol); + } + for (const e of row.outgoing) { + m = Math.max(m, e.fromCol, e.toCol); + } + for (const e of row.passThrough) { + m = Math.max(m, e.fromCol, e.toCol); + } + } + return m + 1; +} diff --git a/src/engine/log/graph-types.ts b/src/engine/log/graph-types.ts new file mode 100644 index 0000000..356da88 --- /dev/null +++ b/src/engine/log/graph-types.ts @@ -0,0 +1,54 @@ +/** + * 提交图 DAG 布局的数据契约(纯逻辑,零 vscode 依赖)。 + * + * 引擎消费最小 commit 投影({@link GraphCommit},仅需 hash + parents),输出渲染器无关的 + * 逐行布局({@link GraphLayoutRow})。渲染层(adapter webview)据此绘制彩色泳道,完整复刻 + * IntelliJ IDEA Git Log 的 Graph 效果——不再依赖 `git log --graph` 的粗糙 ASCII(lane 由 git 分配、 + * 不可控、随列号抖动着色)。算法参考 gitamine 的 nil-slot 复用 [1] 与 git-graph 的分支区间装箱 [2]。 + * + * 调用方须保证 commits 按 `--topo-order`(newest-first 且子在父之上)排序——lane 增量算法依赖 + * 「处理 commit 时其全部在窗口内的子已处理」这一不变量,否则 lane 会断裂。 + * + * [1] P. Vigier, "Commit graph drawing algorithms," pvigier's blog, 2019. + * [2] M. Lange, "git-graph: branch assignment & lane layout," 2025. + */ + +/** 引擎所需的最小 commit 投影(adapter 由 `git log` 解析结果映射)。 */ +export interface GraphCommit { + readonly hash: string; + /** 有序父;`parents[0]` = 首父。root 提交为空数组;>1 个父为 merge(含 octopus)。 */ + readonly parents: readonly string[]; +} + +/** 边段种类,决定渲染器画直线 / 斜线 / 截断淡出。 */ +export type GraphEdgeKind = 'straight' | 'merge-in' | 'dangling'; + +/** + * 单条边段(列号语义,渲染器无关): + * - `incoming`:`fromCol`(上一行底) → `toCol`(= node.col,本行中心),抵达 node 的下行边; + * - `outgoing`:`fromCol`(= node.col) → `toCol`(本行底),从 node 出发的下行边; + * - `passThrough`:`fromCol === toCol`,贯穿本行全高的活跃 lane。 + */ +export interface GraphEdge { + readonly fromCol: number; + readonly toCol: number; + /** 调色板索引(稳定,渲染器按主题映射为实际颜色)。 */ + readonly colorIdx: number; + readonly kind: GraphEdgeKind; +} + +/** 单行布局结果,与输入 commits 数组按索引一一对应(newest-first 拓扑序)。 */ +export interface GraphLayoutRow { + readonly hash: string; + /** 本 commit 节点的列号与调色板索引。 */ + readonly node: { readonly col: number; readonly colorIdx: number }; + /** 入边(上一行底 → 本行 node 中心):抵达 node 的下行边,含收敛斜入。 */ + readonly incoming: readonly GraphEdge[]; + /** 出边(node 中心 → 本行底):首父 `straight`、次父 `merge-in`、截断 `dangling`。 */ + readonly outgoing: readonly GraphEdge[]; + /** 贯穿全高的竖线:穿过本行但不涉及本 node 的活跃 lane。 */ + readonly passThrough: readonly GraphEdge[]; + readonly isMerge: boolean; + /** 是否触及截断边界(有 parent 不在加载窗口内)。 */ + readonly hasDanglingParent: boolean; +} diff --git a/src/engine/log/log-filter.ts b/src/engine/log/log-filter.ts index 0e0f0c5..2734c73 100644 --- a/src/engine/log/log-filter.ts +++ b/src/engine/log/log-filter.ts @@ -18,6 +18,34 @@ export interface LogClientFilter { readonly messageRegex?: RegExp; } +/** + * Log 完整过滤器(单一事实源,engine 层):author/path/grep 交 git log 服务端; + * mergeMode/dateFrom/dateTo/messageRegex 客户端(见 {@link toClientFilter})。 + */ +export interface LogFilter { + readonly author?: string; + readonly path?: string; + readonly grep?: string; + readonly mergeMode?: MergeMode; + readonly dateFrom?: Date; + readonly dateTo?: Date; + /** message 正则模式串(运行时经 {@link safeRegex} 编译)。 */ + readonly messageRegex?: string; +} + +/** 从完整过滤器抽取客户端维度(服务端维度交 git log,见 {@link buildLogArgs})。 */ +export function toClientFilter(filter: LogFilter | undefined): LogClientFilter { + if (!filter) { + return {}; + } + return { + mergeMode: filter.mergeMode, + dateFrom: filter.dateFrom, + dateTo: filter.dateTo, + messageRegex: filter.messageRegex ? safeRegex(filter.messageRegex) : undefined, + }; +} + /** 过滤所需的最小 commit 投影(adapter 由 vscode.git Commit 映射而来)。 */ export interface FilterableCommit { readonly message: string; diff --git a/src/engine/log/log-line.ts b/src/engine/log/log-line.ts new file mode 100644 index 0000000..b4c48b7 --- /dev/null +++ b/src/engine/log/log-line.ts @@ -0,0 +1,62 @@ +/** + * `git log`(Graph 数据源)输出解析器(纯逻辑,零 vscode 依赖)。 + * + * 一次性 NUL/RS 分隔格式取全字段(含 parents),供自计算 DAG lane 布局({@link ../graph-layout}) + * 替代旧 `git log --graph` ASCII 路径。配套 CLI(字段以 NUL `%x00` 分隔、记录以 RS `%x1e` 终止, + * 二者均不会出现在 git 文本输出中,规避多行 subject 歧义): + * + * git log --topo-order [--all] --max-count= [--skip=] [--author --grep -- ] + * --format= + * + * `--topo-order` 为硬性要求:lane 增量算法依赖「子在父之上」严格成立。 + */ + +/** for-each commit 的 --format 值(与 {@link parseLogLines} 字段顺序严格对应,勿单独修改)。 */ +export const LOG_GRAPH_FORMAT = '%H%x00%P%x00%an%x00%ae%x00%aI%x00%s%x1e'; + +/** 一条 commit 的解析结果(字段顺序对应 {@link LOG_GRAPH_FORMAT})。 */ +export interface RawCommit { + readonly hash: string; + /** 有序父 hash(`%P` 空格分隔;root 为空数组)。 */ + readonly parents: readonly string[]; + readonly authorName: string; + readonly authorEmail: string; + /** 作者日期 ISO 严格(`%aI`,`new Date()` 可解析)。 */ + readonly authorDate: string; + /** subject(`%s`,首行)。 */ + readonly subject: string; +} + +const NUL = '\x00'; +const RS = '\x1e'; + +/** + * 解析 `git log --format=` 输出为 RawCommit[]。 + * 容错:跳过空记录与字段不足的记录(避免单条异常中断整个视图)。 + */ +export function parseLogLines(output: string): RawCommit[] { + const commits: RawCommit[] = []; + for (let record of output.split(RS)) { + record = record.replace(/^\r?\n/, ''); // 去除 git 逐 commit 追加的行首换行 + if (record.length === 0) { + continue; + } + const f = record.split(NUL); + if (f.length < 6) { + continue; + } + const hash = f[0]; + if (!hash) { + continue; + } + commits.push({ + hash, + parents: f[1].split(' ').filter(Boolean), + authorName: f[2] ?? '', + authorEmail: f[3] ?? '', + authorDate: f[4] ?? '', + subject: f[5] ?? '', + }); + } + return commits; +} diff --git a/src/engine/log/log-query.ts b/src/engine/log/log-query.ts new file mode 100644 index 0000000..2175127 --- /dev/null +++ b/src/engine/log/log-query.ts @@ -0,0 +1,48 @@ +/** + * Log 查询构造(纯逻辑,零 vscode 依赖)。 + * + * 把 {@link LogFilter} + 范围 + 分页翻译为 `git log` 参数向量(含 {@link LOG_GRAPH_FORMAT}), + * 供 host 侧 `service.execGit(['log', ...buildLogArgs(...)])` 单次取数。服务端维度(author/grep/path) + * 走 git 参数;客户端维度(mergeMode/date/regex)由 host 经 {@link toClientFilter} + {@link applyClientFilters} + * 处理。`--topo-order` 为硬性要求(lane 算法依赖拓扑序);pathspec `-- ` 必须置于末尾。 + */ + +import type { LogFilter } from './log-filter'; +import { LOG_GRAPH_FORMAT } from './log-line'; + +/** 提交范围:`all` = 全部分支(`--all`,IDEA 默认);`current` = 当前分支(HEAD)。 */ +export type LogScope = 'all' | 'current'; + +/** 分页参数。 */ +export interface LogQueryOptions { + readonly maxCount: number; + /** `--skip` 偏移(增量加载下一页);<=0 表示首页。 */ + readonly skip?: number; +} + +/** + * 构造 `git log` 参数向量(不含 `log` 字面量,host 拼接)。 + * 顺序:`--topo-order` → 范围 → 分页 → 服务端过滤 → `--format` → pathspec(末尾)。 + */ +export function buildLogArgs(filter: LogFilter | undefined, scope: LogScope, opts: LogQueryOptions): string[] { + const args: string[] = ['--topo-order']; + if (scope === 'all') { + args.push('--all'); + } + args.push(`--max-count=${opts.maxCount}`); + if (opts.skip && opts.skip > 0) { + args.push(`--skip=${opts.skip}`); + } + if (filter?.author && filter.author.trim()) { + args.push(`--author=${filter.author.trim()}`); + } + if (filter?.grep && filter.grep.trim()) { + args.push(`--grep=${filter.grep.trim()}`); + } + args.push(`--format=${LOG_GRAPH_FORMAT}`); + // pathspec 必须置于参数末尾。 + if (filter?.path && filter.path.trim()) { + args.push('--', filter.path.trim()); + } + return args; +} diff --git a/tests/unit/log-graph-layout.test.ts b/tests/unit/log-graph-layout.test.ts new file mode 100644 index 0000000..ab8ecfa --- /dev/null +++ b/tests/unit/log-graph-layout.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { computeGraphLayout, maxLanes } from '../../src/engine/log/graph-layout'; +import type { GraphCommit } from '../../src/engine/log/graph-types'; + +const C = (hash: string, parents: readonly string[] = []): GraphCommit => ({ hash, parents }); + +describe('computeGraphLayout — 线性历史', () => { + it('单链:全部 col0,首父链同色,纯 straight 出边', () => { + const rows = computeGraphLayout([C('a', ['b']), C('b', ['c']), C('c')]); + expect(rows.map((r) => r.node.col)).toEqual([0, 0, 0]); + expect(rows.every((r) => r.node.colorIdx === rows[0].node.colorIdx)).toBe(true); + expect(rows[0].outgoing.every((e) => e.kind === 'straight')).toBe(true); + expect(rows[2].hasDanglingParent).toBe(false); // c 是 root,非截断 + expect(maxLanes(rows)).toBe(1); + }); +}); + +describe('computeGraphLayout — 分支 + 合并(钻石)', () => { + // a (merge: b,c) + // /| + // b | b@col0 c@col1 + // \| + // d (root) + const rows = computeGraphLayout([C('a', ['b', 'c']), C('b', ['d']), C('c', ['d']), C('d')]); + + it('merge 节点 col0,次父 fan-out 到 col1', () => { + expect(rows[0].node.col).toBe(0); + expect(rows[0].isMerge).toBe(true); + expect(rows[0].outgoing.some((e) => e.kind === 'merge-in' && e.fromCol === 0 && e.toCol === 1)).toBe(true); + }); + + it('两条分支分别落在 col0 / col1,且贯穿彼此 lane', () => { + expect(rows[1].node.col).toBe(0); // b + expect(rows[2].node.col).toBe(1); // c + expect(rows[1].passThrough.some((e) => e.fromCol === 1)).toBe(true); // c 的 lane 穿过 b + }); + + it('汇合点 d:次父以 merge-in 收敛入 col0', () => { + expect(rows[3].node.col).toBe(0); + expect(rows[3].incoming.some((e) => e.kind === 'merge-in' && e.fromCol === 1 && e.toCol === 0)).toBe(true); + }); + + it('相邻 lane 着色区分', () => { + expect(rows[1].node.colorIdx).not.toBe(rows[2].node.colorIdx); + }); + + it('maxLanes = 2', () => { + expect(maxLanes(rows)).toBe(2); + }); +}); + +describe('computeGraphLayout — Octopus 合并(3 parents)', () => { + it('首父 col0,两个次父各 fan-out', () => { + const rows = computeGraphLayout([C('m', ['p1', 'p2', 'p3']), C('p1'), C('p2'), C('p3')]); + expect(rows[0].node.col).toBe(0); + const mergeIns = rows[0].outgoing.filter((e) => e.kind === 'merge-in'); + expect(mergeIns).toHaveLength(2); + expect(new Set(mergeIns.map((e) => e.toCol))).toEqual(new Set([1, 2])); + }); +}); + +describe('computeGraphLayout — 多 root / 不连通历史', () => { + it('两条独立链顺序复用 col0,互不干扰', () => { + const rows = computeGraphLayout([C('a', ['b']), C('b'), C('x', ['y']), C('y')]); + expect(rows[0].node.col).toBe(0); // a + expect(rows[2].node.col).toBe(0); // x 复用释放后的 nil 槽 + expect(maxLanes(rows)).toBe(1); + }); +}); + +describe('computeGraphLayout — 截断边界(dangling parent)', () => { + it('首父不在窗口:hasDanglingParent,emit dangling 出边', () => { + const rows = computeGraphLayout([C('a', ['UNLOADED'])]); + expect(rows[0].hasDanglingParent).toBe(true); + expect(rows[0].outgoing.some((e) => e.kind === 'dangling')).toBe(true); + }); + + it('次父不在窗口:merge + dangling,不为悬空父开活跃槽', () => { + const rows = computeGraphLayout([C('m', ['p', 'UNLOADED']), C('p')]); + expect(rows[0].hasDanglingParent).toBe(true); + expect(rows[0].isMerge).toBe(true); + expect(maxLanes(rows)).toBe(1); // UNLOADED 不占槽 + }); +}); + +describe('computeGraphLayout — 收敛(convergence)', () => { + // a→s, b→s:a 落 col0、b 落 col1,各自首父 s 直行下行;两条 lane 在 s 节点处收敛 + // (col1 的下行边以 merge-in 收敛入 col0),不会为 s 再开第三条 lane。 + it('两个子指向同一父:父节点处收敛(incoming merge-in),不开第三条 lane', () => { + const rows = computeGraphLayout([C('a', ['s']), C('b', ['s']), C('s')]); + expect(rows[0].node.col).toBe(0); // a + expect(rows[1].node.col).toBe(1); // b 开新 lane + expect(rows[1].outgoing.some((e) => e.kind === 'straight' && e.toCol === 1)).toBe(true); // b 首父直行 + expect(rows[2].node.col).toBe(0); // s 落在 col0 + expect(rows[2].incoming.some((e) => e.kind === 'merge-in' && e.fromCol === 1 && e.toCol === 0)).toBe(true); + expect(maxLanes(rows)).toBe(2); // 仅两条 lane + }); +}); + +describe('computeGraphLayout — 确定性与边界', () => { + it('同一输入两次调用结果深相等', () => { + const input = [C('a', ['b', 'c']), C('b'), C('c')]; + expect(computeGraphLayout(input)).toEqual(computeGraphLayout(input)); + }); + + it('空输入返回空数组', () => { + expect(computeGraphLayout([])).toEqual([]); + }); + + it('全 root:全部 col0 不崩溃', () => { + const rows = computeGraphLayout([C('a'), C('b'), C('c')]); + expect(rows.map((r) => r.node.col)).toEqual([0, 0, 0]); + expect(maxLanes(rows)).toBe(1); + }); +}); diff --git a/tests/unit/log-line.test.ts b/tests/unit/log-line.test.ts new file mode 100644 index 0000000..fdf13c4 --- /dev/null +++ b/tests/unit/log-line.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { parseLogLines, LOG_GRAPH_FORMAT } from '../../src/engine/log/log-line'; + +const NUL = '\x00'; +const RS = '\x1e'; + +/** 模拟 git 逐 commit 输出:字段 NUL 分隔、记录以 RS 终止、git 追加一个换行。 */ +const rec = (hash: string, parents: string, an: string, ae: string, aI: string, subject: string): string => + `${hash}${NUL}${parents}${NUL}${an}${NUL}${ae}${NUL}${aI}${NUL}${subject}${RS}\n`; + +describe('LOG_GRAPH_FORMAT', () => { + it('字段顺序:H P an ae aI s,以 NUL 分隔、RS 终止', () => { + expect(LOG_GRAPH_FORMAT).toBe('%H%x00%P%x00%an%x00%ae%x00%aI%x00%s%x1e'); + }); +}); + +describe('parseLogLines', () => { + it('解析单条 commit', () => { + const rows = parseLogLines(rec('aaa', 'bbb', 'Jane', 'j@x.io', '2026-06-29T10:00:00+08:00', 'fix: bug')); + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual({ + hash: 'aaa', + parents: ['bbb'], + authorName: 'Jane', + authorEmail: 'j@x.io', + authorDate: '2026-06-29T10:00:00+08:00', + subject: 'fix: bug', + }); + }); + + it('解析多条(第二条带 git 行首换行)', () => { + const out = rec('aaa', 'bbb', 'A', 'a@x', '2026-06-29T10:00:00+08:00', 's1') + rec('bbb', '', 'B', 'b@x', '2026-06-28T10:00:00+08:00', 's2'); + const rows = parseLogLines(out); + expect(rows).toHaveLength(2); + expect(rows[1].hash).toBe('bbb'); + expect(rows[1].subject).toBe('s2'); + }); + + it('root 提交:parents 为空数组', () => { + const rows = parseLogLines(rec('aaa', '', 'A', 'a@x', '2026-06-29T10:00:00+08:00', 'init')); + expect(rows[0].parents).toEqual([]); + }); + + it('多父(merge):parents 按空格拆分', () => { + const rows = parseLogLines(rec('m', 'p1 p2 p3', 'A', 'a@x', '2026-06-29T10:00:00+08:00', 'merge')); + expect(rows[0].parents).toEqual(['p1', 'p2', 'p3']); + }); + + it('空输出返回空数组', () => { + expect(parseLogLines('')).toEqual([]); + }); + + it('字段不足的记录被跳过(不中断整体解析)', () => { + const bad = `only${NUL}two${RS}\n`; + const good = rec('aaa', '', 'A', 'a@x', '2026-06-29T10:00:00+08:00', 's'); + const rows = parseLogLines(bad + good); + expect(rows).toHaveLength(1); + expect(rows[0].hash).toBe('aaa'); + }); + + it('subject 含特殊字符(| / \\)原样保留', () => { + const rows = parseLogLines(rec('aaa', '', 'A', 'a@x', '2026-06-29T10:00:00+08:00', 'fix: a | b / c \\ d')); + expect(rows[0].subject).toBe('fix: a | b / c \\ d'); + }); +}); diff --git a/tests/unit/log-query.test.ts b/tests/unit/log-query.test.ts new file mode 100644 index 0000000..90e02fc --- /dev/null +++ b/tests/unit/log-query.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { buildLogArgs } from '../../src/engine/log/log-query'; +import type { LogFilter } from '../../src/engine/log/log-filter'; + +const noFilter: LogFilter = {}; + +describe('buildLogArgs — 顺序与必选项', () => { + it('--topo-order 置首(lane 算法依赖拓扑序)', () => { + expect(buildLogArgs(noFilter, 'all', { maxCount: 300 })[0]).toBe('--topo-order'); + }); + + it('scope=all 含 --all;scope=current 不含', () => { + expect(buildLogArgs(noFilter, 'all', { maxCount: 300 })).toContain('--all'); + expect(buildLogArgs(noFilter, 'current', { maxCount: 300 })).not.toContain('--all'); + }); + + it('max-count 与 skip', () => { + expect(buildLogArgs(noFilter, 'all', { maxCount: 500 })).toContain('--max-count=500'); + expect(buildLogArgs(noFilter, 'all', { maxCount: 500, skip: 500 })).toContain('--skip=500'); + expect(buildLogArgs(noFilter, 'all', { maxCount: 500, skip: 0 })).not.toContain('--skip=0'); + }); +}); + +describe('buildLogArgs — 服务端过滤翻译', () => { + it('author / grep 翻译为 flag(去空白)', () => { + const args = buildLogArgs({ author: ' Jane ', grep: 'fix' }, 'all', { maxCount: 300 }); + expect(args).toContain('--author=Jane'); + expect(args).toContain('--grep=fix'); + }); + + it('空 author/grep 不产生 flag', () => { + const args = buildLogArgs({ author: ' ', grep: '' }, 'all', { maxCount: 300 }); + expect(args.some((a) => a.startsWith('--author'))).toBe(false); + expect(args.some((a) => a.startsWith('--grep'))).toBe(false); + }); + + it('path 以 pathspec 形式置于参数末尾(--format 之后)', () => { + const args = buildLogArgs({ path: 'src/a.ts' }, 'all', { maxCount: 300 }); + const fmtIdx = args.findIndex((a) => a.startsWith('--format=')); + const pathspecSep = args.lastIndexOf('--'); + expect(pathspecSep).toBeGreaterThan(fmtIdx); + expect(args[args.length - 1]).toBe('src/a.ts'); + expect(args[args.length - 2]).toBe('--'); + }); + + it('--format 使用 LOG_GRAPH_FORMAT 契约', () => { + const args = buildLogArgs(noFilter, 'all', { maxCount: 300 }); + expect(args).toContain(`--format=${'%H%x00%P%x00%an%x00%ae%x00%aI%x00%s%x1e'}`); + }); +}); From 5127a7a6efc3fa0436f57ae8b5547b4c38a95ac3 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Tue, 30 Jun 2026 00:03:01 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(Log):=20Log=20=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E4=B8=BA=20IDEA=20=E9=A3=8E=E6=A0=BC?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E5=9B=BE=20Webview=20=E5=B9=B6=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E7=8B=AC=E7=AB=8B=20Graph=20=E9=9D=A2=E6=9D=BF;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hyperGit.log 由原生 TreeView 迁移为 Webview:基于父子关系自计算 DAG lane 布局,渲染彩色泳道 / 节点 / 分叉·合并连线 / HEAD·分支·标签标签,完整复刻 IDEA Git Log,弃用 git log --graph 粗糙 ASCII; - 单次 git log --topo-order --all 取数 + for-each-ref 取引用标签;Webview 端虚拟化「每行 SVG + 文本列」、主题调色板(--vscode- 令牌 + hex 回退)、All/Current 切换、滚动增量加载、↑↓ 键导航、选中提交内联变更文件、右键 per-commit 操作菜单(复用既有 9 个命令); - 新增 LogFilterControl 接口使 4 个命令注册器零行为改动迁移;新增 protocol Log 消息契约; - 移除独立 showGraph 面板、graph-webview、graph-parser(单一事实源去重)。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- CHANGELOG.md | 1 + package.json | 14 +- src/adapter/advanced-commands.ts | 2 +- src/adapter/git-cli-commands.ts | 4 +- src/adapter/history-commands.ts | 4 +- src/adapter/misc-commands.ts | 4 +- src/adapter/remote-commands.ts | 4 +- src/adapter/tree/log-tree.ts | 165 -------- src/adapter/webview/graph-webview.ts | 225 ---------- src/adapter/webview/log-webview.ts | 593 +++++++++++++++++++++++++++ src/engine/log/graph-parser.ts | 77 ---- src/extension.ts | 8 +- src/shared/protocol.ts | 81 ++++ tests/unit/log-graph-parser.test.ts | 64 --- 14 files changed, 688 insertions(+), 558 deletions(-) delete mode 100644 src/adapter/tree/log-tree.ts delete mode 100644 src/adapter/webview/graph-webview.ts create mode 100644 src/adapter/webview/log-webview.ts delete mode 100644 src/engine/log/graph-parser.ts delete mode 100644 tests/unit/log-graph-parser.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 16c8d68..c82700c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Added +- **Log 视图升级为 IDEA 风格提交图(Graph DAG)**:`hyperGit.log` 由原生 TreeView(`log-tree.ts`)迁移为 Webview,基于 commit 父子关系**自计算 DAG lane 布局**,完整复刻 IntelliJ IDEA Git Log 的彩色泳道 / 节点 / 分叉·合并连线 / HEAD·分支·标签标签——不再依赖 `git log --graph` 的粗糙 ASCII(lane 由 git 分配、不可控、随列号抖动着色)。新增纯逻辑引擎 `engine/log/graph-layout.ts`(单遍增量 lane 状态机,nil-slot 复用保证分支「直顺」;`graph-color.ts` 沿首父链继承 + 相邻 lane 异色;覆盖 octopus 合并 / 多 root / 收敛 / 截断 dangling 边)、`log-line.ts`(NUL/RS 解析)与 `log-query.ts`(`--topo-order` 保拓扑序;author/grep/path 服务端 + mergeMode/date/regex 客户端复用 `applyClientFilters`),单测 29 例全覆盖。Webview 端虚拟化「每行内联 SVG + 文本列」渲染、主题调色板(`--vscode-` 令牌 + hex 回退)、All/Current 范围切换、滚动增量加载、↑↓ 键导航、选中提交内联展示变更文件(点击打开 diff)、右键 per-commit 操作菜单(复用既有 9 个命令)。移除独立的 `showGraph` 面板与 `graph-parser`(单一事实源去重);新增 `LogFilterControl` 接口使 4 个命令注册器零行为改动完成迁移。 - **Branches 视图多选 + 批量操作**:`hyperGit.branches` 由 `registerTreeDataProvider` 改用 `createTreeView({ canSelectMany: true })`,支持 Ctrl/Cmd/Shift 框选多个分支/标签。批量命令作用于整个选区:**删除分支**(一次 `git branch --merged` 分类已合并/未合并,单条确认弹窗诚实分栏呈现强制删除风险,逐个删除并汇总成功/失败)、**删除标签**、**复制引用**(按行连接)、**收藏切换**。仅单目标语义的操作(检出/合并/变基/重命名/比较)经 `!listMultiSelection` 在多选时从右键菜单隐藏,且因仅读「右键点击项」而始终安全。新增纯逻辑 `engine/ref/selection.collectBranchRefs`(选区归一化 + 「点击在选区之外」手势优先)与 `engine/ref/cleanup` 的 `partitionByMerged`/`formatBranchDeleteConfirm`/`truncateNames`,单测全覆盖。 ## [0.0.1-rc.4] - 2026-06-29 — 第四个预发布候选 diff --git a/package.json b/package.json index f2db3e9..b2249ef 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ { "id": "hyperGit.log", "name": "Log", + "type": "webview", "visibility": "visible" }, { @@ -160,7 +161,6 @@ { "command": "hyperGit.logFilterDate", "title": "按日期过滤", "category": "Hyper Git" }, { "command": "hyperGit.logFilterRegex", "title": "按 message 正则过滤", "category": "Hyper Git" }, { "command": "hyperGit.rewordCommit", "title": "改写最新提交信息", "category": "Hyper Git" }, - { "command": "hyperGit.showGraph", "title": "查看提交图(Graph)", "category": "Hyper Git", "icon": "$(git-commit)" }, { "command": "hyperGit.showConsole", "title": "打开 Console", "category": "Hyper Git" }, { "command": "hyperGit.partialStage", "title": "部分暂存(Hunk)…", "category": "Hyper Git" }, { "command": "hyperGit.partialUnstage", "title": "部分取消暂存(Hunk)…", "category": "Hyper Git" }, @@ -215,9 +215,6 @@ { "command": "hyperGit.createPatch", "when": "view == hyperGit.changes" }, { "command": "hyperGit.applyPatch", "when": "view == hyperGit.changes" }, { "command": "hyperGit.refreshLog", "when": "view == hyperGit.log", "group": "navigation" }, - { "command": "hyperGit.showGraph", "when": "view == hyperGit.log", "group": "navigation" }, - { "command": "hyperGit.cherryPick", "when": "view == hyperGit.log", "group": "navigation" }, - { "command": "hyperGit.revertCommit", "when": "view == hyperGit.log", "group": "navigation" }, { "command": "hyperGit.resetHead", "when": "view == hyperGit.log", "group": "navigation" }, { "command": "hyperGit.logFilterAuthor", "when": "view == hyperGit.log" }, { "command": "hyperGit.logFilterPath", "when": "view == hyperGit.log" }, @@ -251,7 +248,6 @@ { "command": "hyperGit.moveChangelist", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "1_file@1" }, { "command": "hyperGit.showHistory", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "1_file@2" }, { "command": "hyperGit.discardChanges", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "1_file@3" }, - { "command": "hyperGit.copyCommitHash", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@1" }, { "command": "hyperGit.branchCheckout", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/ && !listMultiSelection", "group": "1_branch@1" }, { "command": "hyperGit.branchDelete", "when": "view == hyperGit.branches && viewItem == hyperGit.branch", "group": "1_branch@2" }, { "command": "hyperGit.mergeBranch", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/ && !listMultiSelection", "group": "1_branch@3" }, @@ -265,14 +261,6 @@ { "command": "hyperGit.compareWithCurrent", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch|hyperGit.tag/ && !listMultiSelection", "group": "1_branch@8" }, { "command": "hyperGit.tagCheckout", "when": "view == hyperGit.branches && viewItem == hyperGit.tag && !listMultiSelection", "group": "1_tag@1" }, { "command": "hyperGit.tagDelete", "when": "view == hyperGit.branches && viewItem == hyperGit.tag", "group": "1_tag@2" }, - { "command": "hyperGit.cherryPick", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@1" }, - { "command": "hyperGit.revertCommit", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@2" }, - { "command": "hyperGit.dropCommit", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@3" }, - { "command": "hyperGit.fixupCommit", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@4" }, - { "command": "hyperGit.createBranchFromCommit", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@5" }, - { "command": "hyperGit.createTagFromCommit", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@6" }, - { "command": "hyperGit.showContainingBranches", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@7" }, - { "command": "hyperGit.resetToHere", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@8" }, { "command": "hyperGit.ignorePath", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "1_file@4" }, { "command": "hyperGit.partialStage", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "1_file@5" }, { "command": "hyperGit.partialUnstage", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "1_file@6" }, diff --git a/src/adapter/advanced-commands.ts b/src/adapter/advanced-commands.ts index bb772e2..f32698e 100644 --- a/src/adapter/advanced-commands.ts +++ b/src/adapter/advanced-commands.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import type { BranchNode, BranchesTreeProvider } from './tree/branches-tree'; import type { GitRepositoryService } from './git-repository-service'; -import type { LogNode } from './tree/log-tree'; +import type { LogNode } from './webview/log-webview'; import { filterMergeable } from '../engine/ref/cleanup'; import { selectedBranchRefs } from './branch-selection'; diff --git a/src/adapter/git-cli-commands.ts b/src/adapter/git-cli-commands.ts index 4bb6f9d..fb7775e 100644 --- a/src/adapter/git-cli-commands.ts +++ b/src/adapter/git-cli-commands.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import type { BranchNode, BranchesTreeProvider } from './tree/branches-tree'; import type { ChangeItem, GitRepositoryService } from './git-repository-service'; -import type { LogNode, LogTreeProvider } from './tree/log-tree'; +import type { LogFilterControl, LogNode } from './webview/log-webview'; import { handleGitConflict } from './conflict-ui'; const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); @@ -12,7 +12,7 @@ const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String( * 注册经 git CLI 补齐的操作(M5' batch 1):cherry-pick / revert / reset / branch rename / * ignore / compare branches / reword。均经 `service.execGit`(复用 vscode.git 的同一 git 二进制)。 */ -export function registerGitCliCommands(service: GitRepositoryService, branchesTree: BranchesTreeProvider, logTree: LogTreeProvider): vscode.Disposable[] { +export function registerGitCliCommands(service: GitRepositoryService, branchesTree: BranchesTreeProvider, logTree: LogFilterControl): vscode.Disposable[] { const subs: vscode.Disposable[] = []; subs.push( diff --git a/src/adapter/history-commands.ts b/src/adapter/history-commands.ts index 61d1929..49c79e1 100644 --- a/src/adapter/history-commands.ts +++ b/src/adapter/history-commands.ts @@ -4,7 +4,7 @@ import type { BranchNode } from './tree/branches-tree'; import type { BranchesTreeProvider } from './tree/branches-tree'; import type { BranchFavorites } from './branch-favorites'; import type { ChangeItem, GitRepositoryService } from './git-repository-service'; -import type { LogNode, LogTreeProvider } from './tree/log-tree'; +import type { LogFilterControl, LogNode } from './webview/log-webview'; import { handleGitConflict } from './conflict-ui'; import type { MergeMode } from '../engine/log/log-filter'; import { selectedBranchRefs } from './branch-selection'; @@ -13,7 +13,7 @@ import { formatBranchDeleteConfirm, partitionByMerged, truncateNames } from '../ /** 注册 Log/Branches/Blame/History/Tags 相关命令。 */ export function registerHistoryCommands( service: GitRepositoryService, - logTree: LogTreeProvider, + logTree: LogFilterControl, branchesTree: BranchesTreeProvider, favorites: BranchFavorites, ): vscode.Disposable[] { diff --git a/src/adapter/misc-commands.ts b/src/adapter/misc-commands.ts index 6b5f109..05e6361 100644 --- a/src/adapter/misc-commands.ts +++ b/src/adapter/misc-commands.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as vscode from 'vscode'; import type { GitRepositoryService } from './git-repository-service'; -import type { LogTreeProvider } from './tree/log-tree'; +import type { LogFilterControl } from './webview/log-webview'; import type { BranchesTreeProvider } from './tree/branches-tree'; import { handleGitConflict } from './conflict-ui'; @@ -17,7 +17,7 @@ const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String( export function registerMiscCommands( service: GitRepositoryService, branchesTree: BranchesTreeProvider, - logTree: LogTreeProvider, + logTree: LogFilterControl, ): vscode.Disposable[] { const subs: vscode.Disposable[] = []; diff --git a/src/adapter/remote-commands.ts b/src/adapter/remote-commands.ts index 0293191..4389b64 100644 --- a/src/adapter/remote-commands.ts +++ b/src/adapter/remote-commands.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import type { GitRepositoryService } from './git-repository-service'; import type { BranchesTreeProvider } from './tree/branches-tree'; -import type { LogTreeProvider } from './tree/log-tree'; +import type { LogFilterControl } from './webview/log-webview'; import { handleGitConflict } from './conflict-ui'; const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); @@ -17,7 +17,7 @@ const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String( export function registerRemoteCommands( service: GitRepositoryService, branchesTree: BranchesTreeProvider, - logTree: LogTreeProvider, + logTree: LogFilterControl, ): vscode.Disposable[] { const subs: vscode.Disposable[] = []; diff --git a/src/adapter/tree/log-tree.ts b/src/adapter/tree/log-tree.ts deleted file mode 100644 index 3166c9e..0000000 --- a/src/adapter/tree/log-tree.ts +++ /dev/null @@ -1,165 +0,0 @@ -import * as vscode from 'vscode'; -import type { Commit } from '../../types/git'; -import type { GitRepositoryService } from '../git-repository-service'; -import { applyClientFilters, safeRegex, type LogClientFilter, type MergeMode } from '../../engine/log/log-filter'; -import { parseNameStatus, statusLabel, type CommitFileChange } from '../../engine/log/commit-files'; - -export interface LogCommitNode { - readonly kind: 'commit'; - readonly commit: Commit; -} - -export interface LogFileNode { - readonly kind: 'file'; - readonly hash: string; - readonly change: CommitFileChange; - readonly hasParent: boolean; -} - -export type LogNode = LogCommitNode | LogFileNode; - -/** Log 过滤器:author/path/grep 交 git log 服务端;mergeMode/dateFrom/dateTo/messageRegex 客户端。 */ -export interface LogFilter { - readonly author?: string; - readonly path?: string; - readonly grep?: string; - readonly mergeMode?: MergeMode; - readonly dateFrom?: Date; - readonly dateTo?: Date; - /** message 正则模式串(运行时经 safeRegex 编译)。 */ - readonly messageRegex?: string; -} - -/** - * Log 视图 TreeDataProvider:消费 `Repository.log()`,支持多维过滤;commit 节点可展开显示 - * 该提交的变更文件(`git diff-tree --name-status`),单文件点击打开 diff(commit^ vs commit)。 - */ -export class LogTreeProvider implements vscode.TreeDataProvider, vscode.Disposable { - private readonly _onDidChange = new vscode.EventEmitter(); - readonly onDidChangeTreeData = this._onDidChange.event; - private filter: LogFilter = {}; - - constructor(private readonly service: GitRepositoryService) {} - - setFilter(filter: LogFilter): void { - this.filter = filter; - this._onDidChange.fire(undefined); - } - - clearFilter(): void { - this.filter = {}; - this._onDidChange.fire(undefined); - } - - getFilter(): LogFilter { - return this.filter; - } - - refresh(): void { - this._onDidChange.fire(undefined); - } - - async getChildren(element?: LogNode): Promise { - const repo = this.service.repo; - if (!repo) { - return []; - } - if (element) { - return element.kind === 'commit' ? this.commitFiles(element.commit) : []; - } - try { - const commits = await repo.log({ - maxEntries: 200, - author: this.filter.author, - path: this.filter.path, - grep: this.filter.grep, - }); - const clientFilter: LogClientFilter = { - mergeMode: this.filter.mergeMode, - dateFrom: this.filter.dateFrom, - dateTo: this.filter.dateTo, - messageRegex: this.filter.messageRegex ? safeRegex(this.filter.messageRegex) : undefined, - }; - return applyClientFilters(commits, clientFilter).map((c): LogCommitNode => ({ kind: 'commit', commit: c })); - } catch { - return []; - } - } - - /** 展开 commit:经 diff-tree 取变更文件列表。 */ - private async commitFiles(commit: Commit): Promise { - try { - const out = await this.service.execGit([ - 'diff-tree', - '--no-commit-id', - '--name-status', - '-r', - '--root', - commit.hash, - ]); - const hasParent = commit.parents.length > 0; - return parseNameStatus(out).map((change): LogFileNode => ({ kind: 'file', hash: commit.hash, change, hasParent })); - } catch { - return []; - } - } - - getTreeItem(node: LogNode): vscode.TreeItem { - if (node.kind === 'file') { - return this.createFileItem(node); - } - return this.createCommitItem(node.commit); - } - - private createCommitItem(c: Commit): vscode.TreeItem { - const subject = (c.message.split('\n', 1)[0] ?? c.message).slice(0, 80); - const date = c.authorDate ? formatDate(c.authorDate) : ''; - const isMerge = c.parents.length > 1; - const item = new vscode.TreeItem(subject, vscode.TreeItemCollapsibleState.Collapsed); - item.id = c.hash; - item.description = `${c.authorName ?? '?'} · ${date} · ${c.hash.slice(0, 7)}${isMerge ? ' · merge' : ''}`; - item.tooltip = `${c.hash}\n${c.authorName ?? ''} <${c.authorEmail ?? ''}> · ${date}\n\n${c.message}`; - item.contextValue = 'hyperGit.commit'; - item.iconPath = new vscode.ThemeIcon(isMerge ? 'git-merge' : 'git-commit'); - return item; - } - - private createFileItem(node: LogFileNode): vscode.TreeItem { - const { change, hash, hasParent } = node; - const label = change.oldPath ? `${change.oldPath} → ${change.path}` : change.path; - const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.None); - item.description = statusLabel(change.status); - item.contextValue = 'hyperGit.commitFile'; - item.tooltip = `${statusLabel(change.status)} · ${change.path}\n@ ${hash.slice(0, 7)}`; - item.iconPath = new vscode.ThemeIcon('circle-filled', new vscode.ThemeColor(fileIconColor(change.status))); - item.command = { - command: 'hyperGit.openCommitFileDiff', - title: '打开 Diff', - arguments: [hash, change.path, hasParent], - }; - return item; - } - - dispose(): void { - this._onDidChange.dispose(); - } -} - -function formatDate(d: Date): string { - const m = String(d.getMonth() + 1).padStart(2, '0'); - const day = String(d.getDate()).padStart(2, '0'); - return `${d.getFullYear()}-${m}-${day}`; -} - -function fileIconColor(status: string): string { - if (status.startsWith('A')) { - return 'gitDecoration.addedResourceForeground'; - } - if (status.startsWith('D')) { - return 'gitDecoration.deletedResourceForeground'; - } - if (status.startsWith('R') || status.startsWith('C')) { - return 'gitDecoration.renamedResourceForeground'; - } - return 'gitDecoration.modifiedResourceForeground'; -} diff --git a/src/adapter/webview/graph-webview.ts b/src/adapter/webview/graph-webview.ts deleted file mode 100644 index 23b1fb4..0000000 --- a/src/adapter/webview/graph-webview.ts +++ /dev/null @@ -1,225 +0,0 @@ -import * as crypto from 'crypto'; -import * as vscode from 'vscode'; -import type { GitRepositoryService } from '../git-repository-service'; -import { classifyGraphChar, normalizeGraphWidth, parseGraphLog, type GraphRow } from '../../engine/log/graph-parser'; - -const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); - -/** 按列着色的 lane 调色板(类 IDEA 多色 lane)。 */ -const LANE_COLORS = ['#f85149', '#58a6ff', '#3fb950', '#d29922', '#bc8cff', '#ff7b72', '#56d4dd', '#ffa657']; -const CHAR_W = 10; -const ROW_H = 22; -const NODE_R = 4.5; - -/** - * Git 提交图(WebviewPanel)—— 真实 SVG 拓扑渲染。 - * - * 解析 `git log --graph --format=%x00%H%x00%d%x00%s`(git 已完成 lane 分配),按字符粒度渲染为 SVG: - * `*`→可点击圆点、`|`→竖线、`/ \`→斜线、`_`→横线,按列 floor(col/2) 多色着色(类 IDEA)。 - * 点节点 → host QuickPick 提供 per-commit 操作;订阅 service.onDidChange 实时刷新。 - */ -export class GraphWebview { - private static readonly viewType = 'hyperGit.graph'; - - static async open(service: GitRepositoryService): Promise { - const repo = service.repo; - if (!repo) { - void vscode.window.showWarningMessage('未找到 Git 仓库'); - return; - } - const initial = await GraphWebview.fetchGraph(service); - if (!initial) { - return; - } - const panel = vscode.window.createWebviewPanel(GraphWebview.viewType, 'Git Graph — Hyper Git', vscode.ViewColumn.Active, { - enableScripts: true, - retainContextWhenHidden: true, - }); - panel.webview.html = GraphWebview.renderHtml(initial, repo.rootUri.fsPath); - panel.webview.onDidReceiveMessage((msg) => { - if (msg?.type === 'commit' && typeof msg.hash === 'string') { - void GraphWebview.handleCommitClick(service, msg.hash); - } - }); - // 实时刷新(防抖) - let timer: ReturnType | undefined; - const sub = service.onDidChange(() => { - clearTimeout(timer); - timer = setTimeout(() => { - void GraphWebview.fetchGraph(service).then((g) => { - if (g && panel.visible) { - panel.webview.html = GraphWebview.renderHtml(g, repo.rootUri.fsPath); - } - }); - }, 400); - }); - panel.onDidDispose(() => { - clearTimeout(timer); - sub.dispose(); - }); - } - - private static async fetchGraph(service: GitRepositoryService): Promise { - try { - return await service.execGit(['log', '--graph', '-n', '300', '--all', '--format=%x00%H%x00%d%x00%s']); - } catch (e) { - void vscode.window.showErrorMessage(`获取提交图失败:${errMsg(e)}`); - return undefined; - } - } - - private static async handleCommitClick(service: GitRepositoryService, hash: string): Promise { - const repo = service.repo; - if (!repo) { - return; - } - const pick = await vscode.window.showQuickPick( - [ - { label: 'Cherry-Pick 此提交', op: 'cp' }, - { label: 'Revert 此提交', op: 'rv' }, - { label: 'Reset 当前分支到此提交…', op: 'rs' }, - { label: '从此提交新建分支…', op: 'nb' }, - { label: '从此提交新建标签…', op: 'nt' }, - { label: '查看包含此提交的分支', op: 'cb' }, - { label: '复制 Hash', op: 'copy' }, - ], - { placeHolder: `提交 ${hash.slice(0, 7)}` }, - ); - if (!pick) { - return; - } - try { - switch (pick.op) { - case 'cp': - await service.execGit(['cherry-pick', hash]); - break; - case 'rv': - await service.execGit(['revert', '--no-edit', hash]); - break; - case 'rs': - await vscode.commands.executeCommand('hyperGit.resetToHere', { kind: 'commit', commit: { hash, message: '', parents: [] } }); - return; - case 'nb': { - const name = await vscode.window.showInputBox({ prompt: `从 ${hash.slice(0, 7)} 新建并检出分支`, placeHolder: '新分支名' }); - if (name?.trim()) { - await repo.createBranch(name.trim(), true, hash); - } - break; - } - case 'nt': { - const name = await vscode.window.showInputBox({ prompt: '标签名', placeHolder: '如 v1.0.0' }); - if (name?.trim()) { - await service.execGit(['tag', name.trim(), hash]); - } - break; - } - case 'cb': { - const out = await service.execGit(['branch', '--contains', hash]); - const doc = await vscode.workspace.openTextDocument({ content: `$ git branch --contains ${hash.slice(0, 7)}\n\n${out}`, language: 'plaintext' }); - await vscode.window.showTextDocument(doc, { preview: true }); - return; - } - case 'copy': - await vscode.env.clipboard.writeText(hash); - void vscode.window.showInformationMessage(`已复制 ${hash.slice(0, 7)}`); - return; - } - void vscode.window.showInformationMessage(`已完成:${pick.label}`); - } catch (e) { - void vscode.window.showErrorMessage(`操作失败:${errMsg(e)}`); - } - } - - private static renderHtml(graphOutput: string, repoRoot: string): string { - const nonce = crypto.randomBytes(16).toString('base64'); - const rows = parseGraphLog(graphOutput); - const padded = normalizeGraphWidth(rows); - const graphWidth = (padded[0]?.length ?? 0) * CHAR_W; - const height = rows.length * ROW_H + 8; - const body = GraphWebview.renderSvg(rows, padded, graphWidth, height); - return ` - - - - - - - -

Git 提交图(最近 300 条 · 点击节点查看操作)

-
${escapeHtml(repoRoot)}
-${body} - - -`; - } - - private static renderSvg(rows: readonly GraphRow[], padded: readonly string[], graphWidth: number, height: number): string { - const parts: string[] = []; - parts.push(``); - for (let r = 0; r < rows.length; r++) { - const y = r * ROW_H; - const graph = padded[r] ?? ''; - for (let c = 0; c < graph.length; c++) { - const ch = graph[c]; - const kind = classifyGraphChar(ch); - if (kind === 'blank') { - continue; - } - const x = c * CHAR_W; - const cx = x + CHAR_W / 2; - const color = LANE_COLORS[Math.floor(c / 2) % LANE_COLORS.length]; - switch (kind) { - case 'node': { - const row = rows[r]; - const dataHash = row?.hash ? ` data-hash="${escapeHtml(row.hash)}"` : ''; - parts.push(``); - break; - } - case 'vert': - parts.push(``); - break; - case 'slash': - parts.push(``); - break; - case 'backslash': - parts.push(``); - break; - case 'underscore': - parts.push(``); - break; - } - } - // 文本(hash + refs + subject) - const row = rows[r]; - if (row?.hash) { - const tx = graphWidth + 8; - const ty = y + ROW_H * 0.68; - const shortHash = escapeHtml(row.hash.slice(0, 7)); - const refSpan = row.decorate ? `${escapeHtml(row.decorate)}` : ''; - const subj = row.subject ? `${escapeHtml(row.subject.slice(0, 80))}` : ''; - parts.push(`${shortHash}${refSpan}${subj}`); - } - } - parts.push(''); - return parts.join('\n'); - } -} - -function escapeHtml(s: string): string { - return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} diff --git a/src/adapter/webview/log-webview.ts b/src/adapter/webview/log-webview.ts new file mode 100644 index 0000000..ba179e0 --- /dev/null +++ b/src/adapter/webview/log-webview.ts @@ -0,0 +1,593 @@ +import * as crypto from 'crypto'; +import * as vscode from 'vscode'; +import type { GitRepositoryService } from '../git-repository-service'; +import { parseNameStatus, statusLabel } from '../../engine/log/commit-files'; +import { applyClientFilters, toClientFilter, type LogFilter } from '../../engine/log/log-filter'; +import { DEFAULT_LANE_PALETTE } from '../../engine/log/graph-color'; +import { computeGraphLayout, maxLanes } from '../../engine/log/graph-layout'; +import { parseLogLines } from '../../engine/log/log-line'; +import { buildLogArgs, type LogScope } from '../../engine/log/log-query'; +import type { + GraphRowVM, + LogCommitFileItem, + LogGraphState, + LogHostToWebviewMessage, + LogWebviewToHostMessage, + RefChip, +} from '../../shared/protocol'; + +const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +/** 单页拉取的 commit 数(滚动触底增量加载下一页)。 */ +const PAGE = 1000; + +/** per-commit 操作 → 既有命令 id(webview 右键菜单 → host 重调用,handler 仅需 hash)。 */ +const COMMIT_MENU: ReadonlyArray<{ readonly label: string; readonly command: string }> = [ + { label: '复制 Hash', command: 'hyperGit.copyCommitHash' }, + { label: 'Cherry-Pick 此提交', command: 'hyperGit.cherryPick' }, + { label: 'Revert 此提交', command: 'hyperGit.revertCommit' }, + { label: 'Drop 此提交(删除最新提交)', command: 'hyperGit.dropCommit' }, + { label: 'Fixup 此提交', command: 'hyperGit.fixupCommit' }, + { label: '从此提交新建分支…', command: 'hyperGit.createBranchFromCommit' }, + { label: '从此提交新建标签…', command: 'hyperGit.createTagFromCommit' }, + { label: '查看包含此提交的分支', command: 'hyperGit.showContainingBranches' }, + { label: 'Reset 当前分支到此提交…', command: 'hyperGit.resetToHere' }, +]; + +/** 引用标签查询的 for-each-ref 格式(full objectname 供精确匹配;与 parseChips 字段顺序对应)。 */ +const CHIP_REF_FORMAT = '%(objectname)%00%(refname)%00%(refname:short)%00%(HEAD)'; + +// ─── 命令参数类型(webview 迁移后,命令仍以 LogNode 为参数类型)────────────────── + +export interface LogCommitNode { + readonly kind: 'commit'; + readonly commit: { readonly hash: string; readonly message: string; readonly parents: readonly string[] }; +} +export interface LogFileNode { + readonly kind: 'file'; + readonly hash: string; +} +export type LogNode = LogCommitNode | LogFileNode; + +/** + * Log 视图控制契约:4 个命令注册器按此接口(而非具体 Provider 类)引用, + * 使 TreeView→Webview 迁移对注册器零行为改动,并便于未来替换实现。 + */ +export interface LogFilterControl extends vscode.Disposable { + setFilter(filter: LogFilter): void; + clearFilter(): void; + getFilter(): LogFilter; + refresh(): void; +} + +/** 一页图数据。 */ +interface GraphPage { + readonly rows: readonly GraphRowVM[]; + readonly maxLanes: number; + readonly hasMore: boolean; +} + +/** + * Log 视图(WebviewView):完整复刻 IntelliJ IDEA Git Log 的提交图(DAG)。 + * + * 自计算 lane 布局(engine/log/graph-layout)→ 渲染彩色泳道;host 侧单次 `git log --topo-order` + * 取数 + `for-each-ref` 取引用标签;webview 端虚拟化 SVG 行 + 文本列。保留全部既有交互: + * 7 个过滤命令(经 {@link LogFilterControl})、9 个 per-commit 操作(右键 → host 重调用)、 + * 选中提交查看变更文件、All/Current 范围切换、滚动增量加载、实时刷新。 + */ +export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilterControl { + public static readonly viewType = 'hyperGit.log'; + + private view?: vscode.WebviewView; + private filter: LogFilter = {}; + private scope: LogScope = 'all'; + private refreshTimer: ReturnType | undefined; + private readonly disposables: vscode.Disposable[] = []; + + constructor(private readonly service: GitRepositoryService) { + // 兜底实时刷新:git 状态变化(commit/checkout 等)防抖重拉首页。 + let t: ReturnType | undefined; + this.disposables.push( + this.service.onDidChange(() => { + clearTimeout(t); + t = setTimeout(() => this.refresh(), 400); + }), + ); + } + + setFilter(filter: LogFilter): void { + this.filter = filter; + this.refresh(); + } + + clearFilter(): void { + this.filter = {}; + this.refresh(); + } + + getFilter(): LogFilter { + return this.filter; + } + + refresh(): void { + clearTimeout(this.refreshTimer); + this.refreshTimer = setTimeout(() => { + void this.pushState(); + }, 300); + } + + resolveWebviewView(view: vscode.WebviewView): void { + this.view = view; + view.webview.options = { enableScripts: true, localResourceRoots: [] }; + view.webview.html = this.renderHtml(); + const msgSub = view.webview.onDidReceiveMessage((msg) => this.onMessage(msg as LogWebviewToHostMessage)); + view.onDidDispose(() => { + msgSub.dispose(); + this.view = undefined; + }); + } + + dispose(): void { + clearTimeout(this.refreshTimer); + this.disposables.forEach((d) => d.dispose()); + } + + // ─── Host ↔ Webview 消息 ──────────────────────────────────────────────────── + + private onMessage(msg: LogWebviewToHostMessage): void { + switch (msg.type) { + case 'log/requestState': + void this.pushState(); + break; + case 'log/loadMore': + void this.loadMore(msg.payload.cursor); + break; + case 'log/selectCommit': + void this.sendCommitFiles(msg.payload.hash); + break; + case 'log/openFile': + void vscode.commands.executeCommand( + 'hyperGit.openCommitFileDiff', + msg.payload.hash, + msg.payload.path, + msg.payload.hasParent, + ); + break; + case 'log/setScope': + this.scope = msg.payload.scope; + void this.pushState(); + break; + case 'log/commitAction': + if (msg.payload.op === 'menu') { + void this.handleCommitMenu(msg.payload.hash); + } + break; + } + } + + private post(message: LogHostToWebviewMessage): void { + this.view?.webview.postMessage(message); + } + + // ─── 数据拉取 ─────────────────────────────────────────────────────────────── + + private async pushState(): Promise { + if (!this.view) { + return; + } + this.post({ type: 'log/busy', payload: { busy: true } }); + const page = await this.fetchPage(0); + if (!page) { + this.post({ type: 'log/busy', payload: { busy: false } }); + return; + } + const state: LogGraphState = { + rows: page.rows, + maxLanes: page.maxLanes, + hasMore: page.hasMore, + scope: this.scope, + repoRoot: this.service.repoRoot ?? '', + }; + this.post({ type: 'log/graphData', payload: state }); + } + + private async loadMore(cursor: number): Promise { + const page = await this.fetchPage(cursor); + if (!page || page.rows.length === 0) { + this.post({ type: 'log/busy', payload: { busy: false } }); + return; + } + this.post({ + type: 'log/appendData', + payload: { rows: page.rows, maxLanes: page.maxLanes, hasMore: page.hasMore }, + }); + } + + private async fetchPage(skip: number): Promise { + const repo = this.service.repo; + if (!repo) { + return undefined; + } + try { + const out = await this.service.execGit(['log', ...buildLogArgs(this.filter, this.scope, { maxCount: PAGE, skip })]); + const raws = parseLogLines(out); + if (raws.length === 0) { + return { rows: [], maxLanes: 0, hasMore: false }; + } + // 客户端过滤(mergeMode / date / regex),message 近似取 subject。 + const filterable = raws.map((r) => ({ + message: r.subject, + authorDate: r.authorDate ? new Date(r.authorDate) : undefined, + parents: r.parents, + hash: r.hash, + raw: r, + })); + const survived = applyClientFilters(filterable, toClientFilter(this.filter)); + const layout = computeGraphLayout(survived.map((s) => ({ hash: s.hash, parents: s.parents }))); + const hashSet = new Set(survived.map((s) => s.hash)); + const chips = await this.fetchChips(hashSet); + const rows: GraphRowVM[] = survived.map((s, i) => ({ + hash: s.raw.hash, + shortHash: s.raw.hash.slice(0, 7), + parents: s.raw.parents, + isMerge: s.raw.parents.length > 1, + subject: s.raw.subject, + authorName: s.raw.authorName, + authorDate: s.raw.authorDate, + chips: chips.get(s.raw.hash) ?? [], + layout: layout[i], + })); + return { rows, maxLanes: maxLanes(layout), hasMore: raws.length === PAGE }; + } catch (e) { + void vscode.window.showErrorMessage(`获取提交图失败:${errMsg(e)}`); + return undefined; + } + } + + /** 取引用标签:for-each-ref(full hash 精确匹配)+ repo.state.HEAD 标注当前分支 / detached HEAD。 */ + private async fetchChips(hashes: Set): Promise> { + const map = new Map(); + const headCommit = this.service.repo?.state.HEAD?.commit; + const detached = headCommit && !this.service.repo?.state.HEAD?.name; + try { + const out = await this.service.execGit(['for-each-ref', `--format=${CHIP_REF_FORMAT}`, 'refs/heads', 'refs/remotes', 'refs/tags']); + for (const line of out.split('\n')) { + if (line.length === 0) { + continue; + } + const [hash, refname, shortName, headMark] = line.split('\x00'); + if (!hash || !refname || !hashes.has(hash)) { + continue; + } + const kind: RefChip['kind'] = refname.startsWith('refs/tags/') + ? 'tag' + : refname.startsWith('refs/remotes/') + ? 'remoteBranch' + : 'localBranch'; + const isHeadTarget = headMark === '*' || hash === headCommit; + this.pushChip(map, hash, { name: shortName, kind, isHeadTarget }); + } + } catch { + // 引用标签为增强信息,失败不影响图主体。 + } + if (detached && headCommit && hashes.has(headCommit)) { + this.pushChip(map, headCommit, { name: 'HEAD', kind: 'head' }); + } + // 排序:head → local → remote → tag(稳定)。 + const order: Record = { head: 0, localBranch: 1, remoteBranch: 2, tag: 3 }; + for (const list of map.values()) { + list.sort((a, b) => order[a.kind] - order[b.kind]); + } + return map; + } + + private pushChip(map: Map, hash: string, chip: RefChip): void { + const list = map.get(hash); + if (list) { + list.push(chip); + } else { + map.set(hash, [chip]); + } + } + + private async sendCommitFiles(hash: string): Promise { + const repo = this.service.repo; + if (!repo) { + return; + } + try { + // 复用 Log 既有逻辑:diff-tree 取变更文件。 + const out = await this.service.execGit(['diff-tree', '--no-commit-id', '--name-status', '-r', '--root', hash]); + const changes = parseNameStatus(out); + const files: LogCommitFileItem[] = changes.map((c) => ({ + status: c.status, + statusLabel: statusLabel(c.status), + path: c.oldPath ? `${c.oldPath} → ${c.path}` : c.path, + oldPath: c.oldPath, + themeColor: fileIconColor(c.status), + })); + this.post({ type: 'log/commitFiles', payload: { hash, files } }); + } catch { + this.post({ type: 'log/commitFiles', payload: { hash, files: [] } }); + } + } + + private async handleCommitMenu(hash: string): Promise { + const nodeLike: LogCommitNode = { kind: 'commit', commit: { hash, message: '', parents: [] } }; + const items = COMMIT_MENU.map((m) => ({ label: m.label, command: m.command })); + const pick = await vscode.window.showQuickPick(items, { placeHolder: `提交 ${hash.slice(0, 7)}` }); + if (!pick) { + return; + } + await vscode.commands.executeCommand(pick.command, nodeLike); + } + + // ─── HTML 渲染 ────────────────────────────────────────────────────────────── + + private renderHtml(): string { + const nonce = crypto.randomBytes(16).toString('base64'); + const palette = JSON.stringify(DEFAULT_LANE_PALETTE); + const csp = ['default-src \'none\'', 'style-src \'unsafe-inline\'', `script-src 'nonce-${nonce}'`].join('; '); + return ` + + + + + + + +
+ + + + + +
+
+
+
暂无提交
+
加载中…
+
+
+ + +`; + } +} + +/** 变更文件状态 → gitDecoration 主题色 id(与原 log-tree 的 fileIconColor 语义一致)。 */ +function fileIconColor(status: string): string { + if (status.startsWith('A')) { + return 'gitDecoration.addedResourceForeground'; + } + if (status.startsWith('D')) { + return 'gitDecoration.deletedResourceForeground'; + } + if (status.startsWith('R') || status.startsWith('C')) { + return 'gitDecoration.renamedResourceForeground'; + } + return 'gitDecoration.modifiedResourceForeground'; +} diff --git a/src/engine/log/graph-parser.ts b/src/engine/log/graph-parser.ts deleted file mode 100644 index 9690101..0000000 --- a/src/engine/log/graph-parser.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * `git log --graph` 解析器(纯逻辑,零 vscode 依赖)。 - * - * 解析 `git log --graph --format=%x00%H%x00%d%x00%s` 输出:每行 = graph ASCII 前缀 + NUL 分隔的 - * [hash, decorate, subject]。graph 续行(分叉/合并的 `|\` 等连线,无 commit)不含 NUL。 - * git 已完成 lane 分配(`* | / \ _` 的列布局),解析器只做行级拆分,渲染层按字符粒度还原拓扑。 - */ - -export interface GraphRow { - /** graph ASCII(已去尾空白);续行可能为空串。 */ - readonly graph: string; - /** 完整 hash(仅 commit 行有)。 */ - readonly hash?: string; - /** refs 装饰(%d,如 " (HEAD -> main, origin/main)");已 trim。 */ - readonly decorate?: string; - readonly subject?: string; -} - -const NUL = '\x00'; - -/** - * 解析 `git log --graph --format=%x00%H%x00%d%x00%s` 输出为 GraphRow[]。 - * 容错:跳过空行;无 NUL 的行视为 graph 续行。 - */ -export function parseGraphLog(output: string): GraphRow[] { - const rows: GraphRow[] = []; - for (const line of output.split('\n')) { - if (line.length === 0) { - continue; - } - const firstNul = line.indexOf(NUL); - if (firstNul < 0) { - rows.push({ graph: line.replace(/\s+$/, '') }); - continue; - } - const graph = line.slice(0, firstNul).replace(/\s+$/, ''); - const data = line.slice(firstNul + 1).split(NUL); - rows.push({ - graph, - hash: data[0]?.trim() || undefined, - decorate: data[1]?.trim() || undefined, - subject: data[2] ?? undefined, - }); - } - return rows; -} - -/** 将所有行的 graph 右填充到最大长度(保证列对齐)。 */ -export function normalizeGraphWidth(rows: readonly GraphRow[]): string[] { - let max = 0; - for (const r of rows) { - if (r.graph.length > max) { - max = r.graph.length; - } - } - return rows.map((r) => r.graph.padEnd(max, ' ')); -} - -/** 字符 → 渲染类别。 */ -export type GraphCharKind = 'node' | 'vert' | 'slash' | 'backslash' | 'underscore' | 'blank'; - -export function classifyGraphChar(ch: string): GraphCharKind { - switch (ch) { - case '*': - return 'node'; - case '|': - return 'vert'; - case '/': - return 'slash'; - case '\\': - return 'backslash'; - case '_': - return 'underscore'; - default: - return 'blank'; - } -} diff --git a/src/extension.ts b/src/extension.ts index 16e7684..7e28184 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,7 +10,7 @@ import { BranchFavorites } from './adapter/branch-favorites'; import { CommitService } from './adapter/commit/commit-service'; import { BranchesTreeProvider } from './adapter/tree/branches-tree'; import { ChangesTreeProvider, EmptyChangesProvider } from './adapter/tree/changes-tree'; -import { LogTreeProvider } from './adapter/tree/log-tree'; +import { LogWebviewProvider } from './adapter/webview/log-webview'; import { registerHistoryCommands } from './adapter/history-commands'; import { registerStashCommands } from './adapter/stash-commands'; import { registerGitCliCommands } from './adapter/git-cli-commands'; @@ -21,7 +21,6 @@ import { StashTreeProvider } from './adapter/tree/stash-tree'; import { WorktreeTreeProvider } from './adapter/tree/worktree-tree'; import { registerWorktreeCommands } from './adapter/worktree-commands'; import { CommitWebviewProvider } from './adapter/webview/commit-webview'; -import { GraphWebview } from './adapter/webview/graph-webview'; import { showGitConsole } from './infra/git-console'; import { InlineCommitCodeLensProvider, registerInlineCommitCommand } from './adapter/editor/inline-commit-codelens'; import { BlameAnnotationController } from './adapter/editor/blame-annotation'; @@ -78,7 +77,7 @@ export async function activate(context: vscode.ExtensionContext): Promise conflict: new NullConflictResolver(), }); const commitView = new CommitWebviewProvider(service, registry, commit); - const logTree = new LogTreeProvider(service); + const logTree = new LogWebviewProvider(service); const branchesTree = new BranchesTreeProvider(service, favorites); // Branches 视图启用多选(canSelectMany 仅 createTreeView 支持,registerTreeDataProvider 不支持); // 多选后批量操作(删除分支/标签、复制引用、收藏)作用于整个选区。 @@ -110,7 +109,7 @@ export async function activate(context: vscode.ExtensionContext): Promise changesView, branchesView, vscode.window.registerWebviewViewProvider(CommitWebviewProvider.viewType, commitView), - vscode.window.registerTreeDataProvider('hyperGit.log', logTree), + vscode.window.registerWebviewViewProvider(LogWebviewProvider.viewType, logTree), vscode.window.registerTreeDataProvider('hyperGit.stash', stashTree), vscode.window.registerTreeDataProvider('hyperGit.shelf', shelfTree), vscode.window.registerTreeDataProvider('hyperGit.worktrees', worktreeTree), @@ -128,7 +127,6 @@ export async function activate(context: vscode.ExtensionContext): Promise ...registerWorktreeCommands(service, worktreeTree), vscode.commands.registerCommand('hyperGit.commit', focusCommitView), vscode.commands.registerCommand('hyperGit.commitAndPush', focusCommitView), - vscode.commands.registerCommand('hyperGit.showGraph', () => GraphWebview.open(service)), vscode.commands.registerCommand('hyperGit.showConsole', () => showGitConsole()), vscode.commands.registerCommand('hyperGit.startRebase', () => RebaseWebview.open(service)), vscode.languages.registerCodeLensProvider({ scheme: 'file' }, inlineLens), diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index bacd29f..5b77847 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -6,6 +6,8 @@ */ import type { ConventionalValidation, ConventionalSeverity } from '../engine/commit/conventional-linter'; +import type { GraphLayoutRow } from '../engine/log/graph-types'; +import type { LogScope } from '../engine/log/log-query'; export type { ConventionalValidation, ConventionalSeverity }; @@ -47,3 +49,82 @@ export type WebviewToHostMessage = readonly push: boolean; }; }; + +// ───────────────────────────────────────────────────────────────────────────── +// Log Graph 视图(hyperGit.log,Webview)↔ Extension Host 消息契约。 +// 与 Commit 视图的 union 相互独立(两个 disjoint webview,各自一套消息)。 +// 行布局数据(layout: GraphLayoutRow)来自 engine/log/graph-layout 纯逻辑引擎。 +// ───────────────────────────────────────────────────────────────────────────── + +export type { LogScope }; + +/** 提交行的引用标签(HEAD / 本地分支 / 远程分支 / 标签)。 */ +export interface RefChip { + readonly name: string; + readonly kind: 'head' | 'localBranch' | 'remoteBranch' | 'tag'; + /** HEAD 当前指向的本地分支(加粗 / 箭头强调)。 */ + readonly isHeadTarget?: boolean; +} + +/** 单条提交行的视图模型:原始数据 + 计算好的图布局 + 引用标签。 */ +export interface GraphRowVM { + readonly hash: string; + readonly shortHash: string; + readonly parents: readonly string[]; + readonly isMerge: boolean; + readonly subject: string; + readonly authorName: string; + readonly authorDate: string; + readonly chips: readonly RefChip[]; + readonly layout: GraphLayoutRow; +} + +/** 选中提交的变更文件项(themeColor 为 gitDecoration.* 主题色 id)。 */ +export interface LogCommitFileItem { + readonly status: string; + readonly statusLabel: string; + readonly path: string; + readonly oldPath?: string; + readonly themeColor: string; +} + +/** Host → Webview:图数据全量重置(首帧 / 刷新 / 过滤 / 范围切换)。 */ +export interface LogGraphState { + readonly rows: readonly GraphRowVM[]; + readonly maxLanes: number; + readonly hasMore: boolean; + readonly scope: LogScope; + readonly repoRoot: string; +} + +/** per-commit 操作枚举(webview 右键菜单 → host 重调用既有命令)。 */ +export type LogCommitOp = + | 'copy' + | 'cherryPick' + | 'revert' + | 'drop' + | 'fixup' + | 'newBranch' + | 'newTag' + | 'containingBranches' + | 'reset' + | 'menu'; + +/** Host → Webview(Log Graph)。 */ +export type LogHostToWebviewMessage = + | { readonly type: 'log/graphData'; readonly payload: LogGraphState } + | { + readonly type: 'log/appendData'; + readonly payload: { readonly rows: readonly GraphRowVM[]; readonly maxLanes: number; readonly hasMore: boolean }; + } + | { readonly type: 'log/commitFiles'; readonly payload: { readonly hash: string; readonly files: readonly LogCommitFileItem[] } } + | { readonly type: 'log/busy'; readonly payload: { readonly busy: boolean } }; + +/** Webview → Host(Log Graph)。 */ +export type LogWebviewToHostMessage = + | { readonly type: 'log/requestState' } + | { readonly type: 'log/loadMore'; readonly payload: { readonly cursor: number } } + | { readonly type: 'log/selectCommit'; readonly payload: { readonly hash: string } } + | { readonly type: 'log/commitAction'; readonly payload: { readonly op: LogCommitOp; readonly hash: string } } + | { readonly type: 'log/setScope'; readonly payload: { readonly scope: LogScope } } + | { readonly type: 'log/openFile'; readonly payload: { readonly hash: string; readonly path: string; readonly hasParent: boolean } }; diff --git a/tests/unit/log-graph-parser.test.ts b/tests/unit/log-graph-parser.test.ts deleted file mode 100644 index cbfc2ff..0000000 --- a/tests/unit/log-graph-parser.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { parseGraphLog, normalizeGraphWidth, classifyGraphChar } from '../../src/engine/log/graph-parser'; - -const NUL = '\x00'; - -describe('parseGraphLog', () => { - it('解析 commit 行(graph + hash + decorate + subject)', () => { - const out = `*${NUL}aaa1111${NUL} (HEAD -> main)${NUL}first commit`; - const rows = parseGraphLog(out); - expect(rows).toHaveLength(1); - expect(rows[0]).toEqual({ graph: '*', hash: 'aaa1111', decorate: '(HEAD -> main)', subject: 'first commit' }); - }); - - it('graph 续行(无 NUL)只含 graph', () => { - const out = ['*' + NUL + 'aaa' + NUL + '' + NUL + 'c1', '|\\', '| *' + NUL + 'bbb' + NUL + '' + NUL + 'c2'].join('\n'); - const rows = parseGraphLog(out); - expect(rows).toHaveLength(3); - expect(rows[1].graph).toBe('|\\'); - expect(rows[1].hash).toBeUndefined(); - expect(rows[2].hash).toBe('bbb'); - }); - - it('graph 去除尾部空白', () => { - const out = '* ' + NUL + 'aaa' + NUL + '' + NUL + 's'; - expect(parseGraphLog(out)[0].graph).toBe('*'); - }); - - it('decorate 为空时 trim 为 undefined', () => { - const out = '*' + NUL + 'aaa' + NUL + ' ' + NUL + 's'; - expect(parseGraphLog(out)[0].decorate).toBeUndefined(); - }); - - it('跳过空行', () => { - const out = '\n*' + NUL + 'aaa' + NUL + '' + NUL + 's\n\n'; - expect(parseGraphLog(out)).toHaveLength(1); - }); - - it('subject 含特殊字符(| / 等)原样保留', () => { - const out = '*' + NUL + 'aaa' + NUL + '' + NUL + 'fix: a | b / c'; - expect(parseGraphLog(out)[0].subject).toBe('fix: a | b / c'); - }); -}); - -describe('normalizeGraphWidth', () => { - it('右填充到最大 graph 长度,保证列对齐', () => { - const rows = parseGraphLog(['*' + NUL + 'a' + NUL + '' + NUL + 's', '| *' + NUL + 'b' + NUL + '' + NUL + 's'].join('\n')); - const padded = normalizeGraphWidth(rows); - expect(padded[0]).toBe('* '); // 长度 3(max=3) - expect(padded[1]).toBe('| *'); - expect(padded.every((g) => g.length === 3)).toBe(true); - }); -}); - -describe('classifyGraphChar', () => { - it('识别各类 graph 字符', () => { - expect(classifyGraphChar('*')).toBe('node'); - expect(classifyGraphChar('|')).toBe('vert'); - expect(classifyGraphChar('/')).toBe('slash'); - expect(classifyGraphChar('\\')).toBe('backslash'); - expect(classifyGraphChar('_')).toBe('underscore'); - expect(classifyGraphChar(' ')).toBe('blank'); - expect(classifyGraphChar('x')).toBe('blank'); - }); -}); From 1481a7c97faf6e7b17cd2338fa1b6b3ad556dc4b Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Tue, 30 Jun 2026 00:18:29 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(Log):=20=E7=A7=BB=E9=99=A4=E9=9B=86?= =?UTF-8?q?=E6=88=90=E6=B5=8B=E8=AF=95=E4=B8=AD=E5=AF=B9=E5=B7=B2=E5=88=A0?= =?UTF-8?q?=E9=99=A4=20showGraph=20=E5=91=BD=E4=BB=A4=E7=9A=84=E6=96=AD?= =?UTF-8?q?=E8=A8=80;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit showGraph 独立面板在前序提交中按「单一事实源去重」移除(Log 视图本身已具备完整 Graph),集成冒烟测试仍断言该命令注册导致 CI 失败;同步移除断言,并已校验列表中其余 61 个命令全部注册。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- tests/suite/extension.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/suite/extension.test.js b/tests/suite/extension.test.js index 24d9307..0c7c807 100644 --- a/tests/suite/extension.test.js +++ b/tests/suite/extension.test.js @@ -52,7 +52,6 @@ suite('扩展冒烟测试', function () { 'hyperGit.ignorePath', 'hyperGit.compareBranches', 'hyperGit.rewordCommit', - 'hyperGit.showGraph', 'hyperGit.showConsole', 'hyperGit.partialStage', 'hyperGit.partialUnstage',