Files
paseo/packages/app/src/assistant-selection-copy/content.browser.test.ts
T
e99eb7de3d 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>
2026-08-06 11:27:38 +00:00

461 lines
17 KiB
TypeScript

import { afterEach, describe, expect, it } from "vitest";
import { createAssistantSelectionClipboardContent } from "./content.web";
const fixture = `
<div data-testid="assistant-message">
<div data-paseo-markdown-tag="p">Prefix <span data-paseo-markdown-tag="strong">bold text</span> and <span data-paseo-markdown-tag="code">inline code</span> suffix.</div>
<div data-paseo-markdown-tag="ul">
<div data-paseo-markdown-tag="li"><span data-paseo-markdown-ignore="true">•</span><div><span>First bullet text</span></div></div>
<div data-paseo-markdown-tag="li"><span data-paseo-markdown-ignore="true">•</span><div><span>Second bullet text</span></div></div>
</div>
<div data-paseo-markdown-tag="pre" data-paseo-markdown-language="ts"><span data-paseo-markdown-tag="code">const answer = true;</span></div>
</div>
`;
function mountFixture(): HTMLElement {
const host = document.createElement("div");
host.innerHTML = fixture;
document.body.append(host);
const message = host.querySelector<HTMLElement>('[data-testid="assistant-message"]');
if (!message) {
throw new Error("Expected assistant message fixture");
}
return message;
}
function fixtureElement<T extends Element = HTMLElement>(
root: ParentNode,
selector: string,
index = 0,
): T {
const element = root.querySelectorAll<T>(selector).item(index);
if (!element) {
throw new Error(`Expected fixture element ${selector} at index ${index}`);
}
return element;
}
function textNode(element: Element): Text {
const node = element.firstChild;
if (!(node instanceof Text)) {
throw new Error("Expected text node");
}
return node;
}
function selectText(element: Element, start: number, end: number): Selection {
const node = textNode(element);
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, end);
const selection = window.getSelection();
if (!selection) {
throw new Error("Expected browser selection");
}
selection.removeAllRanges();
selection.addRange(range);
return selection;
}
function selectRange(
startElement: Element,
startOffset: number,
endElement: Element,
endOffset: number,
): Selection {
const range = document.createRange();
range.setStart(textNode(startElement), startOffset);
range.setEnd(textNode(endElement), endOffset);
const selection = window.getSelection();
if (!selection) {
throw new Error("Expected browser selection");
}
selection.removeAllRanges();
selection.addRange(range);
return selection;
}
function selectNodeContents(element: Element): Selection {
const range = document.createRange();
range.selectNodeContents(element);
const selection = window.getSelection();
if (!selection) {
throw new Error("Expected browser selection");
}
selection.removeAllRanges();
selection.addRange(range);
return selection;
}
function copiedMarkdown(selection: Selection): string | null {
return createAssistantSelectionClipboardContent(selection)?.plainText ?? null;
}
afterEach(() => {
window.getSelection()?.removeAllRanges();
document.body.replaceChildren();
});
describe("assistant selection copy ranges", () => {
it("does not handle absent, collapsed, or non-assistant selections", () => {
expect(createAssistantSelectionClipboardContent(null)).toBeNull();
const message = mountFixture();
const strong = fixtureElement(message, '[data-paseo-markdown-tag="strong"]');
expect(copiedMarkdown(selectText(strong, 2, 2))).toBeNull();
const outside = document.createElement("span");
outside.textContent = "outside";
document.body.append(outside);
expect(copiedMarkdown(selectText(outside, 0, 7))).toBeNull();
});
it("does not replace the browser clipboard for a range spanning assistant messages", () => {
const firstMessage = mountFixture();
const secondMessage = mountFixture();
const firstText = fixtureElement(firstMessage, '[data-paseo-markdown-tag="strong"]');
const secondText = fixtureElement(secondMessage, '[data-paseo-markdown-tag="strong"]');
expect(copiedMarkdown(selectRange(firstText, 0, secondText, 4))).toBeNull();
});
it("preserves all structure when the complete assistant message is selected", () => {
const message = mountFixture();
const content = createAssistantSelectionClipboardContent(selectNodeContents(message));
expect(content?.plainText).toBe(
[
"Prefix **bold text** and `inline code` suffix.",
"",
"- First bullet text",
"- Second bullet text",
"",
"```ts",
"const answer = true;",
"```",
].join("\n"),
);
expect(content?.html).not.toContain("<ul>");
expect(content?.html).not.toContain("<li>");
expect(content?.html).toContain("<div>- First bullet text</div>");
expect(content?.html).toContain("<div>- Second bullet text</div>");
});
it.each([
{ tag: "strong", range: [1, 5], expected: "old", forbiddenHtml: "<strong>" },
{ tag: "code", range: [2, 8], expected: "line c", forbiddenHtml: "<code>" },
])(
"copies a partial $tag range without expanding to its delimiters",
({ tag, range, expected, forbiddenHtml }) => {
const message = mountFixture();
const element = fixtureElement(message, `[data-paseo-markdown-tag="${tag}"]`);
const content = createAssistantSelectionClipboardContent(
selectText(element, range[0], range[1]),
);
expect(content?.plainText).toBe(expected);
expect(content?.html).not.toContain(forbiddenHtml);
},
);
it("retains strong delimiters when its complete contents are selected", () => {
const message = mountFixture();
const element = fixtureElement(message, '[data-paseo-markdown-tag="strong"]');
const content = createAssistantSelectionClipboardContent(
selectText(element, 0, textNode(element).length),
);
expect(content?.plainText).toBe("**bold text**");
expect(content?.html).toContain("<strong>");
});
it("copies complete inline code without delimiters when the selection stays inside", () => {
const message = mountFixture();
const element = fixtureElement(message, '[data-paseo-markdown-tag="code"]');
const content = createAssistantSelectionClipboardContent(
selectText(element, 0, textNode(element).length),
);
expect(content?.plainText).toBe("inline code");
expect(content?.html).not.toContain("<code>");
});
it("keeps a complete inline node but drops formatting from a partial node at the other edge", () => {
const message = mountFixture();
const strong = fixtureElement(message, '[data-paseo-markdown-tag="strong"]');
const code = fixtureElement(message, '[data-paseo-markdown-tag="code"]');
expect(copiedMarkdown(selectRange(strong, 0, code, 6))).toBe("**bold text** and inline");
});
it("copies list-item text without inventing a bullet", () => {
const message = mountFixture();
const itemText = fixtureElement(message, '[data-paseo-markdown-tag="li"] div span');
const content = createAssistantSelectionClipboardContent(
selectText(itemText, 0, textNode(itemText).length),
);
expect(content?.plainText).toBe("First bullet text");
expect(content?.html).toContain("<p>First bullet text</p>");
expect(content?.html).not.toContain("<li>");
});
it("retains a bullet when the range includes the marker and the complete item", () => {
const message = mountFixture();
const item = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
expect(copiedMarkdown(selectNodeContents(item))).toBe("- First bullet text");
});
it("preserves paragraph breaks when rich HTML flattens a loose list item", () => {
const message = mountFixture();
const item = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
item.replaceChildren();
item.insertAdjacentHTML(
"beforeend",
'<div data-paseo-markdown-tag="p">First paragraph</div><div data-paseo-markdown-tag="p">Second paragraph</div>',
);
const content = createAssistantSelectionClipboardContent(selectNodeContents(item));
expect(content?.plainText).toBe("- First paragraph\n\n Second paragraph");
expect(content?.html).toMatch(/<div>-\s*First paragraph\s*<br><br>Second paragraph\s*<\/div>/);
});
it("omits a partial leading bullet and retains the marker crossed before the trailing item", () => {
const message = mountFixture();
const selector = '[data-paseo-markdown-tag="li"] div span';
const first = fixtureElement(message, selector);
const second = fixtureElement(message, selector, 1);
expect(copiedMarkdown(selectRange(first, 6, second, textNode(second).length))).toBe(
"bullet text\n\n- Second bullet text",
);
});
it("copies part of a fenced code block without adding an inline or fenced delimiter", () => {
const message = mountFixture();
const blockCode = fixtureElement(
message,
'[data-paseo-markdown-tag="pre"] [data-paseo-markdown-tag="code"]',
);
const content = createAssistantSelectionClipboardContent(selectText(blockCode, 6, 12));
expect(content?.plainText).toBe("answer");
expect(content?.html).not.toContain("<code>");
expect(content?.html).not.toContain("<pre>");
});
it("copies code without a fence when every character is selected inside the block", () => {
const message = mountFixture();
const blockCode = fixtureElement(
message,
'[data-paseo-markdown-tag="pre"] [data-paseo-markdown-tag="code"]',
);
expect(copiedMarkdown(selectText(blockCode, 0, textNode(blockCode).length))).toBe(
"const answer = true;",
);
});
});
/**
* The fixture above holds a code block in one text node. The real renderer splits a
* highlighted line across one text node per syntax token and emits each newline as a
* node of its own (`highlighted-code-block.tsx`), and it puts the hover Copy button
* inside the `pre`. Line breaks and indentation only survive if the code skips
* Turndown, so those cases need a fixture shaped like the real thing.
*/
function highlightedFixture(language: string | null): string {
const languageAttribute =
language === null ? "" : ` data-paseo-markdown-language="${escapeAttribute(language)}"`;
return [
'<div data-testid="assistant-message">',
`<div data-paseo-markdown-tag="pre"${languageAttribute}>`,
'<span data-paseo-markdown-tag="code">',
"<span>const</span><span> answer</span><span> = 1;</span>",
"<span>\n</span>",
"<span> if</span><span> (answer)</span><span> {</span>",
"<span>\n</span>",
"<span> doThing();</span>",
"</span>",
'<div data-paseo-markdown-ignore="true"><span>Copy</span></div>',
"</div>",
'<div data-paseo-markdown-tag="p"><span>After the block.</span></div>',
"</div>",
].join("");
}
function escapeAttribute(value: string): string {
const holder = document.createElement("div");
holder.setAttribute("data-value", value);
return holder.outerHTML.slice('<div data-value="'.length, -"></div>".length);
}
function mountHighlighted(language: string | null = "typescript"): HTMLElement {
const host = document.createElement("div");
host.innerHTML = highlightedFixture(language);
document.body.append(host);
const message = host.querySelector<HTMLElement>('[data-testid="assistant-message"]');
if (!message) {
throw new Error("Expected assistant message fixture");
}
return message;
}
/** The single text node whose content is exactly `text`. */
function tokenText(root: Node, text: string): Text {
const matches = allTextNodes(root, text);
if (matches.length !== 1) {
throw new Error(`Expected exactly one text node "${text}", found ${matches.length}`);
}
return matches[0];
}
function allTextNodes(root: Node, text: string): Text[] {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const matches: Text[] = [];
while (walker.nextNode()) {
if (walker.currentNode.textContent === text) {
matches.push(walker.currentNode as Text);
}
}
return matches;
}
/** The standalone `\n` node that ends rendered code line `index`. */
function lineBreak(root: Node, index: number): Text {
const breaks = allTextNodes(root, "\n");
const node = breaks[index];
if (!node) {
throw new Error(`Expected a line break at index ${index}, found ${breaks.length}`);
}
return node;
}
function copyBetween(start: [Text, number], end: [Text, number]) {
const range = document.createRange();
range.setStart(start[0], start[1]);
range.setEnd(end[0], end[1]);
const selection = window.getSelection();
if (!selection) {
throw new Error("Expected browser selection");
}
selection.removeAllRanges();
selection.addRange(range);
return createAssistantSelectionClipboardContent(selection);
}
/** Select from the start of one text node to the end of another. */
function copyAcross(root: Node, startText: string, endText: string) {
return copyBetween([tokenText(root, startText), 0], [tokenText(root, endText), endText.length]);
}
describe("assistant selection copy inside highlighted code", () => {
it("keeps the line break and the indentation of a partial multi-line selection", () => {
const message = mountHighlighted();
const content = copyAcross(message, "const", " {");
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {");
});
it("keeps multi-line code as a block in the html half so the lines survive", () => {
const message = mountHighlighted();
const content = copyAcross(message, "const", " {");
expect(content?.html).toBe(
'<meta charset="utf-8"><pre><code class="language-typescript">const answer = 1;\n if (answer) {</code></pre>',
);
});
it("does not escape Markdown characters inside copied code", () => {
const message = mountHighlighted();
const content = copyBetween([tokenText(message, " = 1;"), 1], [tokenText(message, " = 1;"), 5]);
expect(content?.plainText).toBe("= 1;");
});
it("handles a common ancestor that is an element rather than a text node", () => {
const message = mountHighlighted();
// Spanning two token spans makes `commonAncestorContainer` the `code` element.
const content = copyBetween(
[tokenText(message, "const"), 1],
[tokenText(message, " answer"), 4],
);
expect(content?.plainText).toBe("onst ans");
});
it("drops a trailing line break swept in from the end of a line", () => {
const message = mountHighlighted();
const content = copyBetween([tokenText(message, " = 1;"), 1], [lineBreak(message, 0), 1]);
expect(content?.plainText).toBe("= 1;");
});
it("drops a trailing line break together with the indentation after it", () => {
const message = mountHighlighted();
const content = copyBetween([tokenText(message, "const"), 0], [tokenText(message, " if"), 2]);
expect(content?.plainText).toBe("const answer = 1;");
});
it("keeps line breaks that are not at the end of the selection", () => {
const message = mountHighlighted();
const content = copyBetween([tokenText(message, "const"), 0], [tokenText(message, " if"), 4]);
expect(content?.plainText).toBe("const answer = 1;\n if");
});
it("leaves the hover copy button out of the copied code", () => {
const message = mountHighlighted();
const content = copyAcross(message, " answer", "Copy");
expect(content?.plainText).toBe(" answer = 1;\n if (answer) {\n doThing();");
expect(content?.plainText).not.toContain("Copy");
});
it("does not treat a selection inside the copy button as code", () => {
const message = mountHighlighted();
expect(copyAcross(message, "Copy", "Copy")).toBeNull();
});
it("omits the language class when the fence carries no info string", () => {
const message = mountHighlighted(null);
const content = copyAcross(message, "const", " {");
expect(content?.html).toBe(
'<meta charset="utf-8"><pre><code>const answer = 1;\n if (answer) {</code></pre>',
);
});
it("escapes a fence info string that would break out of the class attribute", () => {
const message = mountHighlighted('ts" onload="x');
const content = copyAcross(message, "const", " {");
expect(content?.html).toContain('class="language-ts&quot; onload=&quot;x"');
expect(content?.html).not.toContain('onload="x"');
});
it("copies a fully selected multi-line region as code when the selection stays inside", () => {
const message = mountHighlighted();
const blockCode = fixtureElement(
message,
'[data-paseo-markdown-tag="pre"] [data-paseo-markdown-tag="code"]',
);
expect(copiedMarkdown(selectNodeContents(blockCode))).toBe(
"const answer = 1;\n if (answer) {\n doThing();",
);
});
it("retains the fence when a complete code block is selected across its boundary", () => {
const message = mountHighlighted();
const content = copyAcross(message, "const", "After the block.");
expect(content?.plainText).toBe(
"```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
);
});
});