@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.
Files changed (97) hide show
  1. package/lib/browser/decorations/scm-navigator-decorator.d.ts.map +1 -1
  2. package/lib/browser/decorations/scm-navigator-decorator.js +1 -0
  3. package/lib/browser/decorations/scm-navigator-decorator.js.map +1 -1
  4. package/lib/browser/merge-editor/view/merge-editor-panes/merge-editor-pane-header.js +7 -10
  5. package/lib/browser/merge-editor/view/merge-editor-panes/merge-editor-pane-header.js.map +1 -1
  6. package/lib/browser/scm-action-button-widget.js +10 -15
  7. package/lib/browser/scm-action-button-widget.js.map +1 -1
  8. package/lib/browser/scm-amend-component.js +24 -57
  9. package/lib/browser/scm-amend-component.js.map +1 -1
  10. package/lib/browser/scm-commit-widget.d.ts +1 -1
  11. package/lib/browser/scm-commit-widget.d.ts.map +1 -1
  12. package/lib/browser/scm-commit-widget.js +9 -10
  13. package/lib/browser/scm-commit-widget.js.map +1 -1
  14. package/lib/browser/scm-context-key-service.d.ts +2 -0
  15. package/lib/browser/scm-context-key-service.d.ts.map +1 -1
  16. package/lib/browser/scm-context-key-service.js +4 -0
  17. package/lib/browser/scm-context-key-service.js.map +1 -1
  18. package/lib/browser/scm-context-key-service.spec.js +7 -0
  19. package/lib/browser/scm-context-key-service.spec.js.map +1 -1
  20. package/lib/browser/scm-frontend-module.d.ts.map +1 -1
  21. package/lib/browser/scm-frontend-module.js +6 -0
  22. package/lib/browser/scm-frontend-module.js.map +1 -1
  23. package/lib/browser/scm-history-graph-contribution.d.ts +49 -0
  24. package/lib/browser/scm-history-graph-contribution.d.ts.map +1 -0
  25. package/lib/browser/scm-history-graph-contribution.js +280 -0
  26. package/lib/browser/scm-history-graph-contribution.js.map +1 -0
  27. package/lib/browser/scm-history-graph-contribution.spec.d.ts +2 -0
  28. package/lib/browser/scm-history-graph-contribution.spec.d.ts.map +1 -0
  29. package/lib/browser/scm-history-graph-contribution.spec.js +119 -0
  30. package/lib/browser/scm-history-graph-contribution.spec.js.map +1 -0
  31. package/lib/browser/scm-history-graph-helpers.d.ts +59 -6
  32. package/lib/browser/scm-history-graph-helpers.d.ts.map +1 -1
  33. package/lib/browser/scm-history-graph-helpers.js +129 -5
  34. package/lib/browser/scm-history-graph-helpers.js.map +1 -1
  35. package/lib/browser/scm-history-graph-helpers.spec.d.ts +2 -0
  36. package/lib/browser/scm-history-graph-helpers.spec.d.ts.map +1 -0
  37. package/lib/browser/scm-history-graph-helpers.spec.js +154 -0
  38. package/lib/browser/scm-history-graph-helpers.spec.js.map +1 -0
  39. package/lib/browser/scm-history-graph-lanes.d.ts +19 -5
  40. package/lib/browser/scm-history-graph-lanes.d.ts.map +1 -1
  41. package/lib/browser/scm-history-graph-lanes.js +25 -9
  42. package/lib/browser/scm-history-graph-lanes.js.map +1 -1
  43. package/lib/browser/scm-history-graph-lanes.spec.js +62 -25
  44. package/lib/browser/scm-history-graph-lanes.spec.js.map +1 -1
  45. package/lib/browser/scm-history-graph-model.d.ts +75 -6
  46. package/lib/browser/scm-history-graph-model.d.ts.map +1 -1
  47. package/lib/browser/scm-history-graph-model.js +155 -10
  48. package/lib/browser/scm-history-graph-model.js.map +1 -1
  49. package/lib/browser/scm-history-graph-model.spec.js +299 -4
  50. package/lib/browser/scm-history-graph-model.spec.js.map +1 -1
  51. package/lib/browser/scm-history-graph-tooltip.d.ts +27 -2
  52. package/lib/browser/scm-history-graph-tooltip.d.ts.map +1 -1
  53. package/lib/browser/scm-history-graph-tooltip.js +113 -22
  54. package/lib/browser/scm-history-graph-tooltip.js.map +1 -1
  55. package/lib/browser/scm-history-graph-tooltip.spec.d.ts +2 -0
  56. package/lib/browser/scm-history-graph-tooltip.spec.d.ts.map +1 -0
  57. package/lib/browser/scm-history-graph-tooltip.spec.js +213 -0
  58. package/lib/browser/scm-history-graph-tooltip.spec.js.map +1 -0
  59. package/lib/browser/scm-history-graph-widget.d.ts +36 -2
  60. package/lib/browser/scm-history-graph-widget.d.ts.map +1 -1
  61. package/lib/browser/scm-history-graph-widget.js +174 -67
  62. package/lib/browser/scm-history-graph-widget.js.map +1 -1
  63. package/lib/browser/scm-history-graph-widget.spec.d.ts +2 -0
  64. package/lib/browser/scm-history-graph-widget.spec.d.ts.map +1 -0
  65. package/lib/browser/scm-history-graph-widget.spec.js +273 -0
  66. package/lib/browser/scm-history-graph-widget.spec.js.map +1 -0
  67. package/lib/browser/scm-provider.d.ts +9 -1
  68. package/lib/browser/scm-provider.d.ts.map +1 -1
  69. package/lib/browser/scm-provider.js.map +1 -1
  70. package/lib/browser/scm-repositories-widget.js +12 -21
  71. package/lib/browser/scm-repositories-widget.js.map +1 -1
  72. package/lib/browser/scm-tree-widget.js +35 -53
  73. package/lib/browser/scm-tree-widget.js.map +1 -1
  74. package/lib/common/scm-preferences.d.ts +3 -0
  75. package/lib/common/scm-preferences.d.ts.map +1 -1
  76. package/lib/common/scm-preferences.js +24 -0
  77. package/lib/common/scm-preferences.js.map +1 -1
  78. package/package.json +7 -7
  79. package/src/browser/decorations/scm-navigator-decorator.ts +3 -2
  80. package/src/browser/scm-context-key-service.spec.ts +9 -0
  81. package/src/browser/scm-context-key-service.ts +6 -0
  82. package/src/browser/scm-frontend-module.ts +7 -1
  83. package/src/browser/scm-history-graph-contribution.spec.ts +148 -0
  84. package/src/browser/scm-history-graph-contribution.ts +273 -0
  85. package/src/browser/scm-history-graph-helpers.spec.ts +193 -0
  86. package/src/browser/scm-history-graph-helpers.ts +180 -6
  87. package/src/browser/scm-history-graph-lanes.spec.ts +70 -25
  88. package/src/browser/scm-history-graph-lanes.ts +48 -11
  89. package/src/browser/scm-history-graph-model.spec.ts +378 -12
  90. package/src/browser/scm-history-graph-model.ts +181 -13
  91. package/src/browser/scm-history-graph-tooltip.spec.ts +254 -0
  92. package/src/browser/scm-history-graph-tooltip.ts +144 -24
  93. package/src/browser/scm-history-graph-widget.spec.ts +322 -0
  94. package/src/browser/scm-history-graph-widget.tsx +199 -39
  95. package/src/browser/scm-provider.ts +9 -1
  96. package/src/browser/style/scm-history-graph.css +53 -2
  97. 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(() => this.refresh())
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: PAGE_SIZE,
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 >= PAGE_SIZE;
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
- console.error('ScmHistoryGraphModel: failed to load history', err);
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 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.
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
+ });