-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlog-webview.ts
More file actions
1498 lines (1436 loc) · 82.7 KB
/
Copy pathlog-webview.ts
File metadata and controls
1498 lines (1436 loc) · 82.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import * as vscode from 'vscode';
import type { GitRepositoryService } from '../git-repository-service';
import { parseNameStatus, statusLabel, parseShortStat } 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 { getBaseStyles, GRAPH_ROW_H, GRAPH_LANE_W, ICON_CHEVRON_DOWN, ICON_CLOSE } from './shared-styles';
import { getNonce } from './nonce';
import { parseLogLines } from '../../engine/log/log-line';
import { buildLogArgs, type LogScope } from '../../engine/log/log-query';
import { buildFileTree } from '../../engine/tree/file-tree';
import { formatRelative, formatAbsolute } from '../../engine/log/format-time';
import { commitWebUrl } from '../../engine/ci/remote-parser';
import type { GitHubCiService } from '../ci/github-ci-service';
import type {
CiMetaVM,
CiStatusVM,
CommitDetailVM,
GraphRowVM,
LogCommitFileItem,
LogGraphState,
LogHostToWebviewMessage,
LogWebviewToHostMessage,
RefChip,
} from '../../shared/protocol';
const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e));
/** 悬浮详情正文上限:截断以控 1000 行 payload(超限加省略号,正文另有滚动区兜底展示)。 */
const BODY_CAP = 2000;
function capBody(b: string): string {
const t = (b ?? '').replace(/\s+$/, '');
return t.length > BODY_CAP ? `${Array.from(t).slice(0, BODY_CAP).join('')}…` : t;
}
/** 单页拉取的 commit 数(滚动触底增量加载下一页)。 */
const PAGE = 1000;
/** per-commit 操作 → 既有命令 id(webview 右键菜单 → host 重调用,handler 仅需 hash)。 */
const COMMIT_MENU: ReadonlyArray<{ readonly label: string; readonly command: string }> = [
{ label: 'Copy Hash', command: 'hyperGit.copyCommitHash' },
{ label: 'Cherry-Pick Commit', command: 'hyperGit.cherryPick' },
{ label: 'Revert Commit', command: 'hyperGit.revertCommit' },
{ label: 'Drop Commit', command: 'hyperGit.dropCommit' },
{ label: 'Fixup Commit', command: 'hyperGit.fixupCommit' },
{ label: 'Create Branch from Commit…', command: 'hyperGit.createBranchFromCommit' },
{ label: 'Create Tag from Commit…', command: 'hyperGit.createTagFromCommit' },
{ label: 'Show Branches Containing Commit', command: 'hyperGit.showContainingBranches' },
{ label: 'Reset Current Branch to Here…', 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):可视化提交图(DAG)。
*
* 自计算 lane 布局(engine/log/graph-layout)→ 渲染彩色泳道;host 侧单次 `git log --author-date-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';
/** 变更文件展示模式(List/Tree,标题栏图标切换):host 为事实源,随 graphData / log/detailMode 下发。 */
private detailMode: 'flat' | 'tree' = 'flat';
private refreshTimer: ReturnType<typeof setTimeout> | undefined;
private readonly disposables: vscode.Disposable[] = [];
/** scope / dmode 按仓库持久化 key(issue #107 同 branchesGrouping 范式:workspaceState 换 key 重绑)。 */
private static scopeKey(root: string): string {
return `hyperGit.log.scope:${root}`;
}
private static dmodeKey(root: string): string {
return `hyperGit.log.dmode:${root}`;
}
/** context key 同步(标题栏按钮显隐 / 勾选态由 when 子句驱动)。 */
private static setCtx(key: string, value: string | boolean): void {
void vscode.commands.executeCommand('setContext', key, value);
}
constructor(
private readonly service: GitRepositoryService,
private readonly ciService: GitHubCiService,
private readonly workspaceState: vscode.Memento,
) {
// 兜底实时刷新:git 状态变化(commit/checkout 等)防抖重拉首页。
let t: ReturnType<typeof setTimeout> | undefined;
this.disposables.push(
this.service.onDidChange(() => {
clearTimeout(t);
t = setTimeout(() => this.refresh(), 400);
}),
);
// 激活即恢复当前仓库的 scope/dmode 记忆并同步 context key(标题栏控件先于视图解析渲染)。
this.loadRepoScopedPrefs();
// 活跃仓库切换(issue #107):host 级过滤条件是旧仓库语境的产物,跨仓库无意义 → 清空;
// scope/dmode 恢复该仓库的记忆值(无记忆回退 'all'/'flat');图数据重拉由上方 onDidChange 订阅驱动。
this.disposables.push(
service.onDidChangeRepository(() => {
this.filter = {};
this.loadRepoScopedPrefs();
}),
);
}
/** 装载当前仓库的 scope / dmode 偏好(memento → 内存 + context key)。 */
private loadRepoScopedPrefs(): void {
const root = this.service.repoRoot;
this.scope = (root ? this.workspaceState.get<LogScope>(LogWebviewProvider.scopeKey(root)) : undefined) ?? 'all';
this.detailMode = (root ? this.workspaceState.get<'flat' | 'tree'>(LogWebviewProvider.dmodeKey(root)) : undefined) ?? 'flat';
LogWebviewProvider.setCtx('hyperGit.log.scope', this.scope);
LogWebviewProvider.setCtx('hyperGit.log.tree', this.detailMode === 'tree');
}
/** 标题栏 Scope 子菜单选中项(hyperGit.log.scopeAll/Current/Checkpointer 命令入口)。 */
setScope(scope: LogScope): void {
const root = this.service.repoRoot;
if (root) {
void this.workspaceState.update(LogWebviewProvider.scopeKey(root), scope);
}
this.scope = scope;
LogWebviewProvider.setCtx('hyperGit.log.scope', scope);
this.refresh();
}
/** 标题栏 List/Tree 互斥图标切换(同 Branches 平铺/分组范式):免整图重拉,定向下发渲染模式。 */
setDetailMode(mode: 'flat' | 'tree'): void {
if (this.detailMode === mode) {
return;
}
const root = this.service.repoRoot;
if (root) {
void this.workspaceState.update(LogWebviewProvider.dmodeKey(root), mode);
}
this.detailMode = mode;
LogWebviewProvider.setCtx('hyperGit.log.tree', mode === 'tree');
this.post({ type: 'log/detailMode', payload: { mode } });
}
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/retry':
void this.pushState();
break;
case 'log/loadMore':
void this.loadMore(msg.payload.cursor);
break;
case 'log/selectCommit':
// 选中即开右侧详情面板:变更文件 + 提交详情两路并行取数(webview 按 selectedHash 丢弃过期回包)。
void this.sendCommitFiles(msg.payload.hash);
void this.showCommitDetail(msg.payload.hash);
break;
case 'log/openFile':
void vscode.commands.executeCommand(
'hyperGit.openCommitFileDiff',
msg.payload.hash,
msg.payload.path,
msg.payload.status,
msg.payload.oldPath,
);
break;
case 'log/commitAction':
if (msg.payload.op === 'menu') {
void this.handleCommitMenu(msg.payload.hash);
}
break;
case 'log/requestCi':
void this.handleRequestCi(msg.payload.hashes);
break;
case 'log/openExternal':
void this.ciService.openExternal(msg.payload.url);
break;
}
}
/** 组装提交详情 VM(基础字段 + 预格式化时间 + 变更统计 + GitHub URL),下发给 webview 右侧详情面板(#commit-meta)渲染。 */
private async showCommitDetail(hash: string): Promise<void> {
if (!this.service.repo) {
this.post({ type: 'log/commitDetail', payload: { forHash: hash, vm: null } });
return;
}
// 切库竞态守卫(issue #107):hash 属旧仓库语境,迟到响应不作数(可能取到同名歧义提交)。
const rootAtStart = this.service.repoRoot;
try {
// %x00 分隔,与 LOG_GRAPH_FORMAT 同范式;单条 git show 开销极小。
const fmt = '%H%x00%s%x00%b%x00%an%x00%ae%x00%aI%x00%cn%x00%cI%x00%P';
const raw = await this.service.execGit(['show', '-s', `--format=${fmt}`, hash]);
if (this.service.repoRoot !== rootAtStart) {
this.post({ type: 'log/commitDetail', payload: { forHash: hash, vm: null } });
return;
}
const f = raw.split('\0');
if (f.length < 9 || !f[0]) {
this.post({ type: 'log/commitDetail', payload: { forHash: hash, vm: null } });
return;
}
const [fullHash, subject, body, authorName, authorEmail, authorDate, committerName, committerDate, parentsRaw] = f;
const stat = parseShortStat(
await this.service.execGit(['diff-tree', '--no-commit-id', '--shortstat', '-r', '--root', hash]),
);
if (this.service.repoRoot !== rootAtStart) {
this.post({ type: 'log/commitDetail', payload: { forHash: hash, vm: null } });
return;
}
const remote = this.ciService.getGitHubRemote();
const cappedBody = body.length > 4000 ? `${body.slice(0, 4000)}…` : body.replace(/\s+$/, '');
const vm: CommitDetailVM = {
hash: fullHash,
shortHash: fullHash.slice(0, 7),
subject,
body: cappedBody,
authorName,
authorEmail,
authorDate,
authorDateRel: formatRelative(authorDate),
authorDateAbs: formatAbsolute(authorDate),
committerName,
committerDate,
committerDateRel: formatRelative(committerDate),
committerDateAbs: formatAbsolute(committerDate),
parents: parentsRaw ? parentsRaw.trim().split(/\s+/).filter(Boolean) : [],
stat,
githubUrl: remote ? commitWebUrl(remote, fullHash) : undefined,
};
this.post({ type: 'log/commitDetail', payload: { forHash: hash, vm } });
} catch {
this.post({ type: 'log/commitDetail', payload: { forHash: hash, vm: null } });
}
}
private post(message: LogHostToWebviewMessage): void {
this.view?.webview.postMessage(message);
}
// ─── 数据拉取 ───────────────────────────────────────────────────────────────
private async pushState(): Promise<void> {
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,
dmode: this.detailMode,
repoRoot: this.service.repoRoot ?? '',
multiRepo: this.service.listRepositories().length > 1,
};
// 标题栏副标题 = 仓库路径(原 webview 工具栏文本上移,省一行竖直空间);
// 多仓库态同步 context key 以显隐「切换仓库」图标按钮。
this.view.description = this.service.repoRoot ?? undefined;
LogWebviewProvider.setCtx('hyperGit.log.multiRepo', !!state.multiRepo);
this.post({ type: 'log/graphData', payload: state });
// CI 元信息异步随附(不阻塞建图):远程为 GitHub 才启用,未授权则提示登录。
void this.pushCiMeta();
}
/** 推送 CI 能力/授权态(status() 廉价:复用缓存会话)。失败静默回退为不可用。 */
private async pushCiMeta(): Promise<void> {
if (!this.view) {
return;
}
let meta: CiMetaVM;
try {
const s = await this.ciService.status();
meta = { available: s.available, needsSignIn: s.needsAuth, error: s.error };
} catch {
meta = { available: false, needsSignIn: false };
}
// 登录入口迁至标题栏图标按钮(when: hyperGit.log.ciNeedsSignIn),授权完成即自动隐藏。
LogWebviewProvider.setCtx('hyperGit.log.ciNeedsSignIn', meta.needsSignIn);
if (this.view) {
this.post({ type: 'log/ciMeta', payload: meta });
}
}
/** 懒加载可见行 CI(webview 滚动按需请求),取数后守卫 view 仍存在再回填。 */
private async handleRequestCi(hashes: readonly string[]): Promise<void> {
if (hashes.length === 0) {
return;
}
const map = await this.ciService.getStatuses(hashes);
if (!this.view || map.size === 0) {
return;
}
const rec: Record<string, CiStatusVM> = {};
for (const [hash, vm] of map) {
rec[hash] = vm;
}
this.post({ type: 'log/ciData', payload: { map: rec } });
}
private async loadMore(cursor: number): Promise<void> {
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<GraphPage | undefined> {
const repo = this.service.repo;
if (!repo) {
return undefined;
}
// 切库竞态守卫(issue #107):锁定发起时刻的仓库,execGit 期间发生切换则结果作废——
// 旧仓库的迟到响应(graphData/appendData)不得污染新仓库的图(泳道布局按行集计算)。
const rootAtStart = this.service.repoRoot;
try {
const out = await this.service.execGit(['log', ...buildLogArgs(this.filter, this.scope, { maxCount: PAGE, skip })]);
if (this.service.repoRoot !== rootAtStart) {
return undefined; // 切库后的 reset graphData 由 onDidChange 驱动的 refresh 下发
}
const raws = parseLogLines(out);
if (raws.length === 0) {
return { rows: [], maxLanes: 0, hasMore: false };
}
// 客户端过滤(mergeMode / date / regex / checkpoint),message 近似取 subject。
// keepCheckpoint 由 scope 驱动:仅 Checkpointer 视图保留 checkpoint 自动提交,All/Current 剔除。
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), keepCheckpoint: this.scope === 'checkpointer' });
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);
if (this.service.repoRoot !== rootAtStart) {
return undefined; // 第二次 await(for-each-ref)期间的切换同样作废
}
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,
authorEmail: s.raw.authorEmail,
authorDate: s.raw.authorDate,
committerName: s.raw.committerName,
committerDate: s.raw.committerDate,
body: capBody(s.raw.body),
chips: chips.get(s.raw.hash) ?? [],
layout: layout[i],
}));
return { rows, maxLanes: maxLanes(layout), hasMore: raws.length === PAGE };
} catch (e) {
// 失败时以 webview 内错误态呈现(带 Retry),而非模态弹窗——用户可即时重试。
this.post({ type: 'log/error', payload: { message: errMsg(e) } });
return undefined;
}
}
/** 取引用标签:for-each-ref(full hash 精确匹配)+ repo.state.HEAD 标注当前分支 / detached HEAD。 */
private async fetchChips(hashes: Set<string>): Promise<Map<string, RefChip[]>> {
const map = new Map<string, RefChip[]>();
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<RefChip['kind'], number> = { 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<string, RefChip[]>, 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<void> {
const repo = this.service.repo;
if (!repo) {
return;
}
// 切库竞态守卫(issue #107):迟到响应不回填(新仓库的选中/详情由切库后交互重新触发)。
const rootAtStart = this.service.repoRoot;
try {
// 复用 Log 既有逻辑:diff-tree 取变更文件。
const out = await this.service.execGit(['diff-tree', '--no-commit-id', '--name-status', '-r', '--root', hash]);
if (this.service.repoRoot !== rootAtStart) {
return;
}
const changes = parseNameStatus(out);
// path 取干净新路径(供 data-path/建树/端点定位);rename/copy 的 "old → new" 展示由 webview 端用 oldPath 拼出。
const files: LogCommitFileItem[] = changes.map((c) => ({
status: c.status,
statusLabel: statusLabel(c.status),
path: c.path,
oldPath: c.oldPath,
themeColor: fileIconColor(c.status),
}));
// 用干净新路径建目录树(重命名归位到新目录);叶子经 fileIndex 回指展示用 files[i]。
const tree = buildFileTree(changes.map((c) => c.path));
this.post({ type: 'log/commitFiles', payload: { hash, files, tree } });
} catch {
this.post({ type: 'log/commitFiles', payload: { hash, files: [], tree: [] } });
}
}
private async handleCommitMenu(hash: string): Promise<void> {
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: `Commit ${hash.slice(0, 7)}` });
if (!pick) {
return;
}
await vscode.commands.executeCommand(pick.command, nodeLike);
}
// ─── HTML 渲染 ──────────────────────────────────────────────────────────────
private renderHtml(): string {
const nonce = getNonce();
const laneFallback = JSON.stringify(DEFAULT_LANE_PALETTE);
const csp = ['default-src \'none\'', 'style-src \'unsafe-inline\'', `script-src 'nonce-${nonce}'`].join('; ');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<style>
${getBaseStyles()}
* { box-sizing: border-box; }
body { margin: 0; font-family: var(--vscode-font-family); font-size: var(--vscode-font-size); color: var(--vscode-foreground); background: var(--vscode-sideBar-background); display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
#viewport { flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; position: relative; outline: none; }
#spacer { position: relative; }
#rows { position: absolute; left: 0; right: 0; }
.row { display: flex; align-items: center; height: var(--hg-row); padding-right: 8px; cursor: pointer; white-space: nowrap; }
.row:hover { background: var(--vscode-list-hoverBackground); }
.row.selected { background: var(--vscode-list-activeSelectionBackground, var(--vscode-list-inactiveSelectionBackground)); }
.row svg.graph { flex: 0 0 auto; display: block; }
.row svg.graph .node { stroke: var(--vscode-sideBar-background); stroke-width: 1.5; }
.row svg.graph .node-dot { stroke: var(--vscode-sideBar-background); stroke-width: 1; }
.row.selected svg.graph .node { stroke: var(--vscode-focusBorder); stroke-width: 2.2; }
.row.selected svg.graph .node-ring { stroke: var(--vscode-focusBorder); stroke-width: 2; }
.subject { flex: 1 1 auto; min-width: 0; overflow: hidden; display: flex; align-items: center; gap: 6px; }
.msg { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.merge { flex: 0 0 auto; opacity: 0.6; font-size: 10px; padding: 0 2px; }
/* 引用胶囊:实心圆角 pill + 图标前缀,底色跟随本行泳道色(内联 style 注入),类型靠图标区分(对齐官方 GRAPH 视图)。 */
.chips { display: inline-flex; gap: 4px; flex: 0 0 auto; min-width: 0; overflow: hidden; }
.chip { display: inline-flex; align-items: center; gap: 3px; height: 16px; line-height: 16px; font-size: 10px; font-weight: 600; padding: 0 7px 0 6px; border-radius: 8px; white-space: nowrap; max-width: 160px; overflow: hidden; text-overflow: ellipsis; }
.chip .chip-ico { flex: 0 0 auto; width: 11px; height: 11px; display: inline-flex; }
.chip .chip-ico svg { width: 11px; height: 11px; display: block; }
.chip .chip-nm { overflow: hidden; text-overflow: ellipsis; }
.author { flex: 0 0 auto; font-size: calc(var(--vscode-font-size) - 2px); opacity: 0.7; max-width: 110px; overflow: hidden; text-overflow: ellipsis; padding-left: 8px; }
.date { flex: 0 0 auto; font-size: calc(var(--vscode-font-size) - 2px); opacity: 0.55; padding-left: 8px; }
#viewport.narrow .author, #viewport.narrow .date { display: none; }
/* ── 图 + 右侧详情面板水平分栏(panel 容器无法并排子视图 → webview 内自分栏)──
工具栏整体上移 VS Code 标题栏(scope 子菜单 / List-Tree 图标 / 仓库切换 / CI 登录),
仓库路径由 WebviewView.description 呈现 → 省一整行竖直空间。 */
#main { flex: 1 1 auto; display: flex; min-height: 0; }
#commit-panel { display: none; flex: 0 0 42%; min-width: 200px; flex-direction: column; overflow: hidden; }
#commit-panel.show { display: flex; }
/* 可拖拽分割线(gutter):图 ⇄ 面板(横向 col-resize / 堆叠态 row-resize)与面板内 上半区 ⇄ 下半区。
宽高比例经拖拽实时更新(flex-basis 内联),松手持久化(按仓库记忆)。 */
.gutter { flex: 0 0 4px; background: transparent; touch-action: none; }
#gutter-main { cursor: col-resize; }
#main:not(.panel-open) > #gutter-main { display: none; }
#gutter-meta { cursor: row-resize; }
.gutter:hover, .gutter.dragging { background: var(--vscode-focusBorder, rgba(128,128,128,.4)); }
.gutter:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: -1px; }
/* 分栏降级:#main 窄于 560px 时上下堆叠——面板 200px 硬底不可收缩,横向并排会把图区挤成零宽。 */
#main.stacked { flex-direction: column; }
#main.stacked #commit-panel { min-width: 0; }
#main.stacked #gutter-main { cursor: row-resize; }
#details { flex: 0 0 55%; min-height: 0; overflow-y: auto; }
#commit-meta { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
.panel-loading { padding: 10px 12px; font-size: calc(var(--vscode-font-size) - 1px); color: var(--vscode-descriptionForeground); }
#details .dh { position: sticky; top: 0; display: flex; align-items: center; gap: 6px; background: var(--vscode-sideBar-background); padding: 4px 8px; font-size: calc(var(--vscode-font-size) - 2px); color: var(--vscode-descriptionForeground); border-bottom: 1px solid var(--vscode-editorWidget-border, rgba(128,128,128,.15)); }
#details .dh #details-title { flex: 1 1 auto; }
.dh-close { flex: 0 0 auto; background: transparent; border: none; color: var(--vscode-descriptionForeground); cursor: pointer; font-size: 16px; line-height: 1; padding: 0 4px; border-radius: var(--hg-radius-control); }
.dh-close:hover { color: var(--vscode-foreground); background: var(--vscode-list-hoverBackground); }
.dh-close:focus-visible { outline: 2px solid var(--vscode-focusBorder); outline-offset: 1px; }
#details .file { display: flex; align-items: center; gap: 6px; padding: 2px 10px; font-size: calc(var(--vscode-font-size) - 1px); cursor: pointer; }
#details .file:hover { background: var(--vscode-list-hoverBackground); }
#details .file .dot { font-size: 13px; line-height: 1; }
#details .file .nm { overflow: hidden; text-overflow: ellipsis; }
#empty, #error { padding: 28px 16px; text-align: center; color: var(--vscode-descriptionForeground); font-size: calc(var(--vscode-font-size) - 1px); }
#empty .empty-icon { font-size: 30px; opacity: 0.4; margin-bottom: 8px; }
.empty-title { font-size: 13px; color: var(--vscode-foreground); margin-bottom: 3px; }
.empty-hint { font-size: calc(var(--vscode-font-size) - 2px); }
#spinner { position: absolute; bottom: 6px; right: 8px; font-size: calc(var(--vscode-font-size) - 2px); opacity: 0.6; display: none; }
/* ── CI 状态图标(提交行最右侧,固定 16px 槽位,保证 author/date 列对齐)── */
.ci { flex: 0 0 16px; width: 16px; display: inline-flex; align-items: center; justify-content: center; }
.ci svg { display: block; shape-rendering: geometricPrecision; pointer-events: none; }
.ci-success { color: var(--vscode-testing-iconPassed, #3fb950); }
.ci-failure { color: var(--vscode-testing-iconFailed, var(--vscode-errorForeground, #f85149)); }
.ci-pending { color: var(--vscode-testing-iconQueued, var(--vscode-editorWarning-foreground, #d29922)); }
.ci:not(.ci-empty):hover { filter: brightness(1.15); }
.ci:not(.ci-empty):focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: 1px; border-radius: 3px; }
/* narrow 模式隐藏 author/date,但 CI 图标例外保留(核心信号)。 */
#viewport.narrow .ci { display: inline-flex; }
@keyframes ci-rot { to { transform: rotate(360deg); } }
.ci-spin { transform-origin: 50% 50%; animation: ci-rot 1s linear infinite; }
@media (prefers-reduced-motion: reduce) { .ci-spin { animation: none; } }
/* ── CI Tooltip(自定义浮层,置于 #rows 之外,虚拟滚动重写不销毁)── */
#ci-tip { position: fixed; z-index: 50; display: none; max-width: 360px; min-width: 220px; max-height: 320px; overflow: hidden; background: var(--vscode-editorHoverWidget-background, var(--vscode-editorWidget-background)); color: var(--vscode-editorHoverWidget-foreground, var(--vscode-foreground)); border: 1px solid var(--vscode-editorHoverWidget-border, var(--vscode-editorWidget-border, rgba(128,128,128,.3))); border-radius: 4px; box-shadow: 0 2px 8px var(--vscode-widget-shadow, rgba(0,0,0,.35)); font-size: calc(var(--vscode-font-size) - 1px); }
#ci-tip.show { display: flex; flex-direction: column; }
#ci-tip .tip-h { padding: 7px 10px; font-weight: 600; border-bottom: 1px solid var(--vscode-editorHoverWidget-border, rgba(128,128,128,.2)); display: flex; align-items: center; gap: 6px; }
#ci-tip .tip-h .g { flex: 0 0 14px; display: inline-flex; }
#ci-tip .tip-list { overflow-y: auto; max-height: 240px; padding: 2px 0; }
#ci-tip .tip-row { display: flex; align-items: flex-start; gap: 7px; padding: 4px 10px; cursor: pointer; }
#ci-tip .tip-row:hover { background: var(--vscode-list-hoverBackground); }
#ci-tip .tip-row .g { flex: 0 0 14px; display: inline-flex; margin-top: 1px; }
#ci-tip .tip-row .nm { flex: 1 1 auto; min-width: 0; overflow: hidden; }
#ci-tip .tip-row .nm .desc { display: block; font-size: calc(var(--vscode-font-size) - 2px); opacity: 0.7; white-space: normal; word-break: break-word; margin-top: 1px; }
#ci-tip .tip-foot { padding: 6px 10px; border-top: 1px solid var(--vscode-editorHoverWidget-border, rgba(128,128,128,.2)); }
#ci-tip .tip-foot a { color: var(--vscode-textLink-foreground); cursor: pointer; text-decoration: none; }
#ci-tip .tip-foot a:hover { text-decoration: underline; }
#ci-tip .g-success { color: var(--vscode-testing-iconPassed, #3fb950); }
#ci-tip .g-failure { color: var(--vscode-testing-iconFailed, var(--vscode-errorForeground, #f85149)); }
#ci-tip .g-pending { color: var(--vscode-testing-iconQueued, var(--vscode-editorWarning-foreground, #d29922)); }
#ci-tip .g-skipped, #ci-tip .g-unknown { color: var(--vscode-descriptionForeground); }
/* ── 提交详情面板下半区(#commit-meta,复用 .ct-* 视觉语言;editorHoverWidget 语义令牌与 CI 浮层同源)── */
#commit-meta .ct-scroll { padding: 12px 14px; }
#commit-meta .ct-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
#commit-meta .ct-avatar { flex: 0 0 auto; width: 26px; height: 26px; border-radius: 50%; background: var(--vscode-badge-background, rgba(128,128,128,.25)); color: var(--vscode-badge-foreground, var(--vscode-foreground)); display: inline-flex; align-items: center; justify-content: center; }
#commit-meta .ct-avatar svg { width: 16px; height: 16px; opacity: 0.85; }
#commit-meta .ct-who { display: flex; flex-direction: column; min-width: 0; }
#commit-meta .ct-author { font-weight: 600; font-size: 13px; }
#commit-meta .ct-time { font-size: calc(var(--vscode-font-size) - 2px); color: var(--vscode-descriptionForeground); margin-top: 1px; }
#commit-meta .ct-msg { margin-bottom: 10px; }
#commit-meta .ct-subj { font-size: 13px; font-weight: 600; line-height: 1.4; word-break: break-word; }
#commit-meta .ct-body { margin-top: 6px; white-space: pre-wrap; word-break: break-word; font-family: var(--vscode-editor-font-family, var(--vscode-font-family)); font-size: calc(var(--vscode-font-size) - 1px); line-height: 1.5; opacity: 0.9; }
#commit-meta .ct-refs-wrap { margin-bottom: 10px; display: flex; flex-direction: column; gap: 5px; }
#commit-meta .ct-sec { display: flex; gap: 8px; align-items: baseline; font-size: calc(var(--vscode-font-size) - 1px); }
#commit-meta .ct-sec .ct-k { flex: 0 0 66px; color: var(--vscode-descriptionForeground); font-size: 10px; text-transform: uppercase; letter-spacing: .3px; }
#commit-meta .ct-sec .ct-v { flex: 1 1 auto; min-width: 0; word-break: break-word; }
#commit-meta .ct-refs { display: flex; flex-wrap: wrap; gap: 4px; }
/* 面板内引用胶囊完整显示(覆盖行内 .chip 的 max-width/省略号截断):换行不截断,空间由面板承载。 */
#commit-meta .chip { max-width: none; }
/* 高对比度主题:避免大面积彩色填充——chip 改 contrastBorder 描边 + 主题前景色(!important 覆盖行内样式)。 */
body[data-vscode-theme-kind~='high-contrast'] .chip {
color: var(--vscode-foreground) !important;
background: transparent !important;
border: 1px solid var(--vscode-contrastBorder);
}
#commit-meta .chip .chip-nm { overflow: visible; text-overflow: clip; white-space: normal; word-break: break-all; }
#commit-meta .ct-dim { color: var(--vscode-descriptionForeground); }
#commit-meta .ct-stat { display: flex; gap: 12px; padding: 8px 0; border-top: 1px solid var(--vscode-editorHoverWidget-border, rgba(128,128,128,.2)); border-bottom: 1px solid var(--vscode-editorHoverWidget-border, rgba(128,128,128,.2)); font-size: 12px; font-variant-numeric: tabular-nums; }
#commit-meta .ct-stat .files { color: var(--vscode-descriptionForeground); }
#commit-meta .ct-stat .ins { color: var(--vscode-gitDecoration-addedResourceForeground, #3fb950); }
#commit-meta .ct-stat .del { color: var(--vscode-gitDecoration-deletedResourceForeground, #f14c4c); }
#commit-meta .ct-foot { display: flex; align-items: center; gap: 14px; margin-top: 10px; flex-wrap: wrap; }
#commit-meta .ct-sha { font-family: var(--vscode-editor-font-family, var(--vscode-font-family)); font-size: calc(var(--vscode-font-size) - 2px); color: var(--vscode-descriptionForeground); word-break: break-all; }
#commit-meta .ct-gh { color: var(--vscode-textLink-foreground); cursor: pointer; font-size: calc(var(--vscode-font-size) - 1px); display: inline-flex; align-items: center; gap: 4px; }
#commit-meta .ct-gh:hover { text-decoration: underline; }
#commit-meta .ct-gh:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: 2px; border-radius: 2px; }
#commit-meta .ct-gh svg { width: 13px; height: 13px; }
/* ── 变更文件目录树(详情面板 Group By Directory 形态)── */
#details .tree-dir { display: flex; align-items: center; gap: 6px; padding: 2px 10px; font-size: 12px; cursor: pointer; user-select: none; }
#details .tree-dir:hover { background: var(--vscode-list-hoverBackground); }
#details .tree-dir .tree-twist { flex: 0 0 14px; display: inline-flex; align-items: center; justify-content: center; opacity: 0.8; }
#details .tree-dir .tree-twist svg { display: block; }
#details .tree-dir .tree-twist.collapsed svg { transform: rotate(-90deg); }
#details .tree-dir .tree-name { color: var(--vscode-descriptionForeground); overflow: hidden; text-overflow: ellipsis; }
</style>
</head>
<body>
<div id="main">
<div id="viewport" tabindex="0" role="tree" aria-label="Commit graph">
<div id="spacer"><div id="rows"></div></div>
<div id="empty"><div class="empty-icon" aria-hidden="true"><svg width="28" height="28" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"><circle cx="8" cy="8" r="2.8"/><path d="M8 1.5v3.7M8 10.8v3.7"/></svg></div><div class="empty-title">No Commits</div><div class="empty-hint">No commits match the current scope or filter.</div></div>
<div id="error" style="display:none"><div class="empty-title">Failed to Load Commits</div><div class="empty-hint" id="error-msg"></div><button class="hg-btn hg-btn--sm" id="retry-btn" style="margin-top:8px">Retry</button></div>
<div id="spinner">Loading…</div>
</div>
<div class="gutter" id="gutter-main" role="separator" aria-orientation="vertical" tabindex="0" aria-label="Resize commit panel"></div>
<aside id="commit-panel" role="region" aria-label="Commit details">
<section id="details" role="group" aria-label="Changed files"><div class="dh" id="details-head"><span id="details-title"></span><button class="dh-close" id="details-close" title="Deselect commit" aria-label="Deselect commit">${ICON_CLOSE}</button></div><div id="details-list"></div></section>
<div class="gutter" id="gutter-meta" role="separator" aria-orientation="horizontal" tabindex="0" aria-label="Resize changed files section"></div>
<section id="commit-meta" role="group" aria-label="Commit information"></section>
</aside>
</div>
<div id="ci-tip" role="dialog" aria-label="CI check details"></div>
<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
const LANE_FALLBACK = ${laneFallback};
const ICON_CHEVRON = ${JSON.stringify(ICON_CHEVRON_DOWN)};
// 泳道色解析:优先主题 --vscode-charts-* 令牌(深/浅主题自适应),缺失或与其它 lane 撞色时
// 回落 DEFAULT_LANE_PALETTE 原始 distinct hex,保底相邻 lane 可区分(对齐 graph-color 设计注释)。
// 主题热切换监听:webview 不随换主题重载,CSS 变量热更但 JS 快照不会——MutationObserver 观察
// body 属性(VS Code 换主题时更新 class/data-vscode-theme-*),rAF 节流重算并全量重渲可见行。
function computePalette() {
const cs = getComputedStyle(document.body);
const hues = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'cyan', 'orange'];
const resolved = hues.map(function (hue, i) {
const v = cs.getPropertyValue('--vscode-charts-' + hue).trim();
return v || LANE_FALLBACK[i];
});
const seen = Object.create(null);
return resolved.map(function (c, i) {
const key = String(c).toLowerCase();
if (seen[key] === undefined) { seen[key] = true; return c; }
return LANE_FALLBACK[i];
});
}
let PALETTE = computePalette();
let themeRaf = 0;
new MutationObserver(function () {
if (themeRaf) { return; }
themeRaf = requestAnimationFrame(function () {
themeRaf = 0;
const next = computePalette();
if (next.join(',') !== PALETTE.join(',')) {
PALETTE = next;
renderedFirst = -1; // 强制重渲可见行(泳道色/chip 已随旧色内联注入)
scheduleRender();
}
});
}).observe(document.body, { attributes: true });
const ROW_H = ${GRAPH_ROW_H}, LANE_W = ${GRAPH_LANE_W}, NODE_R = 4, GUTTER = 10, OVERSCAN = 8, LOAD_THRESHOLD = 40; // ROW_H/LANE_W 与 CSS --hg-row/--hg-lane 同源(shared-styles 常量注入)
// ── 视图状态按仓库分区(v2,issue #107):选中/目录折叠/分栏比例记忆跟随仓库,切换仓库换装载互不串扰;
// scope 与 List/Tree 模式已上移标题栏(host workspaceState 为事实源,随 graphData 下发),不再入 webview state;
// 无 v2 时从旧平铺结构一次性升级(旧值归首个见到的仓库,不丢偏好)。──
const persistedRaw = vscode.getState() || {};
let persistedRepo = '';
let persistedByRepo = {};
if (persistedRaw.v === 2 && persistedRaw.byRepo) {
persistedByRepo = persistedRaw.byRepo;
} else if (persistedRaw.selectedHash || persistedRaw.scope || persistedRaw.dmode || persistedRaw.dcollapsed) {
persistedByRepo = { '': { selectedHash: persistedRaw.selectedHash, dcollapsed: persistedRaw.dcollapsed } };
}
let selectedHash = null;
let detailsMode = 'flat'; // host 为事实源(graphData.dmode / log/detailMode 下发),webview 仅渲染。
let dcollapsed = new Set();
// 分栏比例(拖拽 gutter 调整,按仓库记忆):panelPct = 面板占 #main 宽(堆叠态为高)比例;detailPct = 上半区占面板高比例。
let panelPct = 0.42, detailPct = 0.55;
function clamp01(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
/** 装载某仓库的分区状态(graphData 到达时调用)。 */
function loadPersistedFor(repoRoot) {
persistedRepo = repoRoot;
const s = persistedByRepo[repoRoot] || persistedByRepo[''] || {};
selectedHash = s.selectedHash || null;
dcollapsed = new Set(s.dcollapsed || []);
panelPct = clamp01(typeof s.panelPct === 'number' ? s.panelPct : 0.42, 0.18, 0.75);
detailPct = clamp01(typeof s.detailPct === 'number' ? s.detailPct : 0.55, 0.15, 0.85);
applyPanelSizes();
}
function persist() {
persistedByRepo[persistedRepo] = { selectedHash: selectedHash, dcollapsed: Array.from(dcollapsed), panelPct: panelPct, detailPct: detailPct };
vscode.setState({ v: 2, byRepo: persistedByRepo });
}
let model = { rows: [], maxLanes: 0, hasMore: false, repoRoot: '', multiRepo: false };
let renderedFirst = -1, renderedLast = -1, fetching = false;
// ── CI 状态(懒加载、仅取可见行;ciByHash 稳定缓存、ciRequested 去重、ciPending 防抖批量)──
// ciByHash 跨 graphData 刷新保留(CI 状态以不可变 hash 为键),杜绝每次 git 状态变化引发的重拉闪烁。
const ciByHash = Object.create(null);
const ciRequested = new Set();
const ciPending = new Set();
let ciMeta = { available: false, needsSignIn: false, error: '' };
let ciReqTimer = null;
// 准实时刷新:仅对可见行中 pending(运行中)状态定时复拉(host 侧 30s TTL 网络门控),终态不再变。
let ciPendingRefreshTimer = null;
let ciRefreshing = false;
const ciTipEl = document.getElementById('ci-tip');
let tipHash = null, tipShowT = null, tipHideT = null, overIcon = false, overTip = false;
const viewport = document.getElementById('viewport');
const spacer = document.getElementById('spacer');
const rowsEl = document.getElementById('rows');
const emptyEl = document.getElementById('empty');
const spinnerEl = document.getElementById('spinner');
const detailsEl = document.getElementById('details');
const detailsList = document.getElementById('details-list');
const detailsTitleEl = document.getElementById('details-title');
const detailsCloseEl = document.getElementById('details-close');
const mainEl = document.getElementById('main');
const gutterMainEl = document.getElementById('gutter-main');
const gutterMetaEl = document.getElementById('gutter-meta');
const commitPanelEl = document.getElementById('commit-panel');
const commitMetaEl = document.getElementById('commit-meta');
let curDetailHash = null, curDetailFiles = [], curDetailTree = [];
let curMetaVm = null, metaFailHash = null; // 面板下半区:VM 缓存(graphData 刷新重渲引用分组)+ 取数失败 hash(同 hash 重点击允许重试)。
const errorEl = document.getElementById('error');
const errorMsgEl = document.getElementById('error-msg');
const retryBtnEl = document.getElementById('retry-btn');
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
// 引用胶囊图标(内联 SVG,仿 codicon git-branch / cloud / tag;fill=currentColor 继承 chip 前景色)。
// 项目未引入 codicon 字体(localResourceRoots=[]、CSP 无 font-src),故图标一律内联,与 ciGlyph 一致。
const ICO_BRANCH = '<svg class="chip-ico" viewBox="0 0 16 16" width="11" height="11" aria-hidden="true"><path fill="currentColor" d="M9.5 3.25a2.25 2.25 0 1 1-3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 1 1 9.5 3.25zm-4 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0zm.75 8.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5z"/></svg>';
const ICO_CLOUD = '<svg class="chip-ico" viewBox="0 0 16 16" width="11" height="11" aria-hidden="true"><path fill="currentColor" d="M4.7 6.04A3.5 3.5 0 0 1 11.4 6.5h.35a2.75 2.75 0 0 1 .25 5.49l-.13.01H4.5a3 3 0 0 1-.3-5.96zM8 5a2.5 2.5 0 0 0-2.45 2.01l-.1.5-.5.06A2 2 0 0 0 4.5 11.5h7.3a1.75 1.75 0 0 0 .05-3.5l-.1-.01h-1.02l-.12-.63A2.5 2.5 0 0 0 8 5z"/></svg>';
const ICO_TAG = '<svg class="chip-ico" viewBox="0 0 16 16" width="11" height="11" aria-hidden="true"><path fill="currentColor" d="M2 2.75A.75.75 0 0 1 2.75 2h5.19c.33 0 .65.13.88.37l4.81 4.8a1.25 1.25 0 0 1 0 1.77l-4.69 4.69a1.25 1.25 0 0 1-1.77 0l-4.8-4.81A1.25 1.25 0 0 1 2 7.94V2.75zm1.5.75v4.44l4.69 4.69 4.44-4.44L7.94 3.5H3.5zm1.75 1a.75.75 0 1 1 0 1.5.75.75 0 0 1 0-1.5z"/></svg>';
function chipIcon(kind) { return kind === 'remoteBranch' ? ICO_CLOUD : kind === 'tag' ? ICO_TAG : ICO_BRANCH; }
// 提交详情浮层图标(内联 SVG,fill=currentColor 继承前景色)。
const ICO_PERSON = '<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0 1.5c-2.5 0-6 1.25-6 3.5V14h12v-1c0-2.25-3.5-3.5-6-3.5z"/></svg>';
const ICO_GH = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path fill="currentColor" d="M8 .2a8 8 0 0 0-2.53 15.6c.4.07.55-.17.55-.38l-.01-1.34c-2.23.49-2.7-1.07-2.7-1.07-.36-.92-.89-1.17-.89-1.17-.73-.5.05-.49.05-.49.8.06 1.23.83 1.23.83.72 1.23 1.88.87 2.34.67.07-.52.28-.87.5-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.83-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.22 2.2.82a7.6 7.6 0 0 1 4 0c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.52.56.83 1.28.83 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48l-.01 2.2c0 .21.15.46.55.38A8 8 0 0 0 8 .2z"/></svg>';
function laneColor(i) { return PALETTE[((i % PALETTE.length) + PALETTE.length) % PALETTE.length]; }
// 实心胶囊前景色:按底色相对亮度择深/白字(WCAG 阈值 0.6),保证任意泳道底色上文字均可读。解析失败回落白字。
function onColor(bg) {
const m = /^#?([0-9a-f]{6})$/i.exec(String(bg).trim());
if (!m) return '#ffffff';
const n = parseInt(m[1], 16), r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.6 ? '#1e1e1e' : '#ffffff';
}
function colX(c) { return c * LANE_W + LANE_W / 2; }
/** 本行实际绘制的最右列号(node + 各边 from/to 的最大值)——行宽据此自适应,消除「全局 maxLanes 撑宽」的留白。 */
function rowMaxCol(row) { const L = row.layout; let m = L.node.col; for (const e of L.incoming) { if (e.fromCol > m) m = e.fromCol; if (e.toCol > m) m = e.toCol; } for (const e of L.outgoing) { if (e.fromCol > m) m = e.fromCol; if (e.toCol > m) m = e.toCol; } for (const e of L.passThrough) { if (e.fromCol > m) m = e.fromCol; if (e.toCol > m) m = e.toCol; } return m; }
function fmtDate(iso) { if (!iso) return ''; const d = new Date(iso); if (isNaN(d)) return ''; const m = String(d.getMonth() + 1).padStart(2, '0'); const da = String(d.getDate()).padStart(2, '0'); return d.getFullYear() + '-' + m + '-' + da; }
function rowSvg(row) {
const L = row.layout;
const cy = ROW_H / 2;
const W = (rowMaxCol(row) + 1) * LANE_W + GUTTER;
const p = ['<svg class="graph" width="', W, '" height="', ROW_H, '" viewBox="0 0 ', W, ' ', ROW_H, '" xmlns="http://www.w3.org/2000/svg">'];
// 泳道连线用三次贝塞尔平滑过渡(对齐官方 GRAPH 视图):控制点取 y 中点、各自锚原 x;
// fromCol===toCol 时自动退化为直线(直行/贯穿/dangling 竖段无需特判)。fill="none" 为 path 必需,避免闭合填充。
const seg = (e) => 'fill="none" stroke="' + laneColor(e.colorIdx) + '" stroke-width="1.6" stroke-linecap="round"';
for (const e of L.passThrough) p.push('<path d="', edgePath(colX(e.fromCol), 0, colX(e.toCol), ROW_H), '" ', seg(e), '/>');
for (const e of L.incoming) p.push('<path d="', edgePath(colX(e.fromCol), 0, colX(e.toCol), cy), '" ', seg(e), '/>');
for (const e of L.outgoing) {
const y2 = e.kind === 'dangling' ? ROW_H * 0.78 : ROW_H;
const op = e.kind === 'dangling' ? ' opacity="0.45"' : '';
p.push('<path d="', edgePath(colX(e.fromCol), cy, colX(e.toCol), y2), '"', op, ' ', seg(e), '/>');
}
// 节点:当前 HEAD 行绘「空心环 + 内点」(双环高亮,对齐官方),普通行绘实心点。环 fill=none 让贯穿竖线透过可见。
const nx = colX(L.node.col), col = laneColor(L.node.colorIdx);
if (isHeadRow(row)) {
// 环/内点不挂 .node 类:避免通用 .node { stroke: sideBar-background } 覆盖内联 lane 色 stroke(SVG presentation 属性优先级低于 CSS)。
p.push('<circle class="node-ring" cx="', nx, '" cy="', cy, '" r="', NODE_R + 1.5, '" fill="none" stroke="', col, '" stroke-width="1.6"/>');
p.push('<circle class="node-dot" cx="', nx, '" cy="', cy, '" r="', NODE_R - 1.2, '" fill="', col, '"/>');
} else {
p.push('<circle class="node" cx="', nx, '" cy="', cy, '" r="', NODE_R, '" fill="', col, '"/>');
}
p.push('</svg>');
return p.join('');
}
/** S 形三次贝塞尔:控制点在 y 中点、各自锚原 x。fromCol===toCol 时退化为竖直直线。 */
function edgePath(x1, y1, x2, y2) {
const my = (y1 + y2) / 2;
return 'M' + x1 + ' ' + y1 + ' C' + x1 + ' ' + my + ' ' + x2 + ' ' + my + ' ' + x2 + ' ' + y2;
}
/** 当前 HEAD 行判定:chips 中有 detached HEAD(kind==='head')或 HEAD 指向的本地分支(isHeadTarget)。 */
function isHeadRow(row) {
const cs = row.chips || [];
for (const c of cs) { if (c.kind === 'head' || c.isHeadTarget) return true; }
return false;
}
function chipsHtml(row) {
if (!row.chips || row.chips.length === 0) return '';
// 对齐官方 GRAPH:胶囊实心底色跟随本行泳道色(node.colorIdx),类型靠图标(分支/云/tag)区分而非颜色;
// 文字色按底色亮度自适应,保证可读。不加原生 title:引用明细统一由编辑器区 Commit 详情面板展示。
const bg = laneColor(row.layout.node.colorIdx);
const fg = onColor(bg);
const parts = ['<span class="chips">'];
for (const c of row.chips) {
const cls = 'chip ' + c.kind + (c.isHeadTarget ? ' head-target' : '');
parts.push('<span class="', cls, '" style="background:', bg, ';color:', fg, '">', chipIcon(c.kind), '<span class="chip-nm">', esc(c.name), '</span></span>');
}
parts.push('</span>');
return parts.join('');
}
function ciGlyph(state) {
if (state === 'success') return '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><circle cx="8" cy="8" r="6.6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M4.8 8.2l2.1 2.1 4.3-4.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>';
if (state === 'failure') return '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><circle cx="8" cy="8" r="6.6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M5.6 5.6l4.8 4.8M10.4 5.6l-4.8 4.8" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>';
if (state === 'pending') return '<svg class="ci-spin" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><circle cx="8" cy="8" r="6.4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-dasharray="10 30"/></svg>';
return '';
}
/** 提交行最右侧的 CI 槽位(固定 16px 保列对齐;available=false 零宽;无数据=空槽不交互)。 */
function ciSlotHtml(row) {
if (!ciMeta.available) return '';
const ci = ciByHash[row.hash];
if (!ci || ci.state === 'unknown') {
return '<span class="ci ci-empty" aria-hidden="true"></span>';
}
const failed = ci.total - ci.passed;
const a11y = ci.state === 'success' ? 'CI passed ' + ci.passed + '/' + ci.total
: ci.state === 'failure' ? 'CI failed, ' + failed + '/' + ci.total + ' checks failing'
: 'CI running ' + ci.passed + '/' + ci.total;
return '<span class="ci ci-' + ci.state + '" data-ci="' + esc(row.hash) + '" tabindex="0" role="button" aria-label="' + esc(a11y) + '">' + ciGlyph(ci.state) + '</span>';
}
function rowHtml(row, idx) {
const sel = row.hash === selectedHash ? ' selected' : '';
// merge 标记:双父提交的 graph 型 SVG(Unicode ⇠ 随字体渲染不稳定且进读屏)。
const MERGE_ICON = '<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="4" cy="4" r="1.8"/><circle cx="4" cy="12" r="1.8"/><circle cx="12" cy="8" r="1.8"/></svg>';
const merge = row.isMerge ? '<span class="merge" title="Merge commit">' + MERGE_ICON + '</span>' : '';
// 列顺序对齐官方 GRAPH:泳道图 → message → 引用胶囊 → author → date → CI。chips 作为 message 右侧后缀。
return '<div class="row' + sel + '" data-i="' + idx + '" data-hash="' + esc(row.hash) + '" role="treeitem" aria-selected="' + (sel !== '') + '">'
+ rowSvg(row)
+ '<span class="subject"><span class="msg">' + esc(row.subject) + '</span>' + merge + chipsHtml(row) + '</span>'
+ '<span class="author">' + esc(row.authorName) + '</span>'
+ '<span class="date">' + fmtDate(row.authorDate) + '</span>'
+ ciSlotHtml(row)
+ '</div>';
}
function render() {
const total = model.rows.length;
const sh = viewport.scrollTop;
const ch = viewport.clientHeight;
const f = Math.max(0, Math.floor(sh / ROW_H) - OVERSCAN);
const n = Math.ceil(ch / ROW_H) + OVERSCAN * 2;
const l = Math.min(total, f + n);
if (f !== renderedFirst || l !== renderedLast) {
renderedFirst = f; renderedLast = l;
const html = [];
for (let i = f; i < l; i++) html.push(rowHtml(model.rows[i], i));
rowsEl.innerHTML = html.join('');
rowsEl.style.transform = 'translateY(' + (f * ROW_H) + 'px)';
}
collectCiRequests(f, l);
spacer.style.height = (total * ROW_H) + 'px';
emptyEl.style.display = total === 0 ? 'block' : 'none';
errorEl.style.display = 'none';
if (model.hasMore && !fetching && l >= total - LOAD_THRESHOLD) {
fetching = true; spinnerEl.style.display = 'block';
vscode.postMessage({ type: 'log/loadMore', payload: { cursor: total } });
}
}
function scheduleRender() { requestAnimationFrame(render); }
/** 收集可见行中尚未取数的 hash(O(可见行),幂等),防抖后批量请求,绝不重复请求已知项。 */
function collectCiRequests(f, l) {
if (!ciMeta.available) return;
for (let i = f; i < l; i++) {
const h = model.rows[i] && model.rows[i].hash;
if (!h || (h in ciByHash) || ciRequested.has(h) || ciPending.has(h)) continue;
ciRequested.add(h);
ciPending.add(h);
}
if (ciPending.size === 0 || ciReqTimer) return;
ciReqTimer = setTimeout(flushCiRequests, 200);
}
function flushCiRequests() {
ciReqTimer = null;
if (ciPending.size === 0) return;
const hashes = Array.from(ciPending);
ciPending.clear();
vscode.postMessage({ type: 'log/requestCi', payload: { hashes: hashes } });
}
/**
* CI 数据到达后**就地**更新可见行图标:只改受影响行的 .ci 槽位(replaceChild/appendChild),
* 绝不重建整行/整页(reduce-reflows),从根源消除「每次 ciData 触发 innerHTML 重写」的全列闪烁。
* 状态类未变(如 pending 复拉、计数更新)时保留原元素,旋转动画不重启、零重绘。
*/
function applyCiData(map) {
const changed = Object.keys(map);
if (changed.length === 0) return;
for (const h of changed) {
ciByHash[h] = map[h];
ciRequested.add(h);
}
// 只遍历已渲染的可见行,命中受影响 hash 即就地标定其 .ci 槽位。
const kids = rowsEl.children;
for (let i = 0; i < kids.length; i++) {
const rowEl = kids[i];
const h = rowEl.getAttribute('data-hash');
if (!(h in map)) continue;
const slot = rowEl.querySelector('.ci');
const ci = ciByHash[h];
const wantCls = ci && ci.state !== 'unknown' ? 'ci-' + ci.state : 'ci-empty';
// 状态类未变(如 pending 复拉、计数更新):保留元素,旋转动画不重启、零重绘。
if (slot && slot.classList.contains(wantCls)) continue;
const fresh = ciSlotHtml({ hash: h });
if (slot && slot.outerHTML === fresh) continue;
const tmp = document.createElement('div');
tmp.innerHTML = fresh;
const newSlot = tmp.firstElementChild;
if (slot) {
if (newSlot) rowEl.replaceChild(newSlot, slot);
else rowEl.removeChild(slot);
} else if (newSlot) {
rowEl.appendChild(newSlot);
}
}
}
/** 准实时刷新:仅对可见行中 pending(运行中)状态的提交定时复拉,转终态后停拉。host 30s TTL 网络门控。 */
function ensurePendingRefresh() {
if (ciPendingRefreshTimer) return;
ciPendingRefreshTimer = setInterval(schedulePendingRefresh, 20000);
}
function stopPendingRefresh() {