@theia/scm 1.71.0-next.72 → 1.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/lib/browser/dirty-diff/dirty-diff-widget.js +1 -1
  2. package/lib/browser/dirty-diff/dirty-diff-widget.js.map +1 -1
  3. package/lib/browser/scm-context-key-service.d.ts +6 -0
  4. package/lib/browser/scm-context-key-service.d.ts.map +1 -1
  5. package/lib/browser/scm-context-key-service.js +12 -0
  6. package/lib/browser/scm-context-key-service.js.map +1 -1
  7. package/lib/browser/scm-contribution.d.ts.map +1 -1
  8. package/lib/browser/scm-contribution.js +129 -1
  9. package/lib/browser/scm-contribution.js.map +1 -1
  10. package/lib/browser/scm-frontend-module.d.ts.map +1 -1
  11. package/lib/browser/scm-frontend-module.js +14 -0
  12. package/lib/browser/scm-frontend-module.js.map +1 -1
  13. package/lib/browser/scm-history-graph-helpers.d.ts +39 -0
  14. package/lib/browser/scm-history-graph-helpers.d.ts.map +1 -0
  15. package/lib/browser/scm-history-graph-helpers.js +167 -0
  16. package/lib/browser/scm-history-graph-helpers.js.map +1 -0
  17. package/lib/browser/scm-history-graph-lanes.d.ts +59 -0
  18. package/lib/browser/scm-history-graph-lanes.d.ts.map +1 -0
  19. package/lib/browser/scm-history-graph-lanes.js +183 -0
  20. package/lib/browser/scm-history-graph-lanes.js.map +1 -0
  21. package/lib/browser/scm-history-graph-lanes.spec.d.ts +2 -0
  22. package/lib/browser/scm-history-graph-lanes.spec.d.ts.map +1 -0
  23. package/lib/browser/scm-history-graph-lanes.spec.js +554 -0
  24. package/lib/browser/scm-history-graph-lanes.spec.js.map +1 -0
  25. package/lib/browser/scm-history-graph-model.d.ts +46 -0
  26. package/lib/browser/scm-history-graph-model.d.ts.map +1 -0
  27. package/lib/browser/scm-history-graph-model.js +184 -0
  28. package/lib/browser/scm-history-graph-model.js.map +1 -0
  29. package/lib/browser/scm-history-graph-model.spec.d.ts +2 -0
  30. package/lib/browser/scm-history-graph-model.spec.d.ts.map +1 -0
  31. package/lib/browser/scm-history-graph-model.spec.js +131 -0
  32. package/lib/browser/scm-history-graph-model.spec.js.map +1 -0
  33. package/lib/browser/scm-history-graph-tooltip.d.ts +14 -0
  34. package/lib/browser/scm-history-graph-tooltip.d.ts.map +1 -0
  35. package/lib/browser/scm-history-graph-tooltip.js +190 -0
  36. package/lib/browser/scm-history-graph-tooltip.js.map +1 -0
  37. package/lib/browser/scm-history-graph-widget.d.ts +77 -0
  38. package/lib/browser/scm-history-graph-widget.d.ts.map +1 -0
  39. package/lib/browser/scm-history-graph-widget.js +490 -0
  40. package/lib/browser/scm-history-graph-widget.js.map +1 -0
  41. package/lib/browser/scm-provider.d.ts +61 -0
  42. package/lib/browser/scm-provider.d.ts.map +1 -1
  43. package/lib/browser/scm-provider.js.map +1 -1
  44. package/package.json +7 -7
  45. package/src/browser/dirty-diff/dirty-diff-widget.ts +1 -1
  46. package/src/browser/scm-context-key-service.ts +18 -0
  47. package/src/browser/scm-contribution.ts +141 -0
  48. package/src/browser/scm-frontend-module.ts +15 -0
  49. package/src/browser/scm-history-graph-helpers.ts +175 -0
  50. package/src/browser/scm-history-graph-lanes.spec.ts +635 -0
  51. package/src/browser/scm-history-graph-lanes.ts +258 -0
  52. package/src/browser/scm-history-graph-model.spec.ts +171 -0
  53. package/src/browser/scm-history-graph-model.ts +207 -0
  54. package/src/browser/scm-history-graph-tooltip.ts +213 -0
  55. package/src/browser/scm-history-graph-widget.tsx +712 -0
  56. package/src/browser/scm-provider.ts +68 -0
  57. package/src/browser/style/index.css +12 -13
  58. package/src/browser/style/scm-history-graph.css +313 -0
