"use client"; import { SymlinkIndicator } from "@/components/shared/symlink-indicator"; import { createContext, createElement, memo, useEffect, useContext, useMemo, useRef, useState, type HTMLAttributes, type ReactNode, type Ref, } from "react"; import { createPortal } from "react-dom"; import ReactMarkdown, { type ExtraProps, type Components } from "react-markdown"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { IconCode, IconMessagePlus } from "@tabler/icons-react"; import { MarkdownTaskContext, remarkPlugins, markdownComponents, } from "@/components/shared/markdown-components"; import { ResizableMarkdownTable } from "@/components/shared/resizable-markdown-table"; import { cn, toRelativePath } from "@/lib/utils"; import { PanelHeaderBarSplit } from "@/components/task/panel-primitives"; import { FileViewerDownloadButton } from "@/components/task/file-viewer-header"; import { EditorCommentPopover } from "@/components/task/editor-comment-popover"; import { CommentViewPopover } from "@/components/task/comment-view-popover"; import { useMarkdownPreviewComments } from "@/hooks/domains/comments/use-markdown-preview-comments"; import { SOURCE_END_ATTR, SOURCE_START_ATTR, type SourceLineRange, } from "@/lib/markdown/source-line-ranges"; import { commentsBeginInRange, commentsOverlapRange } from "@/lib/markdown/preview-comments"; import type { DiffComment } from "@/lib/state/slices/comments"; import { ExternalVcsFileLink, useExternalVcsFileStatus, } from "@/components/editors/external-vcs-file-link"; import { useTranslation } from "react-i18next"; interface MarkdownPreviewToolbarProps { path: string; isSymlink?: boolean; worktreePath?: string; commentCount: number; commentsEnabled: boolean; taskId?: string | null; sessionId?: string; repositoryId?: string | null; repositoryName?: string; showExternalVcsLink: boolean; onDownload?: () => void; onTogglePreview: () => void; } function MarkdownPreviewToolbar({ path, isSymlink, worktreePath, commentCount, commentsEnabled, taskId, sessionId, repositoryId, repositoryName, showExternalVcsLink, onDownload, onTogglePreview, }: MarkdownPreviewToolbarProps) { const { t } = useTranslation(); const fileStatus = useExternalVcsFileStatus(path, sessionId, repositoryName); return ( {toRelativePath(path, worktreePath)} {t("task:preview")} } right={
{showExternalVcsLink && ( )} {commentsEnabled && commentCount > 0 && (
{t("task:commentCount", { count: commentCount })}
)} {t("task:showCode")}
} /> ); } interface MarkdownPreviewContentProps { path: string; isSymlink?: boolean; content: string; worktreePath?: string; sessionId?: string; taskId?: string | null; repositoryId?: string | null; repositoryName?: string; enableComments?: boolean; showExternalVcsLink?: boolean; onDownload?: () => void; onTogglePreview: () => void; } type PositionedNode = { position?: { start?: { line?: number }; end?: { line?: number }; }; }; type SourceBlockProps = HTMLAttributes & ExtraProps & { children?: ReactNode; elementRef?: Ref; node?: PositionedNode; tag: keyof HTMLElementTagNameMap; }; type MarkdownSourceBlockProps = Omit; type PreviewCommentContextValue = { comments: DiffComment[]; showCommentsForRange: (range: SourceLineRange, position: { x: number; y: number }) => void; }; const PreviewCommentContext = createContext(null); function sourceRangeFromNode(node: PositionedNode | undefined): SourceLineRange | null { const startLine = node?.position?.start?.line; const endLine = node?.position?.end?.line ?? startLine; if (!startLine || !endLine) return null; return { startLine, endLine }; } function sourceDataAttrs(range: SourceLineRange | null) { if (!range) return {}; return { [SOURCE_START_ATTR]: range.startLine, [SOURCE_END_ATTR]: range.endLine, }; } export function isInteractiveSourceClickTarget(target: EventTarget | null): boolean { if (!(target instanceof Element)) return false; return Boolean( target.closest("a, button, input, textarea, select, [role='button'], [contenteditable]"), ); } function CommentBadge({ onClick, }: { onClick: (event: React.MouseEvent) => void; }) { const { t } = useTranslation(); return ( ); } function SourceBlock({ tag, node, children, className, elementRef, onClick, ...rest }: SourceBlockProps) { const commentContext = useContext(PreviewCommentContext); const range = sourceRangeFromNode(node); const comments = commentContext?.comments ?? []; const isCommented = range ? commentsOverlapRange(comments, range) : false; const canRenderBadge = tag !== "blockquote" && tag !== "li"; const hasCommentBadge = canRenderBadge && range ? commentsBeginInRange(comments, range) : false; const handleClick = (event: React.MouseEvent) => { onClick?.(event); if (!range || !isCommented || event.defaultPrevented || !commentContext) return; if (isInteractiveSourceClickTarget(event.target)) return; if (window.getSelection()?.toString().trim()) return; commentContext.showCommentsForRange(range, { x: event.clientX, y: event.clientY }); }; const handleBadgeClick = (event: React.MouseEvent) => { if (!range || !commentContext) return; event.preventDefault(); event.stopPropagation(); commentContext.showCommentsForRange(range, { x: event.clientX, y: event.clientY }); }; const element = createElement( tag, { ...rest, ...sourceDataAttrs(range), "data-testid": isCommented ? "markdown-preview-commented-range" : "markdown-preview-source-block", className: cn( "markdown-preview-source-block", isCommented && "markdown-preview-commented-range", className, ), onClick: handleClick, ref: elementRef, }, children, hasCommentBadge && tag !== "pre" ? : null, ); if (!hasCommentBadge || tag !== "pre") return element; return (
{element}
); } function MarkdownPreviewTable({ node, children }: MarkdownSourceBlockProps) { return ( ( {tableContent} )} > {children} ); } function sourceComponent(tag: keyof HTMLElementTagNameMap) { return function MarkdownPreviewSourceComponent(props: MarkdownSourceBlockProps) { return ; }; } const markdownPreviewComponents: Components = { ...markdownComponents, p: sourceComponent("p"), h1: sourceComponent("h1"), h2: sourceComponent("h2"), h3: sourceComponent("h3"), h4: sourceComponent("h4"), h5: sourceComponent("h5"), h6: sourceComponent("h6"), li: sourceComponent("li"), blockquote: sourceComponent("blockquote"), pre: sourceComponent("pre"), table: (props) => , }; type MarkdownPreviewCommentState = ReturnType; function MarkdownPreviewCommentOverlays({ commentsEnabled, overlayRoot, commentState, }: { commentsEnabled: boolean; overlayRoot: HTMLElement | null; commentState: MarkdownPreviewCommentState; }) { const { t } = useTranslation(); if (!commentsEnabled || !overlayRoot) return null; return createPortal( <> {commentState.currentSelection && !commentState.textSelection && ( )} {commentState.textSelection && (
)} {commentState.commentView && ( )} , overlayRoot, ); } export function MarkdownPreviewRenderer({ content, taskId, }: { content: string; taskId?: string | null; }) { return ( {content} ); } export const MarkdownPreviewContent = memo(function MarkdownPreviewContent({ path, isSymlink, content, worktreePath, sessionId, taskId, repositoryId, repositoryName, enableComments = false, showExternalVcsLink = true, onDownload, onTogglePreview, }: MarkdownPreviewContentProps) { const rootRef = useRef(null); const scrollRef = useRef(null); const [overlayRoot, setOverlayRoot] = useState(null); const commentsEnabled = enableComments && !!sessionId; const commentState = useMarkdownPreviewComments({ path, content, sessionId, taskId, repositoryId, enabled: commentsEnabled, rootRef, }); const previewCommentContextValue = useMemo( () => ({ comments: commentState.comments, showCommentsForRange: commentState.showCommentsForRange, }), [commentState.comments, commentState.showCommentsForRange], ); const dismissCommentOverlays = commentState.dismissOverlays; useEffect(() => { setOverlayRoot(document.body); }, []); useEffect(() => { if (!commentsEnabled) return; const scrollElement = scrollRef.current; const dismissOverlays = () => dismissCommentOverlays(); scrollElement?.addEventListener("scroll", dismissOverlays, { passive: true }); window.addEventListener("resize", dismissOverlays); return () => { scrollElement?.removeEventListener("scroll", dismissOverlays); window.removeEventListener("resize", dismissOverlays); }; }, [commentsEnabled, dismissCommentOverlays]); return (
); });