SCM - graph hover provided by extension (#271519)

* Git - 💄 consolidate git blame and timeline hover code

* Git - Delete code that was commented out

* SCM - graph hover provided by extension

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* More fixes

* More fixes

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Ladislau Szomoru
2025-10-15 16:22:12 +02:00
committed by GitHub
parent aceb7fe621
commit a1d4cfa3d8
11 changed files with 52 additions and 148 deletions

View File

@@ -12,7 +12,7 @@ import { AvatarQuery, AvatarQueryCommit, Branch, LogOptions, Ref, RefType } from
import { emojify, ensureEmojis } from './emoji';
import { Commit, CommitShortStat } from './git';
import { OperationKind, OperationResult } from './operation';
import { ISourceControlHistoryItemDetailsProviderRegistry, provideSourceControlHistoryItemAvatar, provideSourceControlHistoryItemMessageLinks } from './historyItemDetailsProvider';
import { ISourceControlHistoryItemDetailsProviderRegistry, provideSourceControlHistoryItemAvatar, provideSourceControlHistoryItemHoverCommands, provideSourceControlHistoryItemMessageLinks } from './historyItemDetailsProvider';
import { throttle } from './decorators';
function compareSourceControlHistoryItemRef(ref1: SourceControlHistoryItemRef, ref2: SourceControlHistoryItemRef): number {
@@ -282,6 +282,8 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec
const commitAvatars = await provideSourceControlHistoryItemAvatar(
this.historyItemDetailProviderRegistry, this.repository, avatarQuery);
const remoteHoverCommands = await provideSourceControlHistoryItemHoverCommands(this.historyItemDetailProviderRegistry, this.repository) ?? [];
await ensureEmojis();
const historyItems: SourceControlHistoryItem[] = [];
@@ -293,6 +295,13 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec
const avatarUrl = commitAvatars?.get(commit.hash);
const references = this._resolveHistoryItemRefs(commit);
const commands: Command[][] = [
getHistoryItemHoverCommitHashCommands(Uri.file(this.repository.root), commit.hash),
processHistoryItemRemoteHoverCommands(remoteHoverCommands, commit.hash)
];
const tooltip = getHistoryItemHover(avatarUrl, commit.authorName, commit.authorEmail, commit.authorDate ?? commit.commitDate, messageWithLinks, commit.shortStat, commands);
historyItems.push({
id: commit.hash,
parentIds: commit.parents,
@@ -304,7 +313,8 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec
displayId: truncate(commit.hash, this.commitShortHashLength, false),
timestamp: commit.authorDate?.getTime(),
statistics: commit.shortStat ?? { files: 0, insertions: 0, deletions: 0 },
references: references.length !== 0 ? references : undefined
references: references.length !== 0 ? references : undefined,
tooltip
} satisfies SourceControlHistoryItem);
}
@@ -663,6 +673,17 @@ export function getHistoryItemHover(authorAvatar: string | undefined, authorName
markdownString.appendMarkdown(`\n\n---\n\n`);
}
// References
// TODO@lszomoru - move these to core
// if (references && references.length > 0) {
// markdownString.appendMarkdown((references ?? []).map(ref => {
// console.log(ref);
// const labelIconId = ref.icon instanceof ThemeIcon ? ref.icon.id : '';
// return `<span style="color:var(--vscode-scmGraph-historyItemHoverDefaultLabelForeground);background-color:var(--vscode-scmGraph-historyItemHoverDefaultLabelBackground);border-radius:10px;">&nbsp;$(${labelIconId})&nbsp;${ref.name}&nbsp;&nbsp;</span>`;
// }).join('&nbsp;&nbsp;'));
// markdownString.appendMarkdown(`\n\n---\n\n`);
// }
// Commands
if (commands && commands.length > 0) {
for (let index = 0; index < commands.length; index++) {
@@ -676,21 +697,5 @@ export function getHistoryItemHover(authorAvatar: string | undefined, authorName
}
}
// markdownString.appendMarkdown(`[\`$(git-commit) ${getCommitShortHash(documentUri, hash)} \`](command:git.viewCommit?${encodeURIComponent(JSON.stringify([documentUri, hash, documentUri]))} "${l10n.t('Open Commit')}")`);
// markdownString.appendMarkdown('&nbsp;');
// markdownString.appendMarkdown(`[$(copy)](command:git.copyContentToClipboard?${encodeURIComponent(JSON.stringify(hash))} "${l10n.t('Copy Commit Hash')}")`);
// // Remote hover commands
// if (commands && commands.length > 0) {
// markdownString.appendMarkdown('&nbsp;&nbsp;|&nbsp;&nbsp;');
// const remoteCommandsMarkdown = commands
// .map(command => `[${command.title}](command:${command.command}?${encodeURIComponent(JSON.stringify([...command.arguments ?? [], hash]))} "${command.tooltip}")`);
// markdownString.appendMarkdown(remoteCommandsMarkdown.join('&nbsp;'));
// }
// markdownString.appendMarkdown('&nbsp;&nbsp;|&nbsp;&nbsp;');
// markdownString.appendMarkdown(`[$(gear)](command:workbench.action.openSettings?%5B%22git.blame%22%5D "${l10n.t('Open Settings')}")`);
return markdownString;
}

View File

@@ -157,13 +157,6 @@
"group": "0_view@2"
}
],
"scm/historyItem/hover": [
{
"command": "github.graph.openOnGitHub",
"when": "github.hasGitHubRepo",
"group": "1_open@1"
}
],
"timeline/item/context": [
{
"command": "github.timeline.openOnGitHub",

View File

@@ -145,7 +145,6 @@ export class MenuId {
static readonly SCMHistoryTitle = new MenuId('SCMHistoryTitle');
static readonly SCMHistoryItemContext = new MenuId('SCMHistoryItemContext');
static readonly SCMHistoryItemChangeContext = new MenuId('SCMHistoryItemChangeContext');
static readonly SCMHistoryItemHover = new MenuId('SCMHistoryItemHover');
static readonly SCMHistoryItemRefContext = new MenuId('SCMHistoryItemRefContext');
static readonly SCMQuickDiffDecorations = new MenuId('SCMQuickDiffDecorations');
static readonly SCMTitle = new MenuId('SCMTitle');

View File

@@ -1661,6 +1661,7 @@ export interface SCMHistoryItemDto {
readonly deletions: number;
};
readonly references?: SCMHistoryItemRefDto[];
readonly tooltip?: string | IMarkdownString | undefined;
}
export interface SCMHistoryItemChangeDto {

View File

@@ -75,11 +75,13 @@ function getHistoryItemIconDto(icon: vscode.Uri | { light: vscode.Uri; dark: vsc
function toSCMHistoryItemDto(historyItem: vscode.SourceControlHistoryItem): SCMHistoryItemDto {
const authorIcon = getHistoryItemIconDto(historyItem.authorIcon);
const tooltip = MarkdownString.fromStrict(historyItem.tooltip);
const references = historyItem.references?.map(r => ({
...r, icon: getHistoryItemIconDto(r.icon)
}));
return { ...historyItem, authorIcon, references };
return { ...historyItem, authorIcon, references, tooltip };
}
function toSCMHistoryItemRefDto(historyItemRef?: vscode.SourceControlHistoryItemRef): SCMHistoryItemRefDto | undefined {

View File

@@ -49,7 +49,7 @@ import { IPreferencesService } from '../../../services/preferences/common/prefer
import { revealInSideBarCommand } from '../../files/browser/fileActions.contribution.js';
import { CellUri } from '../../notebook/common/notebookCommon.js';
import { INotebookService } from '../../notebook/common/notebookService.js';
import { getHistoryItemEditorTitle, getHistoryItemHoverContent } from '../../scm/browser/util.js';
import { getHistoryItemEditorTitle } from '../../scm/browser/util.js';
import { IChatContentReference } from '../common/chatService.js';
import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry, IElementVariableEntry, INotebookOutputVariableEntry, IPromptFileVariableEntry, IPromptTextVariableEntry, ISCMHistoryItemVariableEntry, OmittedState, PromptFileVariableKind, ChatRequestToolReferenceEntry, ISCMHistoryItemChangeVariableEntry, ISCMHistoryItemChangeRangeVariableEntry } from '../common/chatVariableEntries.js';
import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../common/languageModels.js';
@@ -790,7 +790,12 @@ export class SCMHistoryItemAttachmentWidget extends AbstractChatAttachmentWidget
this.element.style.cursor = 'pointer';
this.element.ariaLabel = localize('chat.attachment', "Attached context, {0}", attachment.name);
this._store.add(hoverService.setupManagedHover(hoverDelegate, this.element, () => getHistoryItemHoverContent(themeService, attachment.historyItem), { trapFocus: true }));
const historyItem = attachment.historyItem;
const hoverContent = {
markdown: historyItem.tooltip ?? historyItem.message,
markdownNotSupportedFallback: historyItem.message
} satisfies IManagedHoverTooltipMarkdownString;
this._store.add(hoverService.setupManagedHover(hoverDelegate, this.element, hoverContent, { trapFocus: true }));
this._store.add(dom.addDisposableListener(this.element, dom.EventType.CLICK, (e: MouseEvent) => {
dom.EventHelper.stop(e, true);
@@ -835,7 +840,13 @@ export class SCMHistoryItemChangeAttachmentWidget extends AbstractChatAttachment
this.label.setFile(attachment.value, { fileKind: FileKind.FILE, hidePath: true, nameSuffix });
this.element.ariaLabel = localize('chat.attachment', "Attached context, {0}", attachment.name);
this._store.add(hoverService.setupManagedHover(hoverDelegate, this.element, () => getHistoryItemHoverContent(themeService, attachment.historyItem), { trapFocus: true }));
const historyItem = attachment.historyItem;
const hoverContent = {
markdown: historyItem.tooltip ?? historyItem.message,
markdownNotSupportedFallback: historyItem.message
} satisfies IManagedHoverTooltipMarkdownString;
this._store.add(hoverService.setupManagedHover(hoverDelegate, this.element, hoverContent, { trapFocus: true }));
this.addResourceOpenHandlers(attachment.value, undefined);
this.attachClearButton();

View File

@@ -5,7 +5,7 @@
import './media/scm.css';
import { $, append, h, reset } from '../../../../base/browser/dom.js';
import { IHoverAction, IHoverOptions } from '../../../../base/browser/ui/hover/hover.js';
import { IHoverOptions, IManagedHoverTooltipMarkdownString } from '../../../../base/browser/ui/hover/hover.js';
import { IHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate.js';
import { IconLabel } from '../../../../base/browser/ui/iconLabel/iconLabel.js';
import { IIdentityProvider, IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js';
@@ -29,7 +29,7 @@ import { IFileIconTheme, IThemeService } from '../../../../platform/theme/common
import { IViewPaneOptions, ViewAction, ViewPane, ViewPaneShowActions } from '../../../browser/parts/views/viewPane.js';
import { IViewDescriptorService, ViewContainerLocation } from '../../../common/views.js';
import { renderSCMHistoryItemGraph, toISCMHistoryItemViewModelArray, SWIMLANE_WIDTH, renderSCMHistoryGraphPlaceholder, historyItemHoverLabelForeground, historyItemHoverDefaultLabelBackground, getHistoryItemIndex } from './scmHistory.js';
import { getHistoryItemEditorTitle, getHistoryItemHoverContent, getProviderKey, isSCMHistoryItemChangeNode, isSCMHistoryItemChangeViewModelTreeElement, isSCMHistoryItemLoadMoreTreeElement, isSCMHistoryItemViewModelTreeElement, isSCMRepository } from './util.js';
import { getHistoryItemEditorTitle, getProviderKey, isSCMHistoryItemChangeNode, isSCMHistoryItemChangeViewModelTreeElement, isSCMHistoryItemLoadMoreTreeElement, isSCMHistoryItemViewModelTreeElement, isSCMRepository } from './util.js';
import { ISCMHistoryItem, ISCMHistoryItemChange, ISCMHistoryItemGraphNode, ISCMHistoryItemRef, ISCMHistoryItemViewModel, ISCMHistoryProvider, SCMHistoryItemChangeViewModelTreeElement, SCMHistoryItemLoadMoreTreeElement, SCMHistoryItemViewModelTreeElement } from '../common/history.js';
import { HISTORY_VIEW_PANE_ID, ISCMProvider, ISCMRepository, ISCMService, ISCMViewService, ViewMode } from '../common/scm.js';
import { IListAccessibilityProvider } from '../../../../base/browser/ui/list/listWidget.js';
@@ -53,7 +53,6 @@ import { Iterable } from '../../../../base/common/iterator.js';
import { clamp } from '../../../../base/common/numbers.js';
import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js';
import { compare } from '../../../../base/common/strings.js';
import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js';
import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { IExtensionService } from '../../../services/extensions/common/extensions.js';
@@ -408,7 +407,6 @@ class HistoryItemRenderer implements ICompressibleTreeRenderer<SCMHistoryItemVie
constructor(
private readonly hoverDelegate: IHoverDelegate,
@IClipboardService private readonly _clipboardService: IClipboardService,
@ICommandService private readonly _commandService: ICommandService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
@@ -416,8 +414,7 @@ class HistoryItemRenderer implements ICompressibleTreeRenderer<SCMHistoryItemVie
@IHoverService private readonly _hoverService: IHoverService,
@IKeybindingService private readonly _keybindingService: IKeybindingService,
@IMenuService private readonly _menuService: IMenuService,
@ITelemetryService private readonly _telemetryService: ITelemetryService,
@IThemeService private readonly _themeService: IThemeService
@ITelemetryService private readonly _telemetryService: ITelemetryService
) {
this._badgesConfig = observableConfigValue<'all' | 'filter'>('scm.graph.badges', 'filter', this._configurationService);
}
@@ -445,9 +442,11 @@ class HistoryItemRenderer implements ICompressibleTreeRenderer<SCMHistoryItemVie
const historyItemViewModel = node.element.historyItemViewModel;
const historyItem = historyItemViewModel.historyItem;
const historyItemHover = this._hoverService.setupManagedHover(this.hoverDelegate, templateData.element, getHistoryItemHoverContent(this._themeService, historyItem), {
actions: this._getHoverActions(provider, historyItem),
});
const hoverContent = {
markdown: historyItem.tooltip ?? historyItem.message,
markdownNotSupportedFallback: historyItem.message
} satisfies IManagedHoverTooltipMarkdownString;
const historyItemHover = this._hoverService.setupManagedHover(this.hoverDelegate, templateData.element, hoverContent);
templateData.elementDisposables.add(historyItemHover);
templateData.graphContainer.textContent = '';
@@ -546,34 +545,6 @@ class HistoryItemRenderer implements ICompressibleTreeRenderer<SCMHistoryItemVie
append(templateData.labelContainer, elements.root);
}
private _getHoverActions(provider: ISCMProvider, historyItem: ISCMHistoryItem): IHoverAction[] {
const actions = this._menuService.getMenuActions(MenuId.SCMHistoryItemHover, this._contextKeyService, {
arg: provider,
shouldForwardArgs: true
}).flatMap(item => item[1]);
return [
{
commandId: 'workbench.scm.action.graph.copyHistoryItemId',
iconClass: 'codicon.codicon-copy',
label: historyItem.displayId ?? historyItem.id,
run: () => this._clipboardService.writeText(historyItem.id)
},
...actions.map(action => {
const iconClass = ThemeIcon.isThemeIcon(action.item.icon)
? ThemeIcon.asClassNameArray(action.item.icon).join('.')
: undefined;
return {
commandId: action.id,
label: action.label,
iconClass,
run: () => action.run(historyItem)
};
}) satisfies IHoverAction[]
];
}
private _processMatches(historyItemViewModel: ISCMHistoryItemViewModel, filterData: LabelFuzzyScore | undefined): [IMatch[] | undefined, IMatch[] | undefined] {
if (!filterData) {
return [undefined, undefined];

View File

@@ -3,8 +3,6 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { localize } from '../../../../nls.js';
import * as platform from '../../../../base/common/platform.js';
import { ISCMHistoryItem, SCMHistoryItemChangeViewModelTreeElement, SCMHistoryItemLoadMoreTreeElement, SCMHistoryItemViewModelTreeElement } from '../common/history.js';
import { ISCMResource, ISCMRepository, ISCMResourceGroup, ISCMInput, ISCMActionButton, ISCMViewService, ISCMProvider } from '../common/scm.js';
import { IMenu, MenuItemAction } from '../../../../platform/actions/common/actions.js';
@@ -20,14 +18,6 @@ import { Command } from '../../../../editor/common/languages.js';
import { reset } from '../../../../base/browser/dom.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { IResourceNode, ResourceTree } from '../../../../base/common/resourceTree.js';
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
import { IManagedHoverTooltipMarkdownString } from '../../../../base/browser/ui/hover/hover.js';
import { MarkdownString } from '../../../../base/common/htmlContent.js';
import { URI } from '../../../../base/common/uri.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { fromNow, safeIntl } from '../../../../base/common/date.js';
import { historyItemHoverAdditionsForeground, historyItemHoverDefaultLabelBackground, historyItemHoverDefaultLabelForeground, historyItemHoverDeletionsForeground, historyItemHoverLabelForeground } from './scmHistory.js';
import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js';
export function isSCMViewService(element: any): element is ISCMViewService {
return Array.isArray((element as ISCMViewService).repositories) && Array.isArray((element as ISCMViewService).visibleRepositories);
@@ -156,68 +146,3 @@ export function getRepositoryResourceCount(provider: ISCMProvider): number {
export function getHistoryItemEditorTitle(historyItem: ISCMHistoryItem): string {
return `${historyItem.displayId ?? historyItem.id} - ${historyItem.subject}`;
}
export function getHistoryItemHoverContent(themeService: IThemeService, historyItem: ISCMHistoryItem): IManagedHoverTooltipMarkdownString {
const colorTheme = themeService.getColorTheme();
const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true });
if (historyItem.author) {
const icon = URI.isUri(historyItem.authorIcon)
? `![${historyItem.author}](${historyItem.authorIcon.toString()}|width=20,height=20)`
: ThemeIcon.isThemeIcon(historyItem.authorIcon)
? `$(${historyItem.authorIcon.id})`
: '$(account)';
if (historyItem.authorEmail) {
const emailTitle = localize('emailLinkTitle', "Email");
markdown.appendMarkdown(`${icon} [**${historyItem.author}**](mailto:${historyItem.authorEmail} "${emailTitle} ${historyItem.author}")`);
} else {
markdown.appendMarkdown(`${icon} **${historyItem.author}**`);
}
if (historyItem.timestamp) {
const dateFormatter = safeIntl.DateTimeFormat(platform.language, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' }).value;
markdown.appendMarkdown(`, $(history) ${fromNow(historyItem.timestamp, true, true)} (${dateFormatter.format(historyItem.timestamp)})`);
}
markdown.appendMarkdown('\n\n');
}
markdown.appendMarkdown(`${historyItem.message.replace(/\r\n|\r|\n/g, '\n\n')}\n\n`);
if (historyItem.statistics) {
markdown.appendMarkdown(`---\n\n`);
markdown.appendMarkdown(`<span>${historyItem.statistics.files === 1 ?
localize('fileChanged', "{0} file changed", historyItem.statistics.files) :
localize('filesChanged', "{0} files changed", historyItem.statistics.files)}</span>`);
if (historyItem.statistics.insertions) {
const additionsForegroundColor = colorTheme.getColor(historyItemHoverAdditionsForeground);
markdown.appendMarkdown(`,&nbsp;<span style="color:${additionsForegroundColor};">${historyItem.statistics.insertions === 1 ?
localize('insertion', "{0} insertion{1}", historyItem.statistics.insertions, '(+)') :
localize('insertions', "{0} insertions{1}", historyItem.statistics.insertions, '(+)')}</span>`);
}
if (historyItem.statistics.deletions) {
const deletionsForegroundColor = colorTheme.getColor(historyItemHoverDeletionsForeground);
markdown.appendMarkdown(`,&nbsp;<span style="color:${deletionsForegroundColor};">${historyItem.statistics.deletions === 1 ?
localize('deletion', "{0} deletion{1}", historyItem.statistics.deletions, '(-)') :
localize('deletions', "{0} deletions{1}", historyItem.statistics.deletions, '(-)')}</span>`);
}
}
if ((historyItem.references ?? []).length > 0) {
markdown.appendMarkdown(`\n\n---\n\n`);
markdown.appendMarkdown((historyItem.references ?? []).map(ref => {
const labelIconId = ThemeIcon.isThemeIcon(ref.icon) ? ref.icon.id : '';
const labelBackgroundColor = ref.color ? asCssVariable(ref.color) : asCssVariable(historyItemHoverDefaultLabelBackground);
const labelForegroundColor = ref.color ? asCssVariable(historyItemHoverLabelForeground) : asCssVariable(historyItemHoverDefaultLabelForeground);
return `<span style="color:${labelForegroundColor};background-color:${labelBackgroundColor};border-radius:10px;">&nbsp;$(${labelIconId})&nbsp;${ref.name}&nbsp;&nbsp;</span>`;
}).join('&nbsp;&nbsp;'));
}
return { markdown, markdownNotSupportedFallback: historyItem.message };
}

View File

@@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { IMarkdownString } from '../../../../base/common/htmlContent.js';
import { IObservable } from '../../../../base/common/observable.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { URI } from '../../../../base/common/uri.js';
@@ -73,6 +74,7 @@ export interface ISCMHistoryItem {
readonly timestamp?: number;
readonly statistics?: ISCMHistoryItemStatistics;
readonly references?: ISCMHistoryItemRef[];
readonly tooltip?: string | IMarkdownString | undefined;
}
export interface ISCMHistoryItemGraphNode {

View File

@@ -192,12 +192,6 @@ const apiMenus: IAPIMenu[] = [
description: localize('menus.historyItemContext', "The Source Control history item context menu"),
proposed: 'contribSourceControlHistoryItemMenu'
},
{
key: 'scm/historyItem/hover',
id: MenuId.SCMHistoryItemHover,
description: localize('menus.historyItemHover', "The Source Control history item hover menu"),
proposed: 'contribSourceControlHistoryItemMenu'
},
{
key: 'scm/historyItemRef/context',
id: MenuId.SCMHistoryItemRefContext,

View File

@@ -61,6 +61,7 @@ declare module 'vscode' {
readonly timestamp?: number;
readonly statistics?: SourceControlHistoryItemStatistics;
readonly references?: SourceControlHistoryItemRef[];
readonly tooltip?: string | MarkdownString | undefined;
}
export interface SourceControlHistoryItemRef {