@@ -0,0 +1,712 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import * as React from '@theia/core/shared/react';
18
+ import { Virtuoso } from '@theia/core/shared/react-virtuoso';
19
+ import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
20
+ import { ReactWidget } from '@theia/core/lib/browser/widgets/react-widget';
21
+ import { LabelProvider } from '@theia/core/lib/browser/label-provider';
22
+ import { HoverService } from '@theia/core/lib/browser/hover-service';
23
+ import { MarkdownRenderer, MarkdownRendererFactory } from '@theia/core/lib/browser/markdown-rendering/markdown-renderer';
24
+ import { ScmHistoryGraphModel, HistoryGraphEntry } from './scm-history-graph-model';
25
+ import { ScmHistoryItemRef, ScmHistoryItemChange } from './scm-provider';
26
+ import { ScmService } from './scm-service';
27
+ import { GraphRow } from './scm-history-graph-lanes';
28
+ import URI from '@theia/core/lib/common/uri';
29
+ import { nls } from '@theia/core/lib/common/nls';
30
+ import { CancellationTokenSource } from '@theia/core/lib/common/cancellation';
31
+ import { MenuPath } from '@theia/core/lib/common/menu/menu-types';
32
+ import { ContextMenuRenderer } from '@theia/core/lib/browser/context-menu-renderer';
33
+ import { OpenerService, open } from '@theia/core/lib/browser/opener-service';
34
+ import { DiffUris } from '@theia/core/lib/browser/diff-uris';
35
+ import { ScmContextKeyService } from './scm-context-key-service';
36
+ import {
37
+ laneColor, getChangeStatus, getFileName, getFilePath, getRepoRelativePath,
38
+ getRefBadgeClass, isTagRef, isRemoteRef, deduplicateRefs, DeduplicatedRef
39
+ } from './scm-history-graph-helpers';
40
+ import { buildHtmlTooltip } from './scm-history-graph-tooltip';
41
+
42
+ /** Menu path matching the VS Code 'scm/history/title' contribution point (graph section toolbar). */
43
+ export const SCM_HISTORY_TITLE_MENU: MenuPath = ['plugin_scm/history/title'];
44
+ /** Menu path matching the VS Code 'scm/historyItem/context' contribution point (commit row context menu). */
45
+ export const SCM_HISTORY_ITEM_CONTEXT_MENU: MenuPath = ['plugin_scm/historyItem/context'];
46
+ /** Menu path matching the VS Code 'scm/historyItemRef/context' contribution point (ref badge context menu). */
47
+ export const SCM_HISTORY_ITEM_REF_CONTEXT_MENU: MenuPath = ['plugin_scm/historyItemRef/context'];
48
+
49
+ // ── Layout constants ────────────────────────────────────────────────────────
50
+
51
+ const ROW_HEIGHT = 22;
52
+ /** Horizontal width of each lane column in the SVG — matches VS Code exactly. */
53
+ const LANE_WIDTH = 22;
54
+ /** Y position of the commit dot — vertically centered in the row. */
55
+ const DOT_CY = 11;
56
+
57
+ /** Renders a ref badge as a JSX element for the commit row. */
58
+ function renderJsxRefBadge(
59
+ ref: ScmHistoryItemRef,
60
+ iconClass: string,
61
+ showText: boolean,
62
+ style: React.CSSProperties,
63
+ key: string,
64
+ onContextMenu?: (e: React.MouseEvent) => void
65
+ ): React.ReactElement {
66
+ return (
67
+ <span
68
+ key={key}
69
+ className={`scm-history-ref-badge ${getRefBadgeClass(ref)}`}
70
+ title={ref.description ?? ref.name}
71
+ style={style}
72
+ onContextMenu={onContextMenu}
73
+ >
74
+ <i className={`codicon ${iconClass} scm-history-ref-icon`} />
75
+ {showText && <span className='scm-history-ref-text'>{ref.name}</span>}
76
+ </span>
77
+ );
78
+ }
79
+
80
+ // ── Widget ──────────────────────────────────────────────────────────────────
81
+
82
+ @injectable()
83
+ export class ScmHistoryGraphWidget extends ReactWidget {
84
+
85
+ static readonly ID = 'scm-history-graph-widget';
86
+ static readonly LABEL = nls.localizeByDefault('Graph');
87
+
88
+ @inject(ScmHistoryGraphModel) protected readonly model: ScmHistoryGraphModel;
89
+ @inject(HoverService) protected readonly hoverService: HoverService;
90
+ @inject(MarkdownRendererFactory) protected readonly markdownRendererFactory: MarkdownRendererFactory;
91
+ @inject(ScmService) protected readonly scmService: ScmService;
92
+ @inject(LabelProvider) protected readonly labelProvider: LabelProvider;
93
+ @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer;
94
+ @inject(OpenerService) protected readonly openerService: OpenerService;
95
+ @inject(ScmContextKeyService) protected readonly scmContextKeys: ScmContextKeyService;
96
+
97
+ protected selectedIndex = -1;
98
+ /** Currently selected change row key (`${itemId}-${ci}`), or undefined. */
99
+ protected selectedChangeKey: string | undefined;
100
+ /** Map from commit id → loaded changes (undefined = not loaded yet). */
101
+ protected expandedChanges = new Map<string, ScmHistoryItemChange[] | 'loading'>();
102
+ /** Set of commit ids that are currently expanded. */
103
+ protected expandedIds = new Set<string>();
104
+ /** Map from commit id → in-flight CancellationTokenSource for loadChanges. */
105
+ protected loadChangesCts = new Map<string, CancellationTokenSource>();
106
+
107
+ constructor() {
108
+ super();
109
+ this.id = ScmHistoryGraphWidget.ID;
110
+ this.title.label = ScmHistoryGraphWidget.LABEL;
111
+ this.title.caption = ScmHistoryGraphWidget.LABEL;
112
+ this.title.closable = false;
113
+ this.addClass('scm-history-graph-container');
114
+ this.node.tabIndex = 0;
115
+ }
116
+
117
+ @postConstruct()
118
+ protected init(): void {
119
+ this.toDispose.push(
120
+ this.model.onDidChange(() => {
121
+ this.updateContextKeys();
122
+ this.update();
123
+ })
124
+ );
125
+ this.toDispose.push({
126
+ dispose: () => {
127
+ for (const cts of this.loadChangesCts.values()) {
128
+ cts.cancel();
129
+ cts.dispose();
130
+ }
131
+ this.loadChangesCts.clear();
132
+ }
133
+ });
134
+ this.update();
135
+ }
136
+
137
+ protected updateContextKeys(): void {
138
+ const provider = this.model.provider;
139
+ this.scmContextKeys.scmCurrentHistoryItemRefHasRemote.set(!!provider?.currentHistoryItemRemoteRef);
140
+ this.scmContextKeys.scmCurrentHistoryItemRefHasBase.set(!!provider?.currentHistoryItemBaseRef);
141
+ }
142
+
143
+ protected render(): React.ReactNode {
144
+ const { entries, hasMore, loading } = this.model;
145
+
146
+ // Only show the empty state once the model has completed at least one load attempt.
147
+ // While the model is still initializing (provider not yet set, no load attempted),
148
+ // render a loading indicator to avoid a flash of "no history" on startup.
149
+ if (!loading && entries.length === 0) {
150
+ if (!this.model.hasAttemptedLoad) {
151
+ return (
152
+ <div className='scm-history-loading'>
153
+ {nls.localizeByDefault('Loading')}
154
+ </div>
155
+ );
156
+ }
157
+ return (
158
+ <div className='scm-history-empty'>
159
+ {nls.localize('theia/scm/noHistory', 'No source control history available.')}
160
+ </div>
161
+ );
162
+ }
163
+
164
+ // Determine how many lanes are active for SVG width
165
+ const maxLane = entries.reduce((max, e) => {
166
+ const rowMax = Math.max(e.graphRow.lane, ...e.graphRow.edges.map(ed => Math.max(ed.fromLane, ed.toLane)));
167
+ return Math.max(max, rowMax);
168
+ }, 0);
169
+ const svgWidth = (maxLane + 1) * LANE_WIDTH;
170
+
171
+ const footer = loading
172
+ ? () => <div className='scm-history-loading'>{nls.localizeByDefault('Loading')}</div>
173
+ : hasMore
174
+ ? () => (
175
+ <div
176
+ className='scm-history-load-more'
177
+ onClick={this.handleLoadMore}
178
+ role='button'
179
+ tabIndex={0}
180
+ onKeyDown={this.handleLoadMoreKey}
181
+ >
182
+ {nls.localizeByDefault('Load more')}
183
+ </div>
184
+ )
185
+ : undefined;
186
+
187
+ return (
188
+ <Virtuoso
189
+ className='scm-history-graph-list'
190
+ data={entries as HistoryGraphEntry[]}
191
+ itemContent={(idx, entry) => this.renderRow(entry, idx, svgWidth)}
192
+ endReached={hasMore && !loading ? this.handleEndReached : undefined}
193
+ overscan={500}
194
+ components={footer ? { Footer: footer } : {}}
195
+ style={{ overflowX: 'hidden' }}
196
+ />
197
+ );
198
+ }
199
+
200
+ protected handleEndReached = (): void => {
201
+ if (this.model.hasMore && !this.model.loading) {
202
+ this.model.loadMore();
203
+ }
204
+ };
205
+
206
+ protected renderRow(entry: HistoryGraphEntry, idx: number, svgWidth: number): React.ReactElement {
207
+ const { item, graphRow } = entry;
208
+ const isSelected = idx === this.selectedIndex;
209
+ const isExpanded = this.expandedIds.has(item.id);
210
+ const changes = this.expandedChanges.get(item.id);
211
+ // The first commit (idx === 0) on lane 0 is treated as HEAD/current
212
+ const isHead = idx === 0 && graphRow.lane === 0;
213
+
214
+ return (
215
+ <React.Fragment key={item.id}>
216
+ <div
217
+ className={`scm-history-graph-row${isSelected ? ' selected' : ''}`}
218
+ onClick={() => this.handleRowClick(idx, entry)}
219
+ onContextMenu={e => this.handleRowContextMenu(e, entry)}
220
+ onMouseEnter={e => this.handleRowMouseEnter(e, entry)}
221
+ >
222
+ {this.renderGraphSvg(graphRow, svgWidth, isHead)}
223
+ <div className='scm-history-graph-info'>
224
+ <span className='scm-history-subject'>{item.subject}</span>
225
+ {item.author && (
226
+ <span className='scm-history-author'>{item.author}</span>
227
+ )}
228
+ {item.references && item.references.length > 0 && (
229
+ <span className='scm-history-badges scm-history-badges-right'>
230
+ {this.renderRefBadges(item.references, entry)}
231
+ </span>
232
+ )}
233
+ </div>
234
+ </div>
235
+ {isExpanded && this.renderChangesRows(item.id, svgWidth, graphRow, changes)}
236
+ </React.Fragment>
237
+ );
238
+ }
239
+
240
+ protected _markdownRenderer: MarkdownRenderer | undefined;
241
+ protected get markdownRenderer(): MarkdownRenderer {
242
+ this._markdownRenderer ||= this.markdownRendererFactory();
243
+ return this._markdownRenderer;
244
+ }
245
+
246
+ protected handleRowMouseEnter = (e: React.MouseEvent<HTMLDivElement>, entry: HistoryGraphEntry): void => {
247
+ this.hoverService.requestHover({
248
+ content: buildHtmlTooltip(entry, this.markdownRenderer),
249
+ target: e.currentTarget,
250
+ position: 'right',
251
+ interactive: true,
252
+ });
253
+ };
254
+
255
+ protected renderChangesRows(
256
+ itemId: string,
257
+ svgWidth: number,
258
+ graphRow: GraphRow,
259
+ changes: ScmHistoryItemChange[] | 'loading' | undefined
260
+ ): React.ReactElement {
261
+ if (changes === 'loading' || changes === undefined) {
262
+ return (
263
+ <div key={`${itemId}-loading`} className='scm-history-changes-loading'>
264
+ {nls.localizeByDefault('Loading')}
265
+ </div>
266
+ );
267
+ }
268
+
269
+ if (changes.length === 0) {
270
+ return (
271
+ <div key={`${itemId}-empty`} className='scm-history-changes-empty'>
272
+ {nls.localize('theia/scm/noChanges', 'No changed files.')}
273
+ </div>
274
+ );
275
+ }
276
+
277
+ return (
278
+ <React.Fragment key={`${itemId}-changes`}>
279
+ {changes.map((change, ci) =>
280
+ this.renderChangeRow(change, ci, itemId, svgWidth, graphRow)
281
+ )}
282
+ </React.Fragment>
283
+ );
284
+ }
285
+
286
+ protected renderChangeRow(
287
+ change: ScmHistoryItemChange,
288
+ ci: number,
289
+ itemId: string,
290
+ svgWidth: number,
291
+ graphRow: GraphRow
292
+ ): React.ReactElement {
293
+ const rootUri = this.scmService.selectedRepository?.provider.rootUri;
294
+ const uri = change.modifiedUri ?? change.originalUri ?? change.uri;
295
+ const relativePath = getRepoRelativePath(uri, rootUri);
296
+ const fileName = getFileName(relativePath);
297
+ const dirPath = relativePath.includes('/')
298
+ ? relativePath.slice(0, relativePath.lastIndexOf('/'))
299
+ : '';
300
+ const status = getChangeStatus(change);
301
+ const statusClass = status === 'A' ? 'added' : status === 'D' ? 'deleted' : status === 'R' ? 'renamed' : 'modified';
302
+ const resourceUri = new URI(uri);
303
+ const fileIcon = this.labelProvider.getIcon(resourceUri);
304
+ const changeKey = `${itemId}-change-${ci}`;
305
+ const isSelected = this.selectedChangeKey === changeKey;
306
+
307
+ return (
308
+ <div
309
+ key={changeKey}
310
+ className={`scm-history-change-row${isSelected ? ' selected' : ''}`}
311
+ onClick={e => this.handleChangeClick(e, change, changeKey)}
312
+ role='treeitem'
313
+ tabIndex={-1}
314
+ >
315
+ {this.renderChangeRowSvg(graphRow, svgWidth)}
316
+ <div className='scm-history-change-info'>
317
+ <span className={`${fileIcon} file-icon scm-history-change-file-icon`} />
318
+ <div className='scm-history-change-name-container'>
319
+ <span className='name scm-history-change-name' title={relativePath}>{fileName}</span>
320
+ {dirPath && <span className='path scm-history-change-dir'>{dirPath}</span>}
321
+ </div>
322
+ <span className={`scm-history-change-status ${statusClass}`}>{status}</span>
323
+ </div>
324
+ </div>
325
+ );
326
+ }
327
+
328
+ protected handleChangeClick = (e: React.MouseEvent, change: ScmHistoryItemChange, changeKey: string): void => {
329
+ e.stopPropagation();
330
+ this.selectedChangeKey = changeKey;
331
+ this.update();
332
+ this.openChange(change);
333
+ };
334
+
335
+ protected openChange(change: ScmHistoryItemChange): void {
336
+ try {
337
+ if (change.originalUri && change.modifiedUri) {
338
+ const originalUri = new URI(change.originalUri);
339
+ const modifiedUri = new URI(change.modifiedUri);
340
+ const label = getFileName(getFilePath(change.modifiedUri));
341
+ open(this.openerService, DiffUris.encode(originalUri, modifiedUri, label));
342
+ } else if (change.modifiedUri) {
343
+ open(this.openerService, new URI(change.modifiedUri));
344
+ } else if (change.originalUri) {
345
+ open(this.openerService, new URI(change.originalUri));
346
+ } else {
347
+ open(this.openerService, new URI(change.uri));
348
+ }
349
+ } catch (err) {
350
+ console.error('ScmHistoryGraphWidget: failed to open change', err);
351
+ }
352
+ }
353
+
354
+ protected renderChangeRowSvg(graphRow: GraphRow, svgWidth: number): React.ReactElement {
355
+ const commitX = graphRow.lane * LANE_WIDTH + 11;
356
+ const commitColor = laneColor(graphRow.color);
357
+ const elements: React.ReactElement[] = [];
358
+ let keyIdx = 0;
359
+
360
+ for (const edge of graphRow.edges) {
361
+ if (edge.type !== 'pass-through') {
362
+ continue;
363
+ }
364
+ const x = edge.fromLane * LANE_WIDTH + 11;
365
+ const color = laneColor(edge.color);
366
+ elements.push(
367
+ <path
368
+ key={`pass-${keyIdx++}`}
369
+ fill='none'
370
+ strokeWidth='1px'
371
+ strokeLinecap='round'
372
+ d={`M ${x} 0 V 22`}
373
+ style={{ stroke: color }}
374
+ />
375
+ );
376
+ }
377
+
378
+ if (graphRow.hasContinuation) {
379
+ elements.push(
380
+ <path
381
+ key={`commit-${keyIdx++}`}
382
+ fill='none'
383
+ strokeWidth='1px'
384
+ strokeLinecap='round'
385
+ d={`M ${commitX} 0 V 22`}
386
+ style={{ stroke: commitColor }}
387
+ />
388
+ );
389
+ }
390
+
391
+ return (
392
+ <svg
393
+ className='scm-history-graph-svg'
394
+ style={{ height: `${ROW_HEIGHT}px`, width: `${svgWidth}px` }}
395
+ >
396
+ {elements}
397
+ </svg>
398
+ );
399
+ }
400
+
401
+ /**
402
+ * Renders the SVG graph column for a commit row, matching VS Code's SVG structure:
403
+ *
404
+ * - Each lane is 22px wide; circle cx = lane * 22 + 11, cy = 11
405
+ * - Pass-through lanes: full vertical line M x 0 V 22
406
+ * - Commit lane: top segment (M commitX 0 V 11) + bottom segment (M commitX 11 V 22)
407
+ * - HEAD/current commit (first entry): r=7 with inner dot, NO top segment
408
+ * - Normal commit: r=5
409
+ * - Branch-out edge: diagonal S-curve bezier from commit position (commitX, cy)
410
+ * sweeping down to the new lane at the bottom: M commitX cy C commitX 22 newX cy newX 22
411
+ * - Merge-in edge: bezier from the source lane at the top curving into
412
+ * the commit position: M srcX 0 C srcX cy commitX cy commitX cy
413
+ */
414
+ protected renderGraphSvg(row: GraphRow, svgWidth: number, isHead: boolean): React.ReactElement {
415
+ const commitX = row.lane * LANE_WIDTH + 11;
416
+ const cy = DOT_CY;
417
+ const commitColor = laneColor(row.color);
418
+
419
+ const elements: React.ReactElement[] = [];
420
+ let keyIdx = 0;
421
+
422
+ // Pass-through lines: straight vertical line through the full row height
423
+ for (const edge of row.edges) {
424
+ if (edge.type !== 'pass-through') {
425
+ continue;
426
+ }
427
+ const x = edge.fromLane * LANE_WIDTH + 11;
428
+ const color = laneColor(edge.color);
429
+ elements.push(
430
+ <path
431
+ key={`pass-${keyIdx++}`}
432
+ fill='none'
433
+ strokeWidth='1px'
434
+ strokeLinecap='round'
435
+ d={`M ${x} 0 V 22`}
436
+ style={{ stroke: color }}
437
+ />
438
+ );
439
+ }
440
+
441
+ // Merge-in edges: source lane at top curves into the commit position (mid-row)
442
+ for (const edge of row.edges) {
443
+ if (edge.type !== 'merge-in') {
444
+ continue;
445
+ }
446
+ const srcX = edge.fromLane * LANE_WIDTH + 11;
447
+ const color = laneColor(edge.color);
448
+ // Cubic bezier: start at (srcX, 0), control points pull both ends to mid-row,
449
+ // end at commit position (commitX, cy)
450
+ const d = `M ${srcX} 0 C ${srcX} ${cy}, ${commitX} ${cy}, ${commitX} ${cy}`;
451
+ elements.push(
452
+ <path
453
+ key={`merge-${keyIdx++}`}
454
+ fill='none'
455
+ strokeWidth='1px'
456
+ strokeLinecap='round'
457
+ d={d}
458
+ style={{ stroke: color }}
459
+ />
460
+ );
461
+ }
462
+
463
+ // Commit lane lines — split at cy=11
464
+ // Top segment: only drawn if there is an incoming line from above
465
+ // (i.e. this commit was referenced as a parent by an earlier row)
466
+ if (row.hasTopLine) {
467
+ elements.push(
468
+ <path
469
+ key={`top-${keyIdx++}`}
470
+ fill='none'
471
+ strokeWidth='1px'
472
+ strokeLinecap='round'
473
+ d={`M ${commitX} 0 V ${cy}`}
474
+ style={{ stroke: commitColor }}
475
+ />
476
+ );
477
+ }
478
+ // Bottom segment: only drawn if the lane continues to a parent below
479
+ if (row.hasContinuation) {
480
+ elements.push(
481
+ <path
482
+ key={`bottom-${keyIdx++}`}
483
+ fill='none'
484
+ strokeWidth='1px'
485
+ strokeLinecap='round'
486
+ d={`M ${commitX} ${cy} V 22`}
487
+ style={{ stroke: commitColor }}
488
+ />
489
+ );
490
+ }
491
+
492
+ // Branch-out edges: commit position (mid-row) curves down to a new lane at bottom
493
+ for (const edge of row.edges) {
494
+ if (edge.type !== 'branch-out') {
495
+ continue;
496
+ }
497
+ const dstX = edge.toLane * LANE_WIDTH + 11;
498
+ const color = laneColor(edge.color);
499
+ // Diagonal S-curve bezier: CP1 keeps x at srcX while y goes to bottom;
500
+ // CP2 keeps y at cy while x reaches dstX — produces a smooth sweep
501
+ // down-and-right (or left) from the commit circle to the target lane.
502
+ const srcX = edge.fromLane * LANE_WIDTH + 11;
503
+ const d = `M ${srcX} ${cy} C ${srcX} 22, ${dstX} ${cy}, ${dstX} 22`;
504
+ elements.push(
505
+ <path
506
+ key={`branch-${keyIdx++}`}
507
+ fill='none'
508
+ strokeWidth='1px'
509
+ strokeLinecap='round'
510
+ d={d}
511
+ style={{ stroke: color }}
512
+ />
513
+ );
514
+ }
515
+
516
+ // Commit circle — drawn last so it renders on top of all lines
517
+ if (isHead) {
518
+ elements.push(
519
+ <circle
520
+ key={`circle-${keyIdx++}`}
521
+ cx={commitX}
522
+ cy={cy}
523
+ r={7}
524
+ style={{ strokeWidth: '2px', fill: commitColor }}
525
+ />
526
+ );
527
+ elements.push(
528
+ <circle
529
+ key={`inner-${keyIdx++}`}
530
+ cx={commitX}
531
+ cy={cy}
532
+ r={2}
533
+ style={{ strokeWidth: '4px', fill: 'var(--theia-editor-background)', stroke: 'var(--theia-editor-background)' }}
534
+ />
535
+ );
536
+ } else {
537
+ elements.push(
538
+ <circle
539
+ key={`circle-${keyIdx++}`}
540
+ cx={commitX}
541
+ cy={cy}
542
+ r={5}
543
+ style={{ strokeWidth: '2px', fill: commitColor }}
544
+ />
545
+ );
546
+ }
547
+
548
+ return (
549
+ <svg
550
+ className='scm-history-graph-svg'
551
+ style={{ height: `${ROW_HEIGHT}px`, width: `${svgWidth}px` }}
552
+ >
553
+ {elements}
554
+ </svg>
555
+ );
556
+ }
557
+
558
+ protected renderRefBadges(refs?: readonly ScmHistoryItemRef[], entry?: HistoryGraphEntry): React.ReactNode {
559
+ if (!refs || refs.length === 0) {
560
+ return undefined;
561
+ }
562
+
563
+ const laneColorValue = entry ? laneColor(entry.graphRow.color) : undefined;
564
+ const deduplicated = deduplicateRefs(refs);
565
+ const style: React.CSSProperties = laneColorValue
566
+ ? { backgroundColor: laneColorValue, color: 'var(--theia-scmGraph-historyItemRefForeground, var(--theia-badge-foreground))' }
567
+ : {};
568
+
569
+ const badges: React.ReactElement[] = [];
570
+ for (const info of deduplicated) {
571
+ if (badges.length >= 3) {
572
+ break;
573
+ }
574
+ const { ref, hasBoth } = info as DeduplicatedRef;
575
+ const isTag = isTagRef(ref);
576
+ const isRemote = isRemoteRef(ref);
577
+ const onContextMenu = entry ? (e: React.MouseEvent) => this.handleRefBadgeContextMenu(e, entry, ref) : undefined;
578
+
579
+ if (isTag) {
580
+ badges.push(renderJsxRefBadge(ref, 'codicon-tag', false, style, ref.id, onContextMenu));
581
+ } else if (isRemote) {
582
+ badges.push(renderJsxRefBadge(ref, 'codicon-cloud', true, style, ref.id, onContextMenu));
583
+ } else {
584
+ badges.push(renderJsxRefBadge(ref, 'codicon-git-branch', true, style, ref.id, onContextMenu));
585
+ if (hasBoth && badges.length < 3) {
586
+ badges.push(
587
+ <span
588
+ key={`${ref.id}-cloud`}
589
+ className='scm-history-ref-badge scm-history-ref-badge-cloud'
590
+ title={ref.description ?? ref.name}
591
+ style={style}
592
+ >
593
+ <i className='codicon codicon-cloud scm-history-ref-icon' />
594
+ </span>
595
+ );
596
+ }
597
+ }
598
+ }
599
+ return badges;
600
+ }
601
+
602
+ protected handleRowContextMenu = (e: React.MouseEvent, entry: HistoryGraphEntry): void => {
603
+ e.preventDefault();
604
+ e.stopPropagation();
605
+ const repo = this.scmService.selectedRepository;
606
+ if (!repo) {
607
+ return;
608
+ }
609
+ const { item } = entry;
610
+ if (item.references && item.references.length > 0) {
611
+ this.scmContextKeys.scmHistoryItemRef.set(item.references[0].id);
612
+ } else {
613
+ this.scmContextKeys.scmHistoryItemRef.set(undefined);
614
+ }
615
+ const sourceControlHandle = repo.provider.handle;
616
+ const args = sourceControlHandle !== undefined
617
+ ? [{ sourceControlHandle }, { sourceControlHandle, id: item.id, type: 'historyItem' as const }]
618
+ : [];
619
+ this.contextMenuRenderer.render({
620
+ menuPath: SCM_HISTORY_ITEM_CONTEXT_MENU,
621
+ anchor: e.nativeEvent,
622
+ args,
623
+ context: this.node
624
+ });
625
+ };
626
+
627
+ protected handleRefBadgeContextMenu = (e: React.MouseEvent, entry: HistoryGraphEntry, ref: ScmHistoryItemRef): void => {
628
+ e.preventDefault();
629
+ e.stopPropagation();
630
+ const repo = this.scmService.selectedRepository;
631
+ if (!repo) {
632
+ return;
633
+ }
634
+ this.scmContextKeys.scmHistoryItemRef.set(ref.id);
635
+ const sourceControlHandle = repo.provider.handle;
636
+ const args = sourceControlHandle !== undefined
637
+ ? [{ sourceControlHandle }, { sourceControlHandle, id: ref.id, type: 'historyItemRef' as const }]
638
+ : [];
639
+ this.contextMenuRenderer.render({
640
+ menuPath: SCM_HISTORY_ITEM_REF_CONTEXT_MENU,
641
+ anchor: e.nativeEvent,
642
+ args,
643
+ context: this.node
644
+ });
645
+ };
646
+
647
+ protected handleRowClick = (idx: number, entry: HistoryGraphEntry): void => {
648
+ this.selectedIndex = idx;
649
+ const { item } = entry;
650
+
651
+ if (this.expandedIds.has(item.id)) {
652
+ this.expandedIds.delete(item.id);
653
+ const cts = this.loadChangesCts.get(item.id);
654
+ if (cts) {
655
+ cts.cancel();
656
+ cts.dispose();
657
+ this.loadChangesCts.delete(item.id);
658
+ }
659
+ } else {
660
+ this.expandedIds.add(item.id);
661
+ if (!this.expandedChanges.has(item.id)) {
662
+ this.loadChanges(entry);
663
+ }
664
+ }
665
+
666
+ this.update();
667
+ };
668
+
669
+ protected async loadChanges(entry: HistoryGraphEntry): Promise<void> {
670
+ const { item } = entry;
671
+ const provider = this.model.provider;
672
+ if (!provider) {
673
+ this.expandedChanges.set(item.id, []);
674
+ this.update();
675
+ return;
676
+ }
677
+
678
+ this.expandedChanges.set(item.id, 'loading');
679
+ this.update();
680
+
681
+ const cts = new CancellationTokenSource();
682
+ this.loadChangesCts.set(item.id, cts);
683
+ this.toDispose.push(cts);
684
+ try {
685
+ const parentId = item.parentIds?.[0];
686
+ const changes = await provider.provideHistoryItemChanges(item.id, parentId, cts.token);
687
+ if (!cts.token.isCancellationRequested) {
688
+ this.expandedChanges.set(item.id, changes ?? []);
689
+ this.update();
690
+ }
691
+ } catch (err) {
692
+ if (!cts.token.isCancellationRequested) {
693
+ console.error('ScmHistoryGraphWidget: failed to load changes', err);
694
+ this.expandedChanges.set(item.id, []);
695
+ this.update();
696
+ }
697
+ } finally {
698
+ this.loadChangesCts.delete(item.id);
699
+ }
700
+ }
701
+
702
+ protected handleLoadMore = (): void => {
703
+ this.model.loadMore();
704
+ };
705
+
706
+ protected handleLoadMoreKey = (e: React.KeyboardEvent): void => {
707
+ if (e.key === 'Enter' || e.key === ' ') {
708
+ this.model.loadMore();
709
+ }
710
+ };
711
+
712
+ }