@theia/scm 1.75.0-next.8 → 1.75.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/decorations/scm-navigator-decorator.d.ts.map +1 -1
- package/lib/browser/decorations/scm-navigator-decorator.js +1 -0
- package/lib/browser/decorations/scm-navigator-decorator.js.map +1 -1
- package/lib/browser/merge-editor/view/merge-editor-panes/merge-editor-pane-header.js +7 -10
- package/lib/browser/merge-editor/view/merge-editor-panes/merge-editor-pane-header.js.map +1 -1
- package/lib/browser/scm-action-button-widget.js +10 -15
- package/lib/browser/scm-action-button-widget.js.map +1 -1
- package/lib/browser/scm-amend-component.js +24 -57
- package/lib/browser/scm-amend-component.js.map +1 -1
- package/lib/browser/scm-commit-widget.d.ts +1 -1
- package/lib/browser/scm-commit-widget.d.ts.map +1 -1
- package/lib/browser/scm-commit-widget.js +9 -10
- package/lib/browser/scm-commit-widget.js.map +1 -1
- package/lib/browser/scm-context-key-service.d.ts +2 -0
- package/lib/browser/scm-context-key-service.d.ts.map +1 -1
- package/lib/browser/scm-context-key-service.js +4 -0
- package/lib/browser/scm-context-key-service.js.map +1 -1
- package/lib/browser/scm-context-key-service.spec.js +7 -0
- package/lib/browser/scm-context-key-service.spec.js.map +1 -1
- package/lib/browser/scm-frontend-module.d.ts.map +1 -1
- package/lib/browser/scm-frontend-module.js +6 -0
- package/lib/browser/scm-frontend-module.js.map +1 -1
- package/lib/browser/scm-history-graph-contribution.d.ts +49 -0
- package/lib/browser/scm-history-graph-contribution.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-contribution.js +280 -0
- package/lib/browser/scm-history-graph-contribution.js.map +1 -0
- package/lib/browser/scm-history-graph-contribution.spec.d.ts +2 -0
- package/lib/browser/scm-history-graph-contribution.spec.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-contribution.spec.js +119 -0
- package/lib/browser/scm-history-graph-contribution.spec.js.map +1 -0
- package/lib/browser/scm-history-graph-helpers.d.ts +59 -6
- package/lib/browser/scm-history-graph-helpers.d.ts.map +1 -1
- package/lib/browser/scm-history-graph-helpers.js +129 -5
- package/lib/browser/scm-history-graph-helpers.js.map +1 -1
- package/lib/browser/scm-history-graph-helpers.spec.d.ts +2 -0
- package/lib/browser/scm-history-graph-helpers.spec.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-helpers.spec.js +154 -0
- package/lib/browser/scm-history-graph-helpers.spec.js.map +1 -0
- package/lib/browser/scm-history-graph-lanes.d.ts +19 -5
- package/lib/browser/scm-history-graph-lanes.d.ts.map +1 -1
- package/lib/browser/scm-history-graph-lanes.js +25 -9
- package/lib/browser/scm-history-graph-lanes.js.map +1 -1
- package/lib/browser/scm-history-graph-lanes.spec.js +62 -25
- package/lib/browser/scm-history-graph-lanes.spec.js.map +1 -1
- package/lib/browser/scm-history-graph-model.d.ts +75 -6
- package/lib/browser/scm-history-graph-model.d.ts.map +1 -1
- package/lib/browser/scm-history-graph-model.js +155 -10
- package/lib/browser/scm-history-graph-model.js.map +1 -1
- package/lib/browser/scm-history-graph-model.spec.js +299 -4
- package/lib/browser/scm-history-graph-model.spec.js.map +1 -1
- package/lib/browser/scm-history-graph-tooltip.d.ts +27 -2
- package/lib/browser/scm-history-graph-tooltip.d.ts.map +1 -1
- package/lib/browser/scm-history-graph-tooltip.js +113 -22
- package/lib/browser/scm-history-graph-tooltip.js.map +1 -1
- package/lib/browser/scm-history-graph-tooltip.spec.d.ts +2 -0
- package/lib/browser/scm-history-graph-tooltip.spec.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-tooltip.spec.js +213 -0
- package/lib/browser/scm-history-graph-tooltip.spec.js.map +1 -0
- package/lib/browser/scm-history-graph-widget.d.ts +36 -2
- package/lib/browser/scm-history-graph-widget.d.ts.map +1 -1
- package/lib/browser/scm-history-graph-widget.js +174 -67
- package/lib/browser/scm-history-graph-widget.js.map +1 -1
- package/lib/browser/scm-history-graph-widget.spec.d.ts +2 -0
- package/lib/browser/scm-history-graph-widget.spec.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-widget.spec.js +273 -0
- package/lib/browser/scm-history-graph-widget.spec.js.map +1 -0
- package/lib/browser/scm-provider.d.ts +9 -1
- package/lib/browser/scm-provider.d.ts.map +1 -1
- package/lib/browser/scm-provider.js.map +1 -1
- package/lib/browser/scm-repositories-widget.js +12 -21
- package/lib/browser/scm-repositories-widget.js.map +1 -1
- package/lib/browser/scm-tree-widget.js +35 -53
- package/lib/browser/scm-tree-widget.js.map +1 -1
- package/lib/common/scm-preferences.d.ts +3 -0
- package/lib/common/scm-preferences.d.ts.map +1 -1
- package/lib/common/scm-preferences.js +24 -0
- package/lib/common/scm-preferences.js.map +1 -1
- package/package.json +7 -7
- package/src/browser/decorations/scm-navigator-decorator.ts +3 -2
- package/src/browser/scm-context-key-service.spec.ts +9 -0
- package/src/browser/scm-context-key-service.ts +6 -0
- package/src/browser/scm-frontend-module.ts +7 -1
- package/src/browser/scm-history-graph-contribution.spec.ts +148 -0
- package/src/browser/scm-history-graph-contribution.ts +273 -0
- package/src/browser/scm-history-graph-helpers.spec.ts +193 -0
- package/src/browser/scm-history-graph-helpers.ts +180 -6
- package/src/browser/scm-history-graph-lanes.spec.ts +70 -25
- package/src/browser/scm-history-graph-lanes.ts +48 -11
- package/src/browser/scm-history-graph-model.spec.ts +378 -12
- package/src/browser/scm-history-graph-model.ts +181 -13
- package/src/browser/scm-history-graph-tooltip.spec.ts +254 -0
- package/src/browser/scm-history-graph-tooltip.ts +144 -24
- package/src/browser/scm-history-graph-widget.spec.ts +322 -0
- package/src/browser/scm-history-graph-widget.tsx +199 -39
- package/src/browser/scm-provider.ts +9 -1
- package/src/browser/style/scm-history-graph.css +53 -2
- package/src/common/scm-preferences.ts +27 -0
|
@@ -14,25 +14,47 @@
|
|
|
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
|
-
import { Emitter } from '@theia/core/lib/common/event';
|
|
19
|
+
import { Emitter, Event } 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
|
+
/** How the changes of an expanded history item are laid out. */
|
|
31
|
+
export type ScmHistoryViewMode = 'list' | 'tree';
|
|
32
|
+
|
|
33
|
+
export const ScmHistoryGraphModelProvider = Symbol('ScmHistoryGraphModelProvider');
|
|
34
|
+
/**
|
|
35
|
+
* Resolves the {@link ScmHistoryGraphModel} singleton on first use. The model
|
|
36
|
+
* starts loading history and subscribing to provider events as soon as it is
|
|
37
|
+
* instantiated, so clients that may not need it (e.g. command contributions
|
|
38
|
+
* instantiated at application start) must inject this provider instead of the
|
|
39
|
+
* model itself.
|
|
40
|
+
*/
|
|
41
|
+
export type ScmHistoryGraphModelProvider = () => ScmHistoryGraphModel;
|
|
42
|
+
|
|
27
43
|
export interface HistoryGraphEntry {
|
|
28
44
|
readonly item: ScmHistoryItem;
|
|
29
45
|
readonly graphRow: GraphRow;
|
|
46
|
+
/** True when this item is the commit the current history item ref points at (HEAD). */
|
|
47
|
+
readonly isCurrent: boolean;
|
|
30
48
|
}
|
|
31
49
|
|
|
32
50
|
@injectable()
|
|
33
51
|
export class ScmHistoryGraphModel {
|
|
34
52
|
|
|
35
53
|
@inject(ScmService) protected readonly scmService: ScmService;
|
|
54
|
+
@inject(ScmPreferences) protected readonly scmPreferences: ScmPreferences;
|
|
55
|
+
|
|
56
|
+
@inject(ILogger) @named('scm:ScmHistoryGraphModel')
|
|
57
|
+
protected readonly logger: ILogger;
|
|
36
58
|
|
|
37
59
|
protected readonly toDispose = new DisposableCollection();
|
|
38
60
|
protected readonly toDisposeOnProviderChange = new DisposableCollection();
|
|
@@ -42,10 +64,17 @@ export class ScmHistoryGraphModel {
|
|
|
42
64
|
protected _loading = false;
|
|
43
65
|
protected _hasAttemptedLoad = false;
|
|
44
66
|
protected _provider: ScmHistoryProvider | undefined;
|
|
67
|
+
/** Explicitly picked ref ids to filter the graph by; `undefined` = auto (current/remote/base). */
|
|
68
|
+
protected _historyItemRefFilter: string[] | undefined;
|
|
69
|
+
protected _viewMode: ScmHistoryViewMode = 'list';
|
|
45
70
|
|
|
46
71
|
protected readonly onDidChangeEmitter = new Emitter<void>();
|
|
47
72
|
readonly onDidChange = this.onDidChangeEmitter.event;
|
|
48
73
|
|
|
74
|
+
protected readonly onDidRequestRevealEmitter = new Emitter<number>();
|
|
75
|
+
/** Fired with the index of an entry the view should scroll to and select. */
|
|
76
|
+
readonly onDidRequestReveal: Event<number> = this.onDidRequestRevealEmitter.event;
|
|
77
|
+
|
|
49
78
|
protected cancelSource = new CancellationTokenSource();
|
|
50
79
|
|
|
51
80
|
@postConstruct()
|
|
@@ -53,7 +82,13 @@ export class ScmHistoryGraphModel {
|
|
|
53
82
|
this.toDispose.pushAll([
|
|
54
83
|
Disposable.create(() => this.toDisposeOnProviderChange.dispose()),
|
|
55
84
|
this.onDidChangeEmitter,
|
|
85
|
+
this.onDidRequestRevealEmitter,
|
|
56
86
|
this.scmService.onDidChangeSelectedRepository(() => this.refresh()),
|
|
87
|
+
this.scmPreferences.onPreferenceChanged(e => {
|
|
88
|
+
if (e.preferenceName === 'scm.graph.pageSize') {
|
|
89
|
+
this.reload();
|
|
90
|
+
}
|
|
91
|
+
}),
|
|
57
92
|
]);
|
|
58
93
|
this.refresh();
|
|
59
94
|
}
|
|
@@ -67,6 +102,49 @@ export class ScmHistoryGraphModel {
|
|
|
67
102
|
return this._provider;
|
|
68
103
|
}
|
|
69
104
|
|
|
105
|
+
/** The explicitly picked ref ids filtering the graph, or `undefined` in auto mode. */
|
|
106
|
+
get historyItemRefFilter(): readonly string[] | undefined {
|
|
107
|
+
return this._historyItemRefFilter;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** How the changes of an expanded history item are laid out. */
|
|
111
|
+
get viewMode(): ScmHistoryViewMode {
|
|
112
|
+
return this._viewMode;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Switches how the changes of an expanded history item are laid out. This
|
|
117
|
+
* only affects rendering, so the loaded history is kept as is.
|
|
118
|
+
*/
|
|
119
|
+
setViewMode(viewMode: ScmHistoryViewMode): void {
|
|
120
|
+
if (this._viewMode === viewMode) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this._viewMode = viewMode;
|
|
124
|
+
this.onDidChangeEmitter.fire();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Sets the ref ids to filter the graph by (`undefined` returns to auto
|
|
129
|
+
* mode) and reloads the graph.
|
|
130
|
+
*/
|
|
131
|
+
setHistoryItemRefFilter(refIds: readonly string[] | undefined): void {
|
|
132
|
+
this._historyItemRefFilter = refIds && refIds.length > 0 ? [...refIds] : undefined;
|
|
133
|
+
this.reload();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Whether the current history item ref is part of the graph's filter.
|
|
138
|
+
* Always true in auto mode, which includes the current ref.
|
|
139
|
+
*/
|
|
140
|
+
isCurrentRefInFilter(): boolean {
|
|
141
|
+
if (!this._historyItemRefFilter) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
const currentId = this._provider?.currentHistoryItemRef?.id;
|
|
145
|
+
return currentId !== undefined && this._historyItemRefFilter.includes(currentId);
|
|
146
|
+
}
|
|
147
|
+
|
|
70
148
|
get entries(): readonly HistoryGraphEntry[] {
|
|
71
149
|
return this._entries;
|
|
72
150
|
}
|
|
@@ -89,13 +167,14 @@ export class ScmHistoryGraphModel {
|
|
|
89
167
|
}
|
|
90
168
|
|
|
91
169
|
refresh(): void {
|
|
92
|
-
this.cancelSource.cancel();
|
|
93
|
-
this.cancelSource = new CancellationTokenSource();
|
|
94
|
-
|
|
95
170
|
this.toDisposeOnProviderChange.dispose();
|
|
96
171
|
|
|
97
172
|
const repo = this.scmService.selectedRepository;
|
|
98
173
|
const hp = repo?.provider.historyProvider;
|
|
174
|
+
if (hp !== this._provider) {
|
|
175
|
+
// The repository changed — a filter picked for the old provider does not apply.
|
|
176
|
+
this._historyItemRefFilter = undefined;
|
|
177
|
+
}
|
|
99
178
|
this._provider = hp;
|
|
100
179
|
|
|
101
180
|
if (this._provider) {
|
|
@@ -103,7 +182,10 @@ export class ScmHistoryGraphModel {
|
|
|
103
182
|
this._provider.onDidChangeCurrentHistoryItemRefs(() => this.refresh())
|
|
104
183
|
);
|
|
105
184
|
this.toDisposeOnProviderChange.push(
|
|
106
|
-
this._provider.onDidChangeHistoryItemRefs(
|
|
185
|
+
this._provider.onDidChangeHistoryItemRefs(e => {
|
|
186
|
+
this.pruneHistoryItemRefFilter(e.removed);
|
|
187
|
+
this.refresh();
|
|
188
|
+
})
|
|
107
189
|
);
|
|
108
190
|
} else if (repo) {
|
|
109
191
|
// historyProvider is not yet available; listen for provider changes
|
|
@@ -113,12 +195,66 @@ export class ScmHistoryGraphModel {
|
|
|
113
195
|
);
|
|
114
196
|
}
|
|
115
197
|
|
|
198
|
+
this.reload();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Drops refs that no longer exist from the explicit filter, so that
|
|
203
|
+
* deleting or renaming a filtered ref does not leave the graph stuck
|
|
204
|
+
* requesting history for it. When the last filtered ref is removed,
|
|
205
|
+
* the filter falls back to auto mode.
|
|
206
|
+
*/
|
|
207
|
+
protected pruneHistoryItemRefFilter(removed: readonly ScmHistoryItemRef[]): void {
|
|
208
|
+
if (!this._historyItemRefFilter || removed.length === 0) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const removedIds = new Set(removed.map(ref => ref.id));
|
|
212
|
+
const remaining = this._historyItemRefFilter.filter(id => !removedIds.has(id));
|
|
213
|
+
if (remaining.length !== this._historyItemRefFilter.length) {
|
|
214
|
+
this._historyItemRefFilter = remaining.length > 0 ? remaining : undefined;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Clears the loaded entries and loads the first page again from the current provider. */
|
|
219
|
+
protected reload(): void {
|
|
220
|
+
this.cancelSource.cancel();
|
|
221
|
+
this.cancelSource = new CancellationTokenSource();
|
|
222
|
+
|
|
116
223
|
this._entries = [];
|
|
117
224
|
this._hasMore = false;
|
|
118
225
|
|
|
119
226
|
this.loadPage();
|
|
120
227
|
}
|
|
121
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Locates the commit the current history item ref points at, loading
|
|
231
|
+
* further pages while it is outside the loaded range, and asks the view to
|
|
232
|
+
* reveal it. Returns its index, or `undefined` when there is no current
|
|
233
|
+
* history item ref or the commit is not part of the graph (e.g. the ref is
|
|
234
|
+
* excluded by the filter).
|
|
235
|
+
*/
|
|
236
|
+
async revealCurrentHistoryItem(): Promise<number | undefined> {
|
|
237
|
+
let index = this.indexOfCurrentHistoryItem();
|
|
238
|
+
while (index === undefined && this._hasMore) {
|
|
239
|
+
const loadedBefore = this._entries.length;
|
|
240
|
+
await this.loadMore();
|
|
241
|
+
if (this._entries.length === loadedBefore) {
|
|
242
|
+
// The page added nothing new; paging further cannot find the item.
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
index = this.indexOfCurrentHistoryItem();
|
|
246
|
+
}
|
|
247
|
+
if (index !== undefined) {
|
|
248
|
+
this.onDidRequestRevealEmitter.fire(index);
|
|
249
|
+
}
|
|
250
|
+
return index;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
protected indexOfCurrentHistoryItem(): number | undefined {
|
|
254
|
+
const index = this._entries.findIndex(entry => entry.isCurrent);
|
|
255
|
+
return index === -1 ? undefined : index;
|
|
256
|
+
}
|
|
257
|
+
|
|
122
258
|
async loadMore(): Promise<void> {
|
|
123
259
|
if (this._loading || !this._hasMore) {
|
|
124
260
|
return;
|
|
@@ -141,10 +277,11 @@ export class ScmHistoryGraphModel {
|
|
|
141
277
|
|
|
142
278
|
const token = this.cancelSource.token;
|
|
143
279
|
try {
|
|
280
|
+
const pageSize = this.pageSize;
|
|
144
281
|
const historyItemRefs = this.getCurrentHistoryItemRefs();
|
|
145
282
|
const options: ScmHistoryOptions = {
|
|
146
283
|
skip: this._entries.length,
|
|
147
|
-
limit:
|
|
284
|
+
limit: pageSize,
|
|
148
285
|
historyItemRefs: historyItemRefs.length > 0 ? historyItemRefs : undefined,
|
|
149
286
|
};
|
|
150
287
|
const items = await this._provider.provideHistoryItems(options, token);
|
|
@@ -154,7 +291,7 @@ export class ScmHistoryGraphModel {
|
|
|
154
291
|
}
|
|
155
292
|
|
|
156
293
|
const fetchedItems: ScmHistoryItem[] = items ?? [];
|
|
157
|
-
this._hasMore = fetchedItems.length >=
|
|
294
|
+
this._hasMore = fetchedItems.length >= pageSize;
|
|
158
295
|
|
|
159
296
|
// Filter out any items already loaded so the graph does not show duplicates.
|
|
160
297
|
const existingIds = new Set(this._entries.map(e => e.item.id));
|
|
@@ -164,15 +301,18 @@ export class ScmHistoryGraphModel {
|
|
|
164
301
|
const graphRows = computeGraphRows(allItems.map(i => ({
|
|
165
302
|
id: i.id,
|
|
166
303
|
parentIds: i.parentIds,
|
|
304
|
+
colorIndex: this.resolveColorIndex(i),
|
|
167
305
|
})));
|
|
168
306
|
|
|
307
|
+
const currentRevision = this._provider.currentHistoryItemRef?.revision;
|
|
169
308
|
this._entries = allItems.map((item, idx) => ({
|
|
170
309
|
item,
|
|
171
310
|
graphRow: graphRows[idx],
|
|
311
|
+
isCurrent: currentRevision !== undefined && item.id === currentRevision,
|
|
172
312
|
}));
|
|
173
313
|
} catch (err) {
|
|
174
314
|
if (!token.isCancellationRequested) {
|
|
175
|
-
|
|
315
|
+
this.logger.error('Failed to load history', err);
|
|
176
316
|
}
|
|
177
317
|
} finally {
|
|
178
318
|
if (!token.isCancellationRequested) {
|
|
@@ -183,12 +323,40 @@ export class ScmHistoryGraphModel {
|
|
|
183
323
|
}
|
|
184
324
|
}
|
|
185
325
|
|
|
326
|
+
/** The configured page size (`scm.graph.pageSize`). */
|
|
327
|
+
protected get pageSize(): number {
|
|
328
|
+
return this.scmPreferences['scm.graph.pageSize'] ?? PAGE_SIZE;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Resolves the ref-based color index of an item from its references,
|
|
333
|
+
* preferring current (0) over remote (1) over base (2). Refs excluded by
|
|
334
|
+
* an explicit filter get no role color, mirroring VS Code's color map.
|
|
335
|
+
*/
|
|
336
|
+
protected resolveColorIndex(item: ScmHistoryItem): number | undefined {
|
|
337
|
+
let result: number | undefined;
|
|
338
|
+
for (const ref of item.references ?? []) {
|
|
339
|
+
if (this._historyItemRefFilter && !this._historyItemRefFilter.includes(ref.id)) {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const index = getRefColorIndex(ref, this._provider);
|
|
343
|
+
if (index !== undefined && (result === undefined || index < result)) {
|
|
344
|
+
result = index;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return result;
|
|
348
|
+
}
|
|
349
|
+
|
|
186
350
|
/**
|
|
187
|
-
* Returns the
|
|
188
|
-
*
|
|
189
|
-
*
|
|
351
|
+
* Returns the refs to pass to `provideHistoryItems`: the explicitly picked
|
|
352
|
+
* ref ids when a filter is active, otherwise (auto mode) the revisions of
|
|
353
|
+
* the current branch ref, its remote tracking ref, and the merge-base ref.
|
|
354
|
+
* Providers walk history starting from these refs.
|
|
190
355
|
*/
|
|
191
356
|
protected getCurrentHistoryItemRefs(): string[] {
|
|
357
|
+
if (this._historyItemRefFilter) {
|
|
358
|
+
return [...this._historyItemRefFilter];
|
|
359
|
+
}
|
|
192
360
|
if (!this._provider) {
|
|
193
361
|
return [];
|
|
194
362
|
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2026 Safi Seid-Ahmad, K2view and others.
|
|
3
|
+
//
|
|
4
|
+
// This program and the accompanying materials are made available under the
|
|
5
|
+
// terms of the Eclipse Public License v. 2.0 which is available at
|
|
6
|
+
// http://www.eclipse.org/legal/epl-2.0.
|
|
7
|
+
//
|
|
8
|
+
// This Source Code may also be made available under the following Secondary
|
|
9
|
+
// Licenses when the conditions for such availability set forth in the Eclipse
|
|
10
|
+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
|
11
|
+
// with the GNU Classpath Exception which is available at
|
|
12
|
+
// https://www.gnu.org/software/classpath/license.html.
|
|
13
|
+
//
|
|
14
|
+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
|
+
// *****************************************************************************
|
|
16
|
+
|
|
17
|
+
import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
|
|
18
|
+
const disableJSDOM = enableJSDOM();
|
|
19
|
+
|
|
20
|
+
import { expect } from 'chai';
|
|
21
|
+
import { MarkdownRenderer } from '@theia/core/lib/browser/markdown-rendering/markdown-renderer';
|
|
22
|
+
import { MarkdownString, MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering/markdown-string';
|
|
23
|
+
import { OpenerService } from '@theia/core/lib/browser/opener-service';
|
|
24
|
+
import { markMarkdownLinksWired } from '@theia/core/lib/browser/markdown-rendering/markdown-link-handler';
|
|
25
|
+
import { DisposableCollection } from '@theia/core/lib/common/disposable';
|
|
26
|
+
import URI from '@theia/core/lib/common/uri';
|
|
27
|
+
import { ScmHistoryItem } from './scm-provider';
|
|
28
|
+
import { HistoryGraphEntry } from './scm-history-graph-model';
|
|
29
|
+
import { buildHtmlTooltip, buildProviderTooltip, HistoryTooltipActions } from './scm-history-graph-tooltip';
|
|
30
|
+
|
|
31
|
+
disableJSDOM();
|
|
32
|
+
|
|
33
|
+
function makeEntry(item: Partial<ScmHistoryItem>): HistoryGraphEntry {
|
|
34
|
+
return {
|
|
35
|
+
item: {
|
|
36
|
+
id: '1a56ba96fdc9b6df3a230df7b44e22e5785e3abd',
|
|
37
|
+
subject: 'fix something',
|
|
38
|
+
parentIds: [],
|
|
39
|
+
...item,
|
|
40
|
+
},
|
|
41
|
+
graphRow: { lane: 0, color: 0, topColor: 0, edges: [], hasContinuation: false, hasTopLine: false },
|
|
42
|
+
isCurrent: false,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const markdownRenderer = {
|
|
47
|
+
render: () => ({ element: document.createElement('div'), dispose: () => { } })
|
|
48
|
+
} as unknown as MarkdownRenderer;
|
|
49
|
+
|
|
50
|
+
describe('buildHtmlTooltip', () => {
|
|
51
|
+
let restoreJSDOM: () => void;
|
|
52
|
+
|
|
53
|
+
before(() => {
|
|
54
|
+
restoreJSDOM = enableJSDOM();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
after(() => {
|
|
58
|
+
restoreJSDOM();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('renders the author as a bold mailto link when the email is known', () => {
|
|
62
|
+
const tooltip = buildHtmlTooltip(makeEntry({ author: 'Jane Doe', authorEmail: 'jane@example.com', timestamp: 1000 }), markdownRenderer);
|
|
63
|
+
const link = tooltip.querySelector('a[href="mailto:jane@example.com"]');
|
|
64
|
+
expect(link, 'mailto link').to.not.be.null;
|
|
65
|
+
expect(link!.textContent).to.contain('Jane Doe');
|
|
66
|
+
expect(link!.querySelector('strong'), 'bold author').to.not.be.null;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('renders a plain bold author when no email is known', () => {
|
|
70
|
+
const tooltip = buildHtmlTooltip(makeEntry({ author: 'Jane Doe', timestamp: 1000 }), markdownRenderer);
|
|
71
|
+
expect(tooltip.querySelector('a[href^="mailto:"]')).to.be.null;
|
|
72
|
+
const strong = tooltip.querySelector('.scm-history-tooltip-header strong');
|
|
73
|
+
expect(strong).to.not.be.null;
|
|
74
|
+
expect(strong!.textContent).to.equal('Jane Doe');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('renders the author avatar image when authorIcon is a URL', () => {
|
|
78
|
+
const tooltip = buildHtmlTooltip(makeEntry({ author: 'Jane Doe', authorIcon: 'https://avatars.example.com/u/1' }), markdownRenderer);
|
|
79
|
+
const img = tooltip.querySelector('img.scm-history-tooltip-avatar') as HTMLImageElement;
|
|
80
|
+
expect(img, 'avatar image').to.not.be.null;
|
|
81
|
+
expect(img.src).to.equal('https://avatars.example.com/u/1');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('falls back to the account icon without an avatar', () => {
|
|
85
|
+
const tooltip = buildHtmlTooltip(makeEntry({ author: 'Jane Doe' }), markdownRenderer);
|
|
86
|
+
expect(tooltip.querySelector('img.scm-history-tooltip-avatar')).to.be.null;
|
|
87
|
+
expect(tooltip.querySelector('.scm-history-tooltip-header .codicon-account')).to.not.be.null;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('renders an Open Commit action with the short hash', () => {
|
|
91
|
+
let opened = 0;
|
|
92
|
+
const actions: HistoryTooltipActions = { openCommit: () => { opened++; } };
|
|
93
|
+
const tooltip = buildHtmlTooltip(makeEntry({ displayId: '1a56ba9' }), markdownRenderer, undefined, actions);
|
|
94
|
+
const action = tooltip.querySelector('[title="Open Commit"]') as HTMLElement;
|
|
95
|
+
expect(action, 'open commit action').to.not.be.null;
|
|
96
|
+
expect(action.textContent).to.contain('1a56ba9');
|
|
97
|
+
action.click();
|
|
98
|
+
expect(opened).to.equal(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('renders a Copy Commit Hash action', () => {
|
|
102
|
+
let copied = 0;
|
|
103
|
+
const actions: HistoryTooltipActions = { copyCommitHash: () => { copied++; } };
|
|
104
|
+
const tooltip = buildHtmlTooltip(makeEntry({}), markdownRenderer, undefined, actions);
|
|
105
|
+
const action = tooltip.querySelector('[title="Copy Commit Hash"]') as HTMLElement;
|
|
106
|
+
expect(action, 'copy hash action').to.not.be.null;
|
|
107
|
+
action.click();
|
|
108
|
+
expect(copied).to.equal(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('shows the short hash as plain text when no actions are given', () => {
|
|
112
|
+
const tooltip = buildHtmlTooltip(makeEntry({ displayId: '1a56ba9' }), markdownRenderer);
|
|
113
|
+
expect(tooltip.querySelector('[title="Open Commit"]')).to.be.null;
|
|
114
|
+
expect(tooltip.querySelector('[title="Copy Commit Hash"]')).to.be.null;
|
|
115
|
+
expect(tooltip.textContent).to.contain('1a56ba9');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('buildProviderTooltip', () => {
|
|
120
|
+
let restoreJSDOM: () => void;
|
|
121
|
+
let opened: string[];
|
|
122
|
+
let toDispose: DisposableCollection;
|
|
123
|
+
|
|
124
|
+
/** Renders the markdown value as HTML, so that the wiring of the resulting links can be asserted. */
|
|
125
|
+
const htmlRenderer = {
|
|
126
|
+
render: (markdown: MarkdownString) => {
|
|
127
|
+
const element = document.createElement('div');
|
|
128
|
+
element.innerHTML = markdown.value;
|
|
129
|
+
return { element, dispose: () => { } };
|
|
130
|
+
}
|
|
131
|
+
} as unknown as MarkdownRenderer;
|
|
132
|
+
|
|
133
|
+
const openerService = {
|
|
134
|
+
getOpener: (uri: URI) => Promise.resolve({
|
|
135
|
+
canHandle: () => 1,
|
|
136
|
+
open: () => {
|
|
137
|
+
// decoded, as `HttpOpenHandler` does when opening the target externally
|
|
138
|
+
opened.push(uri.toString(true));
|
|
139
|
+
return Promise.resolve(undefined);
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
} as unknown as OpenerService;
|
|
143
|
+
|
|
144
|
+
/** Clicks the anchor and waits for the asynchronous opener lookup to settle. */
|
|
145
|
+
async function click(tooltip: HTMLElement, selector: string): Promise<void> {
|
|
146
|
+
const anchor = tooltip.querySelector(selector) as HTMLElement;
|
|
147
|
+
expect(anchor, selector).to.not.be.null;
|
|
148
|
+
anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
149
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
before(() => {
|
|
153
|
+
restoreJSDOM = enableJSDOM();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
after(() => {
|
|
157
|
+
restoreJSDOM();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
beforeEach(() => {
|
|
161
|
+
opened = [];
|
|
162
|
+
toDispose = new DisposableCollection();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
afterEach(() => {
|
|
166
|
+
toDispose.dispose();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('renders each section in order', () => {
|
|
170
|
+
const tooltip = buildProviderTooltip(
|
|
171
|
+
[new MarkdownStringImpl('<span>first</span>'), new MarkdownStringImpl('<span>second</span>')],
|
|
172
|
+
htmlRenderer, openerService, toDispose)!;
|
|
173
|
+
expect(tooltip, 'tooltip').to.not.be.undefined;
|
|
174
|
+
expect(tooltip.textContent).to.equal('firstsecond');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// `vscode.git` ends its author and statistics sections with a horizontal rule of its own,
|
|
178
|
+
// so adding separators here would render every boundary as a double line.
|
|
179
|
+
it('adds no separators of its own between sections', () => {
|
|
180
|
+
const tooltip = buildProviderTooltip(
|
|
181
|
+
[new MarkdownStringImpl('<span>first</span><hr>'), new MarkdownStringImpl('<span>second</span>')],
|
|
182
|
+
htmlRenderer, openerService, toDispose)!;
|
|
183
|
+
expect(tooltip.querySelectorAll('hr').length, 'rules').to.equal(1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('renders a plain string tooltip', () => {
|
|
187
|
+
const tooltip = buildProviderTooltip('just text', htmlRenderer, openerService, toDispose)!;
|
|
188
|
+
expect(tooltip, 'tooltip').to.not.be.undefined;
|
|
189
|
+
expect(tooltip.textContent).to.equal('just text');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('returns undefined when the provider supplies no usable content', () => {
|
|
193
|
+
expect(buildProviderTooltip(' ', htmlRenderer, openerService, toDispose)).to.be.undefined;
|
|
194
|
+
expect(buildProviderTooltip([], htmlRenderer, openerService, toDispose)).to.be.undefined;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('routes an author mailto link through the opener service', async () => {
|
|
198
|
+
const tooltip = buildProviderTooltip(
|
|
199
|
+
new MarkdownStringImpl('<a href="mailto:jane@example.com">Jane Doe</a>'), htmlRenderer, openerService, toDispose)!;
|
|
200
|
+
await click(tooltip, 'a[href^="mailto:"]');
|
|
201
|
+
expect(opened).to.deep.equal(['mailto:jane@example.com']);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('executes a command link that the section trusts', async () => {
|
|
205
|
+
const section = new MarkdownStringImpl('<a href="command:git.viewCommit">Open Commit</a>');
|
|
206
|
+
section.isTrusted = { enabledCommands: ['git.viewCommit'] };
|
|
207
|
+
const tooltip = buildProviderTooltip(section, htmlRenderer, openerService, toDispose)!;
|
|
208
|
+
await click(tooltip, 'a[href^="command:"]');
|
|
209
|
+
expect(opened).to.deep.equal(['command:git.viewCommit']);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('blocks a command link that the section does not trust', async () => {
|
|
213
|
+
const section = new MarkdownStringImpl('<a href="command:git.viewCommit">Open Commit</a>');
|
|
214
|
+
section.isTrusted = { enabledCommands: ['git.copyContentToClipboard'] };
|
|
215
|
+
const tooltip = buildProviderTooltip(section, htmlRenderer, openerService, toDispose)!;
|
|
216
|
+
await click(tooltip, 'a[href^="command:"]');
|
|
217
|
+
expect(opened).to.be.empty;
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('does not open a link twice when the renderer already wired it', async () => {
|
|
221
|
+
// The Monaco-based renderer used at runtime routes link activation through the opener
|
|
222
|
+
// service itself, so wiring a second handler on top would open every link twice.
|
|
223
|
+
const wiringRenderer = {
|
|
224
|
+
render: (markdown: MarkdownString) => {
|
|
225
|
+
const element = document.createElement('div');
|
|
226
|
+
element.innerHTML = markdown.value;
|
|
227
|
+
markMarkdownLinksWired(element);
|
|
228
|
+
element.addEventListener('click', event => {
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
opened.push((event.target as HTMLElement).getAttribute('href')!);
|
|
231
|
+
});
|
|
232
|
+
return { element, dispose: () => { } };
|
|
233
|
+
}
|
|
234
|
+
} as unknown as MarkdownRenderer;
|
|
235
|
+
|
|
236
|
+
const section = new MarkdownStringImpl('<a href="command:git.viewCommit">Open Commit</a>');
|
|
237
|
+
section.isTrusted = { enabledCommands: ['git.viewCommit'] };
|
|
238
|
+
const tooltip = buildProviderTooltip(section, wiringRenderer, openerService, toDispose)!;
|
|
239
|
+
await click(tooltip, 'a[href^="command:"]');
|
|
240
|
+
expect(opened).to.deep.equal(['command:git.viewCommit']);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('does not let one section authorize another section\'s commands', async () => {
|
|
244
|
+
const untrusted = new MarkdownStringImpl('<a id="untrusted" href="command:git.viewCommit">Open Commit</a>');
|
|
245
|
+
const trusted = new MarkdownStringImpl('<a id="trusted" href="command:git.copyContentToClipboard">Copy</a>');
|
|
246
|
+
trusted.isTrusted = { enabledCommands: ['git.copyContentToClipboard'] };
|
|
247
|
+
const tooltip = buildProviderTooltip([untrusted, trusted], htmlRenderer, openerService, toDispose)!;
|
|
248
|
+
|
|
249
|
+
await click(tooltip, 'a#untrusted');
|
|
250
|
+
expect(opened, 'untrusted section').to.be.empty;
|
|
251
|
+
await click(tooltip, 'a#trusted');
|
|
252
|
+
expect(opened, 'trusted section').to.deep.equal(['command:git.copyContentToClipboard']);
|
|
253
|
+
});
|
|
254
|
+
});
|