@theia/scm 1.75.0-next.3 → 1.75.0-next.35
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/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 +44 -0
- package/lib/browser/scm-history-graph-contribution.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-contribution.js +226 -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 +66 -0
- package/lib/browser/scm-history-graph-contribution.spec.js.map +1 -0
- package/lib/browser/scm-history-graph-helpers.d.ts +34 -6
- package/lib/browser/scm-history-graph-helpers.d.ts.map +1 -1
- package/lib/browser/scm-history-graph-helpers.js +71 -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 +96 -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 +52 -5
- package/lib/browser/scm-history-graph-model.d.ts.map +1 -1
- package/lib/browser/scm-history-graph-model.js +108 -10
- package/lib/browser/scm-history-graph-model.js.map +1 -1
- package/lib/browser/scm-history-graph-model.spec.js +197 -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 +23 -1
- package/lib/browser/scm-history-graph-widget.d.ts.map +1 -1
- package/lib/browser/scm-history-graph-widget.js +93 -24
- 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 +100 -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/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 +6 -6
- 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 +83 -0
- package/src/browser/scm-history-graph-contribution.ts +222 -0
- package/src/browser/scm-history-graph-helpers.spec.ts +118 -0
- package/src/browser/scm-history-graph-helpers.ts +81 -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 +247 -12
- package/src/browser/scm-history-graph-model.ts +125 -12
- 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 +112 -0
- package/src/browser/scm-history-graph-widget.tsx +104 -35
- package/src/browser/scm-provider.ts +9 -1
- package/src/browser/style/scm-history-graph.css +43 -2
- package/src/common/scm-preferences.ts +27 -0
|
@@ -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
|
+
});
|
|
@@ -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,112 @@
|
|
|
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 { ContextKey, ContextKeyServiceDummyImpl, ContextKeyValue } from '@theia/core/lib/browser/context-key-service';
|
|
22
|
+
import { ScmContextKeyService } from './scm-context-key-service';
|
|
23
|
+
import { ScmHistoryGraphWidget } from './scm-history-graph-widget';
|
|
24
|
+
import { ScmHistoryProvider } from './scm-provider';
|
|
25
|
+
|
|
26
|
+
disableJSDOM();
|
|
27
|
+
|
|
28
|
+
class MockContextKeyService extends ContextKeyServiceDummyImpl {
|
|
29
|
+
override createKey<T extends ContextKeyValue>(key: string, defaultValue: T | undefined): ContextKey<T> {
|
|
30
|
+
let value: T | undefined = defaultValue;
|
|
31
|
+
return {
|
|
32
|
+
set: (v: T | undefined) => { value = v; },
|
|
33
|
+
reset: () => { value = defaultValue; },
|
|
34
|
+
get: () => value
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createScmContextKeyService(): ScmContextKeyService {
|
|
40
|
+
const service = new ScmContextKeyService();
|
|
41
|
+
(service as unknown as { contextKeyService: MockContextKeyService }).contextKeyService = new MockContextKeyService();
|
|
42
|
+
(service as unknown as { init(): void }).init();
|
|
43
|
+
return service;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('ScmHistoryGraphWidget context keys', () => {
|
|
47
|
+
let restoreJSDOM: () => void;
|
|
48
|
+
let widget: ScmHistoryGraphWidget;
|
|
49
|
+
let scmContextKeys: ScmContextKeyService;
|
|
50
|
+
let provider: Partial<ScmHistoryProvider> | undefined;
|
|
51
|
+
let currentRefInFilter: boolean;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
restoreJSDOM = enableJSDOM();
|
|
55
|
+
scmContextKeys = createScmContextKeyService();
|
|
56
|
+
currentRefInFilter = true;
|
|
57
|
+
widget = new ScmHistoryGraphWidget();
|
|
58
|
+
const raw = widget as unknown as Record<string, unknown>;
|
|
59
|
+
raw.scmContextKeys = scmContextKeys;
|
|
60
|
+
Object.defineProperty(raw, 'model', {
|
|
61
|
+
get: () => ({ provider, isCurrentRefInFilter: () => currentRefInFilter })
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
restoreJSDOM();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
function updateContextKeys(): void {
|
|
70
|
+
(widget as unknown as { updateContextKeys(): void }).updateContextKeys();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
it('should set scmCurrentHistoryItemRefInFilter when the provider has a current ref', () => {
|
|
74
|
+
provider = {
|
|
75
|
+
currentHistoryItemRef: { id: 'refs/heads/main', name: 'main' }
|
|
76
|
+
};
|
|
77
|
+
updateContextKeys();
|
|
78
|
+
expect(scmContextKeys.scmCurrentHistoryItemRefInFilter.get()).to.equal(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should clear scmCurrentHistoryItemRefInFilter when the provider has no current ref', () => {
|
|
82
|
+
scmContextKeys.scmCurrentHistoryItemRefInFilter.set(true);
|
|
83
|
+
provider = {};
|
|
84
|
+
updateContextKeys();
|
|
85
|
+
expect(scmContextKeys.scmCurrentHistoryItemRefInFilter.get()).to.equal(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should clear scmCurrentHistoryItemRefInFilter when there is no provider', () => {
|
|
89
|
+
scmContextKeys.scmCurrentHistoryItemRefInFilter.set(true);
|
|
90
|
+
provider = undefined;
|
|
91
|
+
updateContextKeys();
|
|
92
|
+
expect(scmContextKeys.scmCurrentHistoryItemRefInFilter.get()).to.equal(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should clear scmCurrentHistoryItemRefInFilter when the filter excludes the current ref', () => {
|
|
96
|
+
provider = {
|
|
97
|
+
currentHistoryItemRef: { id: 'refs/heads/main', name: 'main' }
|
|
98
|
+
};
|
|
99
|
+
currentRefInFilter = false;
|
|
100
|
+
updateContextKeys();
|
|
101
|
+
expect(scmContextKeys.scmCurrentHistoryItemRefInFilter.get()).to.equal(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should set scmCurrentHistoryItemRefHasRemote when the provider has a remote ref', () => {
|
|
105
|
+
provider = {
|
|
106
|
+
currentHistoryItemRef: { id: 'refs/heads/main', name: 'main' },
|
|
107
|
+
currentHistoryItemRemoteRef: { id: 'refs/remotes/origin/main', name: 'origin/main' }
|
|
108
|
+
};
|
|
109
|
+
updateContextKeys();
|
|
110
|
+
expect(scmContextKeys.scmCurrentHistoryItemRefHasRemote.get()).to.equal(true);
|
|
111
|
+
});
|
|
112
|
+
});
|