@theia/scm 1.75.0-next.3 → 1.75.0-next.35

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 +44 -0
  14. package/lib/browser/scm-history-graph-contribution.d.ts.map +1 -0
  15. package/lib/browser/scm-history-graph-contribution.js +226 -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 +66 -0
  20. package/lib/browser/scm-history-graph-contribution.spec.js.map +1 -0
  21. package/lib/browser/scm-history-graph-helpers.d.ts +34 -6
  22. package/lib/browser/scm-history-graph-helpers.d.ts.map +1 -1
  23. package/lib/browser/scm-history-graph-helpers.js +71 -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 +96 -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 +52 -5
  36. package/lib/browser/scm-history-graph-model.d.ts.map +1 -1
  37. package/lib/browser/scm-history-graph-model.js +108 -10
  38. package/lib/browser/scm-history-graph-model.js.map +1 -1
  39. package/lib/browser/scm-history-graph-model.spec.js +197 -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 +23 -1
  50. package/lib/browser/scm-history-graph-widget.d.ts.map +1 -1
  51. package/lib/browser/scm-history-graph-widget.js +93 -24
  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 +100 -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 +83 -0
  70. package/src/browser/scm-history-graph-contribution.ts +222 -0
  71. package/src/browser/scm-history-graph-helpers.spec.ts +118 -0
  72. package/src/browser/scm-history-graph-helpers.ts +81 -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 +247 -12
  76. package/src/browser/scm-history-graph-model.ts +125 -12
  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 +112 -0
  80. package/src/browser/scm-history-graph-widget.tsx +104 -35
  81. package/src/browser/scm-provider.ts +9 -1
  82. package/src/browser/style/scm-history-graph.css +43 -2
  83. package/src/common/scm-preferences.ts +27 -0
