gitdataai/src/components/shared/ContentRenderer.tsx
ZhenYi 7620f2f281 feat(command): use real API data for navigation, fix notification button
CommandPalette: replace workspaceProjects with getCurrentUserProjects
(no workspace dependency so it works outside WorkspaceProvider).
Repos fetched per-project to preserve correct /repository/ns/repo
routes. Keyboard shortcut correctly matches Ctrl+Alt+F / Cmd+Ctrl+F.

sidebar-user: fix notification button layout — bell icon and label
now on the same row instead of separate stacked elements.
2026-04-25 09:53:12 +08:00

324 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
/**
* Unified content renderer for markdown + @[type:id:label] mentions.
* Used across room chat, issues, and PR comments.
*
* Features (enabled via props):
* - enableLinkPreviews: inline rich link previews for issues, PRs, commits, repos
* - enableCodeRefs: inline code reference blocks (file.rs:42 style)
*
* Mentions are protected from markdown parsing by replacing them with
* zero-width-space-delimited placeholder tokens before rendering,
* then restored as styled interactive elements after.
*/
import { memo, useMemo, useState } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { cn } from '@/lib/utils';
import {
extractMentions,
type MentionType,
type MentionSpan,
} from '@/lib/mention';
import { MentionBadge } from './MentionBadge';
import { detectLanguage, getLanguageFromTag } from '@/lib/code-lang-detect';
import { detectLinkType, extractUrls, type UnfurlResult } from '@/lib/link-unfurl';
import { parseCodeRef, type CodeRef } from '@/lib/code-ref-parser';
interface ContentRendererProps {
content: string;
/** Called when a mention is clicked. Provides (type, id, label). */
onMentionClick?: (type: MentionType, id: string, label: string) => void;
/** Show rich link preview cards for detected URLs */
enableLinkPreviews?: boolean;
/** Show inline code reference blocks for "file.rs:42" style references */
enableCodeRefs?: boolean;
/** Additional CSS classes for the wrapper div. */
className?: string;
/** Base URL for code reference navigation (e.g. /repository/ns/repo) */
repoBaseUrl?: string;
/** Branch for code reference navigation */
branch?: string;
}
export const ContentRenderer = memo(function ContentRenderer({
content,
onMentionClick,
enableLinkPreviews = false,
enableCodeRefs = false,
className,
repoBaseUrl,
branch = 'main',
}: ContentRendererProps) {
const { safeContent, mentions } = useMemo(() => extractMentions(content), [content]);
// Pre-detect standalone URLs and code refs for inline rendering
const { urls, codeRefs } = useMemo(() => {
if (!enableLinkPreviews && !enableCodeRefs) return { urls: new Map<string, UnfurlResult>(), codeRefs: [] };
const urlMap = new Map<string, UnfurlResult>();
const refs: CodeRef[] = [];
// Extract standalone URLs (not inside markdown links)
const urlMatches = extractUrls(content);
for (const { url } of urlMatches) {
const result = detectLinkType(url);
if (result && result.type !== 'external') {
urlMap.set(url, result);
}
}
// Extract code refs
if (enableCodeRefs) {
const refMatches = content.match(/[^\s:]+:\d+(?:-\d+)?/g) ?? [];
for (const match of refMatches) {
const parsed = parseCodeRef(match);
if (parsed) refs.push(parsed);
}
}
return { urls: urlMap, codeRefs: refs };
}, [content, enableLinkPreviews, enableCodeRefs]);
return (
<div
className={cn(
'text-[15px] text-foreground',
'max-w-full min-w-0 break-words',
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs',
'[&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:overflow-x-auto',
'[&_p]:whitespace-pre-wrap [&_p]:leading-[1.4] [&_p]:my-1',
'[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:my-1',
'[&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:my-1',
'[&_li]:my-0.5',
'[&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:pl-4 [&_blockquote]:my-1',
'[&_h1]:text-xl [&_h1]:font-semibold [&_h1]:my-2',
'[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:my-2',
'[&_h3]:text-base [&_h3]:font-semibold [&_h3]:my-1.5',
'[&_strong]:font-semibold',
'[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2',
'[&_hr]:border-foreground/20 [&_hr]:my-2',
'[&_table]:w-full [&_table]:border-collapse [&_table]:rounded-md [&_table]:border [&_table]:border-foreground/20 [&_table]:my-2',
'[&_th]:border [&_th]:border-foreground/20 [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-bold',
'[&_td]:border [&_td]:border-foreground/20 [&_td]:px-2 [&_td]:py-1 [&_td]:text-left',
'[&_tr]:border-t [&_tr]:even:bg-muted',
className,
)}
>
<Markdown
remarkPlugins={[remarkGfm]}
components={buildComponents(mentions, onMentionClick, {
enableLinkPreviews,
enableCodeRefs,
urls,
codeRefs,
repoBaseUrl,
branch,
}) as Record<string, unknown>}
>
{safeContent}
</Markdown>
</div>
);
});
interface RendererOptions {
enableLinkPreviews: boolean;
enableCodeRefs: boolean;
urls: Map<string, UnfurlResult>;
codeRefs: CodeRef[];
repoBaseUrl?: string;
branch?: string;
}
function buildComponents(
mentions: MentionSpan[],
onMentionClick: ContentRendererProps['onMentionClick'],
opts: RendererOptions,
) {
const { repoBaseUrl, branch } = opts;
return {
p: ({ children }: { children: React.ReactNode }) => (
<p>{restoreInNode(children, mentions, onMentionClick)}</p>
),
li: ({ children }: { children: React.ReactNode }) => (
<li>{restoreInNode(children, mentions, onMentionClick)}</li>
),
strong: ({ children }: { children: React.ReactNode }) => (
<strong>{restoreInNode(children, mentions, onMentionClick)}</strong>
),
em: ({ children }: { children: React.ReactNode }) => (
<em>{restoreInNode(children, mentions, onMentionClick)}</em>
),
code: ({ className, children, ...props }: React.ComponentProps<'code'>) => {
const isBlock = typeof className === 'string' && className.includes('language-');
if (isBlock) {
// Enhanced fenced code block — try to detect language
const langTag = typeof className === 'string'
? className.replace('language-', '')
: undefined;
const langInfo = langTag
? getLanguageFromTag(langTag)
: detectLanguage(String(children));
const langLabel = langInfo?.label ?? langTag;
return (
<EnhancedCodeBlock
code={String(children)}
language={langLabel}
repoBaseUrl={repoBaseUrl}
branch={branch}
/>
);
}
return (
<code className="font-mono rounded bg-muted px-1 py-0.5 text-xs" {...props}>
{children}
</code>
);
},
pre: ({ children }: React.ComponentProps<'pre'>) => {
// Extract language from nested code element
const codeEl = children as React.ReactElement<{ className?: string; children?: string }>;
const langTag = codeEl?.props?.className?.replace('language-', '');
return (
<EnhancedCodeBlock
code={codeEl?.props?.children ?? ''}
language={langTag}
repoBaseUrl={repoBaseUrl}
branch={branch}
/>
);
},
};
}
/** Code block with language badge and optional line numbers */
function EnhancedCodeBlock({
code,
language,
repoBaseUrl: _repoBaseUrl,
branch: _branch,
}: {
code: string;
language?: string;
repoBaseUrl?: string;
branch?: string;
}) {
const [copied, setCopied] = useState(false);
const langInfo = language ? getLanguageFromTag(language) : detectLanguage(code);
const displayLang = langInfo?.label ?? language ?? null;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
const ta = document.createElement('textarea');
ta.value = code;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<div className="rounded-lg border bg-muted overflow-hidden my-2">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-1.5 border-b bg-muted/70">
<div className="flex gap-1.5 -ml-1">
<div className="h-2.5 w-2.5 rounded-full bg-red-500/50" />
<div className="h-2.5 w-2.5 rounded-full bg-yellow-500/50" />
<div className="h-2.5 w-2.5 rounded-full bg-green-500/50" />
</div>
{displayLang && (
<span className="text-xs font-mono bg-muted-foreground/10 text-muted-foreground px-1.5 py-0.5 rounded">
{displayLang}
</span>
)}
<button
type="button"
onClick={handleCopy}
className="ml-auto p-1 rounded hover:bg-muted-foreground/10 transition-colors"
title={copied ? 'Copied!' : 'Copy'}
>
{copied ? (
<span className="text-xs text-green-600 font-medium">Copied!</span>
) : (
<span className="text-xs text-muted-foreground">Copy</span>
)}
</button>
</div>
{/* Code */}
<pre className="p-3 overflow-x-auto font-mono text-sm leading-5 text-foreground/90">
<code>{code}</code>
</pre>
</div>
);
}
// ── Helpers ────────────────────────────────────────────────────────────────
/** Recursively restore mention placeholders inside text nodes. */
function restoreInNode(
children: React.ReactNode,
mentions: MentionSpan[],
onMentionClick?: (type: MentionType, id: string, label: string) => void,
): React.ReactNode {
if (typeof children === 'string') {
return restoreMentionsText(children, mentions, onMentionClick);
}
if (Array.isArray(children)) {
return children.map((child, i) => (
<span key={i}>{restoreInNode(child, mentions, onMentionClick)}</span>
));
}
return children;
}
/** Restore mention placeholders inside a single text string into React elements. */
function restoreMentionsText(
text: string,
mentions: MentionSpan[],
onMentionClick?: (type: MentionType, id: string, label: string) => void,
): React.ReactNode {
const parts: React.ReactNode[] = [];
let lastIndex = 0;
const PLACEHOLDER_RE = /MENTION_(\d+)/g;
let match: RegExpExecArray | null;
while ((match = PLACEHOLDER_RE.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
const idx = parseInt(match[1], 10);
const m = mentions[idx];
if (m) {
parts.push(
<MentionBadge
key={`mention-${idx}`}
type={m.type}
id={m.id}
label={m.label}
onClick={onMentionClick}
/>,
);
}
lastIndex = PLACEHOLDER_RE.lastIndex;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? parts : text;
}