@theia/scm 1.71.0-next.72 → 1.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/lib/browser/dirty-diff/dirty-diff-widget.js +1 -1
  2. package/lib/browser/dirty-diff/dirty-diff-widget.js.map +1 -1
  3. package/lib/browser/scm-context-key-service.d.ts +6 -0
  4. package/lib/browser/scm-context-key-service.d.ts.map +1 -1
  5. package/lib/browser/scm-context-key-service.js +12 -0
  6. package/lib/browser/scm-context-key-service.js.map +1 -1
  7. package/lib/browser/scm-contribution.d.ts.map +1 -1
  8. package/lib/browser/scm-contribution.js +129 -1
  9. package/lib/browser/scm-contribution.js.map +1 -1
  10. package/lib/browser/scm-frontend-module.d.ts.map +1 -1
  11. package/lib/browser/scm-frontend-module.js +14 -0
  12. package/lib/browser/scm-frontend-module.js.map +1 -1
  13. package/lib/browser/scm-history-graph-helpers.d.ts +39 -0
  14. package/lib/browser/scm-history-graph-helpers.d.ts.map +1 -0
  15. package/lib/browser/scm-history-graph-helpers.js +167 -0
  16. package/lib/browser/scm-history-graph-helpers.js.map +1 -0
  17. package/lib/browser/scm-history-graph-lanes.d.ts +59 -0
  18. package/lib/browser/scm-history-graph-lanes.d.ts.map +1 -0
  19. package/lib/browser/scm-history-graph-lanes.js +183 -0
  20. package/lib/browser/scm-history-graph-lanes.js.map +1 -0
  21. package/lib/browser/scm-history-graph-lanes.spec.d.ts +2 -0
  22. package/lib/browser/scm-history-graph-lanes.spec.d.ts.map +1 -0
  23. package/lib/browser/scm-history-graph-lanes.spec.js +554 -0
  24. package/lib/browser/scm-history-graph-lanes.spec.js.map +1 -0
  25. package/lib/browser/scm-history-graph-model.d.ts +46 -0
  26. package/lib/browser/scm-history-graph-model.d.ts.map +1 -0
  27. package/lib/browser/scm-history-graph-model.js +184 -0
  28. package/lib/browser/scm-history-graph-model.js.map +1 -0
  29. package/lib/browser/scm-history-graph-model.spec.d.ts +2 -0
  30. package/lib/browser/scm-history-graph-model.spec.d.ts.map +1 -0
  31. package/lib/browser/scm-history-graph-model.spec.js +131 -0
  32. package/lib/browser/scm-history-graph-model.spec.js.map +1 -0
  33. package/lib/browser/scm-history-graph-tooltip.d.ts +14 -0
  34. package/lib/browser/scm-history-graph-tooltip.d.ts.map +1 -0
  35. package/lib/browser/scm-history-graph-tooltip.js +190 -0
  36. package/lib/browser/scm-history-graph-tooltip.js.map +1 -0
  37. package/lib/browser/scm-history-graph-widget.d.ts +77 -0
  38. package/lib/browser/scm-history-graph-widget.d.ts.map +1 -0
  39. package/lib/browser/scm-history-graph-widget.js +490 -0
  40. package/lib/browser/scm-history-graph-widget.js.map +1 -0
  41. package/lib/browser/scm-provider.d.ts +61 -0
  42. package/lib/browser/scm-provider.d.ts.map +1 -1
  43. package/lib/browser/scm-provider.js.map +1 -1
  44. package/package.json +7 -7
  45. package/src/browser/dirty-diff/dirty-diff-widget.ts +1 -1
  46. package/src/browser/scm-context-key-service.ts +18 -0
  47. package/src/browser/scm-contribution.ts +141 -0
  48. package/src/browser/scm-frontend-module.ts +15 -0
  49. package/src/browser/scm-history-graph-helpers.ts +175 -0
  50. package/src/browser/scm-history-graph-lanes.spec.ts +635 -0
  51. package/src/browser/scm-history-graph-lanes.ts +258 -0
  52. package/src/browser/scm-history-graph-model.spec.ts +171 -0
  53. package/src/browser/scm-history-graph-model.ts +207 -0
  54. package/src/browser/scm-history-graph-tooltip.ts +213 -0
  55. package/src/browser/scm-history-graph-widget.tsx +712 -0
  56. package/src/browser/scm-provider.ts +68 -0
  57. package/src/browser/style/index.css +12 -13
  58. package/src/browser/style/scm-history-graph.css +313 -0
