Files
paseo/packages/app/src/components/markdown/renderers.web.tsx
T
Mohamed Boudra 85be2121b8 Link #123 issue and pull request references in chat (#8636)
* feat(app): link #123 issue and pull request references in Markdown

Agents cite issues and pull requests by number; reading one meant looking
up the URL by hand. Markdown in a workspace now links forge shorthand
against the workspace's origin remote: `#N`, GitLab and Gitea `!N`, and
cross-repository `owner/repo#N` on the same host.

Each forge's URL grammar declares which sigil maps to which path, so the
syntax stays per forge: GitHub has no `!`, and its `#` targets /issues/,
which GitHub redirects to the pull request. Recognition goes through the
Markdown engine's text link regex so code spans, fences and authored links
stay plain without a second Markdown parse while streaming. The reference
alternative sits before path recognition so `group/project#12` is not read
as a file path.

* fix(app): keep http:// for self-hosted forge web links

A self-hosted forge cloned over http:// serves its web UI over http on
the same origin. Forcing https made reference, blob and tree links fail
against an HTTP-only port. Cloud hosts and SSH remotes stay on https.
2026-11-03 13:00:24 +07:00

105 lines
3.7 KiB
TypeScript

import { Text } from "react-native";
import { forwardRef, useCallback, useMemo, useState, type ComponentProps } from "react";
import type {
CodeBlockRendererProps,
LinkRendererProps,
MarkdownRenderers,
} from "react-native-enriched-markdown";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useFileLink } from "@/assistant-file-links";
import { useForgeShorthand } from "@/git/forge-shorthand";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import type { Theme } from "@/styles/theme";
import { MermaidFence } from "./fence/mermaid";
import { markdownLinkSource } from "./source";
const ThemedCodeBlock = withUnistyles(HighlightedCodeBlock);
const codeStyle = (theme: Theme) => ({
inheritedStyles: {
color: theme.colors.foreground,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
},
textStyle: { backgroundColor: theme.colors.surface2, padding: 12, borderRadius: 8 },
});
function WebCodeBlock({ code, language }: CodeBlockRendererProps) {
if (language?.toLowerCase() === "mermaid") return <MermaidFence code={code} />;
return <ThemedCodeBlock code={code} language={language} uniProps={codeStyle} />;
}
// The library styles links inline, so a stylesheet :hover rule cannot underline them.
const LINK_HOVER_STYLE = { textDecoration: "underline" } as const;
function WebLink({ link, anchorProps, children }: LinkRendererProps) {
const forgeShorthand = useForgeShorthand();
// A forge reference like `#12` carries its web URL so hover, copy and new-tab opens work.
const forgeUrl = link.sourceType === "text" ? forgeShorthand?.resolve(link.url) : null;
const { tooltipPath, onHoverIn } = useFileLink(
forgeUrl ? markdownLinkSource(forgeUrl) : markdownLinkSource(link.url, link.sourceType),
);
const tooltip = forgeUrl ?? tooltipPath;
const [hovered, setHovered] = useState(false);
const handleMouseEnter = useCallback(() => setHovered(true), []);
const handleMouseLeave = useCallback(() => setHovered(false), []);
const anchorStyle = useMemo(
() => ({
...anchorProps.style,
overflowWrap: "anywhere" as const,
...(hovered ? LINK_HOVER_STYLE : null),
}),
[anchorProps.style, hovered],
);
const anchor = (
<a
{...anchorProps}
href={forgeUrl ?? anchorProps.href}
style={anchorStyle}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
</a>
);
return (
<Tooltip delayDuration={400}>
<TooltipTrigger asChild onHoverIn={onHoverIn}>
<InlineLinkTrigger>{anchor}</InlineLinkTrigger>
</TooltipTrigger>
{tooltip ? (
<TooltipContent side="top" align="start" maxWidth={520}>
<Text selectable={false} style={styles.tooltipPath}>
{tooltip}
</Text>
</TooltipContent>
) : null}
</Tooltip>
);
}
// TooltipTrigger also supplies native Pressable handlers; forward only DOM events.
// A View renders a div, which is invalid inside the library's prose paragraphs.
const InlineLinkTrigger = forwardRef<HTMLSpanElement, ComponentProps<"span">>(
function InlineLinkTrigger({ children, onMouseEnter, onMouseLeave, onFocus, onBlur }, ref) {
return (
<span
ref={ref}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onFocus={onFocus}
onBlur={onBlur}
>
{children}
</span>
);
},
);
const styles = StyleSheet.create((theme) => ({
tooltipPath: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
}));
export const WEB_RENDERERS: MarkdownRenderers = { codeBlock: WebCodeBlock, link: WebLink };