diff --git a/package.json b/package.json index 74c1379cc4b77..7d2a5fa0f8292 100644 --- a/package.json +++ b/package.json @@ -17511,7 +17511,7 @@ "vscode:prepublish": "yarn run bundle" }, "dependencies": { - "@gitkraken/gitkraken-components": "10.3.0", + "@gitkraken/gitkraken-components": "10.4.0", "@gitkraken/provider-apis": "0.22.9", "@gitkraken/shared-web-components": "0.1.1-rc.15", "@lit/react": "1.0.5", @@ -17531,6 +17531,7 @@ "https-proxy-agent": "5.0.1", "iconv-lite": "0.6.3", "lit": "3.1.4", + "marked": "12.0.2", "node-fetch": "2.7.0", "os-browserify": "0.3.0", "path-browserify": "1.0.1", diff --git a/src/git/models/commit.ts b/src/git/models/commit.ts index 7a5afcc455a7a..30cc2b8832f11 100644 --- a/src/git/models/commit.ts +++ b/src/git/models/commit.ts @@ -563,6 +563,7 @@ export class GitCommit implements GitRevisionReference { parents?: string[]; files?: { file?: GitFileChange | null; files?: GitFileChange[] | null } | null; lines?: GitCommitLine[]; + stats?: GitCommitStats; }): GitCommit { let files; if (changes.files != null) { @@ -593,7 +594,7 @@ export class GitCommit implements GitRevisionReference { this.getChangedValue(changes.parents, this.parents) ?? [], this.message, files, - this.stats, + this.getChangedValue(changes.stats, this.stats), this.getChangedValue(changes.lines, this.lines), this.tips, this.stashName, diff --git a/src/plus/webviews/graph/graphWebview.ts b/src/plus/webviews/graph/graphWebview.ts index e79563eb4652f..01f1efcadf0a7 100644 --- a/src/plus/webviews/graph/graphWebview.ts +++ b/src/plus/webviews/graph/graphWebview.ts @@ -1,4 +1,4 @@ -import type { ColorTheme, ConfigurationChangeEvent, Uri } from 'vscode'; +import type { CancellationToken, ColorTheme, ConfigurationChangeEvent, Uri } from 'vscode'; import { CancellationTokenSource, Disposable, env, window } from 'vscode'; import type { CreatePullRequestActionContext, OpenPullRequestActionContext } from '../../../api/gitlens'; import { getAvatarUri } from '../../../avatars'; @@ -36,11 +36,14 @@ import * as StashActions from '../../../git/actions/stash'; import * as TagActions from '../../../git/actions/tag'; import * as WorktreeActions from '../../../git/actions/worktree'; import { GitSearchError } from '../../../git/errors'; +import { CommitFormatter } from '../../../git/formatters/commitFormatter'; import { getBranchId, getBranchNameWithoutRemote, getRemoteNameFromBranchName } from '../../../git/models/branch'; import type { GitCommit } from '../../../git/models/commit'; +import { isStash } from '../../../git/models/commit'; import { uncommitted } from '../../../git/models/constants'; import { GitContributor } from '../../../git/models/contributor'; import type { GitGraph, GitGraphRowType } from '../../../git/models/graph'; +import { getGkProviderThemeIconString } from '../../../git/models/graph'; import { getComparisonRefsForPullRequest, serializePullRequest } from '../../../git/models/pullRequest'; import type { GitBranchReference, @@ -77,7 +80,7 @@ import type { Deferrable } from '../../../system/function'; import { debounce, disposableInterval } from '../../../system/function'; import { find, last, map } from '../../../system/iterable'; import { updateRecordValue } from '../../../system/object'; -import { getSettledValue } from '../../../system/promise'; +import { getSettledValue, pauseOnCancelOrTimeoutMapTuplePromise } from '../../../system/promise'; import { isDarkTheme, isLightTheme } from '../../../system/utils'; import { isWebviewItemContext, isWebviewItemGroupContext, serializeWebviewItemContext } from '../../../system/webview'; import { RepositoryFolderNode } from '../../../views/nodes/abstract/repositoryFolderNode'; @@ -88,6 +91,7 @@ import { isSerializedState } from '../../../webviews/webviewsController'; import type { SubscriptionChangeEvent } from '../../gk/account/subscriptionService'; import type { BranchState, + DidGetRowHoverParams, DidSearchParams, DoubleClickedParams, GetMissingAvatarsParams, @@ -157,6 +161,7 @@ import { GetMissingAvatarsCommand, GetMissingRefsMetadataCommand, GetMoreRowsCommand, + GetRowHoverRequest, OpenPullRequestDetailsCommand, SearchOpenInViewCommand, SearchRequest, @@ -222,6 +227,9 @@ export class GraphWebviewProvider implements WebviewProvider>(); + private _hoverCancellation: CancellationTokenSource | undefined; + private readonly _ipcNotificationMap = new Map, () => Promise>([ [DidChangeColumnsNotification, this.notifyDidChangeColumns], [DidChangeGraphConfigurationNotification, this.notifyDidChangeConfiguration], @@ -629,6 +637,9 @@ export class GraphWebviewProvider implements WebviewProvider(requestType: T, msg: IpcCallMessageType) { + const hover: DidGetRowHoverParams = { + id: msg.params.id, + markdown: undefined, + }; + + if (this._hoverCancellation != null) { + this._hoverCancellation.cancel(); + } + + if (this._graph != null) { + const id = msg.params.id; + + let markdown = this._hoverCache.get(id); + if (markdown == null) { + const cancellation = new CancellationTokenSource(); + this._hoverCancellation = cancellation; + + let cache = true; + let commit; + switch (msg.params.type) { + case 'work-dir-changes': + cache = false; + commit = await this.container.git.getCommit(this._graph.repoPath, uncommitted); + break; + case 'stash-node': { + const stash = await this.container.git.getStash(this._graph.repoPath); + commit = stash?.commits.get(msg.params.id); + break; + } + default: { + commit = await this.container.git.getCommit(this._graph.repoPath, msg.params.id); + break; + } + } + + if (commit != null && !cancellation.token.isCancellationRequested) { + // Check if we have calculated stats for the row and if so apply it to the commit + const stats = this._graph.rowsStats?.get(commit.sha); + if (stats != null) { + commit = commit.with({ + stats: { + ...commit.stats, + additions: stats.additions, + deletions: stats.deletions, + // If `changedFiles` already exists, then use it, otherwise use the files count + changedFiles: commit.stats?.changedFiles ? commit.stats.changedFiles : stats.files, + }, + }); + } + + markdown = this.getCommitTooltip(commit, cancellation.token).catch(() => { + this._hoverCache.delete(id); + return undefined; + }); + if (cache) { + this._hoverCache.set(id, markdown); + } + } + } + + if (markdown != null) { + hover.markdown = await markdown; + } + } + + void this.host.respond(requestType, msg, hover); + } + + private async getCommitTooltip(commit: GitCommit, cancellation: CancellationToken) { + const [remotesResult, _] = await Promise.allSettled([ + this.container.git.getBestRemotesWithProviders(commit.repoPath), + commit.ensureFullDetails(), + ]); + + if (cancellation.isCancellationRequested) throw new CancellationError(); + + const remotes = getSettledValue(remotesResult, []); + const [remote] = remotes; + + let enrichedAutolinks; + let pr; + + if (remote?.hasIntegration()) { + const [enrichedAutolinksResult, prResult] = await Promise.allSettled([ + pauseOnCancelOrTimeoutMapTuplePromise(commit.getEnrichedAutolinks(remote), cancellation), + commit.getAssociatedPullRequest(remote), + ]); + + if (cancellation.isCancellationRequested) throw new CancellationError(); + + const enrichedAutolinksMaybeResult = getSettledValue(enrichedAutolinksResult); + if (!enrichedAutolinksMaybeResult?.paused) { + enrichedAutolinks = enrichedAutolinksMaybeResult?.value; + } + pr = getSettledValue(prResult); + } + + let template; + if (isStash(commit)) { + template = configuration.get('views.formats.stashes.tooltip'); + } else { + template = configuration.get('views.formats.commits.tooltip'); + } + + const tooltip = await CommitFormatter.fromTemplateAsync(template, commit, { + enrichedAutolinks: enrichedAutolinks, + dateFormat: configuration.get('defaultDateFormat'), + getBranchAndTagTips: this.getBranchAndTagTips.bind(this), + messageAutolinks: true, + messageIndent: 4, + pullRequest: pr, + outputFormat: 'markdown', + remotes: remotes, + // unpublished: this.unpublished, + }); + + return tooltip; + } + + private getBranchAndTagTips(sha: string, options?: { compact?: boolean; icons?: boolean }): string | undefined { + if (this._graph == null) return undefined; + + const row = this._graph.rows.find(r => r.sha === sha); + if (row == null) return undefined; + + const tips = []; + if (row.heads?.length) { + tips.push(...row.heads.map(h => (options?.icons ? `$(git-branch) ${h.name}` : h.name))); + } + + if (row.remotes?.length) { + tips.push( + ...row.remotes.map(h => { + const name = `${h.owner ? `${h.owner}/` : ''}${h.name}`; + return options?.icons ? `$(${getGkProviderThemeIconString(h.hostingServiceType)}) ${name}` : name; + }), + ); + } + if (row.tags?.length) { + tips.push(...row.tags.map(h => (options?.icons ? `$(tag) ${h.name}` : h.name))); + } + + return tips.join(', ') || undefined; + } + @debug() private async onEnsureRowRequest(requestType: T, msg: IpcCallMessageType) { if (this._graph == null) return; @@ -2147,6 +2304,12 @@ export class GraphWebviewProvider implements WebviewProvider(scope, 'rows/ensure'); +export type GetRowHoverParams = { + type: GitGraphRowType; + id: string; +}; +export interface DidGetRowHoverParams { + id: string; + markdown?: string; +} +export const GetRowHoverRequest = new IpcRequest(scope, 'row/hover/get'); + export interface SearchParams { search?: SearchQuery; limit?: number; diff --git a/src/webviews/apps/commitDetails/commitDetails.scss b/src/webviews/apps/commitDetails/commitDetails.scss index 7f663252ade60..41ef21f90213f 100644 --- a/src/webviews/apps/commitDetails/commitDetails.scss +++ b/src/webviews/apps/commitDetails/commitDetails.scss @@ -148,7 +148,7 @@ hr { } .md-code { - background: rgba(255, 255, 255, 0.05); + background: var(--vscode-textCodeBlock-background); border-radius: 3px; padding: 0px 4px 2px 4px; font-family: var(--vscode-editor-font-family); diff --git a/src/webviews/apps/commitDetails/components/gl-status-nav.ts b/src/webviews/apps/commitDetails/components/gl-status-nav.ts index 6bba07ce7d57b..1c6d3f65b0bf7 100644 --- a/src/webviews/apps/commitDetails/components/gl-status-nav.ts +++ b/src/webviews/apps/commitDetails/components/gl-status-nav.ts @@ -61,7 +61,7 @@ export class GlStatusNav extends LitElement { } .md-code { - background: rgba(255, 255, 255, 0.05); + background: var(--vscode-textCodeBlock-background); border-radius: 3px; padding: 0px 4px 2px 4px; font-family: var(--vscode-editor-font-family); diff --git a/src/webviews/apps/plus/graph/GraphWrapper.tsx b/src/webviews/apps/plus/graph/GraphWrapper.tsx index acf8926b2de71..ae5d40dcc2131 100644 --- a/src/webviews/apps/plus/graph/GraphWrapper.tsx +++ b/src/webviews/apps/plus/graph/GraphWrapper.tsx @@ -23,6 +23,7 @@ import type { FocusCommandArgs } from '../../../../plus/focus/focus'; import type { Subscription } from '../../../../plus/gk/account/subscription'; import type { DidEnsureRowParams, + DidGetRowHoverParams, DidSearchParams, GraphAvatars, GraphColumnName, @@ -72,6 +73,7 @@ import { GlSearchBox } from '../../shared/components/search/react'; import type { SearchNavigationEventDetail } from '../../shared/components/search/search-box'; import type { DateTimeFormat } from '../../shared/date'; import { formatDate, fromNow } from '../../shared/date'; +import { GlGraphHover } from './hover/graphHover.react'; import type { GraphMinimapDaySelectedEventDetail } from './minimap/minimap'; import { GlGraphMinimapContainer } from './minimap/minimap-container.react'; @@ -83,6 +85,7 @@ export interface GraphWrapperProps { onColumnsChange?: (colsSettings: GraphColumnsConfig) => void; onDoubleClickRef?: (ref: GraphRef, metadata?: GraphRefMetadataItem) => void; onDoubleClickRow?: (row: GraphRow, preserveFocus?: boolean) => void; + onHoverRowPromise?: (row: GraphRow) => Promise; onMissingAvatars?: (emails: Record) => void; onMissingRefsMetadata?: (metadata: GraphMissingRefsMetadata) => void; onMoreRows?: (id?: string) => void; @@ -206,6 +209,7 @@ export function GraphWrapper({ onDoubleClickRef, onDoubleClickRow, onEnsureRowPromise, + onHoverRowPromise, onMissingAvatars, onMissingRefsMetadata, onMoreRows, @@ -267,6 +271,7 @@ export function GraphWrapper({ ); const minimap = useRef(undefined); + const hover = useRef(undefined); const ensuredIds = useRef>(new Set()); const ensuredSkippedIds = useRef>(new Set()); @@ -300,6 +305,7 @@ export function GraphWrapper({ setContext(state.context); break; case DidChangeRowsNotification: + hover.current?.reset(); setRows(state.rows ?? []); setRowsStats(state.rowsStats); setRowsStatsLoading(state.rowsStatsLoading); @@ -344,6 +350,7 @@ export function GraphWrapper({ setLastFetched(state.lastFetched); break; default: { + hover.current?.reset(); setAllowed(state.allowed ?? false); if (!themingChanged) { setStyleProps(state.theming); @@ -464,14 +471,59 @@ export function GraphWrapper({ } }; - const handleOnGraphMouseLeave = (_event: any) => { + const handleOnGraphMouseLeave = (_event: React.MouseEvent) => { minimap.current?.unselect(undefined, true); }; - const handleOnGraphRowHovered = (_event: any, graphZoneType: GraphZoneType, graphRow: GraphRow) => { - if (graphZoneType === refZone || minimap.current == null) return; + const handleOnGraphRowHovered = ( + event: React.MouseEvent, + graphZoneType: GraphZoneType, + graphRow: GraphRow, + ) => { + if (graphZoneType === refZone) return; minimap.current?.select(graphRow.date, true); + + if (onHoverRowPromise == null) return; + + const hoverComponent = hover.current; + if (hoverComponent == null) return; + + const { clientX } = event; + + const rect = event.currentTarget.getBoundingClientRect() as DOMRect; + const x = clientX; + const y = rect.top; + const height = rect.height; + const width = 60; // Add some width, so `skidding` will be able to apply + + const anchor = { + getBoundingClientRect: function () { + return { + width: width, + height: height, + x: x, + y: y, + top: y, + left: x, + right: x + width, + bottom: y + height, + }; + }, + }; + + hoverComponent.requestMarkdown ??= onHoverRowPromise; + hoverComponent.onRowHovered(graphRow, anchor); + }; + + const handleOnGraphRowUnhovered = ( + event: React.MouseEvent, + graphZoneType: GraphZoneType, + graphRow: GraphRow, + ) => { + if (graphZoneType === refZone) return; + + hover.current?.onRowUnhovered(graphRow, event.relatedTarget); }; useEffect(() => { @@ -1461,6 +1513,13 @@ export function GraphWrapper({ visibleDays={visibleDays} onSelected={e => handleOnMinimapDaySelected(e)} > +
{repo !== undefined ? ( <> @@ -1497,8 +1556,9 @@ export function GraphWrapper({ onDoubleClickGraphRow={handleOnDoubleClickRow} onDoubleClickGraphRef={handleOnDoubleClickRef} onGraphColumnsReOrdered={handleOnGraphColumnsReOrdered} - onGraphMouseLeave={minimap.current ? handleOnGraphMouseLeave : undefined} - onGraphRowHovered={minimap.current ? handleOnGraphRowHovered : undefined} + onGraphMouseLeave={handleOnGraphMouseLeave} + onGraphRowHovered={handleOnGraphRowHovered} + onGraphRowUnhovered={handleOnGraphRowUnhovered} onRowContextMenu={handleRowContextMenu} onSettingsClick={handleToggleColumnSettings} onSelectGraphRows={handleSelectGraphRows} @@ -1513,6 +1573,7 @@ export function GraphWrapper({ rowsStatsLoading={rowsStatsLoading} shaLength={graphConfig?.idLength} shiftSelectMode="simple" + suppressNonRefRowTooltips themeOpacityFactor={styleProps?.themeOpacityFactor} useAuthorInitialsForAvatars={!graphConfig?.avatars} workDirStats={workingTreeStats} diff --git a/src/webviews/apps/plus/graph/graph.scss b/src/webviews/apps/plus/graph/graph.scss index aa45188fe4f1b..dc8948cd2ea17 100644 --- a/src/webviews/apps/plus/graph/graph.scss +++ b/src/webviews/apps/plus/graph/graph.scss @@ -1197,7 +1197,7 @@ hr { } .md-code { - background: rgba(255, 255, 255, 0.05); + background: var(--vscode-textCodeBlock-background); border-radius: 3px; padding: 0px 4px 2px 4px; font-family: var(--vscode-editor-font-family); diff --git a/src/webviews/apps/plus/graph/graph.tsx b/src/webviews/apps/plus/graph/graph.tsx index 1a13dd5caa96d..e25a5cc5fff04 100644 --- a/src/webviews/apps/plus/graph/graph.tsx +++ b/src/webviews/apps/plus/graph/graph.tsx @@ -38,6 +38,7 @@ import { GetMissingAvatarsCommand, GetMissingRefsMetadataCommand, GetMoreRowsCommand, + GetRowHoverRequest, OpenPullRequestDetailsCommand, SearchOpenInViewCommand, SearchRequest, @@ -104,6 +105,7 @@ export class GraphApp extends App { onChooseRepository={debounce(() => this.onChooseRepository(), 250)} onDoubleClickRef={(ref, metadata) => this.onDoubleClickRef(ref, metadata)} onDoubleClickRow={(row, preserveFocus) => this.onDoubleClickRow(row, preserveFocus)} + onHoverRowPromise={(row: GraphRow) => this.onHoverRowPromise(row)} onMissingAvatars={(...params) => this.onGetMissingAvatars(...params)} onMissingRefsMetadata={(...params) => this.onGetMissingRefsMetadata(...params)} onMoreRows={(...params) => this.onGetMoreRows(...params)} @@ -543,6 +545,14 @@ export class GraphApp extends App { }); } + private async onHoverRowPromise(row: GraphRow) { + try { + return await this.sendRequest(GetRowHoverRequest, { type: row.type as GitGraphRowType, id: row.sha }); + } catch { + return undefined; + } + } + private onGetMissingAvatars(emails: GraphAvatars) { this.sendCommand(GetMissingAvatarsCommand, { emails: emails }); } diff --git a/src/webviews/apps/plus/graph/hover/graphHover.react.tsx b/src/webviews/apps/plus/graph/hover/graphHover.react.tsx new file mode 100644 index 0000000000000..59db4570152c0 --- /dev/null +++ b/src/webviews/apps/plus/graph/hover/graphHover.react.tsx @@ -0,0 +1,5 @@ +import { reactWrapper } from '../../../shared/components/helpers/react-wrapper'; +import { GlGraphHover as GlGraphHoverWC } from './graphHover'; + +export interface GlGraphHover extends GlGraphHoverWC {} +export const GlGraphHover = reactWrapper(GlGraphHoverWC, { tagName: 'gl-graph-hover' }); diff --git a/src/webviews/apps/plus/graph/hover/graphHover.ts b/src/webviews/apps/plus/graph/hover/graphHover.ts new file mode 100644 index 0000000000000..02414e9435475 --- /dev/null +++ b/src/webviews/apps/plus/graph/hover/graphHover.ts @@ -0,0 +1,155 @@ +import type { GraphRow } from '@gitkraken/gitkraken-components'; +import { css, html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { until } from 'lit/directives/until.js'; +import type { DidGetRowHoverParams } from '../../../../../plus/webviews/graph/protocol'; +import { isPromise } from '../../../../../system/promise'; +import { GlElement } from '../../../shared/components/element'; +import type { GlPopover } from '../../../shared/components/overlays/popover.react'; +import '../../../shared/components/overlays/popover'; +import './markdown'; + +declare global { + interface HTMLElementTagNameMap { + 'gl-graph-hover': GlGraphHover; + } + + // interface GlobalEventHandlersEventMap { + // 'gl-popover-show': CustomEvent; + // 'gl-popover-after-show': CustomEvent; + // 'gl-popover-hide': CustomEvent; + // 'gl-popover-after-hide': CustomEvent; + // } +} + +type Anchor = string | HTMLElement | { getBoundingClientRect: () => Omit }; + +@customElement('gl-graph-hover') +export class GlGraphHover extends GlElement { + static override styles = css``; + + @property({ type: Object }) + anchor?: Anchor; + + @property({ reflect: true, type: Number }) + distance?: number | undefined; + + @property({ reflect: true, type: Boolean }) + open?: boolean = false; + + @property({ reflect: true }) + placement?: GlPopover['placement'] = 'bottom'; + + @property({ type: Object }) + markdown?: Promise | string | undefined; + + @property({ reflect: true, type: Number }) + skidding?: number | undefined; + + @property({ type: Function }) + requestMarkdown: ((row: GraphRow) => Promise) | undefined; + + private hoverMarkdownCache = new Map | string>(); + private hoveredSha: string | undefined; + private unhoverTimer: ReturnType | undefined; + + override render() { + if (!this.markdown) { + this.hide(); + return; + } + + return html` this.hide()} + > +
+ +
+
`; + } + + reset() { + this.hoverMarkdownCache.clear(); + } + + onRowHovered(row: GraphRow, anchor: Anchor) { + console.log('onRowHovered', row.sha); + + if (this.requestMarkdown == null) return; + + this.hoveredSha = row.sha; + + let markdown = this.hoverMarkdownCache.get(row.sha); + if (markdown == null) { + const cache = row.type !== 'work-dir-changes'; + + markdown = this.requestMarkdown(row).then(params => { + if (params?.markdown != null) { + if (cache) { + this.hoverMarkdownCache.set(row.sha, params.markdown); + } + return params.markdown; + } + + this.hoverMarkdownCache.delete(row.sha); + return ''; + }); + + if (cache) { + this.hoverMarkdownCache.set(row.sha, markdown); + } + } + this.showCore(anchor, markdown); + } + + onRowUnhovered(row: GraphRow, relatedTarget: EventTarget | null) { + console.log('onRowUnhovered', row.sha); + + clearTimeout(this.unhoverTimer); + + if ( + relatedTarget != null && + 'closest' in relatedTarget && + (relatedTarget as HTMLElement).closest('gl-graph-hover') + ) { + return; + } + + this.hoveredSha = undefined; + + this.unhoverTimer = setTimeout(() => { + console.log('onRowUnhovered timeout', this.hoveredSha); + if (this.hoveredSha == null) { + this.hide(); + } + }, 100); + } + + private showCore( + anchor: string | HTMLElement | { getBoundingClientRect: () => Omit }, + markdown: Promise | string | undefined, + ) { + if (isPromise(markdown)) { + void markdown.then(markdown => { + this.markdown = markdown; + if (!markdown) { + this.open = false; + } + }); + } + + this.anchor = anchor; + this.markdown = markdown; + this.open = true; + } + + hide() { + this.open = false; + } +} diff --git a/src/webviews/apps/plus/graph/hover/markdown.ts b/src/webviews/apps/plus/graph/hover/markdown.ts new file mode 100644 index 0000000000000..5d36bba5eafb2 --- /dev/null +++ b/src/webviews/apps/plus/graph/hover/markdown.ts @@ -0,0 +1,286 @@ +import { css, html, LitElement } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { until } from 'lit/directives/until.js'; +import type { RendererObject } from 'marked'; +import { marked } from 'marked'; +import type { ThemeIcon } from 'vscode'; + +@customElement('gl-markdown') +export class GLMarkdown extends LitElement { + static override styles = css` + a, + a code { + text-decoration: none; + color: var(--vscode-textLink-foreground); + } + + a:hover, + a:hover code { + color: var(--vscode-textLink-activeForeground); + } + + a:hover:not(.disabled) { + cursor: pointer; + } + + p, + .code, + ul, + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 8px 0; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + line-height: 1.1; + } + + code { + background: var(--vscode-textCodeBlock-background); + border-radius: 3px; + padding: 0px 4px 2px 4px; + font-family: var(--vscode-editor-font-family); + } + + code code-icon { + color: inherit; + font-size: inherit; + vertical-align: middle; + } + + hr { + border: none; + border-top: 1px solid var(--color-foreground--25); + } + + p:first-child, + .code:first-child, + ul:first-child { + margin-top: 0; + } + + p:last-child, + .code:last-child, + ul:last-child { + margin-bottom: 0; + } + + /* MarkupContent Layout */ + ul { + padding-left: 20px; + } + ol { + padding-left: 20px; + } + + li > p { + margin-bottom: 0; + } + + li > ul { + margin-top: 0; + } + `; + + @property({ type: String }) + private markdown = ''; + + override render() { + return html`${this.markdown ? until(this.renderMarkdown(this.markdown), 'Loading...') : ''}`; + } + + private async renderMarkdown(markdown: string) { + marked.setOptions({ + gfm: true, + // smartypants: true, + // langPrefix: 'language-', + }); + + marked.use({ renderer: getMarkdownRenderer() }); + + let rendered = await marked.parse(markdownEscapeEscapedIcons(markdown)); + rendered = renderThemeIconsWithinText(rendered); + return unsafeHTML(rendered); + } +} + +function getMarkdownRenderer(): RendererObject { + return { + // heading: function (text: string, level: number, raw: string, slugger: any): string { + // level = Math.min(6, level); + // const id = slugger.slug(text); + // const hlinks = null; + + // let content; + + // if (hlinks === null) { + // // No heading links + // content = text; + // } else { + // content = ``; + + // if (hlinks === '') { + // // Heading content is the link + // content += `${text}`; + // } else { + // // Headings are prepended with a linked symbol + // content += `${hlinks}${text}`; + // } + // } + + // return ` + // + // ${content} + // `; + // }, + image: (href: string | null, title: string | null, text: string): string => { + let dimensions: string[] = []; + let attributes: string[] = []; + if (href) { + ({ href, dimensions } = parseHrefAndDimensions(href)); + attributes.push(`src="${escapeDoubleQuotes(href)}"`); + } + if (text) { + attributes.push(`alt="${escapeDoubleQuotes(text)}"`); + } + if (title) { + attributes.push(`title="${escapeDoubleQuotes(title)}"`); + } + if (dimensions.length) { + attributes = attributes.concat(dimensions); + } + return ``; + }, + + paragraph: (text: string): string => { + return `

${text}

`; + }, + + link: (href: string, title: string | null | undefined, text: string): string | false => { + if (typeof href !== 'string') { + return ''; + } + + // Remove markdown escapes. Workaround for https://github.com/chjj/marked/issues/829 + if (href === text) { + // raw link case + text = removeMarkdownEscapes(text); + } + + title = typeof title === 'string' ? escapeDoubleQuotes(removeMarkdownEscapes(title)) : ''; + href = removeMarkdownEscapes(href); + + // HTML Encode href + href = href + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + return `${text}`; + }, + + code: function (code: string, infostring: string | undefined, _escaped: boolean): string { + // Remote code may include characters that need to be escaped to be visible in HTML + code = code.replace(/${code}`; + }, + + codespan: function (code: string): string { + // Remote code may include characters that need to be escaped to be visible in HTML + code = code.replace(/${code}`; + }, + }; +} + +const themeIconNameExpression = '[A-Za-z0-9-]+'; +const themeIconModifierExpression = '~[A-Za-z]+'; +const themeIconIdRegex = new RegExp(`^(${themeIconNameExpression})(${themeIconModifierExpression})?$`); +const themeIconsRegex = new RegExp(`\\$\\(${themeIconNameExpression}(?:${themeIconModifierExpression})?\\)`, 'g'); +const themeIconsMarkdownEscapedRegex = new RegExp(`\\\\${themeIconsRegex.source}`, 'g'); +const themeIconsWithinTextRegex = new RegExp( + `(\\\\)?\\$\\((${themeIconNameExpression}(?:${themeIconModifierExpression})?)\\)`, + 'g', +); + +function markdownEscapeEscapedIcons(text: string): string { + // Need to add an extra \ for escaping in markdown + return text.replace(themeIconsMarkdownEscapedRegex, match => `\\${match}`); +} + +function parseHrefAndDimensions(href: string): { href: string; dimensions: string[] } { + const dimensions: string[] = []; + const splitted = href.split('|').map(s => s.trim()); + href = splitted[0]; + const parameters = splitted[1]; + if (parameters) { + const heightFromParams = /height=(\d+)/.exec(parameters); + const widthFromParams = /width=(\d+)/.exec(parameters); + const height = heightFromParams ? heightFromParams[1] : ''; + const width = widthFromParams ? widthFromParams[1] : ''; + const widthIsFinite = isFinite(parseInt(width)); + const heightIsFinite = isFinite(parseInt(height)); + if (widthIsFinite) { + dimensions.push(`width="${width}"`); + } + if (heightIsFinite) { + dimensions.push(`height="${height}"`); + } + } + return { href: href, dimensions: dimensions }; +} + +function renderThemeIconsWithinText(text: string): string { + const elements: string[] = []; + let match: RegExpExecArray | null; + + let textStart = 0; + let textStop = 0; + while ((match = themeIconsWithinTextRegex.exec(text)) !== null) { + textStop = match.index || 0; + if (textStart < textStop) { + elements.push(text.substring(textStart, textStop)); + } + textStart = (match.index || 0) + match[0].length; + + const [, escaped, codicon] = match; + elements.push(escaped ? `$(${codicon})` : renderThemeIcon({ id: codicon })); + } + + if (textStart < text.length) { + elements.push(text.substring(textStart)); + } + return elements.join(''); +} + +function renderThemeIcon(icon: ThemeIcon): string { + const match = themeIconIdRegex.exec(icon.id); + let [, id, modifier] = match ?? [undefined, 'error', undefined]; + if (id.startsWith('gitlens-')) { + id = `gl-${id.substring(8)}`; + } + return /*html*/ ``; +} + +function escapeDoubleQuotes(input: string) { + return input.replace(/"/g, '"'); +} + +function removeMarkdownEscapes(text: string): string { + if (!text) { + return text; + } + return text.replace(/\\([\\`*_{}[\]()#+\-.!~])/g, '$1'); +} diff --git a/src/webviews/apps/shared/base.scss b/src/webviews/apps/shared/base.scss index 3a055a1145edc..0434104f74365 100644 --- a/src/webviews/apps/shared/base.scss +++ b/src/webviews/apps/shared/base.scss @@ -295,7 +295,7 @@ ul { } code { - background: rgba(255, 255, 255, 0.05); + background: var(--vscode-textCodeBlock-background); color: var(--color-foreground--75); border-radius: 3px; padding: 0px 4px 2px 4px; diff --git a/src/webviews/apps/shared/components/overlays/popover.ts b/src/webviews/apps/shared/components/overlays/popover.ts index 8c6c2e89d66bf..595c423e93bae 100644 --- a/src/webviews/apps/shared/components/overlays/popover.ts +++ b/src/webviews/apps/shared/components/overlays/popover.ts @@ -161,7 +161,10 @@ export class GlPopover extends GlElement { @property({ reflect: true }) placement: SlPopup['placement'] = 'bottom'; - @property({ type: Boolean, reflect: true }) + @property({ type: Object }) + anchor?: string | HTMLElement | { getBoundingClientRect: () => Omit }; + + @property({ reflect: true, type: Boolean }) disabled: boolean = false; @property({ type: Number }) @@ -368,6 +371,7 @@ export class GlPopover extends GlElement { arrow:base__arrow " class="popover" + .anchor=${this.anchor} placement=${this.placement} distance=${this.distance} skidding=${this.skidding} diff --git a/yarn.lock b/yarn.lock index 2b838c6cd6d92..c523d4f88043d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -357,10 +357,10 @@ resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw== -"@gitkraken/gitkraken-components@10.3.0": - version "10.3.0" - resolved "https://registry.yarnpkg.com/@gitkraken/gitkraken-components/-/gitkraken-components-10.3.0.tgz#a4b825ced58e16b9a8b692b48ec8189bd26ea551" - integrity sha512-ywNoUgmzWaffiK/rRjgEUG9uyrD6rKJiy2T+CTemAbX1VXw36+jcJnN5521mX1d7wSX8u8xI8kfBNgOG0CSHqg== +"@gitkraken/gitkraken-components@10.4.0": + version "10.4.0" + resolved "https://registry.yarnpkg.com/@gitkraken/gitkraken-components/-/gitkraken-components-10.4.0.tgz#d0e72cec942f415ae377657a6d4ec32e108bdbeb" + integrity sha512-Unuwoo2ENpILK+W0m1v9RWhG47LVp3I1OceM72FJ/rLlqiHyUCBT1VuTNZBiicq7aB4M1dmMjySiq5PXFXMy3w== dependencies: "@axosoft/react-virtualized" "9.22.3-gitkraken.3" classnames "2.5.1" @@ -5213,6 +5213,11 @@ markdown-it@^12.3.2: mdurl "^1.0.1" uc.micro "^1.0.5" +marked@12.0.2: + version "12.0.2" + resolved "https://registry.yarnpkg.com/marked/-/marked-12.0.2.tgz#b31578fe608b599944c69807b00f18edab84647e" + integrity sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q== + mdn-data@2.0.28: version "2.0.28" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.28.tgz#5ec48e7bef120654539069e1ae4ddc81ca490eba" @@ -7264,16 +7269,7 @@ streamx@^2.15.0, streamx@^2.18.0: optionalDependencies: bare-events "^2.2.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -7342,14 +7338,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -8107,16 +8096,7 @@ workerpool@6.2.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==