@@ -0,0 +1,222 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 Safi Seid-Ahmad, K2view 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 { injectable, inject, named, optional } from '@theia/core/shared/inversify';
18
+ import { ILogger } from '@theia/core';
19
+ import { Command, CommandContribution, CommandRegistry } from '@theia/core/lib/common/command';
20
+ import { MenuContribution, MenuModelRegistry } from '@theia/core/lib/common/menu';
21
+ import { nls } from '@theia/core/lib/common/nls';
22
+ import { CancellationTokenSource } from '@theia/core/lib/common/cancellation';
23
+ import { QuickInputService } from '@theia/core/lib/browser';
24
+ import { QuickPickItem, QuickPickSeparator } from '@theia/core/lib/common/quick-pick-service';
25
+ import { codicon } from '@theia/core/lib/browser/widgets/widget';
26
+ import { ScmHistoryGraphModel, ScmHistoryGraphModelProvider } from './scm-history-graph-model';
27
+ import { ScmService } from './scm-service';
28
+ import { ScmHistoryItemRef, ScmHistoryProvider } from './scm-provider';
29
+ import { SCM_HISTORY_TITLE_MENU } from './scm-history-graph-widget';
30
+ import { getRefBadgeClass } from './scm-history-graph-helpers';
31
+
32
+ export namespace ScmHistoryGraphCommands {
33
+ export const REFRESH = Command.toDefaultLocalizedCommand({
34
+ id: 'scmHistoryGraph.refresh',
35
+ category: 'Source Control',
36
+ label: 'Refresh',
37
+ iconClass: codicon('refresh')
38
+ });
39
+ export const PICK_HISTORY_ITEM_REFS = Command.toLocalizedCommand({
40
+ id: 'scmHistoryGraph.pickHistoryItemRefs',
41
+ category: 'Source Control',
42
+ label: 'Pick History Item Reference...',
43
+ iconClass: codicon('git-branch')
44
+ }, 'theia/scm/pickHistoryItemRefs');
45
+ }
46
+
47
+ type RefPickItem = QuickPickItem & { refId?: string; auto?: boolean };
48
+
49
+ /**
50
+ * Contributes the native toolbar actions of the SCM history graph: the
51
+ * history item reference filter picker (VS Code's "Auto" dropdown) and the
52
+ * refresh action. Extension actions (e.g. fetch/pull/push from vscode.git)
53
+ * are contributed separately via the `scm/history/title` menu.
54
+ */
55
+ @injectable()
56
+ export class ScmHistoryGraphContribution implements CommandContribution, MenuContribution {
57
+
58
+ @inject(ScmService) protected readonly scmService: ScmService;
59
+ @inject(ScmHistoryGraphModelProvider) protected readonly modelProvider: ScmHistoryGraphModelProvider;
60
+ @inject(QuickInputService) @optional() protected readonly quickInputService: QuickInputService;
61
+ @inject(ILogger) @named('scm:ScmHistoryGraphContribution') protected readonly logger: ILogger;
62
+
63
+ /**
64
+ * The graph model, resolved lazily: instantiating it starts loading
65
+ * history, so it must only be resolved when a graph command actually
66
+ * runs or the graph toolbar renders (the widget has created the model
67
+ * by then). In particular, `isEnabled`/`isVisible` are also queried by
68
+ * the command palette and therefore consult the `ScmService` instead.
69
+ */
70
+ protected get model(): ScmHistoryGraphModel {
71
+ return this.modelProvider();
72
+ }
73
+
74
+ protected get historyProvider(): ScmHistoryProvider | undefined {
75
+ return this.scmService.selectedRepository?.provider.historyProvider;
76
+ }
77
+
78
+ registerCommands(commands: CommandRegistry): void {
79
+ commands.registerCommand(ScmHistoryGraphCommands.REFRESH, {
80
+ isEnabled: () => !!this.historyProvider,
81
+ isVisible: () => !!this.historyProvider,
82
+ execute: () => this.model.refresh()
83
+ });
84
+ commands.registerCommand(ScmHistoryGraphCommands.PICK_HISTORY_ITEM_REFS, {
85
+ isEnabled: () => !!this.historyProvider && !!this.quickInputService,
86
+ isVisible: () => !!this.historyProvider,
87
+ isToggled: () => !!this.model.historyItemRefFilter,
88
+ execute: () => this.pickHistoryItemRefs()
89
+ });
90
+ }
91
+
92
+ registerMenus(menus: MenuModelRegistry): void {
93
+ // Extension actions from vscode.git sit at order 900-902; the picker
94
+ // goes before them and refresh last, matching VS Code's graph toolbar.
95
+ menus.registerMenuAction([...SCM_HISTORY_TITLE_MENU, 'navigation'], {
96
+ commandId: ScmHistoryGraphCommands.PICK_HISTORY_ITEM_REFS.id,
97
+ order: '100'
98
+ });
99
+ menus.registerMenuAction([...SCM_HISTORY_TITLE_MENU, 'navigation'], {
100
+ commandId: ScmHistoryGraphCommands.REFRESH.id,
101
+ order: '999'
102
+ });
103
+ }
104
+
105
+ protected async pickHistoryItemRefs(): Promise<void> {
106
+ if (!this.quickInputService) {
107
+ return;
108
+ }
109
+ const model = this.model;
110
+ const provider = model.provider;
111
+ if (!provider) {
112
+ return;
113
+ }
114
+ const picker = this.quickInputService.createQuickPick<RefPickItem>();
115
+ const cts = new CancellationTokenSource();
116
+ try {
117
+ picker.placeholder = nls.localize('theia/scm/pickHistoryItemRefsPlaceholder', 'Select one or more history item references');
118
+ picker.canSelectMany = true;
119
+ picker.matchOnDescription = true;
120
+ // Show the picker right away and populate it once the refs arrive,
121
+ // so that a slow ref lookup surfaces as a busy picker instead of a
122
+ // command that appears to do nothing.
123
+ picker.busy = true;
124
+
125
+ const selection = await new Promise<readonly RefPickItem[] | undefined>(resolve => {
126
+ picker.onDidAccept(() => {
127
+ resolve(picker.selectedItems);
128
+ picker.hide();
129
+ });
130
+ // Also cancels a still-running ref lookup when the picker is dismissed.
131
+ picker.onDidHide(() => {
132
+ cts.cancel();
133
+ resolve(undefined);
134
+ });
135
+ picker.show();
136
+
137
+ provider.provideHistoryItemRefs(undefined, cts.token).then(refs => {
138
+ if (cts.token.isCancellationRequested) {
139
+ return;
140
+ }
141
+ const currentFilter = model.historyItemRefFilter;
142
+ const items = this.createRefPickItems(refs ?? [], provider);
143
+ picker.items = items;
144
+ picker.selectedItems = items.filter((item): item is RefPickItem => !QuickPickSeparator.is(item)
145
+ && (currentFilter ? !!item.refId && currentFilter.includes(item.refId) : item.auto === true));
146
+
147
+ // "Auto" is exclusive: checking it unchecks everything else and vice versa
148
+ let lastSelection: readonly RefPickItem[] = picker.selectedItems;
149
+ picker.onDidChangeSelection(newSelection => {
150
+ const autoNow = newSelection.some(item => item.auto);
151
+ if (autoNow && newSelection.length > 1) {
152
+ const autoBefore = lastSelection.some(item => item.auto);
153
+ picker.selectedItems = autoBefore ? newSelection.filter(item => !item.auto) : newSelection.filter(item => item.auto);
154
+ }
155
+ lastSelection = picker.selectedItems;
156
+ });
157
+ picker.busy = false;
158
+ }, err => {
159
+ if (!cts.token.isCancellationRequested) {
160
+ this.logger.error('Failed to load history item refs', err);
161
+ }
162
+ picker.hide();
163
+ });
164
+ });
165
+
166
+ if (!selection) {
167
+ return;
168
+ }
169
+ // The selected repository may have changed while the picker was open;
170
+ // the picked refs belong to the old provider and must not be applied
171
+ // to the new one.
172
+ if (model.provider !== provider) {
173
+ return;
174
+ }
175
+ const refIds = selection.filter(item => item.refId !== undefined).map(item => item.refId!);
176
+ const auto = selection.some(item => item.auto) || refIds.length === 0;
177
+ model.setHistoryItemRefFilter(auto ? undefined : refIds);
178
+ } finally {
179
+ picker.dispose();
180
+ cts.dispose();
181
+ }
182
+ }
183
+
184
+ protected createRefPickItems(refs: readonly ScmHistoryItemRef[], provider: ScmHistoryProvider): (RefPickItem | QuickPickSeparator)[] {
185
+ const autoRefs = [provider.currentHistoryItemRef, provider.currentHistoryItemRemoteRef, provider.currentHistoryItemBaseRef]
186
+ .filter((ref): ref is ScmHistoryItemRef => !!ref);
187
+ const items: (RefPickItem | QuickPickSeparator)[] = [{
188
+ label: nls.localizeByDefault('Auto'),
189
+ description: autoRefs.map(ref => ref.name).join(', '),
190
+ auto: true
191
+ }];
192
+
193
+ const groups: { label: string; badgeClass: string }[] = [
194
+ { label: nls.localize('theia/scm/refPickerBranches', 'branches'), badgeClass: 'head' },
195
+ { label: nls.localize('theia/scm/refPickerRemoteBranches', 'remote branches'), badgeClass: 'remote' },
196
+ { label: nls.localize('theia/scm/refPickerTags', 'tags'), badgeClass: 'tag' }
197
+ ];
198
+ const grouped = new Set<ScmHistoryItemRef>();
199
+ for (const group of groups) {
200
+ const groupRefs = refs.filter(ref => getRefBadgeClass(ref) === group.badgeClass);
201
+ if (groupRefs.length > 0) {
202
+ groupRefs.forEach(ref => grouped.add(ref));
203
+ items.push({ type: 'separator', label: group.label });
204
+ items.push(...groupRefs.map(ref => this.toRefPickItem(ref)));
205
+ }
206
+ }
207
+ const remaining = refs.filter(ref => !grouped.has(ref));
208
+ if (remaining.length > 0) {
209
+ items.push({ type: 'separator' });
210
+ items.push(...remaining.map(ref => this.toRefPickItem(ref)));
211
+ }
212
+ return items;
213
+ }
214
+
215
+ protected toRefPickItem(ref: ScmHistoryItemRef): RefPickItem {
216
+ return {
217
+ label: ref.name,
218
+ description: ref.description,
219
+ refId: ref.id
220
+ };
221
+ }
222
+ }
@@ -0,0 +1,118 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 Safi Seid-Ahmad, K2view 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 { expect } from 'chai';
18
+ import { filterRefsForBadges, getRefBadgePresentation, getRefColorIndex } from './scm-history-graph-helpers';
19
+ import { ScmHistoryItemRef, ScmHistoryProvider } from './scm-provider';
20
+
21
+ describe('getRefColorIndex', () => {
22
+
23
+ const mainRef: ScmHistoryItemRef = { id: 'refs/heads/main', name: 'main' };
24
+ const remoteRef: ScmHistoryItemRef = { id: 'refs/remotes/origin/main', name: 'origin/main' };
25
+ const baseRef: ScmHistoryItemRef = { id: 'refs/remotes/origin/develop', name: 'origin/develop' };
26
+ const otherRef: ScmHistoryItemRef = { id: 'refs/heads/feature', name: 'feature' };
27
+
28
+ const provider = {
29
+ currentHistoryItemRef: mainRef,
30
+ currentHistoryItemRemoteRef: remoteRef,
31
+ currentHistoryItemBaseRef: baseRef,
32
+ } as ScmHistoryProvider;
33
+
34
+ it('returns 0 for the current history item ref', () => {
35
+ expect(getRefColorIndex(mainRef, provider)).to.equal(0);
36
+ });
37
+
38
+ it('returns 1 for the current remote ref', () => {
39
+ expect(getRefColorIndex(remoteRef, provider)).to.equal(1);
40
+ });
41
+
42
+ it('returns 2 for the current base ref', () => {
43
+ expect(getRefColorIndex(baseRef, provider)).to.equal(2);
44
+ });
45
+
46
+ it('returns undefined for any other ref', () => {
47
+ expect(getRefColorIndex(otherRef, provider)).to.be.undefined;
48
+ });
49
+
50
+ it('returns undefined without a provider', () => {
51
+ expect(getRefColorIndex(mainRef, undefined)).to.be.undefined;
52
+ });
53
+ });
54
+
55
+ describe('getRefBadgePresentation', () => {
56
+
57
+ const mainRef: ScmHistoryItemRef = { id: 'refs/heads/main', name: 'main' };
58
+ const remoteRef: ScmHistoryItemRef = { id: 'refs/remotes/origin/main', name: 'origin/main' };
59
+
60
+ const provider = {
61
+ currentHistoryItemRef: mainRef,
62
+ currentHistoryItemRemoteRef: remoteRef,
63
+ } as ScmHistoryProvider;
64
+
65
+ it('prefers the codicon icon transferred from the provider', () => {
66
+ const ref: ScmHistoryItemRef = { ...mainRef, icon: 'codicon codicon-target' };
67
+ expect(getRefBadgePresentation(ref, undefined).iconClass).to.equal('codicon-target');
68
+ });
69
+
70
+ it('falls back to the target icon for the current ref', () => {
71
+ expect(getRefBadgePresentation(mainRef, provider).iconClass).to.equal('codicon-target');
72
+ });
73
+
74
+ it('falls back to category icons for remote, tag, and other local refs', () => {
75
+ expect(getRefBadgePresentation(remoteRef, provider).iconClass).to.equal('codicon-cloud');
76
+ expect(getRefBadgePresentation({ id: 'refs/tags/v1', name: 'v1' }, provider).iconClass).to.equal('codicon-tag');
77
+ expect(getRefBadgePresentation({ id: 'refs/heads/feature', name: 'feature' }, provider).iconClass).to.equal('codicon-git-branch');
78
+ });
79
+
80
+ it('carries the ref color index', () => {
81
+ expect(getRefBadgePresentation(mainRef, provider).colorIndex).to.equal(0);
82
+ expect(getRefBadgePresentation(remoteRef, provider).colorIndex).to.equal(1);
83
+ expect(getRefBadgePresentation({ id: 'refs/heads/feature', name: 'feature' }, provider).colorIndex).to.be.undefined;
84
+ });
85
+ });
86
+
87
+ describe('filterRefsForBadges', () => {
88
+
89
+ const mainRef: ScmHistoryItemRef = { id: 'refs/heads/main', name: 'main' };
90
+ const remoteRef: ScmHistoryItemRef = { id: 'refs/remotes/origin/main', name: 'origin/main' };
91
+ const featureRef: ScmHistoryItemRef = { id: 'refs/heads/feature', name: 'feature' };
92
+ const refs = [mainRef, remoteRef, featureRef];
93
+
94
+ const provider = {
95
+ currentHistoryItemRef: mainRef,
96
+ currentHistoryItemRemoteRef: remoteRef,
97
+ } as ScmHistoryProvider;
98
+
99
+ it("returns all refs in 'all' mode", () => {
100
+ expect(filterRefsForBadges(refs, provider, 'all')).to.deep.equal(refs);
101
+ });
102
+
103
+ it("keeps only the refs used as a filter in 'filter' mode", () => {
104
+ expect(filterRefsForBadges(refs, provider, 'filter')).to.deep.equal([mainRef, remoteRef]);
105
+ });
106
+
107
+ it("returns all refs in 'filter' mode when no provider is available", () => {
108
+ expect(filterRefsForBadges(refs, undefined, 'filter')).to.deep.equal(refs);
109
+ });
110
+
111
+ it("keeps only the explicitly picked refs in 'filter' mode with an explicit filter", () => {
112
+ expect(filterRefsForBadges(refs, provider, 'filter', ['refs/heads/feature'])).to.deep.equal([featureRef]);
113
+ });
114
+
115
+ it("ignores an explicit filter in 'all' mode", () => {
116
+ expect(filterRefsForBadges(refs, provider, 'all', ['refs/heads/feature'])).to.deep.equal(refs);
117
+ });
118
+ });
@@ -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,81 @@ export function getRepoRelativePath(uri: string, rootUri: string | undefined): s
83
83
  return fullPath;
