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; 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) => { event.preventDefault(); if (!isModifiedOpenEvent(event)) { return; } event.stopPropagation(); onAuxPress(); }); const handleHoverIn = useStableEvent(() => { setHovered(true); onHoverIn(); }); const handleHoverOut = useStableEvent(() => setHovered(false)); const hoveredTextStyle = useMemo>( () => [style, hovered && { textDecorationLine: "underline" as const }], [style, hovered], ); const linkPress = useMemo( () => ({ onPress, accessibilityRole: "link" }), [onPress], ); if (isNative) { // Must be a MarkdownTextSpan, not a plain : on iOS the link renders // inside the paragraph's native UITextView, and a plain 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 already, and web uses // the path below. const span = ( {children} ); return ( {Platform.OS === "ios" ? ( {span} ) : ( span )} ); } const anchor = ( {children} ); return {anchor}; } 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 ( {children} ); } 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( () => ({ href: content, text: content, sourceType: "inline-code", }), [content], ); return ( {content} ); } 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 ( {children} {filePath ? ( {filePath} click for side pane ) : null} ); } const LINK_ANCHOR_STYLE: CSSProperties = { display: "contents", color: "inherit", textDecoration: "none", }; function preventAnchorNavigation(event: MouseEvent): void { event.preventDefault(); } function isModifiedOpenEvent(event: MouseEvent): 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, }, }));