@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
@@ -15,12 +15,15 @@
15
15
  // *****************************************************************************
16
16
 
17
17
  import { MarkdownRenderer } from '@theia/core/lib/browser/markdown-rendering/markdown-renderer';
18
- import { MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering/markdown-string';
18
+ import { wireMarkdownLinkHandler } from '@theia/core/lib/browser/markdown-rendering/markdown-link-handler';
19
+ import { MarkdownString, MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering/markdown-string';
20
+ import { OpenerService } from '@theia/core/lib/browser/opener-service';
21
+ import { DisposableCollection } from '@theia/core/lib/common/disposable';
19
22
  import { codicon } from '@theia/core/lib/browser/widgets/widget';
20
23
  import { nls } from '@theia/core/lib/common/nls';
21
- import { ScmHistoryItemRef } from './scm-provider';
24
+ import { ScmHistoryItem, ScmHistoryItemRef, ScmHistoryProvider } from './scm-provider';
22
25
  import { HistoryGraphEntry } from './scm-history-graph-model';
23
- import { laneColor, getRefBadgeClass, deduplicateRefs, isTagRef, isRemoteRef } from './scm-history-graph-helpers';
26
+ import { laneColor, getRefBadgeClass, getRefBadgePresentation, deduplicateRefs, isTagRef, isRemoteRef } from './scm-history-graph-helpers';
24
27
 
25
28
  export function formatRelativeTime(ms: number): string {
26
29
  const now = Date.now();
@@ -75,6 +78,100 @@ export function createHoverHr(): HTMLElement {
75
78
  return document.createElement('hr');
76
79
  }
77
80
 
81
+ export interface HistoryTooltipActions {
82
+ /** Invoked when the user clicks the commit hash (mirrors VS Code's "Open Commit" hover command). */
83
+ openCommit?: () => void;
84
+ /** Invoked when the user clicks the copy action (mirrors VS Code's "Copy Commit Hash" hover command). */
85
+ copyCommitHash?: () => void;
86
+ }
87
+
88
+ /**
89
+ * Renders the hover content supplied by the history provider, if any.
90
+ *
91
+ * Providers own their hover: `vscode.git`, for example, sends the same content as the blame
92
+ * editor decoration hover, including the author `mailto:` link and the "Open Commit",
93
+ * "Copy Commit Hash" and remote (e.g. "Open on GitHub") command links. Rendering it verbatim
94
+ * keeps those actions working without the graph having to know any provider-specific command.
95
+ *
96
+ * Each section is rendered and wired separately so that a section only authorizes the commands
97
+ * declared in its own {@link MarkdownString.isTrusted}. Sections are rendered as given, without
98
+ * separators of our own: a provider that owns its hover also owns how its sections are delimited,
99
+ * and `vscode.git` for one already ends them with a horizontal rule.
100
+ *
101
+ * @returns the rendered element, or `undefined` if the provider supplied no usable content.
102
+ */
103
+ export function buildProviderTooltip(
104
+ tooltip: string | MarkdownString | readonly MarkdownString[],
105
+ markdownRenderer: MarkdownRenderer,
106
+ openerService: OpenerService,
107
+ disposables: DisposableCollection
108
+ ): HTMLElement | undefined {
109
+ const sections = (typeof tooltip === 'string' ? [new MarkdownStringImpl(tooltip)] : Array.isArray(tooltip) ? tooltip : [tooltip as MarkdownString])
110
+ .filter(section => section.value.trim().length > 0);
111
+ if (sections.length === 0) {
112
+ return undefined;
113
+ }
114
+
115
+ const container = document.createElement('div');
116
+ container.className = 'scm-history-tooltip';
117
+ for (const section of sections) {
118
+ const rendered = markdownRenderer.render(section);
119
+ disposables.push(rendered);
120
+ disposables.push(wireMarkdownLinkHandler(rendered.element, section, openerService));
121
+ container.appendChild(rendered.element);
122
+ }
123
+ return container;
124
+ }
125
+
126
+ /**
127
+ * Appends the author part of the tooltip header: the avatar image (or account
128
+ * icon) followed by the bold author name, linked via `mailto:` when the email
129
+ * is known — the same structure as the git blame editor decoration hover.
130
+ */
131
+ function appendAuthor(container: HTMLElement, item: ScmHistoryItem): void {
132
+ if (item.authorIcon && (item.authorIcon.startsWith('http://') || item.authorIcon.startsWith('https://'))) {
133
+ const avatar = document.createElement('img');
134
+ avatar.className = 'scm-history-tooltip-avatar';
135
+ avatar.src = item.authorIcon;
136
+ container.appendChild(avatar);
137
+ } else {
138
+ const icon = document.createElement('i');
139
+ icon.className = codicon('account') + ' icon-inline';
140
+ container.appendChild(icon);
141
+ }
142
+ container.appendChild(document.createTextNode(' '));
143
+
144
+ const name = document.createElement('strong');
145
+ name.textContent = item.author!;
146
+ if (item.authorEmail) {
147
+ const link = document.createElement('a');
148
+ link.href = `mailto:${item.authorEmail}`;
149
+ link.appendChild(name);
150
+ container.appendChild(link);
151
+ } else {
152
+ container.appendChild(name);
153
+ }
154
+ }
155
+
156
+ /** Creates a clickable tooltip action (icon + optional label). */
157
+ function createTooltipAction(title: string, iconName: string, label: string | undefined, onClick: () => void): HTMLElement {
158
+ const action = document.createElement('a');
159
+ action.className = 'scm-history-tooltip-action';
160
+ action.title = title;
161
+ action.setAttribute('role', 'button');
162
+ const icon = document.createElement('i');
163
+ icon.className = codicon(iconName) + ' icon-inline';
164
+ action.appendChild(icon);
165
+ if (label) {
166
+ action.appendChild(document.createTextNode(` ${label}`));
167
+ }
168
+ action.onclick = e => {
169
+ e.preventDefault();
170
+ onClick();
171
+ };
172
+ return action;
173
+ }
174
+
78
175
  /** Creates a ref badge element for the HTML tooltip. */
79
176
  export function buildTooltipRefBadge(
80
177
  ref: ScmHistoryItemRef,
@@ -100,28 +197,32 @@ export function buildTooltipRefBadge(
100
197
  return badge;
101
198
  }
102
199
 
103
- export function buildHtmlTooltip(entry: HistoryGraphEntry, markdownRenderer: MarkdownRenderer): HTMLElement {
200
+ export function buildHtmlTooltip(
201
+ entry: HistoryGraphEntry,
202
+ markdownRenderer: MarkdownRenderer,
203
+ provider?: ScmHistoryProvider,
204
+ actions?: HistoryTooltipActions
205
+ ): HTMLElement {
104
206
  const { item } = entry;
105
207
  const badgeColor = laneColor(entry.graphRow.color);
106
208
  const container = document.createElement('div');
107
209
  container.className = 'scm-history-tooltip';
108
210
 
109
- // Header
211
+ // Header - avatar/account icon, bold author (mailto link), relative + absolute date,
212
+ // matching the git blame editor decoration hover
110
213
  if (item.author || item.timestamp !== undefined) {
111
214
  const header = document.createElement('div');
112
215
  header.className = 'scm-history-tooltip-header';
113
- header.style.fontWeight = 'bold';
114
- header.style.marginBottom = '4px';
115
216
 
116
217
  if (item.author) {
117
- appendIconText(header, 'account', item.author);
218
+ appendAuthor(header, item);
118
219
  }
119
220
  if (item.timestamp !== undefined) {
120
221
  if (item.author) {
121
- header.appendChild(document.createTextNode('\u00a0\u00a0'));
222
+ header.appendChild(document.createTextNode(',\u00a0'));
122
223
  }
123
224
  const timeSpan = document.createElement('span');
124
- appendIconText(timeSpan, 'clock', `${formatRelativeTime(item.timestamp)} (${formatAbsoluteDate(item.timestamp)})`);
225
+ appendIconText(timeSpan, 'history', `${formatRelativeTime(item.timestamp)} (${formatAbsoluteDate(item.timestamp)})`);
125
226
  header.appendChild(timeSpan);
126
227
  }
127
228
 
@@ -170,8 +271,10 @@ export function buildHtmlTooltip(entry: HistoryGraphEntry, markdownRenderer: Mar
170
271
  }
171
272
 
172
273
  // Refs + hash
274
+ const shortHash = item.displayId ?? item.id.substring(0, 7);
275
+ const hasActions = !!(actions?.openCommit || actions?.copyCommitHash);
173
276
  const hasRefs = item.references && item.references.length > 0;
174
- if (hasRefs || item.displayId) {
277
+ if (hasRefs || (!hasActions && item.displayId)) {
175
278
  container.appendChild(createHoverHr());
176
279
 
177
280
  const refsRow = document.createElement('div');
@@ -184,23 +287,18 @@ export function buildHtmlTooltip(entry: HistoryGraphEntry, markdownRenderer: Mar
184
287
  if (hasRefs) {
185
288
  const deduplicated = deduplicateRefs(item.references!);
186
289
  for (const { ref, hasBoth } of deduplicated) {
187
- const isTag = isTagRef(ref);
188
- const isRemote = isRemoteRef(ref);
189
-
190
- if (isTag) {
191
- refsRow.appendChild(buildTooltipRefBadge(ref, 'codicon-tag', true, badgeColor));
192
- } else if (isRemote) {
193
- refsRow.appendChild(buildTooltipRefBadge(ref, 'codicon-cloud', true, badgeColor));
194
- } else {
195
- refsRow.appendChild(buildTooltipRefBadge(ref, 'codicon-git-branch', true, badgeColor));
196
- if (hasBoth) {
197
- refsRow.appendChild(buildTooltipRefBadge(ref, 'codicon-cloud', false, badgeColor, 'scm-history-ref-badge-cloud'));
198
- }
290
+ // Current/remote/base refs are colored by role, others by the row's lane color
291
+ const { iconClass, colorIndex } = getRefBadgePresentation(ref, provider);
292
+ const bgColor = colorIndex !== undefined ? laneColor(colorIndex) : badgeColor;
293
+
294
+ refsRow.appendChild(buildTooltipRefBadge(ref, iconClass, true, bgColor));
295
+ if (!isTagRef(ref) && !isRemoteRef(ref) && hasBoth) {
296
+ refsRow.appendChild(buildTooltipRefBadge(ref, 'codicon-cloud', false, bgColor, 'scm-history-ref-badge-cloud'));
199
297
  }
200
298
  }
201
299
  }
202
300
 
203
- if (item.displayId) {
301
+ if (!hasActions && item.displayId) {
204
302
  const hash = document.createElement('code');
205
303
  hash.textContent = item.displayId;
206
304
  refsRow.appendChild(hash);
@@ -209,5 +307,27 @@ export function buildHtmlTooltip(entry: HistoryGraphEntry, markdownRenderer: Mar
209
307
  container.appendChild(refsRow);
210
308
  }
211
309
 
310
+ // Actions — commit hash and copy, matching the git blame hover's command links
311
+ if (hasActions) {
312
+ container.appendChild(createHoverHr());
313
+
314
+ const actionsRow = document.createElement('div');
315
+ actionsRow.className = 'scm-history-tooltip-actions';
316
+
317
+ if (actions?.openCommit) {
318
+ actionsRow.appendChild(createTooltipAction(
319
+ nls.localize('theia/scm/openCommit', 'Open Commit'), 'git-commit', shortHash, actions.openCommit));
320
+ }
321
+ if (actions?.copyCommitHash) {
322
+ if (actions.openCommit) {
323
+ actionsRow.appendChild(document.createTextNode('  '));
324
+ }
325
+ actionsRow.appendChild(createTooltipAction(
326
+ nls.localize('theia/scm/copyCommitHash', 'Copy Commit Hash'), 'copy', undefined, actions.copyCommitHash));
327
+ }
328
+
329
+ container.appendChild(actionsRow);
330
+ }
331
+
212
332
  return container;
213
333
  }
@@ -0,0 +1,322 @@
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 * as React from '@theia/core/shared/react';
22
+ import { Emitter } from '@theia/core/lib/common/event';
23
+ import { ContextKey, ContextKeyServiceDummyImpl, ContextKeyValue } from '@theia/core/lib/browser/context-key-service';
24
+ import { ScmContextKeyService } from './scm-context-key-service';
25
+ import { ScmHistoryGraphWidget } from './scm-history-graph-widget';
26
+ import { GraphRow } from './scm-history-graph-lanes';
27
+ import { ScmHistoryItemChange, ScmHistoryProvider } from './scm-provider';
28
+
29
+ disableJSDOM();
30
+
31
+ class MockContextKeyService extends ContextKeyServiceDummyImpl {
32
+ override createKey<T extends ContextKeyValue>(key: string, defaultValue: T | undefined): ContextKey<T> {
33
+ let value: T | undefined = defaultValue;
34
+ return {
35
+ set: (v: T | undefined) => { value = v; },
36
+ reset: () => { value = defaultValue; },
37
+ get: () => value
38
+ };
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Lets React finish the work it scheduled while the DOM is still in place.
44
+ * Disposing a `ReactWidget` unmounts its root, and React flushes the resulting
45
+ * passive effects on a `setImmediate` callback that accesses `window`. Without
46
+ * this, that callback runs after JSDOM has been torn down and fails.
47
+ */
48
+ function flushReactWork(): Promise<void> {
49
+ return new Promise<void>(resolve => setImmediate(resolve));
50
+ }
51
+
52
+ function createScmContextKeyService(): ScmContextKeyService {
53
+ const service = new ScmContextKeyService();
54
+ (service as unknown as { contextKeyService: MockContextKeyService }).contextKeyService = new MockContextKeyService();
55
+ (service as unknown as { init(): void }).init();
56
+ return service;
57
+ }
58
+
59
+ describe('ScmHistoryGraphWidget context keys', () => {
60
+ let restoreJSDOM: () => void;
61
+ let widget: ScmHistoryGraphWidget;
62
+ let scmContextKeys: ScmContextKeyService;
63
+ let provider: Partial<ScmHistoryProvider> | undefined;
64
+ let currentRefInFilter: boolean;
65
+
66
+ beforeEach(() => {
67
+ restoreJSDOM = enableJSDOM();
68
+ scmContextKeys = createScmContextKeyService();
69
+ currentRefInFilter = true;
70
+ widget = new ScmHistoryGraphWidget();
71
+ const raw = widget as unknown as Record<string, unknown>;
72
+ raw.scmContextKeys = scmContextKeys;
73
+ Object.defineProperty(raw, 'model', {
74
+ get: () => ({ provider, isCurrentRefInFilter: () => currentRefInFilter })
75
+ });
76
+ });
77
+
78
+ afterEach(() => {
79
+ restoreJSDOM();
80
+ });
81
+
82
+ function updateContextKeys(): void {
83
+ (widget as unknown as { updateContextKeys(): void }).updateContextKeys();
84
+ }
85
+
86
+ it('should set scmCurrentHistoryItemRefInFilter when the provider has a current ref', () => {
87
+ provider = {
88
+ currentHistoryItemRef: { id: 'refs/heads/main', name: 'main' }
89
+ };
90
+ updateContextKeys();
91
+ expect(scmContextKeys.scmCurrentHistoryItemRefInFilter.get()).to.equal(true);
92
+ });
93
+
94
+ it('should clear scmCurrentHistoryItemRefInFilter when the provider has no current ref', () => {
95
+ scmContextKeys.scmCurrentHistoryItemRefInFilter.set(true);
96
+ provider = {};
97
+ updateContextKeys();
98
+ expect(scmContextKeys.scmCurrentHistoryItemRefInFilter.get()).to.equal(false);
99
+ });
100
+
101
+ it('should clear scmCurrentHistoryItemRefInFilter when there is no provider', () => {
102
+ scmContextKeys.scmCurrentHistoryItemRefInFilter.set(true);
103
+ provider = undefined;
104
+ updateContextKeys();
105
+ expect(scmContextKeys.scmCurrentHistoryItemRefInFilter.get()).to.equal(false);
106
+ });
107
+
108
+ it('should clear scmCurrentHistoryItemRefInFilter when the filter excludes the current ref', () => {
109
+ provider = {
110
+ currentHistoryItemRef: { id: 'refs/heads/main', name: 'main' }
111
+ };
112
+ currentRefInFilter = false;
113
+ updateContextKeys();
114
+ expect(scmContextKeys.scmCurrentHistoryItemRefInFilter.get()).to.equal(false);
115
+ });
116
+
117
+ it('should set scmCurrentHistoryItemRefHasRemote when the provider has a remote ref', () => {
118
+ provider = {
119
+ currentHistoryItemRef: { id: 'refs/heads/main', name: 'main' },
120
+ currentHistoryItemRemoteRef: { id: 'refs/remotes/origin/main', name: 'origin/main' }
121
+ };
122
+ updateContextKeys();
123
+ expect(scmContextKeys.scmCurrentHistoryItemRefHasRemote.get()).to.equal(true);
124
+ });
125
+ });
126
+
127
+ describe('ScmHistoryGraphWidget reveal', () => {
128
+ let restoreJSDOM: () => void;
129
+ let widget: ScmHistoryGraphWidget;
130
+ let requestReveal: Emitter<number>;
131
+ let scrolledTo: { index: number; align?: string }[];
132
+
133
+ beforeEach(() => {
134
+ restoreJSDOM = enableJSDOM();
135
+ requestReveal = new Emitter<number>();
136
+ scrolledTo = [];
137
+
138
+ widget = new ScmHistoryGraphWidget();
139
+ const raw = widget as unknown as Record<string, unknown>;
140
+ raw.scmContextKeys = createScmContextKeyService();
141
+ raw.scmPreferences = { onPreferenceChanged: new Emitter<{ preferenceName: string }>().event };
142
+ raw.model = {
143
+ provider: undefined,
144
+ entries: [],
145
+ hasMore: false,
146
+ loading: false,
147
+ hasAttemptedLoad: true,
148
+ isCurrentRefInFilter: () => true,
149
+ onDidChange: new Emitter<void>().event,
150
+ onDidRequestReveal: requestReveal.event
151
+ };
152
+ raw.listRef = { current: { scrollToIndex: (location: { index: number; align?: string }) => scrolledTo.push(location) } };
153
+ // Rendering is out of scope here and would otherwise run asynchronously,
154
+ // after JSDOM has been torn down again.
155
+ raw.update = () => { };
156
+ (widget as unknown as { init(): void }).init();
157
+ });
158
+
159
+ afterEach(async () => {
160
+ widget.dispose();
161
+ await flushReactWork();
162
+ restoreJSDOM();
163
+ });
164
+
165
+ function selectedIndex(): number {
166
+ return (widget as unknown as { selectedIndex: number }).selectedIndex;
167
+ }
168
+
169
+ it('should scroll to the entry the model asks to reveal', () => {
170
+ requestReveal.fire(4);
171
+ expect(scrolledTo).to.deep.equal([{ index: 4, align: 'center' }]);
172
+ });
173
+
174
+ it('should select the entry the model asks to reveal', () => {
175
+ requestReveal.fire(4);
176
+ expect(selectedIndex()).to.equal(4);
177
+ });
178
+
179
+ it('should stop revealing once disposed', () => {
180
+ widget.dispose();
181
+ requestReveal.fire(4);
182
+ expect(scrolledTo).to.be.empty;
183
+ });
184
+ });
185
+
186
+ const ROOT_URI = 'file:///repo';
187
+
188
+ /** Builds the change rows of `commit-1` in the given view mode. */
189
+ function renderChanges(widget: ScmHistoryGraphWidget, viewMode: 'list' | 'tree', paths: string[]): React.ReactElement {
190
+ const raw = widget as unknown as Record<string, unknown>;
191
+ raw.model = { viewMode };
192
+ raw.scmService = { selectedRepository: { provider: { rootUri: ROOT_URI } } };
193
+ raw.labelProvider = { getIcon: () => 'file-icon' };
194
+ const changes: ScmHistoryItemChange[] = paths.map(path => ({
195
+ uri: `${ROOT_URI}/${path}`,
196
+ modifiedUri: `${ROOT_URI}/${path}`,
197
+ originalUri: `${ROOT_URI}/${path}`
198
+ }));
199
+ const graphRow: GraphRow = { lane: 0, color: 0, topColor: 0, edges: [], hasContinuation: false, hasTopLine: false };
200
+ const render = widget as unknown as {
201
+ renderChangesRows(itemId: string, svgWidth: number, graphRow: GraphRow, changes: ScmHistoryItemChange[]): React.ReactElement;
202
+ };
203
+ return render.renderChangesRows('commit-1', 22, graphRow, changes);
204
+ }
205
+
206
+ function allElements(node: React.ReactNode): React.ReactElement[] {
207
+ const result: React.ReactElement[] = [];
208
+ const visit = (candidate: React.ReactNode): void => {
209
+ if (Array.isArray(candidate)) {
210
+ candidate.forEach(visit);
211
+ } else if (React.isValidElement(candidate)) {
212
+ result.push(candidate);
213
+ visit((candidate.props as { children?: React.ReactNode }).children);
214
+ }
215
+ };
216
+ visit(node);
217
+ return result;
218
+ }
219
+
220
+ function elementsWithClass(node: React.ReactNode, className: string): React.ReactElement[] {
221
+ return allElements(node).filter(element => {
222
+ const elementClass = (element.props as { className?: unknown }).className;
223
+ return typeof elementClass === 'string' && elementClass.includes(className);
224
+ });
225
+ }
226
+
227
+ /** The rendered name of a row — the `name scm-history-change-name` span it holds. */
228
+ function nameOf(row: React.ReactElement): string {
229
+ const name = allElements(row).find(element => (element.props as { className?: unknown }).className === 'name scm-history-change-name');
230
+ return String((name?.props as { children?: unknown })?.children ?? '');
231
+ }
232
+
233
+ function folderLabels(rows: React.ReactElement): string[] {
234
+ return elementsWithClass(rows, 'scm-history-change-folder-row').map(nameOf);
235
+ }
236
+
237
+ function fileNames(rows: React.ReactElement): string[] {
238
+ return elementsWithClass(rows, 'scm-history-change-row')
239
+ .filter(row => !((row.props as { className: string }).className.includes('scm-history-change-folder-row')))
240
+ .map(nameOf);
241
+ }
242
+
243
+ describe('ScmHistoryGraphWidget change tree folders', () => {
244
+ let restoreJSDOM: () => void;
245
+ let widget: ScmHistoryGraphWidget;
246
+
247
+ beforeEach(() => {
248
+ restoreJSDOM = enableJSDOM();
249
+ widget = new ScmHistoryGraphWidget();
250
+ const raw = widget as unknown as Record<string, unknown>;
251
+ raw.update = () => { };
252
+ });
253
+
254
+ afterEach(async () => {
255
+ widget.dispose();
256
+ await flushReactWork();
257
+ restoreJSDOM();
258
+ });
259
+
260
+ function internals(): {
261
+ isFolderCollapsed(itemId: string, path: string): boolean;
262
+ toggleFolder(itemId: string, path: string): void;
263
+ collapsedFolders: Map<string, Set<string>>;
264
+ } {
265
+ return widget as unknown as ReturnType<typeof internals>;
266
+ }
267
+
268
+ it('should show folders expanded by default', () => {
269
+ expect(internals().isFolderCollapsed('commit-1', 'src')).to.be.false;
270
+ });
271
+
272
+ it('should collapse a folder on toggle', () => {
273
+ internals().toggleFolder('commit-1', 'src');
274
+ expect(internals().isFolderCollapsed('commit-1', 'src')).to.be.true;
275
+ });
276
+
277
+ it('should expand a collapsed folder on toggle', () => {
278
+ internals().toggleFolder('commit-1', 'src');
279
+ internals().toggleFolder('commit-1', 'src');
280
+ expect(internals().isFolderCollapsed('commit-1', 'src')).to.be.false;
281
+ });
282
+
283
+ it('should keep folder state separate per history item', () => {
284
+ internals().toggleFolder('commit-1', 'src');
285
+ expect(internals().isFolderCollapsed('commit-2', 'src')).to.be.false;
286
+ });
287
+
288
+ it('should render a folder row per folder of the change tree', () => {
289
+ const rows = renderChanges(widget, 'tree', ['src/browser/a.ts', 'src/node/b.ts']);
290
+ expect(folderLabels(rows)).to.deep.equal(['src', 'browser', 'node']);
291
+ expect(fileNames(rows)).to.deep.equal(['a.ts', 'b.ts']);
292
+ });
293
+
294
+ it('should render expanded folders with a down twistie', () => {
295
+ const rows = renderChanges(widget, 'tree', ['src/a.ts']);
296
+ expect(elementsWithClass(rows, 'codicon-chevron-down')).to.have.length(1);
297
+ });
298
+
299
+ it('should render a flat list of files without folder rows in list mode', () => {
300
+ const rows = renderChanges(widget, 'list', ['src/browser/a.ts', 'src/node/b.ts']);
301
+ expect(folderLabels(rows)).to.be.empty;
302
+ expect(fileNames(rows)).to.deep.equal(['a.ts', 'b.ts']);
303
+ });
304
+
305
+ it('should drop the files of a collapsed folder and turn its twistie', () => {
306
+ internals().toggleFolder('commit-1', 'src');
307
+ const rows = renderChanges(widget, 'tree', ['src/browser/a.ts', 'src/node/b.ts']);
308
+ expect(folderLabels(rows)).to.deep.equal(['src']);
309
+ expect(fileNames(rows)).to.be.empty;
310
+ expect(elementsWithClass(rows, 'codicon-chevron-right')).to.have.length(1);
311
+ });
312
+
313
+ it('should forget folder state when the history item is collapsed again', () => {
314
+ const raw = widget as unknown as { expandedIds: Set<string>; handleRowClick(idx: number, entry: unknown): void };
315
+ raw.expandedIds.add('commit-1');
316
+ internals().toggleFolder('commit-1', 'src');
317
+
318
+ raw.handleRowClick(0, { item: { id: 'commit-1' } });
319
+
320
+ expect(internals().collapsedFolders.has('commit-1')).to.be.false;
321
+ });
322
+ });