diff --git a/web/src/lib/components/comment/Comment.svelte b/web/src/lib/components/comment/Comment.svelte index 0b87d9f0d..b1ce88a2f 100644 --- a/web/src/lib/components/comment/Comment.svelte +++ b/web/src/lib/components/comment/Comment.svelte @@ -39,6 +39,7 @@ import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; import Reactions from "$lib/components/reaction/Reactions.svelte"; import { enhanceCodeblocks } from "$lib/markup/highlight"; + import { codeblockToolbars } from "$lib/components/ui/codeblockToolbars"; import type { ReactionGroup } from "$lib/components/reaction/reactions"; interface Props { @@ -97,7 +98,11 @@ {#if editor}
{@render editor()}
{:else if bodyHtml} -
+
{@html bodyHtml}
diff --git a/web/src/lib/components/repo/pulls/PullReviewComment.svelte b/web/src/lib/components/repo/pulls/PullReviewComment.svelte index 02983de02..eab3e3378 100644 --- a/web/src/lib/components/repo/pulls/PullReviewComment.svelte +++ b/web/src/lib/components/repo/pulls/PullReviewComment.svelte @@ -9,6 +9,7 @@ import User from "$lib/components/ui/User.svelte"; import type { MarkupContext } from "$lib/markup"; import { enhanceCodeblocks } from "$lib/markup/highlight"; + import { codeblockToolbars } from "$lib/components/ui/codeblockToolbars"; import { renderMarkup } from "$lib/markup"; import PullReviewCommentEditor from "$lib/components/repo/pulls/PullReviewCommentEditor.svelte"; import ProfilePopover from "$lib/components/profile/ProfilePopover.svelte"; @@ -101,6 +102,7 @@
{@html comment.bodyHtml} diff --git a/web/src/lib/components/repo/tickets/TicketBody.svelte b/web/src/lib/components/repo/tickets/TicketBody.svelte index 6355bbe94..aeae0eb9f 100644 --- a/web/src/lib/components/repo/tickets/TicketBody.svelte +++ b/web/src/lib/components/repo/tickets/TicketBody.svelte @@ -1,5 +1,6 @@ {#if bodyHtml} - -
{@html bodyHtml}
+
+ + {@html bodyHtml} +
{:else if body}
{body}
{/if} diff --git a/web/src/lib/components/ui/CodeBlockToolbar.svelte b/web/src/lib/components/ui/CodeBlockToolbar.svelte new file mode 100644 index 000000000..86d6484e6 --- /dev/null +++ b/web/src/lib/components/ui/CodeBlockToolbar.svelte @@ -0,0 +1,70 @@ + + +{#snippet toggleText()}{toggleLabel}{/snippet} + +
+
+
+
+ +
+
diff --git a/web/src/lib/components/ui/MarkdownEditor.svelte b/web/src/lib/components/ui/MarkdownEditor.svelte index 23acdcb3d..5d0c2992b 100644 --- a/web/src/lib/components/ui/MarkdownEditor.svelte +++ b/web/src/lib/components/ui/MarkdownEditor.svelte @@ -124,6 +124,7 @@ } from "$lib/components/ui/markdownScroll"; import { renderMarkup, type MarkupContext } from "$lib/markup"; import { enhanceCodeblocks } from "$lib/markup/highlight"; + import { codeblockToolbars } from "$lib/components/ui/codeblockToolbars"; interface Tool { label: string; @@ -449,6 +450,7 @@ class="markup px-2.5 py-xs" data-preview-ready use:enhanceCodeblocks={html} + use:codeblockToolbars={html} {@attach alignPreview} > diff --git a/web/src/lib/components/ui/codeblockToolbars.ts b/web/src/lib/components/ui/codeblockToolbars.ts new file mode 100644 index 000000000..70d7170b8 --- /dev/null +++ b/web/src/lib/components/ui/codeblockToolbars.ts @@ -0,0 +1,36 @@ +import { mount, unmount } from "svelte"; +import type { Action } from "svelte/action"; +import CodeBlockToolbar from "$lib/components/ui/CodeBlockToolbar.svelte"; + +export const codeblockToolbars: Action = (node) => { + let toolbars: ReturnType[] = []; + + const clear = () => { + for (const toolbar of toolbars) void unmount(toolbar); + toolbars = []; + }; + + const run = () => { + clear(); + for (const block of node.querySelectorAll("[data-codeblock]")) { + const target = block.querySelector(":scope > [data-codeblock-toolbar]"); + const pre = block.querySelector(":scope > pre"); + if (!target || !pre) continue; + block.removeAttribute("data-expanded"); + target.replaceChildren(); + toolbars.push( + mount(CodeBlockToolbar, { + target, + props: { + block, + code: pre.textContent ?? "", + lines: Number(block.dataset.codeblock) + } + }) + ); + } + }; + + run(); + return { update: run, destroy: clear }; +}; diff --git a/web/src/lib/markup/collapse.test.ts b/web/src/lib/markup/collapse.test.ts new file mode 100644 index 000000000..e3c3a0695 --- /dev/null +++ b/web/src/lib/markup/collapse.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { COLLAPSED_LINES, collapseCodeblocks } from "$lib/markup/collapse"; +import { renderDocument, renderMarkup } from "$lib/markup/render"; +import type { MarkupContext } from "$lib/markup/paths"; + +const ctx: MarkupContext = { repo: "ada.test/infra", ref: "main" }; + +const fence = (lines: number): string => + ["```ts", ...Array.from({ length: lines }, (_, i) => `const x${i} = ${i};`), "```"].join("\n"); + +describe("collapseCodeblocks", () => { + it("leaves short blocks alone", () => { + const html = `
${"a\n".repeat(COLLAPSED_LINES - 1)}
`; + expect(collapseCodeblocks(html)).toBe(html); + }); + + it("wraps tall blocks with their line count and a toolbar slot", () => { + const html = `

x

${"a\n".repeat(COLLAPSED_LINES)}
`; + expect(collapseCodeblocks(html)).toBe( + `

x

${"a\n".repeat(COLLAPSED_LINES)}
` + ); + }); + + it("counts a last line without a trailing newline", () => { + const html = `
${"a\n".repeat(COLLAPSED_LINES - 1)}a
`; + expect(collapseCodeblocks(html)).toContain(`data-codeblock="${COLLAPSED_LINES}"`); + }); + + it("wraps only the innermost of nested pre elements", () => { + const inner = `
${"a\n".repeat(COLLAPSED_LINES)}
`; + const out = collapseCodeblocks(`
x${inner}
`); + expect(out.match(/data-codeblock=/g)).toHaveLength(1); + expect(out.startsWith("
x
{ + it("collapses tall code in bodies", async () => { + expect(await renderMarkup(fence(40), ctx)).toContain('data-codeblock="40"'); + }); + + it("keeps tall code in documents expanded", async () => { + expect(await renderDocument("README.md", fence(40), ctx)).not.toContain("data-codeblock"); + }); + + it("drops a data-codeblock written in the source", async () => { + expect(await renderMarkup('
x
', ctx)).not.toContain( + "data-codeblock" + ); + }); +}); diff --git a/web/src/lib/markup/collapse.ts b/web/src/lib/markup/collapse.ts new file mode 100644 index 000000000..a3d44ff29 --- /dev/null +++ b/web/src/lib/markup/collapse.ts @@ -0,0 +1,17 @@ +export const COLLAPSED_LINES = 28; + +const PRE = /]*)?>(?:(?!])[\s\S])*?<\/pre>/g; + +const lineCount = (pre: string): number => + pre + .replace(/<[^>]*>/g, "") + .replace(/\n$/, "") + .split("\n").length; + +export const collapseCodeblocks = (html: string): string => + html.replace(PRE, (pre) => { + const lines = lineCount(pre); + return lines < COLLAPSED_LINES + ? pre + : `
${pre}
`; + }); diff --git a/web/src/lib/markup/markdown.ts b/web/src/lib/markup/markdown.ts index a9984f983..0001419d0 100644 --- a/web/src/lib/markup/markdown.ts +++ b/web/src/lib/markup/markdown.ts @@ -4,6 +4,7 @@ import { tasklist } from "@mdit/plugin-tasklist"; import MarkdownIt from "markdown-it"; import anchor from "markdown-it-anchor"; import { full as emoji } from "markdown-it-emoji"; +import { collapseCodeblocks } from "$lib/markup/collapse"; import { highlightCodeblocks } from "$lib/markup/highlight"; import { sanitizeMarkup } from "$lib/markup/sanitize"; import type { MarkupContext } from "$lib/markup/paths"; @@ -137,5 +138,6 @@ const rendererFor = (host: string, lines: boolean): MarkdownIt => { export const renderMarkdown = async (source: string, ctx: MarkupContext): Promise => { const raw = rendererFor(ctx.host ?? "", !!ctx.sourceLines).render(source); // sanitize before highlighting so the allowlist doesn't have to allow spans - return highlightCodeblocks(sanitizeMarkup(raw, ctx)); + const clean = sanitizeMarkup(raw, ctx); + return highlightCodeblocks(ctx.collapseCode ? collapseCodeblocks(clean) : clean); }; diff --git a/web/src/lib/markup/paths.ts b/web/src/lib/markup/paths.ts index b957333a9..4604a60a3 100644 --- a/web/src/lib/markup/paths.ts +++ b/web/src/lib/markup/paths.ts @@ -8,6 +8,7 @@ export interface MarkupContext { host?: string; camo?: boolean; sourceLines?: boolean; + collapseCode?: boolean; } const ABSOLUTE = /^[a-z][a-z0-9+.-]*:|^\/\//i; diff --git a/web/src/lib/markup/render.ts b/web/src/lib/markup/render.ts index fc5717551..6adf707c5 100644 --- a/web/src/lib/markup/render.ts +++ b/web/src/lib/markup/render.ts @@ -24,6 +24,7 @@ const keyOf = (filename: string, contents: string, ctx: MarkupContext): string = ctx.ref, ctx.dir ?? "", String(!!ctx.sourceLines), + String(!!ctx.collapseCode), filename ].join("\n") ), @@ -63,5 +64,7 @@ export const renderDocument = async ( // like renderDocument, but for content that is always markdown (issue and // comment bodies) rather than a repo file with an extension to sniff -export const renderMarkup = (contents: string, ctx: MarkupContext): Promise => - render(keyOf("", contents, ctx), contents, ctx); +export const renderMarkup = (contents: string, ctx: MarkupContext): Promise => { + const body = { ...ctx, collapseCode: true }; + return render(keyOf("", contents, body), contents, body); +}; diff --git a/web/src/markup.css b/web/src/markup.css index 95dcfa38d..5a27b2c18 100644 --- a/web/src/markup.css +++ b/web/src/markup.css @@ -121,6 +121,26 @@ @apply bg-transparent p-none font-mono text-[0.875em] leading-[calc(20/13)]; } + .markup :where([data-codeblock]) { + @apply my-sm flex max-h-150 flex-col overflow-hidden rounded border border-border-default; + } + + .markup :where([data-codeblock][data-expanded]) { + @apply max-h-[none]; + } + + .markup :where([data-codeblock]) > pre { + @apply m-none min-h-0 flex-1 overflow-hidden rounded-none border-none; + } + + .markup :where([data-codeblock][data-expanded]) > pre { + @apply overflow-auto; + } + + .markup :where([data-codeblock-toolbar]) { + @apply box-content min-h-8 shrink-0 border-b border-border-default bg-background-default p-xs; + } + .markup kbd { @apply rounded border border-border-default bg-background-subtle px-1.5 py-3xs font-mono text-[0.875em]; }