@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
|
@@ -15,12 +15,15 @@
|
|
|
15
15
|
// *****************************************************************************
|
|
16
16
|
|
|
17
17
|
import { MarkdownRenderer } from '@theia/core/lib/browser/markdown-rendering/markdown-renderer';
|
|
18
|
-
import {
|
|
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(
|
|
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
|
-
|
|
218
|
+
appendAuthor(header, item);
|
|
118
219
|
}
|
|
119
220
|
if (item.timestamp !== undefined) {
|
|
120
221
|
if (item.author) {
|
|
121
|
-
header.appendChild(document.createTextNode('
|
|
222
|
+
header.appendChild(document.createTextNode(',\u00a0'));
|
|
122
223
|
}
|
|
123
224
|
const timeSpan = document.createElement('span');
|
|
124
|
-
appendIconText(timeSpan, '
|
|
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
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
refsRow.appendChild(buildTooltipRefBadge(ref, 'codicon-cloud',
|
|
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
|
+
});
|