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

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
@@ -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;
@@ -17,20 +17,21 @@
17
17
  import { expect } from 'chai';
18
18
  import { Emitter } from '@theia/core/lib/common/event';
19
19
  import { ScmHistoryGraphModel, PAGE_SIZE } from './scm-history-graph-model';
20
- import { ScmHistoryItem, ScmHistoryItemRef, ScmHistoryItemChange, ScmHistoryOptions, ScmHistoryProvider } from './scm-provider';
20
+ import { ScmHistoryItem, ScmHistoryItemRef, ScmHistoryItemRefsChangeEvent, ScmHistoryItemChange, ScmHistoryOptions, ScmHistoryProvider } from './scm-provider';
21
21
  import { ScmRepository } from './scm-repository';
22
22
 
23
23
  class StubHistoryProvider implements ScmHistoryProvider {
24
- readonly currentHistoryItemRef: ScmHistoryItemRef | undefined;
25
- readonly currentHistoryItemRemoteRef: ScmHistoryItemRef | undefined;
26
- readonly currentHistoryItemBaseRef: ScmHistoryItemRef | undefined;
24
+ currentHistoryItemRef: ScmHistoryItemRef | undefined;
25
+ currentHistoryItemRemoteRef: ScmHistoryItemRef | undefined;
26
+ currentHistoryItemBaseRef: ScmHistoryItemRef | undefined;
27
27
 
28
28
  readonly onDidChangeCurrentHistoryItemRefs = new Emitter<void>().event;
29
- readonly onDidChangeHistoryItemRefs = new Emitter<{
30
- readonly added: readonly ScmHistoryItemRef[];
31
- readonly removed: readonly ScmHistoryItemRef[];
32
- readonly modified: readonly ScmHistoryItemRef[];
33
- }>().event;
29
+ protected readonly onDidChangeHistoryItemRefsEmitter = new Emitter<ScmHistoryItemRefsChangeEvent>();
30
+ readonly onDidChangeHistoryItemRefs = this.onDidChangeHistoryItemRefsEmitter.event;
31
+
32
+ fireHistoryItemRefsChanged(event: ScmHistoryItemRefsChangeEvent): void {
33
+ this.onDidChangeHistoryItemRefsEmitter.fire(event);
34
+ }
34
35
 
35
36
  /** Pages this provider will return on consecutive calls (FIFO). */
36
37
  pages: ScmHistoryItem[][] = [];
@@ -75,30 +76,46 @@ function makeItems(start: number, count: number): ScmHistoryItem[] {
75
76
  /**
76
77
  * Instantiates the model with a stub provider, bypassing inversify and the
77
78
  * SCM service so tests can drive pagination directly via loadPage().
79
+ * When `wireRepository` is set, the provider is instead wired through a stub
80
+ * selected repository and refresh(), so provider event subscriptions are live.
78
81
  */
79
- function createModel(provider: ScmHistoryProvider): { model: ScmHistoryGraphModel; loadPage(): Promise<void> } {
82
+ function createModel(
83
+ provider: ScmHistoryProvider,
84
+ preferences: Record<string, unknown> = {},
85
+ wireRepository = false
86
+ ): { model: ScmHistoryGraphModel; loadPage(): Promise<void>; firePreferenceChanged(preferenceName: string): void } {
80
87
  const model = new ScmHistoryGraphModel();
81
88
  const internals = model as unknown as {
82
89
  scmService: {
83
90
  onDidChangeSelectedRepository: Emitter<ScmRepository | undefined>['event'];
84
91
  selectedRepository: ScmRepository | undefined;
85
92
  };
93
+ scmPreferences: Record<string, unknown>;
86
94
  _provider: ScmHistoryProvider | undefined;
87
95
  init(): void;
88
96
  loadPage(): Promise<void>;
89
97
  };
98
+ const preferenceEmitter = new Emitter<{ preferenceName: string }>();
90
99
  internals.scmService = {
91
100
  onDidChangeSelectedRepository: new Emitter<ScmRepository | undefined>().event,
92
- selectedRepository: undefined,
101
+ selectedRepository: wireRepository ? { provider: { historyProvider: provider } } as unknown as ScmRepository : undefined,
93
102
  };
103
+ internals.scmPreferences = Object.assign(preferences, { onPreferenceChanged: preferenceEmitter.event });
94
104
  internals.init();
95
- internals._provider = provider;
105
+ if (!wireRepository) {
106
+ internals._provider = provider;
107
+ }
96
108
  return {
97
109
  model,
98
110
  loadPage: () => internals.loadPage(),
111
+ firePreferenceChanged: preferenceName => preferenceEmitter.fire({ preferenceName }),
99
112
  };
100
113
  }
101
114
 
115
+ async function nextTick(): Promise<void> {
116
+ return new Promise(resolve => setTimeout(resolve, 0));
117
+ }
118
+
102
119
  describe('ScmHistoryGraphModel - pagination', () => {
103
120
 
104
121
  it('hasMore is true when a full page returns', async () => {
@@ -169,3 +186,221 @@ describe('ScmHistoryGraphModel - pagination', () => {
169
186
  expect(model.entries).to.have.length(PAGE_SIZE, 'should not grow when all items are duplicates');
170
187
  });
171
188
  });
189
+
190
+ describe('ScmHistoryGraphModel - page size preference', () => {
191
+
192
+ it('requests the configured page size and paginates by it', async () => {
193
+ const provider = new StubHistoryProvider();
194
+ provider.pages = [makeItems(1, 10)];
195
+ const { model, loadPage } = createModel(provider, { 'scm.graph.pageSize': 10 });
196
+
197
+ await loadPage();
198
+
199
+ expect(provider.receivedOptions[0].limit).to.equal(10);
200
+ expect(model.entries).to.have.length(10);
201
+ expect(model.hasMore).to.be.true;
202
+ });
203
+
204
+ it('falls back to the default page size without a preference value', async () => {
205
+ const provider = new StubHistoryProvider();
206
+ provider.pages = [makeItems(1, 10)];
207
+ const { loadPage } = createModel(provider);
208
+
209
+ await loadPage();
210
+
211
+ expect(provider.receivedOptions[0].limit).to.equal(PAGE_SIZE);
212
+ });
213
+
214
+ it('reloads from the start when scm.graph.pageSize changes', async () => {
215
+ const provider = new StubHistoryProvider();
216
+ provider.pages = [makeItems(1, 10), makeItems(1, 5)];
217
+ const preferences: Record<string, unknown> = { 'scm.graph.pageSize': 10 };
218
+ const { loadPage, firePreferenceChanged } = createModel(provider, preferences);
219
+
220
+ await loadPage();
221
+ expect(provider.receivedOptions[0].limit).to.equal(10);
222
+
223
+ preferences['scm.graph.pageSize'] = 5;
224
+ firePreferenceChanged('scm.graph.pageSize');
225
+ await nextTick();
226
+
227
+ expect(provider.receivedOptions[1].limit).to.equal(5);
228
+ expect(provider.receivedOptions[1].skip).to.equal(0);
229
+ });
230
+ });
231
+
232
+ describe('ScmHistoryGraphModel - history item ref filter', () => {
233
+
234
+ const mainRef: ScmHistoryItemRef = { id: 'refs/heads/main', name: 'main', revision: 'c1' };
235
+
236
+ function createFilterProvider(): StubHistoryProvider {
237
+ const provider = new StubHistoryProvider();
238
+ provider.currentHistoryItemRef = mainRef;
239
+ provider.pages = [makeItems(1, 3), makeItems(1, 3), makeItems(1, 3)];
240
+ return provider;
241
+ }
242
+
243
+ it('is in auto mode by default: current ref revisions are requested and the current ref is in the filter', async () => {
244
+ const provider = createFilterProvider();
245
+ const { model, loadPage } = createModel(provider);
246
+ await loadPage();
247
+ expect(provider.receivedOptions[0].historyItemRefs).to.deep.equal(['c1']);
248
+ expect(model.historyItemRefFilter).to.be.undefined;
249
+ expect(model.isCurrentRefInFilter()).to.be.true;
250
+ });
251
+
252
+ it('passes the picked ref ids to the provider and reloads', async () => {
253
+ const provider = createFilterProvider();
254
+ const { model } = createModel(provider);
255
+ model.setHistoryItemRefFilter(['refs/heads/feature']);
256
+ await nextTick();
257
+ expect(provider.receivedOptions[0].historyItemRefs).to.deep.equal(['refs/heads/feature']);
258
+ expect(model.historyItemRefFilter).to.deep.equal(['refs/heads/feature']);
259
+ });
260
+
261
+ it('reports whether the current ref is part of the filter', async () => {
262
+ const provider = createFilterProvider();
263
+ const { model } = createModel(provider);
264
+ model.setHistoryItemRefFilter(['refs/heads/feature']);
265
+ await nextTick();
266
+ expect(model.isCurrentRefInFilter()).to.be.false;
267
+ model.setHistoryItemRefFilter(['refs/heads/feature', 'refs/heads/main']);
268
+ await nextTick();
269
+ expect(model.isCurrentRefInFilter()).to.be.true;
270
+ });
271
+
272
+ it('returns to auto mode when the filter is cleared', async () => {
273
+ const provider = createFilterProvider();
274
+ const { model } = createModel(provider);
275
+ model.setHistoryItemRefFilter(['refs/heads/feature']);
276
+ await nextTick();
277
+ model.setHistoryItemRefFilter(undefined);
278
+ await nextTick();
279
+ expect(model.historyItemRefFilter).to.be.undefined;
280
+ expect(provider.receivedOptions[1].historyItemRefs).to.deep.equal(['c1']);
281
+ });
282
+
283
+ it('does not apply ref role colors to refs excluded by the filter', async () => {
284
+ const provider = createFilterProvider();
285
+ provider.pages = [[
286
+ { id: 'c1', subject: 'head', parentIds: ['c2'], references: [mainRef] },
287
+ { id: 'c2', subject: 'base', parentIds: [] },
288
+ ]];
289
+ const { model } = createModel(provider);
290
+ model.setHistoryItemRefFilter(['refs/heads/feature']);
291
+ await nextTick();
292
+ expect(model.entries[0].graphRow.color).to.not.equal(0);
293
+ });
294
+ });
295
+
296
+ describe('ScmHistoryGraphModel - ref filter pruning', () => {
297
+
298
+ const mainRef: ScmHistoryItemRef = { id: 'refs/heads/main', name: 'main', revision: 'c1' };
299
+ const featureRef: ScmHistoryItemRef = { id: 'refs/heads/feature', name: 'feature', revision: 'c5' };
300
+
301
+ function createWiredProvider(): StubHistoryProvider {
302
+ const provider = new StubHistoryProvider();
303
+ provider.currentHistoryItemRef = mainRef;
304
+ return provider;
305
+ }
306
+
307
+ it('drops a removed ref from the filter and keeps the remaining ones', async () => {
308
+ const provider = createWiredProvider();
309
+ const { model } = createModel(provider, {}, true);
310
+ model.setHistoryItemRefFilter([featureRef.id, mainRef.id]);
311
+ await nextTick();
312
+
313
+ provider.fireHistoryItemRefsChanged({ added: [], removed: [featureRef], modified: [] });
314
+ await nextTick();
315
+
316
+ expect(model.historyItemRefFilter).to.deep.equal([mainRef.id]);
317
+ const last = provider.receivedOptions[provider.receivedOptions.length - 1];
318
+ expect(last.historyItemRefs).to.deep.equal([mainRef.id]);
319
+ });
320
+
321
+ it('falls back to auto mode when all filtered refs are removed', async () => {
322
+ const provider = createWiredProvider();
323
+ const { model } = createModel(provider, {}, true);
324
+ model.setHistoryItemRefFilter([featureRef.id]);
325
+ await nextTick();
326
+
327
+ provider.fireHistoryItemRefsChanged({ added: [], removed: [featureRef], modified: [] });
328
+ await nextTick();
329
+
330
+ expect(model.historyItemRefFilter).to.be.undefined;
331
+ const last = provider.receivedOptions[provider.receivedOptions.length - 1];
332
+ expect(last.historyItemRefs).to.deep.equal(['c1']);
333
+ });
334
+
335
+ it('keeps the filter when unrelated refs are removed', async () => {
336
+ const provider = createWiredProvider();
337
+ const { model } = createModel(provider, {}, true);
338
+ model.setHistoryItemRefFilter([mainRef.id]);
339
+ await nextTick();
340
+
341
+ provider.fireHistoryItemRefsChanged({ added: [], removed: [featureRef], modified: [] });
342
+ await nextTick();
343
+
344
+ expect(model.historyItemRefFilter).to.deep.equal([mainRef.id]);
345
+ });
346
+ });
347
+
348
+ describe('ScmHistoryGraphModel - ref colors and current item', () => {
349
+
350
+ const mainRef: ScmHistoryItemRef = { id: 'refs/heads/main', name: 'main', revision: 'c2' };
351
+ const originMainRef: ScmHistoryItemRef = { id: 'refs/remotes/origin/main', name: 'origin/main', revision: 'c1' };
352
+
353
+ /** Remote is one commit ahead: c1 (origin/main) → c2 (main = HEAD) → c3. */
354
+ function createBehindProvider(): StubHistoryProvider {
355
+ const provider = new StubHistoryProvider();
356
+ provider.currentHistoryItemRef = mainRef;
357
+ provider.currentHistoryItemRemoteRef = originMainRef;
358
+ provider.pages = [[
359
+ { id: 'c1', subject: 'remote only', parentIds: ['c2'], references: [originMainRef] },
360
+ { id: 'c2', subject: 'local head', parentIds: ['c3'], references: [mainRef] },
361
+ { id: 'c3', subject: 'base', parentIds: [] },
362
+ ]];
363
+ return provider;
364
+ }
365
+
366
+ it('colors the remote ref tip with the remote ref color', async () => {
367
+ const { model, loadPage } = createModel(createBehindProvider());
368
+ await loadPage();
369
+ expect(model.entries[0].graphRow.color).to.equal(1);
370
+ });
371
+
372
+ it('colors the current ref chain with the current ref color', async () => {
373
+ const { model, loadPage } = createModel(createBehindProvider());
374
+ await loadPage();
375
+ expect(model.entries[1].graphRow.color).to.equal(0);
376
+ expect(model.entries[2].graphRow.color).to.equal(0);
377
+ });
378
+
379
+ it('prefers the current ref color when a commit carries both local and remote refs', async () => {
380
+ const provider = new StubHistoryProvider();
381
+ const syncedMain: ScmHistoryItemRef = { ...mainRef, revision: 'c1' };
382
+ const syncedRemote: ScmHistoryItemRef = { ...originMainRef, revision: 'c1' };
383
+ provider.currentHistoryItemRef = syncedMain;
384
+ provider.currentHistoryItemRemoteRef = syncedRemote;
385
+ provider.pages = [[
386
+ { id: 'c1', subject: 'in sync', parentIds: [], references: [syncedRemote, syncedMain] },
387
+ ]];
388
+ const { model, loadPage } = createModel(provider);
389
+ await loadPage();
390
+ expect(model.entries[0].graphRow.color).to.equal(0);
391
+ });
392
+
393
+ it('marks only the entry at the current ref revision as current', async () => {
394
+ const { model, loadPage } = createModel(createBehindProvider());
395
+ await loadPage();
396
+ expect(model.entries.map(e => e.isCurrent)).to.deep.equal([false, true, false]);
397
+ });
398
+
399
+ it('marks no entry as current when the provider has no current ref', async () => {
400
+ const provider = new StubHistoryProvider();
401
+ provider.pages = [makeItems(1, 3)];
402
+ const { model, loadPage } = createModel(provider);
403
+ await loadPage();
404
+ expect(model.entries.every(e => !e.isCurrent)).to.be.true;
405
+ });
406
+ });
@@ -14,25 +14,44 @@
14
14
  // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
15
  // *****************************************************************************
16
16
 
17
- import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
17
+ import { injectable, inject, postConstruct, named } from '@theia/core/shared/inversify';
18
18
  import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
19
19
  import { Emitter } from '@theia/core/lib/common/event';
20
20
  import { CancellationTokenSource } from '@theia/core/lib/common/cancellation';
21
21
  import { ScmService } from './scm-service';
22
- import { ScmHistoryItem, ScmHistoryProvider, ScmHistoryOptions } from './scm-provider';
22
+ import { ScmHistoryItem, ScmHistoryItemRef, ScmHistoryProvider, ScmHistoryOptions } from './scm-provider';
23
23
  import { computeGraphRows, GraphRow } from './scm-history-graph-lanes';
24
+ import { ILogger } from '@theia/core';
25
+ import { getRefColorIndex } from './scm-history-graph-helpers';
26
+ import { ScmPreferences } from '../common/scm-preferences';
24
27
 
25
28
  export const PAGE_SIZE = 50;
26
29
 
30
+ export const ScmHistoryGraphModelProvider = Symbol('ScmHistoryGraphModelProvider');
31
+ /**
32
+ * Resolves the {@link ScmHistoryGraphModel} singleton on first use. The model
33
+ * starts loading history and subscribing to provider events as soon as it is
34
+ * instantiated, so clients that may not need it (e.g. command contributions
35
+ * instantiated at application start) must inject this provider instead of the
36
+ * model itself.
37
+ */
38
+ export type ScmHistoryGraphModelProvider = () => ScmHistoryGraphModel;
39
+
27
40
  export interface HistoryGraphEntry {
28
41
  readonly item: ScmHistoryItem;
29
42
  readonly graphRow: GraphRow;
43
+ /** True when this item is the commit the current history item ref points at (HEAD). */
44
+ readonly isCurrent: boolean;
30
45
  }
31
46
 
32
47
  @injectable()
33
48
  export class ScmHistoryGraphModel {
34
49
 
35
50
  @inject(ScmService) protected readonly scmService: ScmService;
51
+ @inject(ScmPreferences) protected readonly scmPreferences: ScmPreferences;
52
+
53
+ @inject(ILogger) @named('scm:ScmHistoryGraphModel')
54
+ protected readonly logger: ILogger;
36
55
 
37
56
  protected readonly toDispose = new DisposableCollection();
38
57
  protected readonly toDisposeOnProviderChange = new DisposableCollection();
@@ -42,6 +61,8 @@ export class ScmHistoryGraphModel {
42
61
  protected _loading = false;
43
62
  protected _hasAttemptedLoad = false;
44
63
  protected _provider: ScmHistoryProvider | undefined;
64
+ /** Explicitly picked ref ids to filter the graph by; `undefined` = auto (current/remote/base). */
65
+ protected _historyItemRefFilter: string[] | undefined;
45
66
 
46
67
  protected readonly onDidChangeEmitter = new Emitter<void>();
47
68
  readonly onDidChange = this.onDidChangeEmitter.event;
@@ -54,6 +75,11 @@ export class ScmHistoryGraphModel {
54
75
  Disposable.create(() => this.toDisposeOnProviderChange.dispose()),
55
76
  this.onDidChangeEmitter,
56
77
  this.scmService.onDidChangeSelectedRepository(() => this.refresh()),
78
+ this.scmPreferences.onPreferenceChanged(e => {
79
+ if (e.preferenceName === 'scm.graph.pageSize') {
80
+ this.reload();
81
+ }
82
+ }),
57
83
  ]);
58
84
  this.refresh();
59
85
  }
@@ -67,6 +93,32 @@ export class ScmHistoryGraphModel {
67
93
  return this._provider;
68
94
  }
69
95
 
96
+ /** The explicitly picked ref ids filtering the graph, or `undefined` in auto mode. */
97
+ get historyItemRefFilter(): readonly string[] | undefined {
98
+ return this._historyItemRefFilter;
99
+ }
100
+
101
+ /**
102
+ * Sets the ref ids to filter the graph by (`undefined` returns to auto
103
+ * mode) and reloads the graph.
104
+ */
105
+ setHistoryItemRefFilter(refIds: readonly string[] | undefined): void {
106
+ this._historyItemRefFilter = refIds && refIds.length > 0 ? [...refIds] : undefined;
107
+ this.reload();
108
+ }
109
+
110
+ /**
111
+ * Whether the current history item ref is part of the graph's filter.
112
+ * Always true in auto mode, which includes the current ref.
113
+ */
114
+ isCurrentRefInFilter(): boolean {
115
+ if (!this._historyItemRefFilter) {
116
+ return true;
117
+ }
118
+ const currentId = this._provider?.currentHistoryItemRef?.id;
119
+ return currentId !== undefined && this._historyItemRefFilter.includes(currentId);
120
+ }
121
+
70
122
  get entries(): readonly HistoryGraphEntry[] {
71
123
  return this._entries;
72
124
  }
@@ -89,13 +141,14 @@ export class ScmHistoryGraphModel {
89
141
  }
90
142
 
91
143
  refresh(): void {
92
- this.cancelSource.cancel();
93
- this.cancelSource = new CancellationTokenSource();
94
-
95
144
  this.toDisposeOnProviderChange.dispose();
96
145
 
97
146
  const repo = this.scmService.selectedRepository;
98
147
  const hp = repo?.provider.historyProvider;
148
+ if (hp !== this._provider) {
149
+ // The repository changed — a filter picked for the old provider does not apply.
150
+ this._historyItemRefFilter = undefined;
151
+ }
99
152
  this._provider = hp;
100
153
 
101
154
  if (this._provider) {
@@ -103,7 +156,10 @@ export class ScmHistoryGraphModel {
103
156
  this._provider.onDidChangeCurrentHistoryItemRefs(() => this.refresh())
104
157
  );
105
158
  this.toDisposeOnProviderChange.push(
106
- this._provider.onDidChangeHistoryItemRefs(() => this.refresh())
159
+ this._provider.onDidChangeHistoryItemRefs(e => {
160
+ this.pruneHistoryItemRefFilter(e.removed);
161
+ this.refresh();
162
+ })
107
163
  );
108
164
  } else if (repo) {
109
165
  // historyProvider is not yet available; listen for provider changes
@@ -113,6 +169,31 @@ export class ScmHistoryGraphModel {
113
169
  );
114
170
  }
115
171
 
172
+ this.reload();
173
+ }
174
+
175
+ /**
176
+ * Drops refs that no longer exist from the explicit filter, so that
177
+ * deleting or renaming a filtered ref does not leave the graph stuck
178
+ * requesting history for it. When the last filtered ref is removed,
179
+ * the filter falls back to auto mode.
180
+ */
181
+ protected pruneHistoryItemRefFilter(removed: readonly ScmHistoryItemRef[]): void {
182
+ if (!this._historyItemRefFilter || removed.length === 0) {
183
+ return;
184
+ }
185
+ const removedIds = new Set(removed.map(ref => ref.id));
186
+ const remaining = this._historyItemRefFilter.filter(id => !removedIds.has(id));
187
+ if (remaining.length !== this._historyItemRefFilter.length) {
188
+ this._historyItemRefFilter = remaining.length > 0 ? remaining : undefined;
189
+ }
190
+ }
191
+
192
+ /** Clears the loaded entries and loads the first page again from the current provider. */
193
+ protected reload(): void {
194
+ this.cancelSource.cancel();
195
+ this.cancelSource = new CancellationTokenSource();
196
+
116
197
  this._entries = [];
117
198
  this._hasMore = false;
118
199
 
@@ -141,10 +222,11 @@ export class ScmHistoryGraphModel {
141
222
 
142
223
  const token = this.cancelSource.token;
143
224
  try {
225
+ const pageSize = this.pageSize;
144
226
  const historyItemRefs = this.getCurrentHistoryItemRefs();
145
227
  const options: ScmHistoryOptions = {
146
228
  skip: this._entries.length,
147
- limit: PAGE_SIZE,
229
+ limit: pageSize,
148
230
  historyItemRefs: historyItemRefs.length > 0 ? historyItemRefs : undefined,
149
231
  };
150
232
  const items = await this._provider.provideHistoryItems(options, token);
@@ -154,7 +236,7 @@ export class ScmHistoryGraphModel {
154
236
  }
155
237
 
156
238
  const fetchedItems: ScmHistoryItem[] = items ?? [];
157
- this._hasMore = fetchedItems.length >= PAGE_SIZE;
239
+ this._hasMore = fetchedItems.length >= pageSize;
158
240
 
159
241
  // Filter out any items already loaded so the graph does not show duplicates.
160
242
  const existingIds = new Set(this._entries.map(e => e.item.id));
@@ -164,15 +246,18 @@ export class ScmHistoryGraphModel {
164
246
  const graphRows = computeGraphRows(allItems.map(i => ({
165
247
  id: i.id,
166
248
  parentIds: i.parentIds,
249
+ colorIndex: this.resolveColorIndex(i),
167
250
  })));
168
251
 
252
+ const currentRevision = this._provider.currentHistoryItemRef?.revision;
169
253
  this._entries = allItems.map((item, idx) => ({
170
254
  item,
171
255
  graphRow: graphRows[idx],
256
+ isCurrent: currentRevision !== undefined && item.id === currentRevision,
172
257
  }));
173
258
  } catch (err) {
174
259
  if (!token.isCancellationRequested) {
175
- console.error('ScmHistoryGraphModel: failed to load history', err);
260
+ this.logger.error('Failed to load history', err);
176
261
  }
177
262
  } finally {
178
263
  if (!token.isCancellationRequested) {
@@ -183,12 +268,40 @@ export class ScmHistoryGraphModel {
183
268
  }
184
269
  }
185
270
 
271
+ /** The configured page size (`scm.graph.pageSize`). */
272
+ protected get pageSize(): number {
273
+ return this.scmPreferences['scm.graph.pageSize'] ?? PAGE_SIZE;
274
+ }
275
+
186
276
  /**
187
- * Returns the revisions of the current branch ref, its remote tracking ref,
188
- * and the merge-base ref to pass to `provideHistoryItems`. Providers walk
189
- * history starting from these revisions.
277
+ * Resolves the ref-based color index of an item from its references,
278
+ * preferring current (0) over remote (1) over base (2). Refs excluded by
279
+ * an explicit filter get no role color, mirroring VS Code's color map.
280
+ */
281
+ protected resolveColorIndex(item: ScmHistoryItem): number | undefined {
282
+ let result: number | undefined;
283
+ for (const ref of item.references ?? []) {
284
+ if (this._historyItemRefFilter && !this._historyItemRefFilter.includes(ref.id)) {
285
+ continue;
286
+ }
287
+ const index = getRefColorIndex(ref, this._provider);
288
+ if (index !== undefined && (result === undefined || index < result)) {
289
+ result = index;
290
+ }
291
+ }
292
+ return result;
293
+ }
294
+
295
+ /**
296
+ * Returns the refs to pass to `provideHistoryItems`: the explicitly picked
297
+ * ref ids when a filter is active, otherwise (auto mode) the revisions of
298
+ * the current branch ref, its remote tracking ref, and the merge-base ref.
299
+ * Providers walk history starting from these refs.
190
300
  */
191
301
  protected getCurrentHistoryItemRefs(): string[] {
302
+ if (this._historyItemRefFilter) {
303
+ return [...this._historyItemRefFilter];
304
+ }
192
305
  if (!this._provider) {
193
306
  return [];
194
307
  }