// ***************************************************************************** // Copyright (C) 2026 EclipseSource GmbH and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { MarkdownRenderer } from '@theia/core/lib/browser/markdown-rendering/markdown-renderer'; import { MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering/markdown-string'; import { codicon } from '@theia/core/lib/browser/widgets/widget'; import { nls } from '@theia/core/lib/common/nls'; import { ScmHistoryItemRef } from './scm-provider'; import { HistoryGraphEntry } from './scm-history-graph-model'; import { laneColor, getRefBadgeClass, deduplicateRefs, isTagRef, isRemoteRef } from './scm-history-graph-helpers'; export function formatRelativeTime(ms: number): string { const now = Date.now(); const diffMs = now - ms; const diffSec = Math.floor(diffMs / 1000); const diffMin = Math.floor(diffSec / 60); const diffHour = Math.floor(diffMin / 60); const diffDay = Math.floor(diffHour / 24); if (diffDay > 30) { return new Date(ms).toLocaleDateString(); } if (diffDay >= 1) { return diffDay === 1 ? nls.localizeByDefault('{0} day ago', diffDay) : nls.localizeByDefault('{0} days ago', diffDay); } if (diffHour >= 1) { return diffHour === 1 ? nls.localize('theia/scm/1HourAgo', '1 hour ago') : nls.localizeByDefault('{0} hours ago', diffHour); } if (diffMin >= 1) { return diffMin === 1 ? nls.localize('theia/scm/1MinuteAgo', '1 minute ago') : nls.localizeByDefault('{0} minutes ago', diffMin); } return nls.localize('theia/scm/justNow', 'just now'); } export function formatAbsoluteDate(ms: number): string { return new Date(ms).toLocaleString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit', }); } /** * Appends a theme icon `` element followed by a text node into a container. */ export function appendIconText(container: HTMLElement, iconName: string, text: string): void { const icon = document.createElement('i'); icon.className = codicon(iconName) + ' icon-inline'; container.appendChild(icon); container.appendChild(document.createTextNode(` ${text}`)); } export function createHoverHr(): HTMLElement { return document.createElement('hr'); } /** Creates a ref badge element for the HTML tooltip. */ export function buildTooltipRefBadge( ref: ScmHistoryItemRef, iconClass: string, showText: boolean, bgColor: string, extraClass?: string ): HTMLElement { const badge = document.createElement('span'); badge.className = `scm-history-ref-badge ${getRefBadgeClass(ref)} tooltip-badge${extraClass ? ' ' + extraClass : ''}`; badge.title = ref.description ?? ref.name; badge.style.backgroundColor = bgColor; badge.style.color = 'var(--theia-scmGraph-historyItemRefForeground, var(--theia-badge-foreground))'; const icon = document.createElement('i'); icon.className = `codicon ${iconClass} scm-history-ref-icon`; badge.appendChild(icon); if (showText) { const text = document.createElement('span'); text.className = 'scm-history-ref-text'; text.textContent = ref.name; badge.appendChild(text); } return badge; } export function buildHtmlTooltip(entry: HistoryGraphEntry, markdownRenderer: MarkdownRenderer): HTMLElement { const { item } = entry; const badgeColor = laneColor(entry.graphRow.color); const container = document.createElement('div'); container.className = 'scm-history-tooltip'; // Header if (item.author || item.timestamp !== undefined) { const header = document.createElement('div'); header.className = 'scm-history-tooltip-header'; header.style.fontWeight = 'bold'; header.style.marginBottom = '4px'; if (item.author) { appendIconText(header, 'account', item.author); } if (item.timestamp !== undefined) { if (item.author) { header.appendChild(document.createTextNode('\u00a0\u00a0')); } const timeSpan = document.createElement('span'); appendIconText(timeSpan, 'clock', `${formatRelativeTime(item.timestamp)} (${formatAbsoluteDate(item.timestamp)})`); header.appendChild(timeSpan); } container.appendChild(header); container.appendChild(createHoverHr()); } // Body const bodyText = (item.message && item.message.trim() !== item.subject.trim()) ? item.message.trim() : item.subject; const bodyMd = new MarkdownStringImpl(bodyText); const rendered = markdownRenderer.render(bodyMd); container.appendChild(rendered.element); // Stats if (item.statistics) { const s = item.statistics; container.appendChild(createHoverHr()); const stats = document.createElement('div'); stats.className = 'scm-history-tooltip-stats'; const fileCount = s.files === 1 ? '1 file changed' : `${s.files} files changed`; const filesStrong = document.createElement('strong'); filesStrong.textContent = fileCount; stats.appendChild(filesStrong); if (s.insertions > 0) { stats.appendChild(document.createTextNode(', ')); const ins = document.createElement('span'); ins.className = 'scm-history-stat-added'; ins.textContent = `${s.insertions} insertion${s.insertions === 1 ? '' : 's'}(+)`; stats.appendChild(ins); } if (s.deletions > 0) { stats.appendChild(document.createTextNode(', ')); const del = document.createElement('span'); del.className = 'scm-history-stat-deleted'; del.textContent = `${s.deletions} deletion${s.deletions === 1 ? '' : 's'}(-)`; stats.appendChild(del); } container.appendChild(stats); } // Refs + hash const hasRefs = item.references && item.references.length > 0; if (hasRefs || item.displayId) { container.appendChild(createHoverHr()); const refsRow = document.createElement('div'); refsRow.className = 'scm-history-tooltip-refs'; refsRow.style.display = 'flex'; refsRow.style.flexWrap = 'wrap'; refsRow.style.gap = '4px'; refsRow.style.alignItems = 'center'; if (hasRefs) { const deduplicated = deduplicateRefs(item.references!); for (const { ref, hasBoth } of deduplicated) { const isTag = isTagRef(ref); const isRemote = isRemoteRef(ref); if (isTag) { refsRow.appendChild(buildTooltipRefBadge(ref, 'codicon-tag', true, badgeColor)); } else if (isRemote) { refsRow.appendChild(buildTooltipRefBadge(ref, 'codicon-cloud', true, badgeColor)); } else { refsRow.appendChild(buildTooltipRefBadge(ref, 'codicon-git-branch', true, badgeColor)); if (hasBoth) { refsRow.appendChild(buildTooltipRefBadge(ref, 'codicon-cloud', false, badgeColor, 'scm-history-ref-badge-cloud')); } } } } if (item.displayId) { const hash = document.createElement('code'); hash.textContent = item.displayId; refsRow.appendChild(hash); } container.appendChild(refsRow); } return container; }