84
84
  }
85
85
 
86
+ /**
87
+ * Returns the ref-based color index of the given ref (0 = current ref,
88
+ * 1 = remote ref, 2 = base ref), or `undefined` when the ref is none of
89
+ * the provider's current history item refs. Mirrors VS Code's scm graph
90
+ * color map.
91
+ */
92
+ export function getRefColorIndex(ref: ScmHistoryItemRef, provider: ScmHistoryProvider | undefined): number | undefined {
93
+ if (!provider) {
94
+ return undefined;
95
+ }
96
+ if (ref.id === provider.currentHistoryItemRef?.id) {
97
+ return 0;
98
+ }
99
+ if (ref.id === provider.currentHistoryItemRemoteRef?.id) {
100
+ return 1;
101
+ }
102
+ if (ref.id === provider.currentHistoryItemBaseRef?.id) {
103
+ return 2;
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ /**
109
+ * Filters which refs get badges in the graph, following VS Code's
110
+ * `scm.graph.badges` setting: `'all'` shows every ref, `'filter'` shows only
111
+ * the refs used as the graph's filter — the explicitly picked ref ids, or,
112
+ * without an explicit filter (auto mode), the current, remote, and base refs.
113
+ * Without provider information all refs are kept.
114
+ */
115
+ export function filterRefsForBadges(
116
+ refs: readonly ScmHistoryItemRef[],
117
+ provider: ScmHistoryProvider | undefined,
118
+ badges: 'all' | 'filter',
119
+ filter?: readonly string[]
120
+ ): readonly ScmHistoryItemRef[] {
121
+ if (badges === 'all') {
122
+ return refs;
123
+ }
124
+ if (filter) {
125
+ return refs.filter(ref => filter.includes(ref.id));
126
+ }
127
+ if (!provider) {
128
+ return refs;
129
+ }
130
+ return refs.filter(ref => getRefColorIndex(ref, provider) !== undefined);
131
+ }
132
+
133
+ export interface RefBadgePresentation {
134
+ /** Icon class for the badge icon, e.g. 'codicon-git-branch'. */
135
+ iconClass: string;
136
+ /** Ref-based color index (0-2), or `undefined` to use the row color. */
137
+ colorIndex?: number;
138
+ }
139
+
140
+ /**
141
+ * Resolves how a ref badge is presented: the provider-supplied codicon icon
142
+ * when available (VS Code renders `ref.icon` unconditionally), otherwise a
143
+ * category fallback with the `target` icon marking the current ref; plus the
144
+ * ref-based color index.
145
+ */
146
+ export function getRefBadgePresentation(ref: ScmHistoryItemRef, provider: ScmHistoryProvider | undefined): RefBadgePresentation {
147
+ const colorIndex = getRefColorIndex(ref, provider);
148
+ let iconClass: string;
149
+ if (ref.icon && ref.icon.startsWith('codicon ')) {
150
+ iconClass = ref.icon.substring('codicon '.length);
151
+ } else if (isTagRef(ref)) {
152
+ iconClass = 'codicon-tag';
153
+ } else if (isRemoteRef(ref)) {
154
+ iconClass = 'codicon-cloud';
155
+ } else {
156
+ iconClass = colorIndex === 0 ? 'codicon-target' : 'codicon-git-branch';
157
+ }
158
+ return { iconClass, colorIndex };
159
+ }
160
+
86
161
  export function getRefBadgeClass(ref: ScmHistoryItemRef): string {
87
162
  const cat = (ref.category ?? '').toLowerCase();
88
163
  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