@theia/scm 1.75.0-next.4 → 1.75.0-next.42

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 (83) hide show
  1. package/lib/browser/decorations/scm-navigator-decorator.d.ts.map +1 -1
  2. package/lib/browser/decorations/scm-navigator-decorator.js +1 -0
  3. package/lib/browser/decorations/scm-navigator-decorator.js.map +1 -1
  4. package/lib/browser/scm-context-key-service.d.ts +2 -0
  5. package/lib/browser/scm-context-key-service.d.ts.map +1 -1
  6. package/lib/browser/scm-context-key-service.js +4 -0
  7. package/lib/browser/scm-context-key-service.js.map +1 -1
  8. package/lib/browser/scm-context-key-service.spec.js +7 -0
  9. package/lib/browser/scm-context-key-service.spec.js.map +1 -1
  10. package/lib/browser/scm-frontend-module.d.ts.map +1 -1
  11. package/lib/browser/scm-frontend-module.js +6 -0
  12. package/lib/browser/scm-frontend-module.js.map +1 -1
  13. package/lib/browser/scm-history-graph-contribution.d.ts +49 -0
  14. package/lib/browser/scm-history-graph-contribution.d.ts.map +1 -0
  15. package/lib/browser/scm-history-graph-contribution.js +280 -0
  16. package/lib/browser/scm-history-graph-contribution.js.map +1 -0
  17. package/lib/browser/scm-history-graph-contribution.spec.d.ts +2 -0
  18. package/lib/browser/scm-history-graph-contribution.spec.d.ts.map +1 -0
  19. package/lib/browser/scm-history-graph-contribution.spec.js +119 -0
  20. package/lib/browser/scm-history-graph-contribution.spec.js.map +1 -0
  21. package/lib/browser/scm-history-graph-helpers.d.ts +59 -6
  22. package/lib/browser/scm-history-graph-helpers.d.ts.map +1 -1
  23. package/lib/browser/scm-history-graph-helpers.js +129 -5
  24. package/lib/browser/scm-history-graph-helpers.js.map +1 -1
  25. package/lib/browser/scm-history-graph-helpers.spec.d.ts +2 -0
  26. package/lib/browser/scm-history-graph-helpers.spec.d.ts.map +1 -0
  27. package/lib/browser/scm-history-graph-helpers.spec.js +154 -0
  28. package/lib/browser/scm-history-graph-helpers.spec.js.map +1 -0
  29. package/lib/browser/scm-history-graph-lanes.d.ts +19 -5
  30. package/lib/browser/scm-history-graph-lanes.d.ts.map +1 -1
  31. package/lib/browser/scm-history-graph-lanes.js +25 -9
  32. package/lib/browser/scm-history-graph-lanes.js.map +1 -1
  33. package/lib/browser/scm-history-graph-lanes.spec.js +62 -25
  34. package/lib/browser/scm-history-graph-lanes.spec.js.map +1 -1
  35. package/lib/browser/scm-history-graph-model.d.ts +75 -6
  36. package/lib/browser/scm-history-graph-model.d.ts.map +1 -1
  37. package/lib/browser/scm-history-graph-model.js +155 -10
  38. package/lib/browser/scm-history-graph-model.js.map +1 -1
  39. package/lib/browser/scm-history-graph-model.spec.js +299 -4
  40. package/lib/browser/scm-history-graph-model.spec.js.map +1 -1
  41. package/lib/browser/scm-history-graph-tooltip.d.ts +27 -2
  42. package/lib/browser/scm-history-graph-tooltip.d.ts.map +1 -1
  43. package/lib/browser/scm-history-graph-tooltip.js +113 -22
  44. package/lib/browser/scm-history-graph-tooltip.js.map +1 -1
  45. package/lib/browser/scm-history-graph-tooltip.spec.d.ts +2 -0
  46. package/lib/browser/scm-history-graph-tooltip.spec.d.ts.map +1 -0
  47. package/lib/browser/scm-history-graph-tooltip.spec.js +213 -0
  48. package/lib/browser/scm-history-graph-tooltip.spec.js.map +1 -0
  49. package/lib/browser/scm-history-graph-widget.d.ts +36 -2
  50. package/lib/browser/scm-history-graph-widget.d.ts.map +1 -1
  51. package/lib/browser/scm-history-graph-widget.js +154 -27
  52. package/lib/browser/scm-history-graph-widget.js.map +1 -1
  53. package/lib/browser/scm-history-graph-widget.spec.d.ts +2 -0
  54. package/lib/browser/scm-history-graph-widget.spec.d.ts.map +1 -0
  55. package/lib/browser/scm-history-graph-widget.spec.js +262 -0
  56. package/lib/browser/scm-history-graph-widget.spec.js.map +1 -0
  57. package/lib/browser/scm-provider.d.ts +9 -1
  58. package/lib/browser/scm-provider.d.ts.map +1 -1
  59. package/lib/browser/scm-provider.js.map +1 -1
  60. package/lib/common/scm-preferences.d.ts +3 -0
  61. package/lib/common/scm-preferences.d.ts.map +1 -1
  62. package/lib/common/scm-preferences.js +24 -0
  63. package/lib/common/scm-preferences.js.map +1 -1
  64. package/package.json +6 -6
  65. package/src/browser/decorations/scm-navigator-decorator.ts +3 -2
  66. package/src/browser/scm-context-key-service.spec.ts +9 -0
  67. package/src/browser/scm-context-key-service.ts +6 -0
  68. package/src/browser/scm-frontend-module.ts +7 -1
  69. package/src/browser/scm-history-graph-contribution.spec.ts +148 -0
  70. package/src/browser/scm-history-graph-contribution.ts +273 -0
  71. package/src/browser/scm-history-graph-helpers.spec.ts +193 -0
  72. package/src/browser/scm-history-graph-helpers.ts +180 -6
  73. package/src/browser/scm-history-graph-lanes.spec.ts +70 -25
  74. package/src/browser/scm-history-graph-lanes.ts +48 -11
  75. package/src/browser/scm-history-graph-model.spec.ts +378 -12
  76. package/src/browser/scm-history-graph-model.ts +181 -13
  77. package/src/browser/scm-history-graph-tooltip.spec.ts +254 -0
  78. package/src/browser/scm-history-graph-tooltip.ts +144 -24
  79. package/src/browser/scm-history-graph-widget.spec.ts +310 -0
  80. package/src/browser/scm-history-graph-widget.tsx +199 -39
  81. package/src/browser/scm-provider.ts +9 -1
  82. package/src/browser/style/scm-history-graph.css +53 -2
  83. package/src/common/scm-preferences.ts +27 -0
