* 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>
250 lines
7.9 KiB
TypeScript
250 lines
7.9 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Pressable, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
|
import { StyleSheet } from "react-native-unistyles";
|
|
import { MarkdownTextSpan } from "@/components/markdown-text";
|
|
import * as Clipboard from "expo-clipboard";
|
|
import { Check, Copy } from "lucide-react-native";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { HighlightToken } from "@getpaseo/highlight";
|
|
import { isNative, isWeb } from "@/constants/platform";
|
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
|
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
|
import { highlightToKeyedLines, type KeyedLine } from "@/utils/highlight-cache";
|
|
import {
|
|
markdownCopyCodeBlockDataSet,
|
|
markdownCopyDataSet,
|
|
TRAILING_CODE_LINE_BREAKS,
|
|
} from "@/assistant-selection-copy/markup";
|
|
|
|
interface HighlightedCodeBlockProps {
|
|
code: string;
|
|
language: string | null | undefined;
|
|
inheritedStyles: TextStyle;
|
|
textStyle: TextStyle;
|
|
}
|
|
|
|
// Fence info strings ("```ts", "```typescript", "```ts {1,3}") map to the
|
|
// extension-based parser table in @getpaseo/highlight. Aliases here only
|
|
// cover names that don't already match an extension key in parsers.ts.
|
|
const LANGUAGE_ALIASES: Record<string, string> = {
|
|
typescript: "ts",
|
|
javascript: "js",
|
|
python: "py",
|
|
rust: "rs",
|
|
golang: "go",
|
|
"c++": "cpp",
|
|
csharp: "cs",
|
|
"c#": "cs",
|
|
objc: "m",
|
|
"objective-c": "m",
|
|
markdown: "md",
|
|
elixir: "ex",
|
|
};
|
|
|
|
function fenceLanguageToExtension(info: string | null | undefined): string | null {
|
|
if (!info) return null;
|
|
const first = info.trim().split(/\s+/)[0]?.toLowerCase();
|
|
if (!first) return null;
|
|
const normalized = first.replace(/^\./, "");
|
|
return LANGUAGE_ALIASES[normalized] ?? normalized;
|
|
}
|
|
|
|
function stripTerminalFenceNewline(code: string): string {
|
|
return code.endsWith("\n") ? code.slice(0, -1) : code;
|
|
}
|
|
|
|
export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
|
code,
|
|
language,
|
|
inheritedStyles,
|
|
textStyle,
|
|
}: HighlightedCodeBlockProps) {
|
|
// Box styles (bg / padding / border / radius / margin) go on the wrapper View
|
|
// so the absolute copy button positions relative to the visible code area,
|
|
// not to a parent that includes the Text's own marginVertical.
|
|
const { containerStyle, innerTextStyle } = useMemo(
|
|
() => splitFenceStyle(inheritedStyles, textStyle),
|
|
[inheritedStyles, textStyle],
|
|
);
|
|
const renderedCode = useMemo(() => stripTerminalFenceNewline(code), [code]);
|
|
const copyDataSet = useMemo(
|
|
() => ({ ...CODE_SURFACE_DATASET, ...markdownCopyCodeBlockDataSet(language) }),
|
|
[language],
|
|
);
|
|
|
|
const keyedLines = useMemo<KeyedLine[] | null>(
|
|
() => highlightToKeyedLines(renderedCode, fenceLanguageToExtension(language)),
|
|
[renderedCode, language],
|
|
);
|
|
|
|
const isCompact = useIsCompactFormFactor();
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
|
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
|
const controlsVisible = isHovered || isNative || isCompact;
|
|
// Copy the code without its trailing blank lines. A fence body ends in a newline,
|
|
// and ends in more than one when the author left a blank line before the closing
|
|
// fence; pasting any of them into a terminal runs the last line.
|
|
const getCode = useCallback(() => code.replace(TRAILING_CODE_LINE_BREAKS, ""), [code]);
|
|
|
|
return (
|
|
<View
|
|
style={containerStyle}
|
|
dataSet={copyDataSet}
|
|
onPointerEnter={handlePointerEnter}
|
|
onPointerLeave={handlePointerLeave}
|
|
>
|
|
{keyedLines ? (
|
|
<MarkdownTextSpan style={innerTextStyle} copyTag="code">
|
|
{renderCodeSegments(keyedLines)}
|
|
</MarkdownTextSpan>
|
|
) : (
|
|
<MarkdownTextSpan style={innerTextStyle} copyTag="code">
|
|
{renderedCode}
|
|
</MarkdownTextSpan>
|
|
)}
|
|
<CopyButton getCode={getCode} visible={controlsVisible} />
|
|
</View>
|
|
);
|
|
});
|
|
|
|
function renderCodeSegments(keyedLines: KeyedLine[]): React.ReactNode[] {
|
|
const segments: React.ReactNode[] = [];
|
|
for (let lineIndex = 0; lineIndex < keyedLines.length; lineIndex += 1) {
|
|
const line = keyedLines[lineIndex];
|
|
if (lineIndex > 0) {
|
|
segments.push(<CodeTextSpan key={`${line.key}-newline`} text={"\n"} />);
|
|
}
|
|
for (const { key, token } of line.tokens) {
|
|
segments.push(<TokenSpan key={`${line.key}-${key}`} token={token} />);
|
|
}
|
|
}
|
|
return segments;
|
|
}
|
|
|
|
interface TokenSpanProps {
|
|
token: HighlightToken;
|
|
}
|
|
|
|
const TokenSpan = React.memo(function TokenSpan({ token }: TokenSpanProps) {
|
|
return (
|
|
<MarkdownTextSpan style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
|
|
{token.text}
|
|
</MarkdownTextSpan>
|
|
);
|
|
});
|
|
|
|
interface CodeTextSpanProps {
|
|
text: string;
|
|
}
|
|
|
|
const CodeTextSpan = React.memo(function CodeTextSpan({ text }: CodeTextSpanProps) {
|
|
return <MarkdownTextSpan>{text}</MarkdownTextSpan>;
|
|
});
|
|
|
|
interface SplitStyles {
|
|
containerStyle: StyleProp<ViewStyle>;
|
|
innerTextStyle: StyleProp<TextStyle>;
|
|
}
|
|
|
|
const CONTAINER_BASE: ViewStyle = { position: "relative" };
|
|
const WEB_SELECTABLE: TextStyle = isWeb ? ({ userSelect: "text" } as TextStyle) : {};
|
|
|
|
function splitFenceStyle(inheritedStyles: TextStyle, textStyle: TextStyle): SplitStyles {
|
|
const { fontFamily, fontSize, color, ...box } = textStyle;
|
|
const textOnly: TextStyle = { ...WEB_SELECTABLE };
|
|
if (fontFamily !== undefined) textOnly.fontFamily = fontFamily;
|
|
if (fontSize !== undefined) textOnly.fontSize = fontSize;
|
|
if (fontSize !== undefined) textOnly.lineHeight = Math.round(fontSize * 1.45);
|
|
if (color !== undefined) textOnly.color = color;
|
|
return {
|
|
containerStyle: [box as ViewStyle, CONTAINER_BASE],
|
|
innerTextStyle: [inheritedStyles, textOnly],
|
|
};
|
|
}
|
|
|
|
interface CopyButtonProps {
|
|
getCode: () => string;
|
|
visible: boolean;
|
|
}
|
|
|
|
const COPIED_RESET_MS = 1500;
|
|
|
|
const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButtonProps) {
|
|
const { t } = useTranslation();
|
|
const [copied, setCopied] = useState(false);
|
|
const resetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (resetRef.current) clearTimeout(resetRef.current);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handlePress = useCallback(async () => {
|
|
const content = getCode();
|
|
if (!content) return;
|
|
await Clipboard.setStringAsync(content);
|
|
setCopied(true);
|
|
if (resetRef.current) clearTimeout(resetRef.current);
|
|
resetRef.current = setTimeout(() => {
|
|
setCopied(false);
|
|
resetRef.current = null;
|
|
}, COPIED_RESET_MS);
|
|
}, [getCode]);
|
|
|
|
const visibilityStyle = visible
|
|
? copyButtonStyles.containerVisible
|
|
: copyButtonStyles.containerHidden;
|
|
const wrapperStyle = useMemo(
|
|
() => [copyButtonStyles.container, visibilityStyle],
|
|
[visibilityStyle],
|
|
);
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={handlePress}
|
|
style={wrapperStyle}
|
|
pointerEvents={visible ? "auto" : "none"}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={copied ? t("message.actions.copied") : t("message.actions.copyCode")}
|
|
hitSlop={8}
|
|
dataSet={markdownCopyDataSet.ignore}
|
|
>
|
|
{({ hovered }) => {
|
|
const iconColor = hovered
|
|
? copyButtonStyles.iconHoveredColor.color
|
|
: copyButtonStyles.iconColor.color;
|
|
return copied ? (
|
|
<Check size={14} color={iconColor} />
|
|
) : (
|
|
<Copy size={14} color={iconColor} />
|
|
);
|
|
}}
|
|
</Pressable>
|
|
);
|
|
});
|
|
|
|
const copyButtonStyles = StyleSheet.create((theme) => ({
|
|
container: {
|
|
position: "absolute",
|
|
top: theme.spacing[2],
|
|
right: theme.spacing[2],
|
|
padding: theme.spacing[1],
|
|
},
|
|
containerVisible: {
|
|
opacity: 1,
|
|
},
|
|
containerHidden: {
|
|
opacity: 0,
|
|
},
|
|
iconColor: {
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
iconHoveredColor: {
|
|
color: theme.colors.foreground,
|
|
},
|
|
}));
|