@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.
- package/lib/browser/dirty-diff/dirty-diff-widget.js +1 -1
- package/lib/browser/dirty-diff/dirty-diff-widget.js.map +1 -1
- package/lib/browser/scm-context-key-service.d.ts +6 -0
- package/lib/browser/scm-context-key-service.d.ts.map +1 -1
- package/lib/browser/scm-context-key-service.js +12 -0
- package/lib/browser/scm-context-key-service.js.map +1 -1
- package/lib/browser/scm-contribution.d.ts.map +1 -1
- package/lib/browser/scm-contribution.js +129 -1
- package/lib/browser/scm-contribution.js.map +1 -1
- package/lib/browser/scm-frontend-module.d.ts.map +1 -1
- package/lib/browser/scm-frontend-module.js +14 -0
- package/lib/browser/scm-frontend-module.js.map +1 -1
- package/lib/browser/scm-history-graph-helpers.d.ts +39 -0
- package/lib/browser/scm-history-graph-helpers.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-helpers.js +167 -0
- package/lib/browser/scm-history-graph-helpers.js.map +1 -0
- package/lib/browser/scm-history-graph-lanes.d.ts +59 -0
- package/lib/browser/scm-history-graph-lanes.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-lanes.js +183 -0
- package/lib/browser/scm-history-graph-lanes.js.map +1 -0
- package/lib/browser/scm-history-graph-lanes.spec.d.ts +2 -0
- package/lib/browser/scm-history-graph-lanes.spec.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-lanes.spec.js +554 -0
- package/lib/browser/scm-history-graph-lanes.spec.js.map +1 -0
- package/lib/browser/scm-history-graph-model.d.ts +46 -0
- package/lib/browser/scm-history-graph-model.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-model.js +184 -0
- package/lib/browser/scm-history-graph-model.js.map +1 -0
- package/lib/browser/scm-history-graph-model.spec.d.ts +2 -0
- package/lib/browser/scm-history-graph-model.spec.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-model.spec.js +131 -0
- package/lib/browser/scm-history-graph-model.spec.js.map +1 -0
- package/lib/browser/scm-history-graph-tooltip.d.ts +14 -0
- package/lib/browser/scm-history-graph-tooltip.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-tooltip.js +190 -0
- package/lib/browser/scm-history-graph-tooltip.js.map +1 -0
- package/lib/browser/scm-history-graph-widget.d.ts +77 -0
- package/lib/browser/scm-history-graph-widget.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-widget.js +490 -0
- package/lib/browser/scm-history-graph-widget.js.map +1 -0
- package/lib/browser/scm-provider.d.ts +61 -0
- package/lib/browser/scm-provider.d.ts.map +1 -1
- package/lib/browser/scm-provider.js.map +1 -1
- package/package.json +7 -7
- package/src/browser/dirty-diff/dirty-diff-widget.ts +1 -1
- package/src/browser/scm-context-key-service.ts +18 -0
- package/src/browser/scm-contribution.ts +141 -0
- package/src/browser/scm-frontend-module.ts +15 -0
- package/src/browser/scm-history-graph-helpers.ts +175 -0
- package/src/browser/scm-history-graph-lanes.spec.ts +635 -0
- package/src/browser/scm-history-graph-lanes.ts +258 -0
- package/src/browser/scm-history-graph-model.spec.ts +171 -0
- package/src/browser/scm-history-graph-model.ts +207 -0
- package/src/browser/scm-history-graph-tooltip.ts +213 -0
- package/src/browser/scm-history-graph-widget.tsx +712 -0
- package/src/browser/scm-provider.ts +68 -0
- package/src/browser/style/index.css +12 -13
- 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
|
+
}
|