* fix(app): make markdown links tappable on iOS A markdown link's label renders as nested React elements (link > textgroup > text -> MarkdownInheritedText spans). The link's onPress was placed on the wrapping UITextView span, but react-native-uitextview only attaches onPress to the *string* children it converts into tappable RNUITextViewChild nodes - element children pass through untouched, so the handler never reached a node the native tap recognizer could fire. Link text stayed visible and selectable but dead to taps; web links never opened. Thread the link's press handler down to the leaf text spans via a new AssistantLinkPressProvider so it lands on the real tappable string nodes. MarkdownInheritedText consumes the context and forwards onPress to its MarkdownTextSpan; null outside a link, so ordinary text is unaffected. Gated to iOS - Android forwards onPress through nested <Text> already, and web uses the <a> path. * refactor(app): export useAssistantLinkPress from module index Import it through the assistant-file-links public surface instead of reaching into the internal link-press-context file, matching the rest of message.tsx. Addresses Greptile review feedback on #1334. --------- Co-authored-by: Kevin Aspesi <kevinaspesi@Kevins-Mac-mini.local>
292 lines
8.3 KiB
TypeScript
292 lines
8.3 KiB
TypeScript
import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
|
|
import {
|
|
Platform,
|
|
Pressable,
|
|
Text,
|
|
View,
|
|
type StyleProp,
|
|
type TextStyle,
|
|
type ViewStyle,
|
|
} from "react-native";
|
|
import { StyleSheet } from "react-native-unistyles";
|
|
import { isNative, isWeb } from "@/constants/platform";
|
|
import { MarkdownTextSpan } from "@/components/markdown-text";
|
|
import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context";
|
|
import { Shortcut } from "@/components/ui/shortcut";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
|
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
|
import { useAssistantFileLinkResolverContext } from "./provider";
|
|
import type { AssistantFileLinkSource } from "./resolver";
|
|
import { useFileLink } from "./use-file-link";
|
|
|
|
interface AssistantMarkdownLinkProps {
|
|
source: AssistantFileLinkSource;
|
|
style: StyleProp<TextStyle>;
|
|
monoSurface?: boolean;
|
|
children: ReactNode;
|
|
}
|
|
|
|
export function AssistantMarkdownLink({
|
|
source,
|
|
style,
|
|
monoSurface,
|
|
children,
|
|
}: AssistantMarkdownLinkProps) {
|
|
const [hovered, setHovered] = useState(false);
|
|
const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source);
|
|
const { configRef } = useAssistantFileLinkResolverContext();
|
|
const workspaceRoot = configRef.current.workspaceRoot;
|
|
const tooltipPath = useMemo(
|
|
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
|
|
[target, workspaceRoot],
|
|
);
|
|
const handleAnchorClickCapture = useStableEvent((event: MouseEvent<HTMLAnchorElement>) => {
|
|
event.preventDefault();
|
|
if (!isModifiedOpenEvent(event)) {
|
|
return;
|
|
}
|
|
event.stopPropagation();
|
|
onAuxPress();
|
|
});
|
|
const handleHoverIn = useStableEvent(() => {
|
|
setHovered(true);
|
|
onHoverIn();
|
|
});
|
|
const handleHoverOut = useStableEvent(() => setHovered(false));
|
|
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
|
|
() => [style, hovered && { textDecorationLine: "underline" as const }],
|
|
[style, hovered],
|
|
);
|
|
const linkPress = useMemo<AssistantLinkPress>(
|
|
() => ({ onPress, accessibilityRole: "link" }),
|
|
[onPress],
|
|
);
|
|
|
|
if (isNative) {
|
|
// Must be a MarkdownTextSpan, not a plain <Text>: on iOS the link renders
|
|
// inside the paragraph's native UITextView, and a plain <Text> nested there
|
|
// is not hoisted into a UITextViewChild, so its text is silently dropped
|
|
// (the link disappears). The span composes correctly and stays selectable.
|
|
//
|
|
// Tap-to-open: react-native-uitextview only wires onPress onto the *string*
|
|
// children it turns into RNUITextViewChild nodes — the element children that
|
|
// markdown emits for link text pass through untouched, so an onPress placed
|
|
// here never reaches a tappable native node. We thread it down through
|
|
// AssistantLinkPressProvider so each leaf text span re-attaches it to its
|
|
// own string children, where the native tap recognizer can find it. iOS
|
|
// only: Android forwards onPress through nested <Text> already, and web uses
|
|
// the <a> path below.
|
|
const span = (
|
|
<MarkdownTextSpan
|
|
accessibilityRole="link"
|
|
monoSurface={monoSurface}
|
|
onPress={onPress}
|
|
style={style}
|
|
>
|
|
{children}
|
|
</MarkdownTextSpan>
|
|
);
|
|
return (
|
|
<FileLinkHoverTooltip filePath={tooltipPath}>
|
|
{Platform.OS === "ios" ? (
|
|
<AssistantLinkPressProvider value={linkPress}>{span}</AssistantLinkPressProvider>
|
|
) : (
|
|
span
|
|
)}
|
|
</FileLinkHoverTooltip>
|
|
);
|
|
}
|
|
|
|
const anchor = (
|
|
<a
|
|
href={source.href}
|
|
onClickCapture={handleAnchorClickCapture}
|
|
onAuxClickCapture={preventAnchorNavigation}
|
|
style={LINK_ANCHOR_STYLE}
|
|
>
|
|
<Pressable
|
|
accessibilityRole="link"
|
|
onPress={onPress}
|
|
onHoverIn={handleHoverIn}
|
|
onHoverOut={handleHoverOut}
|
|
>
|
|
<Text dataSet={monoSurface ? CODE_SURFACE_DATASET : undefined} style={hoveredTextStyle}>
|
|
{children}
|
|
</Text>
|
|
</Pressable>
|
|
</a>
|
|
);
|
|
|
|
return <FileLinkHoverTooltip filePath={tooltipPath}>{anchor}</FileLinkHoverTooltip>;
|
|
}
|
|
|
|
interface AssistantMarkdownCodeLinkProps {
|
|
source: AssistantFileLinkSource;
|
|
inheritedStyles: TextStyle;
|
|
codeInlineStyle: TextStyle;
|
|
linkStyle: TextStyle;
|
|
children: ReactNode;
|
|
}
|
|
|
|
export function AssistantMarkdownCodeLink({
|
|
source,
|
|
inheritedStyles,
|
|
codeInlineStyle,
|
|
linkStyle,
|
|
children,
|
|
}: AssistantMarkdownCodeLinkProps) {
|
|
const style = useMemo(
|
|
() => [inheritedStyles, codeInlineStyle, linkStyle],
|
|
[inheritedStyles, codeInlineStyle, linkStyle],
|
|
);
|
|
return (
|
|
<AssistantMarkdownLink source={source} style={style} monoSurface>
|
|
{children}
|
|
</AssistantMarkdownLink>
|
|
);
|
|
}
|
|
|
|
function formatInlinePathTargetForTooltip(
|
|
target: { path: string; lineStart?: number; lineEnd?: number },
|
|
workspaceRoot: string | undefined,
|
|
): string {
|
|
let result = relativizePathToWorkspace(target.path, workspaceRoot);
|
|
if (target.lineStart) {
|
|
result += `:${target.lineStart}`;
|
|
if (target.lineEnd && target.lineEnd !== target.lineStart) {
|
|
result += `-${target.lineEnd}`;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function relativizePathToWorkspace(filePath: string, workspaceRoot: string | undefined): string {
|
|
if (!workspaceRoot) {
|
|
return filePath;
|
|
}
|
|
const root = workspaceRoot.replace(/\/+$/, "");
|
|
if (!root) {
|
|
return filePath;
|
|
}
|
|
if (filePath === root) {
|
|
return ".";
|
|
}
|
|
const prefix = `${root}/`;
|
|
if (filePath.startsWith(prefix)) {
|
|
return filePath.slice(prefix.length);
|
|
}
|
|
return filePath;
|
|
}
|
|
|
|
interface AssistantInlineCodePathLinkProps {
|
|
content: string;
|
|
inheritedStyles: TextStyle;
|
|
codeInlineStyle: TextStyle;
|
|
linkStyle: TextStyle;
|
|
}
|
|
|
|
export function AssistantInlineCodePathLink({
|
|
content,
|
|
inheritedStyles,
|
|
codeInlineStyle,
|
|
linkStyle,
|
|
}: AssistantInlineCodePathLinkProps) {
|
|
const source = useMemo<AssistantFileLinkSource>(
|
|
() => ({
|
|
href: content,
|
|
text: content,
|
|
sourceType: "inline-code",
|
|
}),
|
|
[content],
|
|
);
|
|
|
|
return (
|
|
<AssistantMarkdownCodeLink
|
|
source={source}
|
|
inheritedStyles={inheritedStyles}
|
|
codeInlineStyle={codeInlineStyle}
|
|
linkStyle={linkStyle}
|
|
>
|
|
{content}
|
|
</AssistantMarkdownCodeLink>
|
|
);
|
|
}
|
|
|
|
const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
|
|
// RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps
|
|
// the tooltip wrapper from breaking inline link flow.
|
|
display: "inline-flex" as ViewStyle["display"],
|
|
};
|
|
|
|
const FILE_LINK_TOOLTIP_MOD_KEYS = ["mod"];
|
|
|
|
function FileLinkHoverTooltip({
|
|
filePath,
|
|
children,
|
|
}: {
|
|
filePath: string | null;
|
|
children: ReactNode;
|
|
}) {
|
|
if (!isWeb) {
|
|
return children;
|
|
}
|
|
return (
|
|
<Tooltip delayDuration={400}>
|
|
<TooltipTrigger asChild>
|
|
<View style={FILE_LINK_TOOLTIP_TRIGGER_STYLE}>{children}</View>
|
|
</TooltipTrigger>
|
|
{filePath ? (
|
|
<TooltipContent side="top" align="start" maxWidth={520}>
|
|
<View style={styles.tooltipBody}>
|
|
<Text selectable={false} style={styles.tooltipPath}>
|
|
{filePath}
|
|
</Text>
|
|
<View style={styles.tooltipHintRow}>
|
|
<Shortcut keys={FILE_LINK_TOOLTIP_MOD_KEYS} />
|
|
<Text selectable={false} style={styles.tooltipHintText}>
|
|
click for side pane
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</TooltipContent>
|
|
) : null}
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
const LINK_ANCHOR_STYLE: CSSProperties = {
|
|
display: "contents",
|
|
color: "inherit",
|
|
textDecoration: "none",
|
|
};
|
|
|
|
function preventAnchorNavigation(event: MouseEvent<HTMLAnchorElement>): void {
|
|
event.preventDefault();
|
|
}
|
|
|
|
function isModifiedOpenEvent(event: MouseEvent<HTMLElement>): boolean {
|
|
return event.metaKey || event.ctrlKey;
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
tooltipBody: {
|
|
gap: theme.spacing[1],
|
|
},
|
|
tooltipPath: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.xs,
|
|
fontWeight: theme.fontWeight.normal,
|
|
},
|
|
tooltipHintRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
},
|
|
tooltipHintText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
fontWeight: theme.fontWeight.normal,
|
|
},
|
|
}));
|