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.
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
'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;
|
||
}
|