@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.
Files changed (83) 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/scm-context-key-service.d.ts +2 -0
  5. package/lib/browser/scm-context-key-service.d.ts.map +1 -1
  6. package/lib/browser/scm-context-key-service.js +4 -0
  7. package/lib/browser/scm-context-key-service.js.map +1 -1
  8. package/lib/browser/scm-context-key-service.spec.js +7 -0
  9. package/lib/browser/scm-context-key-service.spec.js.map +1 -1
  10. package/lib/browser/scm-frontend-module.d.ts.map +1 -1
  11. package/lib/browser/scm-frontend-module.js +6 -0
  12. package/lib/browser/scm-frontend-module.js.map +1 -1
  13. package/lib/browser/scm-history-graph-contribution.d.ts +44 -0
  14. package/lib/browser/scm-history-graph-contribution.d.ts.map +1 -0
  15. package/lib/browser/scm-history-graph-contribution.js +226 -0
  16. package/lib/browser/scm-history-graph-contribution.js.map +1 -0
  17. package/lib/browser/scm-history-graph-contribution.spec.d.ts +2 -0
  18. package/lib/browser/scm-history-graph-contribution.spec.d.ts.map +1 -0
  19. package/lib/browser/scm-history-graph-contribution.spec.js +66 -0
  20. package/lib/browser/scm-history-graph-contribution.spec.js.map +1 -0
  21. package/lib/browser/scm-history-graph-helpers.d.ts +34 -6
  22. package/lib/browser/scm-history-graph-helpers.d.ts.map +1 -1
  23. package/lib/browser/scm-history-graph-helpers.js +71 -5
  24. package/lib/browser/scm-history-graph-helpers.js.map +1 -1
  25. package/lib/browser/scm-history-graph-helpers.spec.d.ts +2 -0
  26. package/lib/browser/scm-history-graph-helpers.spec.d.ts.map +1 -0
  27. package/lib/browser/scm-history-graph-helpers.spec.js +96 -0
  28. package/lib/browser/scm-history-graph-helpers.spec.js.map +1 -0
  29. package/lib/browser/scm-history-graph-lanes.d.ts +19 -5
  30. package/lib/browser/scm-history-graph-lanes.d.ts.map +1 -1
  31. package/lib/browser/scm-history-graph-lanes.js +25 -9
  32. package/lib/browser/scm-history-graph-lanes.js.map +1 -1
  33. package/lib/browser/scm-history-graph-lanes.spec.js +62 -25
  34. package/lib/browser/scm-history-graph-lanes.spec.js.map +1 -1
  35. package/lib/browser/scm-history-graph-model.d.ts +52 -5
  36. package/lib/browser/scm-history-graph-model.d.ts.map +1 -1
  37. package/lib/browser/scm-history-graph-model.js +108 -10
  38. package/lib/browser/scm-history-graph-model.js.map +1 -1
  39. package/lib/browser/scm-history-graph-model.spec.js +197 -4
  40. package/lib/browser/scm-history-graph-model.spec.js.map +1 -1
  41. package/lib/browser/scm-history-graph-tooltip.d.ts +27 -2
  42. package/lib/browser/scm-history-graph-tooltip.d.ts.map +1 -1
  43. package/lib/browser/scm-history-graph-tooltip.js +113 -22
  44. package/lib/browser/scm-history-graph-tooltip.js.map +1 -1
  45. package/lib/browser/scm-history-graph-tooltip.spec.d.ts +2 -0
  46. package/lib/browser/scm-history-graph-tooltip.spec.d.ts.map +1 -0
  47. package/lib/browser/scm-history-graph-tooltip.spec.js +213 -0
  48. package/lib/browser/scm-history-graph-tooltip.spec.js.map +1 -0
  49. package/lib/browser/scm-history-graph-widget.d.ts +23 -1
  50. package/lib/browser/scm-history-graph-widget.d.ts.map +1 -1
  51. package/lib/browser/scm-history-graph-widget.js +93 -24
  52. package/lib/browser/scm-history-graph-widget.js.map +1 -1
  53. package/lib/browser/scm-history-graph-widget.spec.d.ts +2 -0
  54. package/lib/browser/scm-history-graph-widget.spec.d.ts.map +1 -0
  55. package/lib/browser/scm-history-graph-widget.spec.js +100 -0
  56. package/lib/browser/scm-history-graph-widget.spec.js.map +1 -0
  57. package/lib/browser/scm-provider.d.ts +9 -1
  58. package/lib/browser/scm-provider.d.ts.map +1 -1
  59. package/lib/browser/scm-provider.js.map +1 -1
  60. package/lib/common/scm-preferences.d.ts +3 -0
  61. package/lib/common/scm-preferences.d.ts.map +1 -1
  62. package/lib/common/scm-preferences.js +24 -0
  63. package/lib/common/scm-preferences.js.map +1 -1
  64. package/package.json +6 -6
  65. package/src/browser/decorations/scm-navigator-decorator.ts +3 -2
  66. package/src/browser/scm-context-key-service.spec.ts +9 -0
  67. package/src/browser/scm-context-key-service.ts +6 -0
  68. package/src/browser/scm-frontend-module.ts +7 -1
  69. package/src/browser/scm-history-graph-contribution.spec.ts +83 -0
  70. package/src/browser/scm-history-graph-contribution.ts +222 -0
  71. package/src/browser/scm-history-graph-helpers.spec.ts +118 -0
  72. package/src/browser/scm-history-graph-helpers.ts +81 -6
  73. package/src/browser/scm-history-graph-lanes.spec.ts +70 -25
  74. package/src/browser/scm-history-graph-lanes.ts +48 -11
  75. package/src/browser/scm-history-graph-model.spec.ts +247 -12
  76. package/src/browser/scm-history-graph-model.ts +125 -12
  77. package/src/browser/scm-history-graph-tooltip.spec.ts +254 -0
  78. package/src/browser/scm-history-graph-tooltip.ts +144 -24
  79. package/src/browser/scm-history-graph-widget.spec.ts +112 -0
  80. package/src/browser/scm-history-graph-widget.tsx +104 -35
  81. package/src/browser/scm-provider.ts +9 -1
  82. package/src/browser/style/scm-history-graph.css +43 -2
  83. 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 { 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,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
+ });