@@ -14,16 +14,16 @@
14
14
  // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
15
  // *****************************************************************************
16
16
 
17
- import { ScmHistoryItemRef, ScmHistoryItemChange } from './scm-provider';
17
+ import { ScmHistoryItemRef, ScmHistoryItemChange, ScmHistoryProvider } from './scm-provider';
18
18
 
19
19
  /**
20
- * Returns the CSS color variable for the given lane index.
20
+ * Returns the CSS color variable for the given color index.
21
21
  * Uses Theia's `--theia-scmGraph-*` variables, mirroring the VS Code
22
22
  * scm graph color scheme:
23
- * lane 0 (current ref) → historyItemRefColor
24
- * lane 1 (remote ref) → historyItemRemoteRefColor
25
- * lane 2 (base ref) → historyItemBaseRefColor
26
- * lane 3–7 → foreground1–5
23
+ * 0 (current ref) → historyItemRefColor
24
+ * 1 (remote ref) → historyItemRemoteRefColor
25
+ * 2 (base ref) → historyItemBaseRefColor
26
+ * 3–7 (default rotation) → foreground1–5
27
27
  */
28
28
  export function laneColor(index: number): string {
29
29
  switch (index % 8) {
@@ -83,6 +83,180 @@ export function getRepoRelativePath(uri: string, rootUri: string | undefined): s
83
83
  return fullPath;
84
84
  }
85
85
 
86
+ export interface ChangeTreeRowBase {
87
+ /** Repo-relative path of the row; the expansion key for folders. */
88
+ readonly path: string;
89
+ /** Nesting level, used for indentation. */
90
+ readonly depth: number;
91
+ }
92
+
93
+ export interface ChangeTreeFolderRow extends ChangeTreeRowBase {
94
+ readonly type: 'folder';
95
+ /** Segments rendered for this row; a compacted chain shows `browser/model`. */
96
+ readonly label: string;
97
+ }
98
+
99
+ export interface ChangeTreeFileRow extends ChangeTreeRowBase {
100
+ readonly type: 'file';
101
+ readonly change: ScmHistoryItemChange;
102
+ /** Position of the change in the list it came from, so row keys stay stable. */
103
+ readonly index: number;
104
+ }
105
+
106
+ export type ChangeTreeRow = ChangeTreeFolderRow | ChangeTreeFileRow;
107
+
108
+ interface ChangeTreeFolder {
109
+ readonly folders: Map<string, ChangeTreeFolder>;
110
+ readonly files: { change: ScmHistoryItemChange; index: number; name: string }[];
111
+ }
112
+
113
+ /**
114
+ * Arranges history item changes into the rows of a file tree: folders before
115
+ * files, each sorted by name, with single-child folder chains compacted into
116
+ * one row (`src/browser`) the way VS Code's SCM tree renders them. Folders
117
+ * listed in `collapsedFolders` (by their full path) hide their descendants.
118
+ */
119
+ export function buildChangeTreeRows(
120
+ changes: readonly ScmHistoryItemChange[],
121
+ rootUri: string | undefined,
122
+ collapsedFolders: ReadonlySet<string>
123
+ ): ChangeTreeRow[] {
124
+ const root: ChangeTreeFolder = { folders: new Map(), files: [] };
125
+ changes.forEach((change, index) => {
126
+ const relativePath = getRepoRelativePath(change.modifiedUri ?? change.originalUri ?? change.uri, rootUri);
127
+ const segments = relativePath.split('/').filter(segment => segment.length > 0);
128
+ const name = segments.pop() ?? relativePath;
129
+ let folder = root;
130
+ for (const segment of segments) {
131
+ let child = folder.folders.get(segment);
132
+ if (!child) {
133
+ child = { folders: new Map(), files: [] };
134
+ folder.folders.set(segment, child);
135
+ }
136
+ folder = child;
137
+ }
138
+ folder.files.push({ change, index, name });
139
+ });
140
+
141
+ const rows: ChangeTreeRow[] = [];
142
+ collectChangeTreeRows(root, '', '', 0, collapsedFolders, rows);
143
+ return rows;
144
+ }
145
+
146
+ function collectChangeTreeRows(
147
+ folder: ChangeTreeFolder,
148
+ path: string,
149
+ label: string,
150
+ depth: number,
151
+ collapsedFolders: ReadonlySet<string>,
152
+ rows: ChangeTreeRow[]
153
+ ): void {
154
+ for (const name of [...folder.folders.keys()].sort(compareNames)) {
155
+ let child = folder.folders.get(name)!;
156
+ let childPath = path ? `${path}/${name}` : name;
157
+ let childLabel = label ? `${label}/${name}` : name;
158
+ // Compact a chain of folders that each hold nothing but one folder.
159
+ while (child.files.length === 0 && child.folders.size === 1) {
160
+ const [onlyName, onlyChild] = [...child.folders][0];
161
+ child = onlyChild;
162
+ childPath = `${childPath}/${onlyName}`;
163
+ childLabel = `${childLabel}/${onlyName}`;
164
+ }
165
+ rows.push({ type: 'folder', path: childPath, label: childLabel, depth });
166
+ if (!collapsedFolders.has(childPath)) {
167
+ collectChangeTreeRows(child, childPath, '', depth + 1, collapsedFolders, rows);
168
+ }
169
+ }
170
+ for (const file of [...folder.files].sort((left, right) => compareNames(left.name, right.name))) {
171
+ rows.push({
172
+ type: 'file',
173
+ path: path ? `${path}/${file.name}` : file.name,
174
+ depth,
175
+ change: file.change,
176
+ index: file.index
177
+ });
178
+ }
179
+ }
180
+
181
+ function compareNames(left: string, right: string): number {
182
+ return left.localeCompare(right);
183
+ }
184
+
185
+ /**
186
+ * Returns the ref-based color index of the given ref (0 = current ref,
187
+ * 1 = remote ref, 2 = base ref), or `undefined` when the ref is none of
188
+ * the provider's current history item refs. Mirrors VS Code's scm graph
189
+ * color map.
190
+ */
191
+ export function getRefColorIndex(ref: ScmHistoryItemRef, provider: ScmHistoryProvider | undefined): number | undefined {
192
+ if (!provider) {
193
+ return undefined;
194
+ }
195
+ if (ref.id === provider.currentHistoryItemRef?.id) {
196
+ return 0;
197
+ }
198
+ if (ref.id === provider.currentHistoryItemRemoteRef?.id) {
199
+ return 1;
200
+ }
201
+ if (ref.id === provider.currentHistoryItemBaseRef?.id) {
202
+ return 2;
203
+ }
204
+ return undefined;
205
+ }
206
+
207
+ /**
208
+ * Filters which refs get badges in the graph, following VS Code's
209
+ * `scm.graph.badges` setting: `'all'` shows every ref, `'filter'` shows only
210
+ * the refs used as the graph's filter — the explicitly picked ref ids, or,
211
+ * without an explicit filter (auto mode), the current, remote, and base refs.
212
+ * Without provider information all refs are kept.
213
+ */
214
+ export function filterRefsForBadges(
215
+ refs: readonly ScmHistoryItemRef[],
216
+ provider: ScmHistoryProvider | undefined,
217
+ badges: 'all' | 'filter',
218
+ filter?: readonly string[]
219
+ ): readonly ScmHistoryItemRef[] {
220
+ if (badges === 'all') {
221
+ return refs;
222
+ }
223
+ if (filter) {
224
+ return refs.filter(ref => filter.includes(ref.id));
225
+ }
226
+ if (!provider) {
227
+ return refs;
228
+ }
229
+ return refs.filter(ref => getRefColorIndex(ref, provider) !== undefined);
230
+ }
231
+
232
+ export interface RefBadgePresentation {
233
+ /** Icon class for the badge icon, e.g. 'codicon-git-branch'. */
234
+ iconClass: string;
235
+ /** Ref-based color index (0-2), or `undefined` to use the row color. */
236
+ colorIndex?: number;
237
+ }
238
+
239
+ /**
240
+ * Resolves how a ref badge is presented: the provider-supplied codicon icon
241
+ * when available (VS Code renders `ref.icon` unconditionally), otherwise a
242
+ * category fallback with the `target` icon marking the current ref; plus the
243
+ * ref-based color index.
244
+ */
245
+ export function getRefBadgePresentation(ref: ScmHistoryItemRef, provider: ScmHistoryProvider | undefined): RefBadgePresentation {
246
+ const colorIndex = getRefColorIndex(ref, provider);
247
+ let iconClass: string;
248
+ if (ref.icon && ref.icon.startsWith('codicon ')) {
249
+ iconClass = ref.icon.substring('codicon '.length);
250
+ } else if (isTagRef(ref)) {
251
+ iconClass = 'codicon-tag';
252
+ } else if (isRemoteRef(ref)) {
253
+ iconClass = 'codicon-cloud';
254
+ } else {
255
+ iconClass = colorIndex === 0 ? 'codicon-target' : 'codicon-git-branch';
256
+ }
257
+ return { iconClass, colorIndex };
258
+ }
259
+
86
260
  export function getRefBadgeClass(ref: ScmHistoryItemRef): string {
87
261
  const cat = (ref.category ?? '').toLowerCase();
88
262
  if (cat === 'heads' || cat === 'head' || ref.id.startsWith('refs/heads/')) {
@@ -37,9 +37,14 @@ describe('computeGraphRows', () => {
37
37
  expect(lanes(commits)).to.deep.equal([0, 0, 0]);
38
38
  });
39
39
 
40
- it('colors are all 0', () => {
40
+ it('colors default to the first rotation color (foreground1 = index 3)', () => {
41
41
  const rows = computeGraphRows(commits);
42
- expect(rows.map(r => r.color)).to.deep.equal([0, 0, 0]);
42
+ expect(rows.map(r => r.color)).to.deep.equal([3, 3, 3]);
43
+ });
44
+
45
+ it('topColor equals color when no ref color overrides the lane', () => {
46
+ const rows = computeGraphRows(commits);
47
+ rows.forEach(r => expect(r.topColor).to.equal(r.color));
43
48
  });
44
49
 
45
50
  it('first parent same-lane continuation emits no separate edge (handled by commit line)', () => {
@@ -201,9 +206,13 @@ describe('computeGraphRows', () => {
201
206
  expect(mergeIn.length).to.equal(0);
202
207
  });
203
208
 
204
- it('lane colors are correct modulo 8', () => {
209
+ it('lane colors rotate through the default registry (indices 3-7)', () => {
205
210
  const rows = computeGraphRows(commits);
206
- rows.forEach(r => expect(r.color).to.equal(r.lane % 8));
211
+ // O and P1 share lane 0 → first rotation color; P2/P3 opened new lanes
212
+ expect(rows[0].color).to.equal(3);
213
+ expect(rows[1].color).to.equal(3);
214
+ expect(rows[2].color).to.not.equal(rows[3].color);
215
+ rows.forEach(r => expect(r.color).to.be.within(3, 7));
207
216
  });
208
217
  });
209
218
 
@@ -387,25 +396,14 @@ describe('computeGraphRows', () => {
387
396
  });
388
397
 
389
398
  // -------------------------------------------------------------------------
390
- // Lane color wraps at 8
399
+ // Default color rotation (VS Code cycles scmGraph.foreground1-5)
391
400
  // -------------------------------------------------------------------------
392
- describe('color wrapping', () => {
393
- it('lane color is always lane % 8', () => {
394
- // 4 parallel chains so we have lanes 0, 1, 2, 3 occupied at once.
395
- // Chain structure: X0→X1→X2, Y0→Y1→Y2, etc., interleaved.
396
- const commits = [
397
- { id: 'X0', parentIds: ['X1'] },
398
- { id: 'Y0', parentIds: ['Y1'] },
399
- { id: 'Z0', parentIds: ['Z1'] },
400
- { id: 'W0', parentIds: ['W1'] },
401
- { id: 'X1', parentIds: [] },
402
- { id: 'Y1', parentIds: [] },
403
- { id: 'Z1', parentIds: [] },
404
- { id: 'W1', parentIds: [] },
405
- ];
401
+ describe('default color rotation', () => {
402
+ it('cycles through indices 3-7 and wraps', () => {
403
+ // 6 independent tips → 6 lanes; the sixth wraps back to the first color
404
+ const commits = ['A', 'B', 'C', 'D', 'E', 'F'].map(id => ({ id, parentIds: [id.toLowerCase()] }));
406
405
  const rows = computeGraphRows(commits);
407
- // Every row's color must equal lane % 8
408
- rows.forEach(r => expect(r.color).to.equal(r.lane % 8));
406
+ expect(rows.map(r => r.color)).to.deep.equal([3, 4, 5, 6, 7, 3]);
409
407
  });
410
408
 
411
409
  it('assigns distinct lanes to 8 simultaneous branches', () => {
@@ -423,10 +421,57 @@ describe('computeGraphRows', () => {
423
421
  const rows = computeGraphRows(commits);
424
422
  const laneSet = new Set(rows.map(r => r.lane));
425
423
  expect(laneSet.size).to.equal(8); // all distinct lanes
426
- // Color of lane 7 is 7, lane 0 is 0
427
- const lane7 = rows.find(r => r.lane === 7);
428
- expect(lane7).to.not.be.undefined;
429
- expect(lane7!.color).to.equal(7);
424
+ // Default colors never use the reserved ref color indices 0-2
425
+ rows.forEach(r => expect(r.color).to.be.within(3, 7));
426
+ });
427
+ });
428
+
429
+ // -------------------------------------------------------------------------
430
+ // Ref-based colors (VS Code colorMap: current=0, remote=1, base=2)
431
+ // -------------------------------------------------------------------------
432
+ describe('ref-based colors', () => {
433
+ // Remote is one commit ahead: R (origin/main) → L (main = HEAD) → A
434
+ const behindCommits = [
435
+ { id: 'R', parentIds: ['L'], colorIndex: 1 },
436
+ { id: 'L', parentIds: ['A'], colorIndex: 0 },
437
+ { id: 'A', parentIds: [] },
438
+ ];
439
+
440
+ it('a tip with a color index colors its row', () => {
441
+ const rows = computeGraphRows(behindCommits);
442
+ expect(rows[0].color).to.equal(1);
443
+ });
444
+
445
+ it('an overriding commit recolors the chain from its row downward', () => {
446
+ const rows = computeGraphRows(behindCommits);
447
+ expect(rows[1].color).to.equal(0);
448
+ expect(rows[2].color).to.equal(0);
449
+ });
450
+
451
+ it('topColor keeps the inherited color for the segment above an overriding commit', () => {
452
+ const rows = computeGraphRows(behindCommits);
453
+ expect(rows[0].topColor).to.equal(1);
454
+ expect(rows[1].topColor).to.equal(1);
455
+ expect(rows[2].topColor).to.equal(0);
456
+ });
457
+
458
+ // Diverged: L (main = HEAD) and R (origin/main) both branched off A
459
+ const divergedCommits = [
460
+ { id: 'L', parentIds: ['A'], colorIndex: 0 },
461
+ { id: 'R', parentIds: ['A'], colorIndex: 1 },
462
+ { id: 'A', parentIds: [] },
463
+ ];
464
+
465
+ it('pass-through edges use the propagated ref color', () => {
466
+ const rows = computeGraphRows(divergedCommits);
467
+ const pass = rows[1].edges.find(e => e.type === 'pass-through');
468
+ expect(pass?.color).to.equal(0);
469
+ });
470
+
471
+ it('merge-in edges from a ref-colored lane keep that color', () => {
472
+ const rows = computeGraphRows(divergedCommits);
473
+ const mergeIn = rows[2].edges.find(e => e.type === 'merge-in');
474
+ expect(mergeIn?.color).to.equal(1);
430
475
  });
431
476
  });
432
477
 
@@ -47,8 +47,14 @@ export interface GraphEdge {
47
47
  export interface GraphRow {
48
48
  /** Lane index where the commit node is rendered. */
49
49
  readonly lane: number;
50
- /** Color index for the commit node dot. */
50
+ /** Color index for the commit node dot and the lane segment below it. */
51
51
  readonly color: number;
52
+ /**
53
+ * Color index for the lane segment above the commit node. Differs from
54
+ * `color` when this commit's ref color overrides the color inherited from
55
+ * the chain above (e.g. the current branch tip below remote-only commits).
56
+ */
57
+ readonly topColor: number;
52
58
  /** Edges crossing or originating on this row. */
53
59
  readonly edges: readonly GraphEdge[];
54
60
  /**
@@ -71,11 +77,29 @@ export interface GraphRow {
71
77
  interface MutableGraphRow {
72
78
  lane: number;
73
79
  color: number;
80
+ topColor: number;
74
81
  edges: GraphEdge[];
75
82
  hasContinuation: boolean;
76
83
  hasTopLine: boolean;
77
84
  }
78
85
 
86
+ export interface GraphCommitInput {
87
+ id: string;
88
+ parentIds?: readonly string[];
89
+ /**
90
+ * Ref-based color index resolved from the commit's references
91
+ * (0 = current ref, 1 = remote ref, 2 = base ref). When set, it colors
92
+ * this row and propagates down the first-parent chain, mirroring
93
+ * VS Code's scm graph color map.
94
+ */
95
+ colorIndex?: number;
96
+ }
97
+
98
+ /** First color index of the default rotation (`scmGraph.foreground1`). */
99
+ const DEFAULT_COLOR_FIRST = 3;
100
+ /** Number of default rotation colors (`scmGraph.foreground1-5`). */
101
+ const DEFAULT_COLOR_COUNT = 5;
102
+
79
103
  /**
80
104
  * Compute graph rows for an ordered list of commits (topological order,
81
105
  * newest first). Each commit must supply its own `id` and `parentIds`.
@@ -84,14 +108,22 @@ interface MutableGraphRow {
84
108
  * @returns One `GraphRow` per commit in the same order.
85
109
  */
86
110
  export function computeGraphRows(
87
- commits: ReadonlyArray<{ id: string; parentIds?: readonly string[] }>
111
+ commits: ReadonlyArray<GraphCommitInput>
88
112
  ): GraphRow[] {
89
113
  // lanes[i] = id of commit that "owns" lane i (i.e. we are waiting for this
90
114
  // commit to appear in the list so we can close the lane).
91
115
  const lanes: (string | undefined)[] = [];
92
- // laneColors[i] = the color index permanently assigned to lane i
116
+ // laneColors[i] = the color index currently assigned to lane i
93
117
  const laneColors: (number | undefined)[] = [];
94
118
 
119
+ // Default lane colors rotate through foreground1-5, like VS Code's
120
+ // color registry; indices 0-2 are reserved for ref-based colors.
121
+ let rotation = -1;
122
+ const nextDefaultColor = (): number => {
123
+ rotation = (rotation + 1) % DEFAULT_COLOR_COUNT;
124
+ return DEFAULT_COLOR_FIRST + rotation;
125
+ };
126
+
95
127
  const rows: MutableGraphRow[] = [];
96
128
 
97
129
  for (const commit of commits) {
@@ -106,10 +138,15 @@ export function computeGraphRows(
106
138
  // Not yet tracked → open a new lane
107
139
  myLane = firstFreeLane(lanes);
108
140
  lanes[myLane] = commit.id;
109
- laneColors[myLane] = myLane % 8;
141
+ laneColors[myLane] = commit.colorIndex ?? nextDefaultColor();
110
142
  }
111
143
 
112
- const myColor = laneColors[myLane] ?? myLane % 8;
144
+ // The segment above the commit keeps the color inherited from the
145
+ // chain above; the commit's own ref color (if any) takes over from
146
+ // this row downward.
147
+ const topColor = laneColors[myLane] ?? DEFAULT_COLOR_FIRST;
148
+ const myColor = commit.colorIndex ?? topColor;
149
+ laneColors[myLane] = myColor;
113
150
 
114
151
  // Collect any duplicate lane occupants: other lanes that were kept
115
152
  // alive pointing at this same commit (sibling branch tips whose lane
@@ -120,7 +157,7 @@ export function computeGraphRows(
120
157
  if (hasTopLine) {
121
158
  for (let li = 0; li < lanes.length; li++) {
122
159
  if (lanes[li] === commit.id && li !== myLane) {
123
- duplicateLanes.push({ lane: li, color: laneColors[li] ?? li % 8 });
160
+ duplicateLanes.push({ lane: li, color: laneColors[li] ?? DEFAULT_COLOR_FIRST });
124
161
  lanes[li] = undefined;
125
162
  laneColors[li] = undefined;
126
163
  }
@@ -170,7 +207,7 @@ export function computeGraphRows(
170
207
  // Additional parents get new lanes — branch-out
171
208
  const newLane = firstFreeLane(lanes);
172
209
  lanes[newLane] = pid;
173
- laneColors[newLane] = newLane % 8;
210
+ laneColors[newLane] = nextDefaultColor();
174
211
  parentLanes.push(newLane);
175
212
  parentIsExisting.push(false);
176
213
  }
@@ -206,7 +243,7 @@ export function computeGraphRows(
206
243
  if (!occupant || occupant === commit.id) {
207
244
  continue;
208
245
  }
209
- const color = laneColors[li] ?? li % 8;
246
+ const color = laneColors[li] ?? DEFAULT_COLOR_FIRST;
210
247
  edges.push({ fromLane: li, toLane: li, color, type: 'pass-through' });
211
248
  }
212
249
 
@@ -225,11 +262,11 @@ export function computeGraphRows(
225
262
  if (isExisting) {
226
263
  // Merge-in: an existing lane at the top of the row curves into
227
264
  // the commit position at mid-row.
228
- const color = laneColors[toLane] ?? toLane % 8;
265
+ const color = laneColors[toLane] ?? DEFAULT_COLOR_FIRST;
229
266
  edges.push({ fromLane: toLane, toLane: myLane, color, type: 'merge-in' });
230
267
  } else {
231
268
  // Branch-out: the commit spawns a new lane below mid-row.
232
- const color = laneColors[toLane] ?? toLane % 8;
269
+ const color = laneColors[toLane] ?? DEFAULT_COLOR_FIRST;
233
270
  edges.push({ fromLane: myLane, toLane, color, type: 'branch-out' });
234
271
  }
235
272
  }
@@ -245,7 +282,7 @@ export function computeGraphRows(
245
282
  // at the parent) so it persists as a pass-through to the parent row.
246
283
  const hasContinuation = parentIds.length > 0 && (parentLanes[0] === myLane || lanes[myLane] === parentIds[0]);
247
284
 
248
- rows.push({ lane: myLane, color: myColor, edges, hasContinuation, hasTopLine });
285
+ rows.push({ lane: myLane, color: myColor, topColor, edges, hasContinuation, hasTopLine });
249
286
  }
250
287
 
251
288
  return rows;