diff --git a/CHANGELOG.md b/CHANGELOG.md index be158f686f2ff..ca78760b29f13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added - Adds [Cursor](https://cursor.so) support — closes [#3222](https://github.com/gitkraken/vscode-gitlens/issues/3222) +- Adds monospace formatting in commit messages — closes [#2350](https://github.com/gitkraken/vscode-gitlens/issues/2350) ### Changed diff --git a/src/annotations/autolinks.ts b/src/annotations/autolinks.ts index 36c22ad41ebd2..784ba8c687cc1 100644 --- a/src/annotations/autolinks.ts +++ b/src/annotations/autolinks.ts @@ -15,8 +15,9 @@ import { debug } from '../system/decorators/log'; import { encodeUrl } from '../system/encoding'; import { join, map } from '../system/iterable'; import { Logger } from '../system/logger'; +import { escapeMarkdown } from '../system/markdown'; import type { MaybePausedResult } from '../system/promise'; -import { capitalize, encodeHtmlWeak, escapeMarkdown, escapeRegex, getSuperscript } from '../system/string'; +import { capitalize, encodeHtmlWeak, escapeRegex, getSuperscript } from '../system/string'; import { configuration } from '../system/vscode/configuration'; const emptyAutolinkMap = Object.freeze(new Map()); diff --git a/src/git/formatters/commitFormatter.ts b/src/git/formatters/commitFormatter.ts index fe8bd7bd43efd..cd861c4d51041 100644 --- a/src/git/formatters/commitFormatter.ts +++ b/src/git/formatters/commitFormatter.ts @@ -23,9 +23,10 @@ import { emojify } from '../../emojis'; import { arePlusFeaturesEnabled } from '../../plus/gk/utils'; import type { ShowInCommitGraphCommandArgs } from '../../plus/webviews/graph/protocol'; import { join, map } from '../../system/iterable'; +import { escapeMarkdown } from '../../system/markdown'; import { isPromise } from '../../system/promise'; import type { TokenOptions } from '../../system/string'; -import { encodeHtmlWeak, escapeMarkdown, getSuperscript } from '../../system/string'; +import { encodeHtmlWeak, getSuperscript } from '../../system/string'; import { configuration } from '../../system/vscode/configuration'; import type { ContactPresence } from '../../vsls/vsls'; import type { PreviousLineComparisonUrisResult } from '../gitProvider'; @@ -692,7 +693,7 @@ export class CommitFormatter extends Formatter { message = encodeHtmlWeak(message); } if (outputFormat === 'markdown') { - message = escapeMarkdown(message, { quoted: true }); + message = escapeMarkdown(message, { quoted: true, inlineBackticks: true }); } if (this._options.messageAutolinks) { diff --git a/src/git/remotes/github.ts b/src/git/remotes/github.ts index eccb4bba64901..37de4dd303dba 100644 --- a/src/git/remotes/github.ts +++ b/src/git/remotes/github.ts @@ -9,7 +9,8 @@ import type { Brand, Unbrand } from '../../system/brand'; import { fromNow } from '../../system/date'; import { memoize } from '../../system/decorators/memoize'; import { encodeUrl } from '../../system/encoding'; -import { equalsIgnoreCase, escapeMarkdown, unescapeMarkdown } from '../../system/string'; +import { escapeMarkdown, unescapeMarkdown } from '../../system/markdown'; +import { equalsIgnoreCase } from '../../system/string'; import { getIssueOrPullRequestMarkdownIcon } from '../models/issue'; import { isSha } from '../models/reference'; import type { Repository } from '../models/repository'; diff --git a/src/git/remotes/gitlab.ts b/src/git/remotes/gitlab.ts index 530386d485e50..6b397cff11d93 100644 --- a/src/git/remotes/gitlab.ts +++ b/src/git/remotes/gitlab.ts @@ -8,7 +8,8 @@ import type { Brand, Unbrand } from '../../system/brand'; import { fromNow } from '../../system/date'; import { memoize } from '../../system/decorators/memoize'; import { encodeUrl } from '../../system/encoding'; -import { equalsIgnoreCase, escapeMarkdown, unescapeMarkdown } from '../../system/string'; +import { escapeMarkdown, unescapeMarkdown } from '../../system/markdown'; +import { equalsIgnoreCase } from '../../system/string'; import { getIssueOrPullRequestMarkdownIcon } from '../models/issue'; import { isSha } from '../models/reference'; import type { Repository } from '../models/repository'; diff --git a/src/system/markdown.ts b/src/system/markdown.ts new file mode 100644 index 0000000000000..4626cdcd5039e --- /dev/null +++ b/src/system/markdown.ts @@ -0,0 +1,87 @@ +const escapeMarkdownRegex = /[\\*_{}[\]()#+\-.!]/g; +const unescapeMarkdownRegex = /\\([\\`*_{}[\]()#+\-.!])/g; + +const escapeMarkdownHeaderRegex = /^===/gm; +const unescapeMarkdownHeaderRegex = /^\u200b===/gm; + +// const sampleMarkdown = '## message `not code` *not important* _no underline_ \n> don\'t quote me \n- don\'t list me \n+ don\'t list me \n1. don\'t list me \nnot h1 \n=== \nnot h2 \n---\n***\n---\n___'; +const markdownQuotedRegex = /\r?\n/g; +const markdownBacktickRegex = /`/g; + +export function escapeMarkdown(s: string, options: { quoted?: boolean; inlineBackticks?: boolean } = {}): string { + s = s + // Escape markdown + .replace(escapeMarkdownRegex, '\\$&') + // Escape markdown header (since the above regex won't match it) + .replace(escapeMarkdownHeaderRegex, '\u200b==='); + + if (options.inlineBackticks) { + s = escapeMarkdownCodeBlocks(s); + } else { + s = s.replace(markdownBacktickRegex, '\\$&'); + } + if (!options.quoted) return s; + + // Keep under the same block-quote but with line breaks + return s.trim().replace(markdownQuotedRegex, '\t\\\n> '); +} + +/** + * escapes markdown code blocks + */ +export function escapeMarkdownCodeBlocks(s: string) { + const tripleBackticks = '```'; + const escapedTripleBackticks = '\\`\\`\\`'; + + let result = ''; + let allowed = true; + let quotesOpened = false; + let buffer = ''; + + for (let i = 0; i < s.length; i += 1) { + const char = s[i]; + const chain = s.substring(i, i + 3); + if (char === '\n' && quotesOpened) { + allowed = false; + } + if (chain === tripleBackticks) { + if (quotesOpened) { + quotesOpened = false; + if (allowed) { + result += `${tripleBackticks}${buffer}${tripleBackticks}`; + } else { + result += `${escapedTripleBackticks}${buffer}${escapedTripleBackticks}`; + allowed = true; + } + buffer = ''; + } else { + quotesOpened = true; + } + // skip chain + i += 2; + continue; + } + if (quotesOpened) { + buffer += char; + } else { + result += char; + } + } + + if (quotesOpened) { + // Handle unclosed code block + result += allowed ? tripleBackticks + buffer : escapedTripleBackticks + buffer; + } + + return result; +} + +export function unescapeMarkdown(s: string): string { + return ( + s + // Unescape markdown + .replace(unescapeMarkdownRegex, '$1') + // Unescape markdown header + .replace(unescapeMarkdownHeaderRegex, '===') + ); +} diff --git a/src/system/string.ts b/src/system/string.ts index 7a21e6807db68..9dce2a08a6997 100644 --- a/src/system/string.ts +++ b/src/system/string.ts @@ -130,38 +130,6 @@ export function encodeHtmlWeak(s: string | undefined): string | undefined { }); } -const escapeMarkdownRegex = /[\\`*_{}[\]()#+\-.!]/g; -const unescapeMarkdownRegex = /\\([\\`*_{}[\]()#+\-.!])/g; - -const escapeMarkdownHeaderRegex = /^===/gm; -const unescapeMarkdownHeaderRegex = /^\u200b===/gm; - -// const sampleMarkdown = '## message `not code` *not important* _no underline_ \n> don\'t quote me \n- don\'t list me \n+ don\'t list me \n1. don\'t list me \nnot h1 \n=== \nnot h2 \n---\n***\n---\n___'; -const markdownQuotedRegex = /\r?\n/g; - -export function escapeMarkdown(s: string, options: { quoted?: boolean } = {}): string { - s = s - // Escape markdown - .replace(escapeMarkdownRegex, '\\$&') - // Escape markdown header (since the above regex won't match it) - .replace(escapeMarkdownHeaderRegex, '\u200b==='); - - if (!options.quoted) return s; - - // Keep under the same block-quote but with line breaks - return s.trim().replace(markdownQuotedRegex, '\t\\\n> '); -} - -export function unescapeMarkdown(s: string): string { - return ( - s - // Unescape markdown - .replace(unescapeMarkdownRegex, '$1') - // Unescape markdown header - .replace(unescapeMarkdownHeaderRegex, '===') - ); -} - export function escapeRegex(s: string) { return s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); } diff --git a/src/webviews/apps/plus/graph/GraphWrapper.tsx b/src/webviews/apps/plus/graph/GraphWrapper.tsx index 23f0dece3cc3d..f216531b816a3 100644 --- a/src/webviews/apps/plus/graph/GraphWrapper.tsx +++ b/src/webviews/apps/plus/graph/GraphWrapper.tsx @@ -71,6 +71,7 @@ import { createCommandLink } from '../../shared/commands'; import { GlButton } from '../../shared/components/button.react'; import { CodeIcon } from '../../shared/components/code-icon.react'; import { GlConnect } from '../../shared/components/integrations/connect.react'; +import { GlMarkdown } from '../../shared/components/markdown/markdown.react'; import { MenuDivider, MenuItem, MenuLabel, MenuList } from '../../shared/components/menu/react'; import { PopMenu } from '../../shared/components/overlays/pop-menu/react'; import { GlPopover } from '../../shared/components/overlays/popover.react'; @@ -1604,6 +1605,8 @@ export function GraphWrapper({ avatarUrlByEmail={avatars} columnsSettings={columns} contexts={context} + // @ts-expect-error returnType of formatCommitMessage callback expects to be string, but it works fine with react element + formatCommitMessage={e => } cssVariables={styleProps?.cssVariables} dimMergeCommits={graphConfig?.dimMergeCommits} downstreamsByUpstream={downstreams} diff --git a/src/webviews/apps/plus/graph/hover/graphHover.ts b/src/webviews/apps/plus/graph/hover/graphHover.ts index 6f6a5042566bf..f2a55b671b0fb 100644 --- a/src/webviews/apps/plus/graph/hover/graphHover.ts +++ b/src/webviews/apps/plus/graph/hover/graphHover.ts @@ -8,8 +8,8 @@ import { debounce } from '../../../../../system/function'; import { getSettledValue, isPromise } from '../../../../../system/promise'; import { GlElement } from '../../../shared/components/element'; import type { GlPopover } from '../../../shared/components/overlays/popover.react'; +import '../../../shared/components/markdown/markdown'; import '../../../shared/components/overlays/popover'; -import './markdown'; declare global { interface HTMLElementTagNameMap { diff --git a/src/webviews/apps/shared/components/markdown/markdown.react.tsx b/src/webviews/apps/shared/components/markdown/markdown.react.tsx new file mode 100644 index 0000000000000..d1d55fbd7b7d6 --- /dev/null +++ b/src/webviews/apps/shared/components/markdown/markdown.react.tsx @@ -0,0 +1,5 @@ +import { reactWrapper } from '../helpers/react-wrapper'; +import { GlMarkdown as GlMarkdownWC } from './markdown'; + +export interface GlMarkdown extends GlMarkdownWC {} +export const GlMarkdown = reactWrapper(GlMarkdownWC, { tagName: 'gl-markdown' }); diff --git a/src/webviews/apps/plus/graph/hover/markdown.ts b/src/webviews/apps/shared/components/markdown/markdown.ts similarity index 99% rename from src/webviews/apps/plus/graph/hover/markdown.ts rename to src/webviews/apps/shared/components/markdown/markdown.ts index 39f5cac00739b..a8756e517c879 100644 --- a/src/webviews/apps/plus/graph/hover/markdown.ts +++ b/src/webviews/apps/shared/components/markdown/markdown.ts @@ -7,7 +7,7 @@ import { marked } from 'marked'; import type { ThemeIcon } from 'vscode'; @customElement('gl-markdown') -export class GLMarkdown extends LitElement { +export class GlMarkdown extends LitElement { static override styles = css` a, a code {