diff --git a/src/adapter/history-commands.ts b/src/adapter/history-commands.ts index b696fe3..883b7e1 100644 --- a/src/adapter/history-commands.ts +++ b/src/adapter/history-commands.ts @@ -15,6 +15,8 @@ import { diffPrunedRefs, formatBranchDeleteConfirm, partitionByMerged, truncateN import { diffShapeFromCode } from '../engine/diff/change-side'; import { resolveDiffSides } from './diff-sides'; import { gitErrorText } from '../engine/git-state/git-error'; +import type { AuthorTerm } from '../engine/log/log-filter'; +import { buildAuthorListArgs, parseShortlog, type AuthorStat } from '../engine/log/author-suggest'; /** 注册 Log/Branches/Blame/History/Tags 相关命令。 */ export function registerHistoryCommands( @@ -29,11 +31,7 @@ export function registerHistoryCommands( subs.push(vscode.commands.registerCommand('hyperGit.refreshBranches', () => branchesTree.refresh())); subs.push( - vscode.commands.registerCommand('hyperGit.logFilterAuthor', async () => { - const author = await vscode.window.showInputBox({ prompt: 'Filter by Author', placeHolder: 'e.g. John' }); - const f = logTree.getFilter(); - logTree.setFilter({ ...f, author: author && author.trim() ? author.trim() : undefined }); - }), + vscode.commands.registerCommand('hyperGit.logFilterAuthor', () => pickAuthors(service, logTree)), ); subs.push( @@ -736,6 +734,95 @@ export function registerHistoryCommands( return subs; } +/** + * 作者过滤选择器(仓库首个 createQuickPick 动态用法):候选来自 `git shortlog`(按当前视图 scope 取数、 + * 按提交数降序),QuickPick 内建模糊过滤实现「输入前缀 → 建议下拉」;canSelectMany 多选(git 对多条 + * `--author` 取 OR)。输入不等于任何作者姓名时头部动态插入「Use pattern」逃生口(原样正则透传, + * 保留旧 InputBox 的任意 pattern 能力);确认时空选 = 清除 author 维度,Esc = 不变。 + */ +async function pickAuthors(service: GitRepositoryService, logTree: LogFilterControl): Promise { + /** exact 项勾选键(同名异邮箱不串)。 */ + const key = (t: { name: string; email: string }) => `${t.name} <${t.email}>`; + const qp = vscode.window.createQuickPick(); + qp.canSelectMany = true; + qp.matchOnDescription = true; + qp.matchOnDetail = true; + qp.title = 'Filter by Author'; + qp.placeholder = 'Type to search authors; check to filter (empty selection clears)'; + // 先展示再取数:busy 遮盖等待;事件先于 await 注册,取数期间的键入即触发 rebuild。 + qp.busy = true; + qp.items = []; + qp.show(); + + // 勾选自维护:重建 items 会重置 selectedItems,须记录并在重建后回写。 + // 预勾选仅 exact 项(pattern 项随输入动态生成,无法预置);重建触发的程序化 selection + // 事件与用户点击不可区分,以时间窗抑制(程序化事件在赋值后即刻送达)。 + let checked = new Set(logTree.getFilter().authors?.filter((t) => t.kind === 'exact').map(key) ?? []); + let patternOn = false; + let suppressUntil = 0; + let authors: readonly AuthorStat[] = []; + const rebuild = (value: string): void => { + const v = value.trim(); + const patternItem: AuthorPickItem[] = + v && !authors.some((a) => a.name === v) + ? [{ label: `Use pattern "${v}"`, alwaysShow: true, description: 'regular expression (OR-ed)', term: { kind: 'pattern', pattern: v } }] + : []; + suppressUntil = Date.now() + 100; + qp.items = [...patternItem, ...baseItems(authors)]; + qp.selectedItems = qp.items.filter((i) => (i.term.kind === 'exact' ? checked.has(key(i.term)) : patternOn)); + }; + qp.onDidChangeValue(rebuild); + qp.onDidChangeSelection((sel) => { + if (Date.now() < suppressUntil) { + return; + } + patternOn = sel.some((i) => i.term.kind === 'pattern'); + checked = new Set(sel.flatMap((i) => (i.term.kind === 'exact' ? [key(i.term)] : []))); + }); + + // settle 幂等守卫:accept 后 onDidHide 仍会触发;undefined = 取消不改,[] = 清除 author 维度。 + let settled = false; + const settle = (terms: readonly AuthorTerm[] | undefined): void => { + if (settled) { + return; + } + settled = true; + qp.dispose(); + if (terms) { + logTree.setFilter({ ...logTree.getFilter(), authors: terms.length ? terms : undefined }); + } + }; + qp.onDidAccept(() => settle(qp.selectedItems.map((i) => i.term))); + qp.onDidHide(() => settle(undefined)); + + // 候选按当前视图 scope 一次取数(非逐键);切库竞态守卫沿用 rootAtStart 范式; + // 空仓库 / unborn HEAD / 查询失败 → 空列表降级(仅 pattern 逃生口可用)。 + const rootAtStart = service.repoRoot; + authors = parseShortlog(await service.execGit(['shortlog', ...buildAuthorListArgs(logTree.getScope())]).catch(() => '')); + if (service.repoRoot !== rootAtStart) { + qp.dispose(); + return; + } + // 取数完成以 qp.value(而非 '')重建:纳入等待期间的键入。 + rebuild(qp.value); + qp.busy = false; +} + +/** shortlog 统计 → QuickPick 项(label=姓名、description=提交数、detail=email 供模糊匹配)。 */ +function baseItems(authors: readonly AuthorStat[]): AuthorPickItem[] { + return authors.map((a) => ({ + label: a.name, + description: a.count === 1 ? '1 commit' : `${a.count} commits`, + detail: a.email, + term: { kind: 'exact', name: a.name, email: a.email }, + })); +} + +/** 作者候选 QuickPick 项(label=姓名、description=提交数、detail=email,term 为过滤写回项)。 */ +interface AuthorPickItem extends vscode.QuickPickItem { + readonly term: AuthorTerm; +} + /** * 列出当前 `refs/remotes/*` 短名(如 `origin/master`),供 prune 前后做差集。 * 解析 `git for-each-ref --format=%(refname:short) refs/remotes`:逐行去空白、去空行。 diff --git a/src/adapter/webview/log-webview.ts b/src/adapter/webview/log-webview.ts index b11b020..929977f 100644 --- a/src/adapter/webview/log-webview.ts +++ b/src/adapter/webview/log-webview.ts @@ -72,6 +72,8 @@ export interface LogFilterControl extends vscode.Disposable { setFilter(filter: LogFilter): void; clearFilter(): void; getFilter(): LogFilter; + /** 当前视图 scope(作者候选等按视图范围取数的消费方使用;视图状态,不入 LogFilter)。 */ + getScope(): LogScope; refresh(): void; } @@ -186,6 +188,10 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter return this.filter; } + getScope(): LogScope { + return this.scope; + } + refresh(): void { clearTimeout(this.refreshTimer); this.refreshTimer = setTimeout(() => { diff --git a/src/engine/log/author-suggest.ts b/src/engine/log/author-suggest.ts new file mode 100644 index 0000000..0cf7a70 --- /dev/null +++ b/src/engine/log/author-suggest.ts @@ -0,0 +1,35 @@ +/** + * 作者候选查询(Filter by Author 多选):`git shortlog` 取数参数 + 输出解析(纯逻辑,零 vscode 依赖)。 + * + * shortlog 在 git 侧完成聚合计数与降序排序(巨库 stdout 仅数百行,优于全量 `log --format` 逐行回传), + * engine 仅保留一个容错解析函数;引用范围复用 {@link scopeRefs} 与 Graph 视图一致(单一事实源)。 + */ + +import { scopeRefs, type LogScope } from './log-query'; + +/** 作者统计(shortlog 一行:身份 + 提交数)。 */ +export interface AuthorStat { + readonly name: string; + readonly email: string; + readonly count: number; +} + +/** shortlog 参数:`-s` 仅计数、`-n` 按数降序、`-e` 含 email;引用范围随 scope。 + * `current` 的 refs 为空时必须补显式 `HEAD`——shortlog 无 rev 时非 tty 下会等 stdin 而挂起。 */ +export function buildAuthorListArgs(scope: LogScope): string[] { + const refs = scopeRefs(scope); + return ['-s', '-n', '-e', ...(refs.length > 0 ? refs : ['HEAD'])]; +} + +/** 解析 `git shortlog -se` 输出行(`\t `);坏行/空输入容错跳过。 + * git ident 禁止 name/email 含 `<` `>` 或换行,`` 段无切分歧义。 */ +export function parseShortlog(output: string): AuthorStat[] { + const stats: AuthorStat[] = []; + for (const line of output.split('\n')) { + const m = line.match(/^\s*(\d+)\t(.*) <([^<>]*)>\s*$/); + if (m) { + stats.push({ count: Number(m[1]), name: m[2], email: m[3] }); + } + } + return stats; +} diff --git a/src/engine/log/log-filter.ts b/src/engine/log/log-filter.ts index 5d6456d..9af9420 100644 --- a/src/engine/log/log-filter.ts +++ b/src/engine/log/log-filter.ts @@ -1,7 +1,7 @@ /** * Log 客户端过滤器(纯逻辑,零 vscode 依赖)。 * - * author / path / grep 交给 `git log`(repo.log)服务端过滤;以下维度 git log 稳定 API 不支持或 + * authors / path / grep 交给 `git log`(repo.log)服务端过滤;以下维度 git log 稳定 API 不支持或 * 表达不便,改在客户端对已取回的提交做过滤:合并模式(parents)/ 日期范围(authorDate)/ message 正则。 * engine 不依赖 vscode,故以 {@link FilterableCommit} 抽象 commit 所需字段,由 adapter 映射。 */ @@ -25,11 +25,35 @@ export interface LogClientFilter { } /** - * Log 完整过滤器(单一事实源,engine 层):author/path/grep 交 git log 服务端; + * 作者过滤项(OR 语义,git 对多条 `--author` 取并集,可与 pattern 混选): + * - `exact`:从仓库作者列表选中的身份,经 {@link exactAuthorPattern} 锚定字面匹配(同名异邮箱不混淆); + * - `pattern`:手输正则原样透传(保留旧 InputBox 的任意 pattern 能力)。 + */ +export type AuthorTerm = + | { readonly kind: 'exact'; readonly name: string; readonly email: string } + | { readonly kind: 'pattern'; readonly pattern: string }; + +/** BRE 特殊字符集:git `--author` 缺省 POSIX BRE(非 ERE),仅 `\ . [ * ^ $` 有特殊义; + * `+ ? | ( ) {` 在 BRE 中是普通字符,加反斜杠反成量词/分组操作符(实测 `\+` 使含 `+` 的邮箱归零命中),勿扩大转义集。 */ +const BRE_SPECIAL = /[.*[\]^$\\]/g; + +/** 按 BRE 语义转义字面串(`+ ? |` 等保持裸写,见 {@link BRE_SPECIAL})。 */ +export function escapeBreLiteral(s: string): string { + return s.replace(BRE_SPECIAL, '\\$&'); +} + +/** exact 身份 → 锚定字面 pattern:`--author` 对 `Name ` 整行匹配,首尾锚定 + 全字面转义。 */ +export function exactAuthorPattern(name: string, email: string): string { + return `^${escapeBreLiteral(`${name} <${email}>`)}$`; +} + +/** + * Log 完整过滤器(单一事实源,engine 层):authors/path/grep 交 git log 服务端; * mergeMode/dateFrom/dateTo/messageRegex 客户端(见 {@link toClientFilter})。 */ export interface LogFilter { - readonly author?: string; + /** 作者过滤项集合(多选 OR),见 {@link AuthorTerm};空/缺省即不过滤。 */ + readonly authors?: readonly AuthorTerm[]; readonly path?: string; readonly grep?: string; readonly mergeMode?: MergeMode; diff --git a/src/engine/log/log-query.ts b/src/engine/log/log-query.ts index 6965532..abd3afe 100644 --- a/src/engine/log/log-query.ts +++ b/src/engine/log/log-query.ts @@ -9,7 +9,7 @@ * (`row.authorDate`)对齐,肉眼所见与排列顺序永远一致。pathspec `-- ` 必须置于末尾。 */ -import type { LogFilter } from './log-filter'; +import { exactAuthorPattern, type LogFilter } from './log-filter'; import { LOG_GRAPH_FORMAT } from './log-line'; /** @@ -33,6 +33,21 @@ export interface LogQueryOptions { readonly skip?: number; } +/** scope → git 引用范围参数(单一事实源,{@link buildLogArgs} 与作者候选查询共享): + * `current` 为空数组 = git log 缺省 HEAD;shortlog 等调用方需自行补显式 HEAD。 */ +export function scopeRefs(scope: LogScope): string[] { + if (scope === 'checkpointer') { + // 原始完整视图:遍历 refs/ 全部引用,含 conductor 等工具的 checkpoint 快照与归档头。 + return ['--all']; + } + if (scope === 'all') { + // 干净视图:仅 heads/tags/remotes,排除 refs/conductor-* 等工具注入的内部引用, + // 以免其携带的游离/陈旧提交以游离泳道污染人类历史(`--all` 无法排除,prune 亦触不及)。 + return ['--branches', '--tags', '--remotes']; + } + return []; +} + /** * 构造 `git log` 参数向量(不含 `log` 字面量,host 拼接)。 * 顺序:`--author-date-order` → 范围 → 分页 → 服务端过滤 → `--format` → pathspec(末尾)。 @@ -40,21 +55,19 @@ export interface LogQueryOptions { export function buildLogArgs(filter: LogFilter | undefined, scope: LogScope, opts: LogQueryOptions): string[] { // 排序键须与视图日期列(row.authorDate)对齐;lane 算法仅依赖「子在父之上」不变量, // --author-date-order 与 --topo-order 均满足,且额外按作者日期倒序、跨支正确交错(详见文件头)。 - const args: string[] = ['--author-date-order']; - if (scope === 'checkpointer') { - // 原始完整视图:遍历 refs/ 全部引用,含 conductor 等工具的 checkpoint 快照与归档头。 - args.push('--all'); - } else if (scope === 'all') { - // 干净视图:仅 heads/tags/remotes,排除 refs/conductor-* 等工具注入的内部引用, - // 以免其携带的游离/陈旧提交以游离泳道污染人类历史(`--all` 无法排除,prune 亦触不及)。 - args.push('--branches', '--tags', '--remotes'); - } + const args: string[] = ['--author-date-order', ...scopeRefs(scope)]; 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()}`); + // authors → 多条 `--author=`(git 取 OR);去重防重复 flag,空 pattern 跳过(对齐「空串不产 flag」)。 + const seen = new Set(); + for (const term of filter?.authors ?? []) { + const v = term.kind === 'exact' ? exactAuthorPattern(term.name, term.email) : term.pattern.trim(); + if (v && !seen.has(v)) { + seen.add(v); + args.push(`--author=${v}`); + } } if (filter?.grep && filter.grep.trim()) { args.push(`--grep=${filter.grep.trim()}`); diff --git a/tests/unit/author-suggest.test.ts b/tests/unit/author-suggest.test.ts new file mode 100644 index 0000000..78e8baa --- /dev/null +++ b/tests/unit/author-suggest.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { buildAuthorListArgs, parseShortlog } from '../../src/engine/log/author-suggest'; +import { scopeRefs } from '../../src/engine/log/log-query'; +import { escapeBreLiteral, exactAuthorPattern } from '../../src/engine/log/log-filter'; + +describe('buildAuthorListArgs — shortlog 取数', () => { + it('三 scope 引用范围与 Graph 视图一致(scopeRefs 单一事实源)', () => { + expect(buildAuthorListArgs('all')).toEqual(['-s', '-n', '-e', '--branches', '--tags', '--remotes']); + expect(buildAuthorListArgs('checkpointer')).toEqual(['-s', '-n', '-e', '--all']); + // current 必须显式 HEAD:shortlog 无 rev 时非 tty 下等 stdin 会挂起 + expect(buildAuthorListArgs('current')).toEqual(['-s', '-n', '-e', 'HEAD']); + }); + + it('scopeRefs 提取后行为不变(重构护栏)', () => { + expect(scopeRefs('all')).toEqual(['--branches', '--tags', '--remotes']); + expect(scopeRefs('checkpointer')).toEqual(['--all']); + expect(scopeRefs('current')).toEqual([]); + }); +}); + +describe('parseShortlog — 容错解析', () => { + it('标准行(前导空格 + countName ),保留输出顺序(-n 已按数降序)', () => { + const out = [' 96\tAurelius Huang ', ' 3\tThreeFish '].join('\n'); + expect(parseShortlog(out)).toEqual([ + { name: 'Aurelius Huang', email: 'threefish.ai@gmail.com', count: 96 }, + { name: 'ThreeFish', email: 'a@b.com', count: 3 }, + ]); + }); + + it('本仓库真实输出形态(含正则元字符作者名与 + 号邮箱)', () => { + const out = ' 36\tdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>'; + expect(parseShortlog(out)).toEqual([{ name: 'dependabot[bot]', email: '49699333+dependabot[bot]@users.noreply.github.com', count: 36 }]); + }); + + it('坏行跳过、空输入返回空数组(空仓库/降级路径)', () => { + expect(parseShortlog('')).toEqual([]); + expect(parseShortlog('\n')).toEqual([]); + expect(parseShortlog('garbage line without tab\n12\tno-email-here\n')).toEqual([]); + }); +}); + +describe('BRE 转义 — git --author 缺省 POSIX BRE(非 ERE)', () => { + it('仅转义 \\ . [ * ^ $;+ ? | ( ) { 裸写(加反斜杠反成量词/分组操作符)', () => { + expect(escapeBreLiteral('a.b[c]*^$\\d+?|({}')).toBe('a\\.b\\[c\\]\\*\\^\\$\\\\d+?|({}'); + expect(escapeBreLiteral('plain')).toBe('plain'); + }); + + it('exact 身份 → 锚定全行字面 pattern(--author 匹配 Name 整行)', () => { + expect(exactAuthorPattern('Jane', 'jane@x.com')).toBe('^Jane $'); + expect(exactAuthorPattern('dependabot[bot]', '49699333+nx@users.noreply.github.com')).toBe( + '^dependabot\\[bot\\] <49699333+nx@users\\.noreply\\.github\\.com>$', + ); + }); +}); diff --git a/tests/unit/log-query.test.ts b/tests/unit/log-query.test.ts index 9627896..42f55fb 100644 --- a/tests/unit/log-query.test.ts +++ b/tests/unit/log-query.test.ts @@ -36,17 +36,60 @@ describe('buildLogArgs — 顺序与必选项', () => { }); 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('exact 作者翻译为锚定字面 pattern(首尾 ^$ + 全字面转义,同名异邮箱不混淆)', () => { + const args = buildLogArgs({ authors: [{ kind: 'exact', name: 'Jane', email: 'jane@x.com' }] }, 'all', { maxCount: 300 }); + expect(args).toContain('--author=^Jane $'); + }); + + it('多个 exact 作者产生多条 --author(git 取 OR)', () => { + const args = buildLogArgs( + { + authors: [ + { kind: 'exact', name: 'Jane', email: 'jane@x.com' }, + { kind: 'exact', name: 'Bob', email: 'bob@x.com' }, + ], + }, + 'all', + { maxCount: 300 }, + ); + const authorFlags = args.filter((a) => a.startsWith('--author=')); + expect(authorFlags).toHaveLength(2); + }); + + it('pattern 项原样透传(保留旧 InputBox 任意正则能力),与 exact 可混存且去重', () => { + const exact = { kind: 'exact', name: 'Jane', email: 'jane@x.com' } as const; + const args = buildLogArgs( + { authors: [{ kind: 'pattern', pattern: ' bot@example ' }, exact, exact] }, + 'all', + { maxCount: 300 }, + ); + const authorFlags = args.filter((a) => a.startsWith('--author=')); + expect(authorFlags).toContain('--author=bot@example'); + expect(authorFlags).toHaveLength(2); }); - it('空 author/grep 不产生 flag', () => { - const args = buildLogArgs({ author: ' ', grep: '' }, 'all', { maxCount: 300 }); + it('BRE 转义护栏:+ ? | 裸写(BRE 普通字符,\\+ 反成量词致归零命中),. [ ] \\ 转义', () => { + const args = buildLogArgs( + { authors: [{ kind: 'exact', name: 'dependabot[bot]', email: '49699333+nx@users.noreply.github.com' }] }, + 'all', + { maxCount: 300 }, + ); + const flag = args.find((a) => a.startsWith('--author=')); + expect(flag).toBe('--author=^dependabot\\[bot\\] <49699333+nx@users\\.noreply\\.github\\.com>$'); + }); + + it('空 pattern / 空数组 / 全空白不产生 flag', () => { // 用 `--author=` / `--grep=` 精确前缀,避免与既有的 `--author-date-order` 排序 flag 误判 - expect(args.some((a) => a.startsWith('--author='))).toBe(false); - expect(args.some((a) => a.startsWith('--grep='))).toBe(false); + expect(buildLogArgs({ authors: [] }, 'all', { maxCount: 300 }).some((a) => a.startsWith('--author='))).toBe(false); + expect( + buildLogArgs({ authors: [{ kind: 'pattern', pattern: ' ' }] }, 'all', { maxCount: 300 }).some((a) => a.startsWith('--author=')), + ).toBe(false); + expect(buildLogArgs({ grep: '' }, 'all', { maxCount: 300 }).some((a) => a.startsWith('--grep='))).toBe(false); + }); + + it('grep 翻译为 flag(去空白)', () => { + const args = buildLogArgs({ grep: ' fix ' }, 'all', { maxCount: 300 }); + expect(args).toContain('--grep=fix'); }); it('path 以 pathspec 形式置于参数末尾(--format 之后)', () => {