fix(app): keep line breaks and indentation in copied code (#2935)

* fix(app): keep line breaks and indentation in copied code

#2930 strips the pre/code tags from a partly selected code block, which
is right for the Markdown syntax but leaves the code itself going
through Turndown as prose. Turndown collapses whitespace, so two
selected lines arrive as one, the indentation is gone, and
Markdown-significant characters come back escaped.

Divert a partial code selection before that: copy the selected text
verbatim. Fully selected regions are untouched and still produce a
fence, which is what hasSelectedAllContents was already deciding.

The html half needs pre for anything multi-line -- HTML collapses
newlines too, so a rich paste target would lose the lines the same way.
A single line stays undecorated, matching the rule that a partial
selection does not carry its container's formatting.

Two trailing-newline fixes ride along, since both make a paste run code
nobody asked to run. Newlines between rendered lines are their own text
nodes, so a selection that overshoots the end of a line carries one;
strip it. The block's Copy button was handed the raw fence body, which
ends in a newline; hand it the rendered text instead.

* fix(app): strip every trailing blank line from copied code

The Copy button stripped one trailing newline, so a fence whose author
left a blank line before the closing delimiter still put an executable
newline on the clipboard. Pasting that into a terminal runs the last
line, which is the thing the strip existed to prevent.

Both copy paths now share one regex in markup.ts and remove every
trailing break, plus any indentation that followed the last one.

Reported by Greptile on #2935.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(app): infer code copy format from selection boundary

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Christoph Leiter
2026-08-06 11:27:38 +00:00
committed by GitHub
co-authored by Claude Opus 5 Mohamed Boudra
parent 2298e9efdf
commit e99eb7de3d
7 changed files with 528 additions and 14 deletions
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
createCodeClipboardContent,
createMarkdownClipboardContent,
type MarkdownClipboardEnvironment,
type RichClipboardWriter,
@@ -90,6 +91,41 @@ describe("createMarkdownClipboardContent", () => {
});
});
describe("createCodeClipboardContent", () => {
it("keeps the code verbatim and escapes it for the html half", () => {
const content = createCodeClipboardContent('if (a < b && c > "d") {', { block: false });
expect(content.plainText).toBe('if (a < b && c > "d") {');
expect(content.html).toContain("if (a &lt; b &amp;&amp; c &gt;");
});
it("leaves a single line undecorated but wraps multi-line code in pre", () => {
expect(createCodeClipboardContent("one", { block: false }).html).not.toContain("<code>");
expect(createCodeClipboardContent("one\ntwo", { block: true }).html).toContain(
"<pre><code>one\ntwo</code></pre>",
);
});
it("adds a language class only when the info string carries one", () => {
expect(createCodeClipboardContent("x\ny", { block: true, language: " " }).html).toContain(
"<pre><code>",
);
expect(createCodeClipboardContent("x\ny", { block: true, language: "ts" }).html).toContain(
'<pre><code class="language-ts">',
);
});
it("escapes an info string that would break out of the class attribute", () => {
const content = createCodeClipboardContent("x\ny", {
block: true,
language: 'ts" onload="alert(1)',
});
expect(content.html).toContain('class="language-ts&quot; onload=&quot;alert(1)"');
expect(content.html).not.toContain('onload="alert(1)"');
});
});
describe("writeMarkdownToRichClipboard", () => {
it("writes plain text and html when a rich clipboard writer is available", async () => {
const clipboard = createRecordingClipboard();
+39
View File
@@ -26,6 +26,45 @@ export function createMarkdownClipboardContent(markdown: string): MarkdownClipbo
};
}
export interface CodeClipboardOptions {
language?: string | null;
/** Wrap in `pre`/`code` so the line structure survives. */
block: boolean;
}
/**
* Clipboard payload for a selection that lies inside rendered code.
*
* `plainText` is the code exactly as it was selected, so it pastes straight into a
* shell or an editor.
*
* The html half stays undecorated for a single line, matching the rule that a partial
* selection does not carry its container's formatting. Multi-line code is the
* exception and needs `pre`: HTML collapses newlines, so anything else loses the line
* structure in a rich target exactly the way the plain half would.
*
* The fence info string is agent-authored, so the language is escaped as an attribute
* value rather than interpolated raw.
*/
export function createCodeClipboardContent(
code: string,
options: CodeClipboardOptions,
): MarkdownClipboardContent {
const escapedCode = markdownRenderer.utils.escapeHtml(code);
if (!options.block) {
return { plainText: code, html: `<meta charset="utf-8">${escapedCode}` };
}
const language = options.language?.trim();
const className = language
? ` class="language-${markdownRenderer.utils.escapeHtml(language)}"`
: "";
return {
plainText: code,
html: `<meta charset="utf-8"><pre><code${className}>${escapedCode}</code></pre>`,
};
}
export async function writeMarkdownToRichClipboard(
markdown: string,
environment: MarkdownClipboardEnvironment,