@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
@@ -0,0 +1,148 @@
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 { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
18
+ const disableJSDOM = enableJSDOM();
19
+
20
+ import { expect } from 'chai';
21
+ import { Command, CommandHandler, CommandRegistry } from '@theia/core/lib/common/command';
22
+ import { Disposable } from '@theia/core/lib/common/disposable';
23
+ import { ScmHistoryGraphContribution, ScmHistoryGraphCommands } from './scm-history-graph-contribution';
24
+ import { ScmHistoryGraphModel, ScmHistoryViewMode } from './scm-history-graph-model';
25
+ import { ScmHistoryProvider } from './scm-provider';
26
+
27
+ disableJSDOM();
28
+
29
+ interface ContributionInternals {
30
+ scmService: { selectedRepository: { provider: { historyProvider?: ScmHistoryProvider } } | undefined };
31
+ modelProvider: () => ScmHistoryGraphModel;
32
+ quickInputService: unknown;
33
+ scmContextKeys: unknown;
34
+ }
35
+
36
+ const ALL_COMMANDS = [
37
+ ScmHistoryGraphCommands.REFRESH,
38
+ ScmHistoryGraphCommands.PICK_HISTORY_ITEM_REFS,
39
+ ScmHistoryGraphCommands.REVEAL_CURRENT_HISTORY_ITEM,
40
+ ScmHistoryGraphCommands.SET_LIST_VIEW_MODE,
41
+ ScmHistoryGraphCommands.SET_TREE_VIEW_MODE,
42
+ ];
43
+
44
+ function createContribution(historyProvider: ScmHistoryProvider | undefined, currentRefInFilter = true): {
45
+ handlers: Map<string, CommandHandler>;
46
+ modelResolved(): boolean;
47
+ revealCount(): number;
48
+ viewMode(): ScmHistoryViewMode;
49
+ } {
50
+ const contribution = new ScmHistoryGraphContribution();
51
+ let resolved = false;
52
+ let reveals = 0;
53
+ let viewMode: ScmHistoryViewMode = 'list';
54
+ const internals = contribution as unknown as ContributionInternals;
55
+ internals.scmService = {
56
+ selectedRepository: historyProvider ? { provider: { historyProvider } } : undefined,
57
+ };
58
+ internals.modelProvider = () => {
59
+ resolved = true;
60
+ return {
61
+ historyItemRefFilter: undefined,
62
+ revealCurrentHistoryItem: async () => { reveals++; return undefined; },
63
+ get viewMode(): ScmHistoryViewMode { return viewMode; },
64
+ setViewMode: (mode: ScmHistoryViewMode) => { viewMode = mode; },
65
+ } as unknown as ScmHistoryGraphModel;
66
+ };
67
+ internals.quickInputService = {};
68
+ internals.scmContextKeys = {
69
+ scmCurrentHistoryItemRefInFilter: { get: () => currentRefInFilter },
70
+ };
71
+
72
+ const handlers = new Map<string, CommandHandler>();
73
+ const registry = {
74
+ registerCommand: (command: Command, handler: CommandHandler): Disposable => {
75
+ handlers.set(command.id, handler);
76
+ return Disposable.NULL;
77
+ },
78
+ } as unknown as CommandRegistry;
79
+ contribution.registerCommands(registry);
80
+ return { handlers, modelResolved: () => resolved, revealCount: () => reveals, viewMode: () => viewMode };
81
+ }
82
+
83
+ describe('ScmHistoryGraphContribution', () => {
84
+
85
+ it('does not resolve the graph model for enablement and visibility checks', () => {
86
+ const { handlers, modelResolved } = createContribution({} as ScmHistoryProvider);
87
+ for (const command of ALL_COMMANDS) {
88
+ const handler = handlers.get(command.id)!;
89
+ expect(handler.isEnabled!()).to.be.true;
90
+ expect(handler.isVisible!()).to.be.true;
91
+ }
92
+ expect(modelResolved()).to.be.false;
93
+ });
94
+
95
+ it('disables the commands when the selected repository has no history provider', () => {
96
+ const { handlers, modelResolved } = createContribution(undefined);
97
+ for (const command of ALL_COMMANDS) {
98
+ const handler = handlers.get(command.id)!;
99
+ expect(handler.isEnabled!()).to.be.false;
100
+ expect(handler.isVisible!()).to.be.false;
101
+ }
102
+ expect(modelResolved()).to.be.false;
103
+ });
104
+
105
+ it('disables revealing the current history item when the filter excludes the current ref', () => {
106
+ const { handlers } = createContribution({} as ScmHistoryProvider, false);
107
+ const handler = handlers.get(ScmHistoryGraphCommands.REVEAL_CURRENT_HISTORY_ITEM.id)!;
108
+ expect(handler.isEnabled!()).to.be.false;
109
+ });
110
+
111
+ it('keeps revealing the current history item visible when the filter excludes the current ref', () => {
112
+ const { handlers } = createContribution({} as ScmHistoryProvider, false);
113
+ const handler = handlers.get(ScmHistoryGraphCommands.REVEAL_CURRENT_HISTORY_ITEM.id)!;
114
+ expect(handler.isVisible!()).to.be.true;
115
+ });
116
+
117
+ it('asks the model to reveal the current history item when executed', async () => {
118
+ const { handlers, revealCount } = createContribution({} as ScmHistoryProvider);
119
+ await handlers.get(ScmHistoryGraphCommands.REVEAL_CURRENT_HISTORY_ITEM.id)!.execute();
120
+ expect(revealCount()).to.equal(1);
121
+ });
122
+
123
+ it('switches the model to the tree view mode', async () => {
124
+ const { handlers, viewMode } = createContribution({} as ScmHistoryProvider);
125
+ await handlers.get(ScmHistoryGraphCommands.SET_TREE_VIEW_MODE.id)!.execute();
126
+ expect(viewMode()).to.equal('tree');
127
+ });
128
+
129
+ it('switches the model back to the list view mode', async () => {
130
+ const { handlers, viewMode } = createContribution({} as ScmHistoryProvider);
131
+ await handlers.get(ScmHistoryGraphCommands.SET_TREE_VIEW_MODE.id)!.execute();
132
+ await handlers.get(ScmHistoryGraphCommands.SET_LIST_VIEW_MODE.id)!.execute();
133
+ expect(viewMode()).to.equal('list');
134
+ });
135
+
136
+ it('toggles the view mode command matching the current view mode', async () => {
137
+ const { handlers } = createContribution({} as ScmHistoryProvider);
138
+ const list = handlers.get(ScmHistoryGraphCommands.SET_LIST_VIEW_MODE.id)!;
139
+ const tree = handlers.get(ScmHistoryGraphCommands.SET_TREE_VIEW_MODE.id)!;
140
+ expect(list.isToggled!()).to.be.true;
141
+ expect(tree.isToggled!()).to.be.false;
142
+
143
+ await tree.execute();
144
+
145
+ expect(list.isToggled!()).to.be.false;
146
+ expect(tree.isToggled!()).to.be.true;
147
+ });
148
+ });
@@ -0,0 +1,273 @@
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 { ScmContextKeyService } from './scm-context-key-service';
29
+ import { ScmHistoryItemRef, ScmHistoryProvider } from './scm-provider';
30
+ import { SCM_HISTORY_TITLE_MENU } from './scm-history-graph-widget';
31
+ import { getRefBadgeClass } from './scm-history-graph-helpers';
32
+
33
+ export namespace ScmHistoryGraphCommands {
34
+ export const REFRESH = Command.toDefaultLocalizedCommand({
35
+ id: 'scmHistoryGraph.refresh',
36
+ category: 'Source Control',
37
+ label: 'Refresh',
38
+ iconClass: codicon('refresh')
39
+ });
40
+ export const PICK_HISTORY_ITEM_REFS = Command.toLocalizedCommand({
41
+ id: 'scmHistoryGraph.pickHistoryItemRefs',
42
+ category: 'Source Control',
43
+ label: 'Pick History Item Reference...',
44
+ iconClass: codicon('git-branch')
45
+ }, 'theia/scm/pickHistoryItemRefs');
46
+ export const REVEAL_CURRENT_HISTORY_ITEM = Command.toDefaultLocalizedCommand({
47
+ id: 'scmHistoryGraph.revealCurrentHistoryItem',
48
+ category: 'Source Control',
49
+ label: 'Go to Current History Item',
50
+ iconClass: codicon('target')
51
+ });
52
+ export const SET_LIST_VIEW_MODE = Command.toDefaultLocalizedCommand({
53
+ id: 'scmHistoryGraph.setListViewMode',
54
+ category: 'Source Control',
55
+ label: 'View as List'
56
+ });
57
+ export const SET_TREE_VIEW_MODE = Command.toDefaultLocalizedCommand({
58
+ id: 'scmHistoryGraph.setTreeViewMode',
59
+ category: 'Source Control',
60
+ label: 'View as Tree'
61
+ });
62
+ }
63
+
64
+ type RefPickItem = QuickPickItem & { refId?: string; auto?: boolean };
65
+
66
+ /**
67
+ * Contributes the native toolbar actions of the SCM history graph: the
68
+ * history item reference filter picker (VS Code's "Auto" dropdown) and the
69
+ * refresh action. Extension actions (e.g. fetch/pull/push from vscode.git)
70
+ * are contributed separately via the `scm/history/title` menu.
71
+ */
72
+ @injectable()
73
+ export class ScmHistoryGraphContribution implements CommandContribution, MenuContribution {
74
+
75
+ @inject(ScmService) protected readonly scmService: ScmService;
76
+ @inject(ScmHistoryGraphModelProvider) protected readonly modelProvider: ScmHistoryGraphModelProvider;
77
+ @inject(QuickInputService) @optional() protected readonly quickInputService: QuickInputService;
78
+ @inject(ILogger) @named('scm:ScmHistoryGraphContribution') protected readonly logger: ILogger;
79
+ @inject(ScmContextKeyService) protected readonly scmContextKeys: ScmContextKeyService;
80
+
81
+ /**
82
+ * The graph model, resolved lazily: instantiating it starts loading
83
+ * history, so it must only be resolved when a graph command actually
84
+ * runs or the graph toolbar renders (the widget has created the model
85
+ * by then). In particular, `isEnabled`/`isVisible` are also queried by
86
+ * the command palette and therefore consult the `ScmService` instead.
87
+ */
88
+ protected get model(): ScmHistoryGraphModel {
89
+ return this.modelProvider();
90
+ }
91
+
92
+ protected get historyProvider(): ScmHistoryProvider | undefined {
93
+ return this.scmService.selectedRepository?.provider.historyProvider;
94
+ }
95
+
96
+ registerCommands(commands: CommandRegistry): void {
97
+ commands.registerCommand(ScmHistoryGraphCommands.REFRESH, {
98
+ isEnabled: () => !!this.historyProvider,
99
+ isVisible: () => !!this.historyProvider,
100
+ execute: () => this.model.refresh()
101
+ });
102
+ commands.registerCommand(ScmHistoryGraphCommands.PICK_HISTORY_ITEM_REFS, {
103
+ isEnabled: () => !!this.historyProvider && !!this.quickInputService,
104
+ isVisible: () => !!this.historyProvider,
105
+ isToggled: () => !!this.model.historyItemRefFilter,
106
+ execute: () => this.pickHistoryItemRefs()
107
+ });
108
+ commands.registerCommand(ScmHistoryGraphCommands.REVEAL_CURRENT_HISTORY_ITEM, {
109
+ // There is nothing to reveal while the filter excludes the current ref,
110
+ // mirroring VS Code's precondition for this action.
111
+ isEnabled: () => !!this.historyProvider && this.scmContextKeys.scmCurrentHistoryItemRefInFilter.get() === true,
112
+ isVisible: () => !!this.historyProvider,
113
+ execute: () => this.model.revealCurrentHistoryItem()
114
+ });
115
+ commands.registerCommand(ScmHistoryGraphCommands.SET_LIST_VIEW_MODE, {
116
+ isEnabled: () => !!this.historyProvider,
117
+ isVisible: () => !!this.historyProvider,
118
+ isToggled: () => this.model.viewMode === 'list',
119
+ execute: () => this.model.setViewMode('list')
120
+ });
121
+ commands.registerCommand(ScmHistoryGraphCommands.SET_TREE_VIEW_MODE, {
122
+ isEnabled: () => !!this.historyProvider,
123
+ isVisible: () => !!this.historyProvider,
124
+ isToggled: () => this.model.viewMode === 'tree',
125
+ execute: () => this.model.setViewMode('tree')
126
+ });
127
+ }
128
+
129
+ registerMenus(menus: MenuModelRegistry): void {
130
+ // Extension actions from vscode.git sit at order 900-902; the picker
131
+ // goes before them and refresh last, matching VS Code's graph toolbar.
132
+ menus.registerMenuAction([...SCM_HISTORY_TITLE_MENU, 'navigation'], {
133
+ commandId: ScmHistoryGraphCommands.PICK_HISTORY_ITEM_REFS.id,
134
+ order: '100'
135
+ });
136
+ menus.registerMenuAction([...SCM_HISTORY_TITLE_MENU, 'navigation'], {
137
+ commandId: ScmHistoryGraphCommands.REVEAL_CURRENT_HISTORY_ITEM.id,
138
+ order: '200'
139
+ });
140
+ menus.registerMenuAction([...SCM_HISTORY_TITLE_MENU, 'navigation'], {
141
+ commandId: ScmHistoryGraphCommands.REFRESH.id,
142
+ order: '999'
143
+ });
144
+ // Groups other than 'navigation' are rendered in the toolbar's overflow menu,
145
+ // which is where VS Code places the view mode toggles as well.
146
+ menus.registerMenuAction([...SCM_HISTORY_TITLE_MENU, '9_viewmode'], {
147
+ commandId: ScmHistoryGraphCommands.SET_LIST_VIEW_MODE.id,
148
+ order: '1'
149
+ });
150
+ menus.registerMenuAction([...SCM_HISTORY_TITLE_MENU, '9_viewmode'], {
151
+ commandId: ScmHistoryGraphCommands.SET_TREE_VIEW_MODE.id,
152
+ order: '2'
153
+ });
154
+ }
155
+
156
+ protected async pickHistoryItemRefs(): Promise<void> {
157
+ if (!this.quickInputService) {
158
+ return;
159
+ }
160
+ const model = this.model;
161
+ const provider = model.provider;
162
+ if (!provider) {
163
+ return;
164
+ }
165
+ const picker = this.quickInputService.createQuickPick<RefPickItem>();
166
+ const cts = new CancellationTokenSource();
167
+ try {
168
+ picker.placeholder = nls.localize('theia/scm/pickHistoryItemRefsPlaceholder', 'Select one or more history item references');
169
+ picker.canSelectMany = true;
170
+ picker.matchOnDescription = true;
171
+ // Show the picker right away and populate it once the refs arrive,
172
+ // so that a slow ref lookup surfaces as a busy picker instead of a
173
+ // command that appears to do nothing.
174
+ picker.busy = true;
175
+
176
+ const selection = await new Promise<readonly RefPickItem[] | undefined>(resolve => {
177
+ picker.onDidAccept(() => {
178
+ resolve(picker.selectedItems);
179
+ picker.hide();
180
+ });
181
+ // Also cancels a still-running ref lookup when the picker is dismissed.
182
+ picker.onDidHide(() => {
183
+ cts.cancel();
184
+ resolve(undefined);
185
+ });
186
+ picker.show();
187
+
188
+ provider.provideHistoryItemRefs(undefined, cts.token).then(refs => {
189
+ if (cts.token.isCancellationRequested) {
190
+ return;
191
+ }
192
+ const currentFilter = model.historyItemRefFilter;
193
+ const items = this.createRefPickItems(refs ?? [], provider);
194
+ picker.items = items;
195
+ picker.selectedItems = items.filter((item): item is RefPickItem => !QuickPickSeparator.is(item)
196
+ && (currentFilter ? !!item.refId && currentFilter.includes(item.refId) : item.auto === true));
197
+
198
+ // "Auto" is exclusive: checking it unchecks everything else and vice versa
199
+ let lastSelection: readonly RefPickItem[] = picker.selectedItems;
200
+ picker.onDidChangeSelection(newSelection => {
201
+ const autoNow = newSelection.some(item => item.auto);
202
+ if (autoNow && newSelection.length > 1) {
203
+ const autoBefore = lastSelection.some(item => item.auto);
204
+ picker.selectedItems = autoBefore ? newSelection.filter(item => !item.auto) : newSelection.filter(item => item.auto);
205
+ }
206
+ lastSelection = picker.selectedItems;
207
+ });
208
+ picker.busy = false;
209
+ }, err => {
210
+ if (!cts.token.isCancellationRequested) {
211
+ this.logger.error('Failed to load history item refs', err);
212
+ }
213
+ picker.hide();
214
+ });
215
+ });
216
+
217
+ if (!selection) {
218
+ return;
219
+ }
220
+ // The selected repository may have changed while the picker was open;
221
+ // the picked refs belong to the old provider and must not be applied
222
+ // to the new one.
223
+ if (model.provider !== provider) {
224
+ return;
225
+ }
226
+ const refIds = selection.filter(item => item.refId !== undefined).map(item => item.refId!);
227
+ const auto = selection.some(item => item.auto) || refIds.length === 0;
228
+ model.setHistoryItemRefFilter(auto ? undefined : refIds);
229
+ } finally {
230
+ picker.dispose();
231
+ cts.dispose();
232
+ }
233
+ }
234
+
235
+ protected createRefPickItems(refs: readonly ScmHistoryItemRef[], provider: ScmHistoryProvider): (RefPickItem | QuickPickSeparator)[] {
236
+ const autoRefs = [provider.currentHistoryItemRef, provider.currentHistoryItemRemoteRef, provider.currentHistoryItemBaseRef]
237
+ .filter((ref): ref is ScmHistoryItemRef => !!ref);
238
+ const items: (RefPickItem | QuickPickSeparator)[] = [{
239
+ label: nls.localizeByDefault('Auto'),
240
+ description: autoRefs.map(ref => ref.name).join(', '),
241
+ auto: true
242
+ }];
243
+
244
+ const groups: { label: string; badgeClass: string }[] = [
245
+ { label: nls.localize('theia/scm/refPickerBranches', 'branches'), badgeClass: 'head' },
246
+ { label: nls.localize('theia/scm/refPickerRemoteBranches', 'remote branches'), badgeClass: 'remote' },
247
+ { label: nls.localize('theia/scm/refPickerTags', 'tags'), badgeClass: 'tag' }
248
+ ];
249
+ const grouped = new Set<ScmHistoryItemRef>();
250
+ for (const group of groups) {
251
+ const groupRefs = refs.filter(ref => getRefBadgeClass(ref) === group.badgeClass);
252
+ if (groupRefs.length > 0) {
253
+ groupRefs.forEach(ref => grouped.add(ref));
254
+ items.push({ type: 'separator', label: group.label });
255
+ items.push(...groupRefs.map(ref => this.toRefPickItem(ref)));
256
+ }
257
+ }
258
+ const remaining = refs.filter(ref => !grouped.has(ref));
259
+ if (remaining.length > 0) {
260
+ items.push({ type: 'separator' });
261
+ items.push(...remaining.map(ref => this.toRefPickItem(ref)));
262
+ }
263
+ return items;
264
+ }
265
+
266
+ protected toRefPickItem(ref: ScmHistoryItemRef): RefPickItem {
267
+ return {
268
+ label: ref.name,
269
+ description: ref.description,
270
+ refId: ref.id
271
+ };
272
+ }
273
+ }
@@ -0,0 +1,193 @@
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 {
19
+ buildChangeTreeRows, ChangeTreeFileRow, ChangeTreeRow, filterRefsForBadges, getFileName, getRefBadgePresentation, getRefColorIndex
20
+ } from './scm-history-graph-helpers';
21
+ import { ScmHistoryItemChange, ScmHistoryItemRef, ScmHistoryProvider } from './scm-provider';
22
+
23
+ describe('getRefColorIndex', () => {
24
+
25
+ const mainRef: ScmHistoryItemRef = { id: 'refs/heads/main', name: 'main' };
26
+ const remoteRef: ScmHistoryItemRef = { id: 'refs/remotes/origin/main', name: 'origin/main' };
27
+ const baseRef: ScmHistoryItemRef = { id: 'refs/remotes/origin/develop', name: 'origin/develop' };
28
+ const otherRef: ScmHistoryItemRef = { id: 'refs/heads/feature', name: 'feature' };
29
+
30
+ const provider = {
31
+ currentHistoryItemRef: mainRef,
32
+ currentHistoryItemRemoteRef: remoteRef,
33
+ currentHistoryItemBaseRef: baseRef,
34
+ } as ScmHistoryProvider;
35
+
36
+ it('returns 0 for the current history item ref', () => {
37
+ expect(getRefColorIndex(mainRef, provider)).to.equal(0);
38
+ });
39
+
40
+ it('returns 1 for the current remote ref', () => {
41
+ expect(getRefColorIndex(remoteRef, provider)).to.equal(1);
42
+ });
43
+
44
+ it('returns 2 for the current base ref', () => {
45
+ expect(getRefColorIndex(baseRef, provider)).to.equal(2);
46
+ });
47
+
48
+ it('returns undefined for any other ref', () => {
49
+ expect(getRefColorIndex(otherRef, provider)).to.be.undefined;
50
+ });
51
+
52
+ it('returns undefined without a provider', () => {
53
+ expect(getRefColorIndex(mainRef, undefined)).to.be.undefined;
54
+ });
55
+ });
56
+
57
+ describe('getRefBadgePresentation', () => {
58
+
59
+ const mainRef: ScmHistoryItemRef = { id: 'refs/heads/main', name: 'main' };
60
+ const remoteRef: ScmHistoryItemRef = { id: 'refs/remotes/origin/main', name: 'origin/main' };
61
+
62
+ const provider = {
63
+ currentHistoryItemRef: mainRef,
64
+ currentHistoryItemRemoteRef: remoteRef,
65
+ } as ScmHistoryProvider;
66
+
67
+ it('prefers the codicon icon transferred from the provider', () => {
68
+ const ref: ScmHistoryItemRef = { ...mainRef, icon: 'codicon codicon-target' };
69
+ expect(getRefBadgePresentation(ref, undefined).iconClass).to.equal('codicon-target');
70
+ });
71
+
72
+ it('falls back to the target icon for the current ref', () => {
73
+ expect(getRefBadgePresentation(mainRef, provider).iconClass).to.equal('codicon-target');
74
+ });
75
+
76
+ it('falls back to category icons for remote, tag, and other local refs', () => {
77
+ expect(getRefBadgePresentation(remoteRef, provider).iconClass).to.equal('codicon-cloud');
78
+ expect(getRefBadgePresentation({ id: 'refs/tags/v1', name: 'v1' }, provider).iconClass).to.equal('codicon-tag');
79
+ expect(getRefBadgePresentation({ id: 'refs/heads/feature', name: 'feature' }, provider).iconClass).to.equal('codicon-git-branch');
80
+ });
81
+
82
+ it('carries the ref color index', () => {
83
+ expect(getRefBadgePresentation(mainRef, provider).colorIndex).to.equal(0);
84
+ expect(getRefBadgePresentation(remoteRef, provider).colorIndex).to.equal(1);
85
+ expect(getRefBadgePresentation({ id: 'refs/heads/feature', name: 'feature' }, provider).colorIndex).to.be.undefined;
86
+ });
87
+ });
88
+
89
+ describe('filterRefsForBadges', () => {
90
+
91
+ const mainRef: ScmHistoryItemRef = { id: 'refs/heads/main', name: 'main' };
92
+ const remoteRef: ScmHistoryItemRef = { id: 'refs/remotes/origin/main', name: 'origin/main' };
93
+ const featureRef: ScmHistoryItemRef = { id: 'refs/heads/feature', name: 'feature' };
94
+ const refs = [mainRef, remoteRef, featureRef];
95
+
96
+ const provider = {
97
+ currentHistoryItemRef: mainRef,
98
+ currentHistoryItemRemoteRef: remoteRef,
99
+ } as ScmHistoryProvider;
100
+
101
+ it("returns all refs in 'all' mode", () => {
102
+ expect(filterRefsForBadges(refs, provider, 'all')).to.deep.equal(refs);
103
+ });
104
+
105
+ it("keeps only the refs used as a filter in 'filter' mode", () => {
106
+ expect(filterRefsForBadges(refs, provider, 'filter')).to.deep.equal([mainRef, remoteRef]);
107
+ });
108
+
109
+ it("returns all refs in 'filter' mode when no provider is available", () => {
110
+ expect(filterRefsForBadges(refs, undefined, 'filter')).to.deep.equal(refs);
111
+ });
112
+
113
+ it("keeps only the explicitly picked refs in 'filter' mode with an explicit filter", () => {
114
+ expect(filterRefsForBadges(refs, provider, 'filter', ['refs/heads/feature'])).to.deep.equal([featureRef]);
115
+ });
116
+
117
+ it("ignores an explicit filter in 'all' mode", () => {
118
+ expect(filterRefsForBadges(refs, provider, 'all', ['refs/heads/feature'])).to.deep.equal(refs);
119
+ });
120
+ });
121
+
122
+ describe('buildChangeTreeRows', () => {
123
+
124
+ const rootUri = 'file:///repo';
125
+
126
+ function change(path: string): ScmHistoryItemChange {
127
+ return { uri: `${rootUri}/${path}`, modifiedUri: `${rootUri}/${path}` };
128
+ }
129
+
130
+ /** Renders the rows as indented lines, so the expectations read like a tree. */
131
+ function describeRows(rows: readonly ChangeTreeRow[]): string[] {
132
+ return rows.map(row => `${' '.repeat(row.depth)}${row.type === 'folder' ? row.label + '/' : getFileName(row.path)}`);
133
+ }
134
+
135
+ it('keeps files at the root at depth 0', () => {
136
+ const rows = buildChangeTreeRows([change('a.ts'), change('b.ts')], rootUri, new Set());
137
+ expect(describeRows(rows)).to.deep.equal(['a.ts', 'b.ts']);
138
+ });
139
+
140
+ it('groups files under their folder', () => {
141
+ const rows = buildChangeTreeRows([change('src/a.ts'), change('src/b.ts')], rootUri, new Set());
142
+ expect(describeRows(rows)).to.deep.equal(['src/', ' a.ts', ' b.ts']);
143
+ });
144
+
145
+ it('compacts folder chains that hold a single child folder', () => {
146
+ const rows = buildChangeTreeRows([change('src/browser/a.ts')], rootUri, new Set());
147
+ expect(describeRows(rows)).to.deep.equal(['src/browser/', ' a.ts']);
148
+ });
149
+
150
+ it('stops compacting where a folder chain branches', () => {
151
+ const rows = buildChangeTreeRows([change('src/browser/a.ts'), change('src/node/b.ts')], rootUri, new Set());
152
+ expect(describeRows(rows)).to.deep.equal(['src/', ' browser/', ' a.ts', ' node/', ' b.ts']);
153
+ });
154
+
155
+ it('does not compact a folder that also holds files', () => {
156
+ const rows = buildChangeTreeRows([change('src/a.ts'), change('src/browser/b.ts')], rootUri, new Set());
157
+ expect(describeRows(rows)).to.deep.equal(['src/', ' browser/', ' b.ts', ' a.ts']);
158
+ });
159
+
160
+ it('sorts folders before files, each alphabetically', () => {
161
+ const rows = buildChangeTreeRows([change('z.ts'), change('a.ts'), change('src/b.ts')], rootUri, new Set());
162
+ expect(describeRows(rows)).to.deep.equal(['src/', ' b.ts', 'a.ts', 'z.ts']);
163
+ });
164
+
165
+ it('hides the descendants of a collapsed folder', () => {
166
+ const rows = buildChangeTreeRows([change('src/a.ts'), change('b.ts')], rootUri, new Set(['src']));
167
+ expect(describeRows(rows)).to.deep.equal(['src/', 'b.ts']);
168
+ });
169
+
170
+ it('addresses a compacted folder by its full path', () => {
171
+ const rows = buildChangeTreeRows([change('src/browser/a.ts')], rootUri, new Set(['src/browser']));
172
+ expect(describeRows(rows)).to.deep.equal(['src/browser/']);
173
+ });
174
+
175
+ it('carries the original change on each file row', () => {
176
+ const first = change('src/a.ts');
177
+ const rows = buildChangeTreeRows([first], rootUri, new Set());
178
+ expect(rows[1].type).to.equal('file');
179
+ expect((rows[1] as ChangeTreeFileRow).change).to.equal(first);
180
+ });
181
+
182
+ it('keeps the index of the change in the original list, so row keys stay stable', () => {
183
+ const rows = buildChangeTreeRows([change('z.ts'), change('src/a.ts')], rootUri, new Set());
184
+ // Sorted as: src/, src/a.ts (index 1), z.ts (index 0).
185
+ expect(rows.filter(row => row.type === 'file').map(row => (row as ChangeTreeFileRow).index)).to.deep.equal([1, 0]);
186
+ });
187
+
188
+ it('falls back to the full path when the change is outside the repository root', () => {
189
+ const outside: ScmHistoryItemChange = { uri: 'file:///elsewhere/x.ts', modifiedUri: 'file:///elsewhere/x.ts' };
190
+ const rows = buildChangeTreeRows([outside], rootUri, new Set());
191
+ expect(describeRows(rows)).to.deep.equal(['elsewhere/', ' x.ts']);
192
+ });
193
+ });