@@ -0,0 +1,258 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ /**
18
+ * DAG graph lane computation for SCM commit history visualization.
19
+ *
20
+ * Each row in the graph has:
21
+ * - `lane`: the column index where the commit circle is drawn
22
+ * - `color`: a lane index (0-7) used to pick a CSS color variable
23
+ * - `edges`: segments to draw on this row — each segment connects two lane
24
+ * positions and carries a color and type
25
+ *
26
+ * Edge types determine how each segment is rendered in the SVG:
27
+ * - `'pass-through'`: straight vertical line through the full row height
28
+ * - `'branch-out'`: bezier curve starting at the commit's Y (mid-row),
29
+ * sweeping down to the new lane at the bottom of the row
30
+ * - `'merge-in'`: bezier curve starting at the source lane at the top of
31
+ * the row, sweeping into the commit's Y (mid-row)
32
+ */
33
+
34
+ export type GraphEdgeType = 'pass-through' | 'branch-out' | 'merge-in';
35
+
36
+ export interface GraphEdge {
37
+ /** Source lane position (top of the row for pass-through/merge-in; commit lane for branch-out). */
38
+ readonly fromLane: number;
39
+ /** Target lane position (bottom of the row for pass-through/branch-out; commit lane for merge-in). */
40
+ readonly toLane: number;
41
+ /** Color index (0–7) for this edge. */
42
+ readonly color: number;
43
+ /** How this edge should be rendered in the SVG. */
44
+ readonly type: GraphEdgeType;
45
+ }
46
+
47
+ export interface GraphRow {
48
+ /** Lane index where the commit node is rendered. */
49
+ readonly lane: number;
50
+ /** Color index for the commit node dot. */
51
+ readonly color: number;
52
+ /** Edges crossing or originating on this row. */
53
+ readonly edges: readonly GraphEdge[];
54
+ /**
55
+ * Whether the commit's lane continues downward (i.e. first parent stays in
56
+ * the same lane). When false, no bottom line segment is drawn below the
57
+ * commit circle (root commit or merge convergence where the lane is freed).
58
+ */
59
+ readonly hasContinuation: boolean;
60
+ /**
61
+ * Whether there is an incoming line from above on the commit's lane (i.e.
62
+ * the commit was referenced as a parent by an earlier row). When false,
63
+ * no top line segment is drawn above the commit circle.
64
+ */
65
+ readonly hasTopLine: boolean;
66
+ }
67
+
68
+ /**
69
+ * Mutable version of GraphRow used internally during computation.
70
+ */
71
+ interface MutableGraphRow {
72
+ lane: number;
73
+ color: number;
74
+ edges: GraphEdge[];
75
+ hasContinuation: boolean;
76
+ hasTopLine: boolean;
77
+ }
78
+
79
+ /**
80
+ * Compute graph rows for an ordered list of commits (topological order,
81
+ * newest first). Each commit must supply its own `id` and `parentIds`.
82
+ *
83
+ * @param commits Topologically sorted commits (newest → oldest).
84
+ * @returns One `GraphRow` per commit in the same order.
85
+ */
86
+ export function computeGraphRows(
87
+ commits: ReadonlyArray<{ id: string; parentIds?: readonly string[] }>
88
+ ): GraphRow[] {
89
+ // lanes[i] = id of commit that "owns" lane i (i.e. we are waiting for this
90
+ // commit to appear in the list so we can close the lane).
91
+ const lanes: (string | undefined)[] = [];
92
+ // laneColors[i] = the color index permanently assigned to lane i
93
+ const laneColors: (number | undefined)[] = [];
94
+
95
+ const rows: MutableGraphRow[] = [];
96
+
97
+ for (const commit of commits) {
98
+ const parentIds = commit.parentIds ?? [];
99
+
100
+ // --- 1. Find or assign a lane for this commit -----------------------
101
+ let myLane = lanes.indexOf(commit.id);
102
+ // hasTopLine: true when this commit was already reserved as a parent by
103
+ // an earlier row — meaning there IS a connection coming from above.
104
+ const hasTopLine = myLane !== -1;
105
+ if (myLane === -1) {
106
+ // Not yet tracked → open a new lane
107
+ myLane = firstFreeLane(lanes);
108
+ lanes[myLane] = commit.id;
109
+ laneColors[myLane] = myLane % 8;
110
+ }
111
+
112
+ const myColor = laneColors[myLane] ?? myLane % 8;
113
+
114
+ // Collect any duplicate lane occupants: other lanes that were kept
115
+ // alive pointing at this same commit (sibling branch tips whose lane
116
+ // was preserved until the parent row). These emit merge-in edges and
117
+ // are freed before the pass-through snapshot so they don't appear as
118
+ // spurious pass-through lines on this row.
119
+ const duplicateLanes: { lane: number; color: number }[] = [];
120
+ if (hasTopLine) {
121
+ for (let li = 0; li < lanes.length; li++) {
122
+ if (lanes[li] === commit.id && li !== myLane) {
123
+ duplicateLanes.push({ lane: li, color: laneColors[li] ?? li % 8 });
124
+ lanes[li] = undefined;
125
+ laneColors[li] = undefined;
126
+ }
127
+ }
128
+ }
129
+
130
+ // --- 2. Snapshot current lane state before mutations ----------------
131
+ const lanesCopy = lanes.slice();
132
+
133
+ // --- 3. Determine parent lane assignments ---------------------------
134
+ const parentLanes: number[] = [];
135
+ // Track which parents were ALREADY in existing lanes (merge-in) vs new
136
+ const parentIsExisting: boolean[] = [];
137
+
138
+ for (let pi = 0; pi < parentIds.length; pi++) {
139
+ const pid = parentIds[pi];
140
+
141
+ const parentLane = lanesCopy.indexOf(pid);
142
+
143
+ if (parentLane !== -1 && parentLane !== myLane) {
144
+ if (hasTopLine) {
145
+ // Pre-reserved commit: parent already has a lane → merge-in edge on this row.
146
+ parentLanes.push(parentLane);
147
+ parentIsExisting.push(true);
148
+ } else {
149
+ // Non-pre-reserved sibling branch tip: keep this lane alive so it
150
+ // passes through as a pass-through line until the parent row, where
151
+ // a merge-in is emitted via the duplicateLanes mechanism.
152
+ // No edge is emitted here; record myLane so hasContinuation is true.
153
+ lanes[myLane] = pid;
154
+ parentLanes.push(myLane);
155
+ parentIsExisting.push(false);
156
+ }
157
+ } else if (parentLane === myLane) {
158
+ // First parent inherits this lane (fast-forward) — straight down
159
+ parentLanes.push(myLane);
160
+ parentIsExisting.push(false); // treated as inline continuation
161
+ } else {
162
+ // New parent → assign a lane
163
+ if (pi === 0) {
164
+ // First parent continues in the same lane
165
+ lanes[myLane] = pid;
166
+ // Keep the same color as myLane for the first parent
167
+ parentLanes.push(myLane);
168
+ parentIsExisting.push(false);
169
+ } else {
170
+ // Additional parents get new lanes — branch-out
171
+ const newLane = firstFreeLane(lanes);
172
+ lanes[newLane] = pid;
173
+ laneColors[newLane] = newLane % 8;
174
+ parentLanes.push(newLane);
175
+ parentIsExisting.push(false);
176
+ }
177
+ }
178
+ }
179
+
180
+ // If the commit has no parents (root commit), free the lane
181
+ if (parentIds.length === 0) {
182
+ lanes[myLane] = undefined;
183
+ laneColors[myLane] = undefined;
184
+ } else if (!parentLanes.includes(myLane)) {
185
+ // No parent inherited myLane — decide whether to free or keep the lane.
186
+ if (hasTopLine) {
187
+ // Pre-reserved commit (merge convergence): free the lane immediately.
188
+ lanes[myLane] = undefined;
189
+ laneColors[myLane] = undefined;
190
+ } else {
191
+ // Non-pre-reserved sibling branch tip: keep the lane alive pointing
192
+ // at the first parent so it persists as a pass-through until the
193
+ // parent row. At the parent row, the duplicate lane occupant is
194
+ // detected and a merge-in edge is emitted there instead.
195
+ lanes[myLane] = parentIds[0];
196
+ }
197
+ }
198
+
199
+ // --- 4. Emit edges --------------------------------------------------
200
+ const edges: GraphEdge[] = [];
201
+
202
+ // Pass-through lines: lanes that were occupied before this row and
203
+ // are NOT the commit's own lane continue straight through.
204
+ for (let li = 0; li < lanesCopy.length; li++) {
205
+ const occupant = lanesCopy[li];
206
+ if (!occupant || occupant === commit.id) {
207
+ continue;
208
+ }
209
+ const color = laneColors[li] ?? li % 8;
210
+ edges.push({ fromLane: li, toLane: li, color, type: 'pass-through' });
211
+ }
212
+
213
+ for (let pi = 0; pi < parentIds.length; pi++) {
214
+ const toLane = parentLanes[pi];
215
+ const isExisting = parentIsExisting[pi];
216
+
217
+ if (toLane === myLane) {
218
+ // First parent continues in the same lane — no edge is emitted.
219
+ // The vertical connection is represented by hasContinuation:true on
220
+ // this row and hasTopLine:true on the parent's row; the SVG renderer
221
+ // draws the top/bottom line segments around the commit circle instead.
222
+ continue;
223
+ }
224
+
225
+ if (isExisting) {
226
+ // Merge-in: an existing lane at the top of the row curves into
227
+ // the commit position at mid-row.
228
+ const color = laneColors[toLane] ?? toLane % 8;
229
+ edges.push({ fromLane: toLane, toLane: myLane, color, type: 'merge-in' });
230
+ } else {
231
+ // Branch-out: the commit spawns a new lane below mid-row.
232
+ const color = laneColors[toLane] ?? toLane % 8;
233
+ edges.push({ fromLane: myLane, toLane, color, type: 'branch-out' });
234
+ }
235
+ }
236
+
237
+ // Merge-in edges for duplicate lane occupants (sibling branch tips
238
+ // converging into this commit's lane).
239
+ for (const dl of duplicateLanes) {
240
+ edges.push({ fromLane: dl.lane, toLane: myLane, color: dl.color, type: 'merge-in' });
241
+ }
242
+
243
+ // hasContinuation: true if the first parent continues in the same lane,
244
+ // OR if this is a sibling branch tip whose lane was kept alive (pointing
245
+ // at the parent) so it persists as a pass-through to the parent row.
246
+ const hasContinuation = parentIds.length > 0 && (parentLanes[0] === myLane || lanes[myLane] === parentIds[0]);
247
+
248
+ rows.push({ lane: myLane, color: myColor, edges, hasContinuation, hasTopLine });
249
+ }
250
+
251
+ return rows;
252
+ }
253
+
254
+ /** Returns the index of the first undefined slot, or lanes.length if full. */
255
+ function firstFreeLane(lanes: (string | undefined)[]): number {
256
+ const idx = lanes.indexOf(undefined);
257
+ return idx === -1 ? lanes.length : idx;
258
+ }
@@ -0,0 +1,171 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { expect } from 'chai';
18
+ import { Emitter } from '@theia/core/lib/common/event';
19
+ import { ScmHistoryGraphModel, PAGE_SIZE } from './scm-history-graph-model';
20
+ import { ScmHistoryItem, ScmHistoryItemRef, ScmHistoryItemChange, ScmHistoryOptions, ScmHistoryProvider } from './scm-provider';
21
+ import { ScmRepository } from './scm-repository';
22
+
23
+ class StubHistoryProvider implements ScmHistoryProvider {
24
+ readonly currentHistoryItemRef: ScmHistoryItemRef | undefined;
25
+ readonly currentHistoryItemRemoteRef: ScmHistoryItemRef | undefined;
26
+ readonly currentHistoryItemBaseRef: ScmHistoryItemRef | undefined;
27
+
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;
34
+
35
+ /** Pages this provider will return on consecutive calls (FIFO). */
36
+ pages: ScmHistoryItem[][] = [];
37
+ /** Captured options for each call (so tests can assert what was sent). */
38
+ receivedOptions: ScmHistoryOptions[] = [];
39
+
40
+ async provideHistoryItemRefs(): Promise<ScmHistoryItemRef[] | undefined> {
41
+ return [];
42
+ }
43
+
44
+ async provideHistoryItems(options: ScmHistoryOptions): Promise<ScmHistoryItem[] | undefined> {
45
+ this.receivedOptions.push(options);
46
+ return this.pages.shift() ?? [];
47
+ }
48
+
49
+ async provideHistoryItemChanges(): Promise<ScmHistoryItemChange[] | undefined> {
50
+ return [];
51
+ }
52
+
53
+ async resolveHistoryItem(): Promise<ScmHistoryItem | undefined> {
54
+ return undefined;
55
+ }
56
+
57
+ async resolveHistoryItemRefsCommonAncestor(): Promise<string | undefined> {
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ function makeItems(start: number, count: number): ScmHistoryItem[] {
63
+ const items: ScmHistoryItem[] = [];
64
+ for (let i = 0; i < count; i++) {
65
+ const id = `c${start + i}`;
66
+ items.push({
67
+ id,
68
+ subject: `commit ${id}`,
69
+ parentIds: [`c${start + i + 1}`],
70
+ });
71
+ }
72
+ return items;
73
+ }
74
+
75
+ /**
76
+ * Instantiates the model with a stub provider, bypassing inversify and the
77
+ * SCM service so tests can drive pagination directly via loadPage().
78
+ */
79
+ function createModel(provider: ScmHistoryProvider): { model: ScmHistoryGraphModel; loadPage(): Promise<void> } {
80
+ const model = new ScmHistoryGraphModel();
81
+ const internals = model as unknown as {
82
+ scmService: {
83
+ onDidChangeSelectedRepository: Emitter<ScmRepository | undefined>['event'];
84
+ selectedRepository: ScmRepository | undefined;
85
+ };
86
+ _provider: ScmHistoryProvider | undefined;
87
+ init(): void;
88
+ loadPage(): Promise<void>;
89
+ };
90
+ internals.scmService = {
91
+ onDidChangeSelectedRepository: new Emitter<ScmRepository | undefined>().event,
92
+ selectedRepository: undefined,
93
+ };
94
+ internals.init();
95
+ internals._provider = provider;
96
+ return {
97
+ model,
98
+ loadPage: () => internals.loadPage(),
99
+ };
100
+ }
101
+
102
+ describe('ScmHistoryGraphModel - pagination', () => {
103
+
104
+ it('hasMore is true when a full page returns', async () => {
105
+ const provider = new StubHistoryProvider();
106
+ provider.pages = [makeItems(1, PAGE_SIZE)];
107
+ const { model, loadPage } = createModel(provider);
108
+
109
+ await loadPage();
110
+
111
+ expect(model.entries).to.have.length(PAGE_SIZE);
112
+ expect(model.hasMore).to.be.true;
113
+ });
114
+
115
+ it('hasMore is false when fewer than a full page is returned', async () => {
116
+ const provider = new StubHistoryProvider();
117
+ provider.pages = [makeItems(1, 10)];
118
+ const { model, loadPage } = createModel(provider);
119
+
120
+ await loadPage();
121
+
122
+ expect(model.entries).to.have.length(10);
123
+ expect(model.hasMore).to.be.false;
124
+ });
125
+
126
+ it('paginates using skip = current entry count', async () => {
127
+ const provider = new StubHistoryProvider();
128
+ provider.pages = [
129
+ makeItems(1, PAGE_SIZE),
130
+ makeItems(51, PAGE_SIZE),
131
+ makeItems(101, 10),
132
+ ];
133
+ const { model, loadPage } = createModel(provider);
134
+
135
+ await loadPage();
136
+ expect(model.entries).to.have.length(PAGE_SIZE);
137
+ expect(provider.receivedOptions[0].skip).to.equal(0);
138
+
139
+ await loadPage();
140
+ expect(model.entries).to.have.length(2 * PAGE_SIZE);
141
+ expect(provider.receivedOptions[1].skip).to.equal(PAGE_SIZE);
142
+
143
+ await loadPage();
144
+ expect(model.entries).to.have.length(2 * PAGE_SIZE + 10);
145
+ expect(provider.receivedOptions[2].skip).to.equal(2 * PAGE_SIZE);
146
+ });
147
+
148
+ it('passes skip and not cursor (cursor is not part of the VS Code SCM history options API)', async () => {
149
+ const provider = new StubHistoryProvider();
150
+ provider.pages = [makeItems(1, PAGE_SIZE)];
151
+ const { loadPage } = createModel(provider);
152
+
153
+ await loadPage();
154
+
155
+ expect((provider.receivedOptions[0] as { cursor?: string }).cursor).to.be.undefined;
156
+ expect(provider.receivedOptions[0]).to.have.property('skip');
157
+ });
158
+
159
+ it('does not grow entries when a provider re-returns duplicates', async () => {
160
+ const provider = new StubHistoryProvider();
161
+ const firstPage = makeItems(1, PAGE_SIZE);
162
+ provider.pages = [firstPage, firstPage.slice()];
163
+ const { model, loadPage } = createModel(provider);
164
+
165
+ await loadPage();
166
+ expect(model.entries).to.have.length(PAGE_SIZE);
167
+
168
+ await loadPage();
169
+ expect(model.entries).to.have.length(PAGE_SIZE, 'should not grow when all items are duplicates');
170
+ });
171
+ });
@@ -0,0 +1,207 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
18
+ import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
19
+ import { Emitter } from '@theia/core/lib/common/event';
20
+ import { CancellationTokenSource } from '@theia/core/lib/common/cancellation';
21
+ import { ScmService } from './scm-service';
22
+ import { ScmHistoryItem, ScmHistoryProvider, ScmHistoryOptions } from './scm-provider';
23
+ import { computeGraphRows, GraphRow } from './scm-history-graph-lanes';
24
+
25
+ export const PAGE_SIZE = 50;
26
+
27
+ export interface HistoryGraphEntry {
28
+ readonly item: ScmHistoryItem;
29
+ readonly graphRow: GraphRow;
30
+ }
31
+
32
+ @injectable()
33
+ export class ScmHistoryGraphModel {
34
+
35
+ @inject(ScmService) protected readonly scmService: ScmService;
36
+
37
+ protected readonly toDispose = new DisposableCollection();
38
+ protected readonly toDisposeOnProviderChange = new DisposableCollection();
39
+
40
+ protected _entries: HistoryGraphEntry[] = [];
41
+ protected _hasMore = false;
42
+ protected _loading = false;
43
+ protected _hasAttemptedLoad = false;
44
+ protected _provider: ScmHistoryProvider | undefined;
45
+
46
+ protected readonly onDidChangeEmitter = new Emitter<void>();
47
+ readonly onDidChange = this.onDidChangeEmitter.event;
48
+
49
+ protected cancelSource = new CancellationTokenSource();
50
+
51
+ @postConstruct()
52
+ protected init(): void {
53
+ this.toDispose.pushAll([
54
+ Disposable.create(() => this.toDisposeOnProviderChange.dispose()),
55
+ this.onDidChangeEmitter,
56
+ this.scmService.onDidChangeSelectedRepository(() => this.refresh()),
57
+ ]);
58
+ this.refresh();
59
+ }
60
+
61
+ dispose(): void {
62
+ this.cancelSource.cancel();
63
+ this.toDispose.dispose();
64
+ }
65
+
66
+ get provider(): ScmHistoryProvider | undefined {
67
+ return this._provider;
68
+ }
69
+
70
+ get entries(): readonly HistoryGraphEntry[] {
71
+ return this._entries;
72
+ }
73
+
74
+ get hasMore(): boolean {
75
+ return this._hasMore;
76
+ }
77
+
78
+ get loading(): boolean {
79
+ return this._loading;
80
+ }
81
+
82
+ /**
83
+ * Returns true once the model has completed at least one load attempt
84
+ * (regardless of whether history items were found). This is used by
85
+ * the widget to distinguish "still initializing" from "no history".
86
+ */
87
+ get hasAttemptedLoad(): boolean {
88
+ return this._hasAttemptedLoad;
89
+ }
90
+
91
+ refresh(): void {
92
+ this.cancelSource.cancel();
93
+ this.cancelSource = new CancellationTokenSource();
94
+
95
+ this.toDisposeOnProviderChange.dispose();
96
+
97
+ const repo = this.scmService.selectedRepository;
98
+ const hp = repo?.provider.historyProvider;
99
+ this._provider = hp;
100
+
101
+ if (this._provider) {
102
+ this.toDisposeOnProviderChange.push(
103
+ this._provider.onDidChangeCurrentHistoryItemRefs(() => this.refresh())
104
+ );
105
+ this.toDisposeOnProviderChange.push(
106
+ this._provider.onDidChangeHistoryItemRefs(() => this.refresh())
107
+ );
108
+ } else if (repo) {
109
+ // historyProvider is not yet available; listen for provider changes
110
+ // so that refresh() is retried when historyProvider becomes available.
111
+ this.toDisposeOnProviderChange.push(
112
+ repo.provider.onDidChange(() => this.refresh())
113
+ );
114
+ }
115
+
116
+ this._entries = [];
117
+ this._hasMore = false;
118
+
119
+ this.loadPage();
120
+ }
121
+
122
+ async loadMore(): Promise<void> {
123
+ if (this._loading || !this._hasMore) {
124
+ return;
125
+ }
126
+ await this.loadPage();
127
+ }
128
+
129
+ protected async loadPage(): Promise<void> {
130
+ if (!this._provider) {
131
+ this._entries = [];
132
+ this._hasMore = false;
133
+ this._loading = false;
134
+ this._hasAttemptedLoad = true;
135
+ this.onDidChangeEmitter.fire();
136
+ return;
137
+ }
138
+
139
+ this._loading = true;
140
+ this.onDidChangeEmitter.fire();
141
+
142
+ const token = this.cancelSource.token;
143
+ try {
144
+ const historyItemRefs = this.getCurrentHistoryItemRefs();
145
+ const options: ScmHistoryOptions = {
146
+ skip: this._entries.length,
147
+ limit: PAGE_SIZE,
148
+ historyItemRefs: historyItemRefs.length > 0 ? historyItemRefs : undefined,
149
+ };
150
+ const items = await this._provider.provideHistoryItems(options, token);
151
+
152
+ if (token.isCancellationRequested) {
153
+ return;
154
+ }
155
+
156
+ const fetchedItems: ScmHistoryItem[] = items ?? [];
157
+ this._hasMore = fetchedItems.length >= PAGE_SIZE;
158
+
159
+ // Filter out any items already loaded so the graph does not show duplicates.
160
+ const existingIds = new Set(this._entries.map(e => e.item.id));
161
+ const newItems = fetchedItems.filter(i => !existingIds.has(i.id));
162
+
163
+ const allItems = [...this._entries.map(e => e.item), ...newItems];
164
+ const graphRows = computeGraphRows(allItems.map(i => ({
165
+ id: i.id,
166
+ parentIds: i.parentIds,
167
+ })));
168
+
169
+ this._entries = allItems.map((item, idx) => ({
170
+ item,
171
+ graphRow: graphRows[idx],
172
+ }));
173
+ } catch (err) {
174
+ if (!token.isCancellationRequested) {
175
+ console.error('ScmHistoryGraphModel: failed to load history', err);
176
+ }
177
+ } finally {
178
+ if (!token.isCancellationRequested) {
179
+ this._loading = false;
180
+ this._hasAttemptedLoad = true;
181
+ this.onDidChangeEmitter.fire();
182
+ }
183
+ }
184
+ }
185
+
186
+ /**
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.
190
+ */
191
+ protected getCurrentHistoryItemRefs(): string[] {
192
+ if (!this._provider) {
193
+ return [];
194
+ }
195
+ const refs: string[] = [];
196
+ if (this._provider.currentHistoryItemRef) {
197
+ refs.push(this._provider.currentHistoryItemRef.revision ?? this._provider.currentHistoryItemRef.id);
198
+ }
199
+ if (this._provider.currentHistoryItemRemoteRef) {
200
+ refs.push(this._provider.currentHistoryItemRemoteRef.revision ?? this._provider.currentHistoryItemRemoteRef.id);
201
+ }
202
+ if (this._provider.currentHistoryItemBaseRef) {
203
+ refs.push(this._provider.currentHistoryItemBaseRef.revision ?? this._provider.currentHistoryItemBaseRef.id);
204
+ }
205
+ return refs;
206
+ }
207
+ }