1721 lines · 55.2 KB · TypeScript
RawDownload
import { useAtomValue } from "@effect/atom-react";import {  CheckIcon,  ChevronRightIcon,  CopyIcon,  GlobeIcon,  InfoIcon,  LightbulbIcon,  Maximize2Icon,  MessageSquareWarningIcon,  Minimize2Icon,  OctagonAlertIcon,  TriangleAlertIcon,  WrapTextIcon,} from "lucide-react";import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts";import {  isAtomCommandInterrupted,  squashAtomCommandFailure,  type AtomCommandResult,} from "@t3tools/client-runtime/state/runtime";import * as Cause from "effect/Cause";import { AsyncResult } from "effect/unstable/reactivity";import React, {  Children,  Suspense,  type ClipboardEvent as ReactClipboardEvent,  type MouseEvent as ReactMouseEvent,  isValidElement,  use,  useCallback,  memo,  useEffect,  useMemo,  useRef,  useState,  type ReactNode,} from "react";import type { Components, Options as ReactMarkdownOptions } from "react-markdown";import ReactMarkdown from "react-markdown";import { defaultUrlTransform } from "react-markdown";import rehypeRaw from "rehype-raw";import rehypeSanitize, { defaultSchema } from "rehype-sanitize";import remarkBreaks from "remark-breaks";import remarkGfm from "remark-gfm";import { remarkGithubAlerts } from "../markdown-github-alerts";import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";import { PierreEntryIcon } from "./chat/PierreEntryIcon";import {  resolveExternalWebLinkHost,  showExternalLinkContextMenu,} from "./chat/externalLinkContextMenu";import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons";import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";import { Button } from "./ui/button";import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible";import { ScrollArea } from "./ui/scroll-area";import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";import { stackedThreadToast, toastManager } from "./ui/toast";import { recordVisitForThread } from "../browserHistoryStore";import { useOpenInPreferredEditor } from "../editorPreferences";import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";import { fnv1a32 } from "../lib/diffRendering";import { LRUCache } from "../lib/lruCache";import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";import { RenderErrorBoundary } from "./RenderErrorBoundary";import { useTheme } from "../hooks/useTheme";import { getClientSettings } from "../hooks/useSettings";import {  chatMarkdownClipboardPayload,  serializeTableElementToCsv,  serializeTableElementToMarkdown,} from "../markdown-clipboard";import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";import {  normalizeMarkdownLinkDestination,  resolveInlineCodeFileLinkMeta,  resolveMarkdownFileLinkMeta,  rewriteMarkdownFileUriHref,  type MarkdownFileLinkMeta,} from "../markdown-links";import { readLocalApi } from "../localApi";import { cn } from "../lib/utils";import { useRightPanelStore } from "../rightPanelStore";import { useActiveEnvironmentId } from "../state/entities";import { serverEnvironment } from "../state/server";import { assetEnvironment } from "../state/assets";import { usePreparedConnection } from "../state/session";import { previewEnvironment } from "../state/preview";import { useAtomCommand } from "../state/use-atom-command";import { useAtomQueryRunner } from "../state/use-atom-query-runner";import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink";import { writeTextToClipboard } from "../hooks/useCopyToClipboard";import { isPreviewSupportedInRuntime } from "../previewStateStore";import {  isBrowserPreviewFile,  openFileInPreview,  openUrlInPreview,  BrowserPreviewUnavailableError,} from "../browser/openFileInPreview"; interface ChatMarkdownProps {  text: string;  cwd: string | undefined;  threadRef?: ScopedThreadRef | undefined;  onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined;  isStreaming?: boolean;  skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;  className?: string;  /** Treat single newlines as hard breaks — chat-style user input. */  lineBreaks?: boolean;} const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/;const MAX_HIGHLIGHT_CACHE_ENTRIES = 500;const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; interface MarkdownActionFailureContext {  readonly operation: string;  readonly target?: string;  readonly format?: "markdown" | "csv";  readonly language?: string;  readonly fenceTitle?: string;  readonly copyTarget?: string;} function reportMarkdownActionFailure(context: MarkdownActionFailureContext, cause: unknown): void {  console.error("[chat-markdown] action failed", context, cause);} const highlightedCodeCache = new LRUCache<string>(  MAX_HIGHLIGHT_CACHE_ENTRIES,  MAX_HIGHLIGHT_CACHE_MEMORY_BYTES,); function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null {  const firstLineEnd = markdown.indexOf("\n", listItemStart);  const firstLine = markdown.slice(    listItemStart,    firstLineEnd === -1 ? markdown.length : firstLineEnd,  );  const match = firstLine.match(/^(?:\s*(?:[-+*]|\d+[.)])\s+)(\[[ xX]\])/);  if (!match?.[1]) return null;  return listItemStart + firstLine.indexOf(match[1]);}const CHAT_MARKDOWN_SANITIZE_SCHEMA = {  ...defaultSchema,  attributes: {    ...defaultSchema.attributes,    "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"),    code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"],    blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"],  },  protocols: {    ...defaultSchema.protocols,    href: [...(defaultSchema.protocols?.href ?? []), "file"],  },} satisfies Parameters<typeof rehypeSanitize>[0]; const CHAT_MARKDOWN_REMARK_PLUGINS = [  remarkGfm,  remarkGithubAlerts,  remarkNormalizeListItemIndentation,  remarkPreserveCodeMeta,  remarkTagInlineCode,] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [  remarkGfm,  remarkGithubAlerts,  remarkNormalizeListItemIndentation,  remarkBreaks,  remarkPreserveCodeMeta,  remarkTagInlineCode,] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; const CHAT_MARKDOWN_REHYPE_PLUGINS = [  rehypeRaw,  [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>; /** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */const GITHUB_ALERT_PRESENTATIONS: Record<  string,  { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string }> = {  note: {    label: "Note",    Icon: InfoIcon,    borderClassName: "border-blue-500/70",    titleClassName: "text-blue-600 dark:text-blue-400",  },  tip: {    label: "Tip",    Icon: LightbulbIcon,    borderClassName: "border-emerald-500/70",    titleClassName: "text-emerald-600 dark:text-emerald-400",  },  important: {    label: "Important",    Icon: MessageSquareWarningIcon,    borderClassName: "border-purple-500/70",    titleClassName: "text-purple-600 dark:text-purple-400",  },  warning: {    label: "Warning",    Icon: TriangleAlertIcon,    borderClassName: "border-amber-500/70",    titleClassName: "text-amber-600 dark:text-amber-500",  },  caution: {    label: "Caution",    Icon: OctagonAlertIcon,    borderClassName: "border-red-500/70",    titleClassName: "text-red-600 dark:text-red-400",  },}; function extractFenceLanguage(className: string | undefined): string {  const match = className?.match(CODE_FENCE_LANGUAGE_REGEX);  const raw = match?.[1] ?? "text";  // Shiki doesn't bundle a gitignore grammar; ini is a close match (#685)  return raw === "gitignore" ? "ini" : raw;} const FENCE_TITLE_ATTR_REGEX = /(?:^|\s)(?:title|file(?:name)?)=(?:"([^"]+)"|'([^']+)'|(\S+))/i;const FENCE_FILENAME_TOKEN_REGEX = /^[\w@][\w@./-]*\.[A-Za-z0-9]+$/; /** Pulls a filename out of fence meta: ```ts title="x.ts" / ```ts src/main.ts */function extractFenceTitle(meta: string | undefined): string | null {  if (!meta) return null;  const attrMatch = FENCE_TITLE_ATTR_REGEX.exec(meta);  const attrTitle = attrMatch?.[1] ?? attrMatch?.[2] ?? attrMatch?.[3];  if (attrTitle) return attrTitle;  return meta.split(/\s+/).find((candidate) => FENCE_FILENAME_TOKEN_REGEX.test(candidate)) ?? null;} function extractPreCodeMeta(node: unknown): string | undefined {  const children = (    node as      | {          children?: Array<{            type?: string;            tagName?: string;            data?: { meta?: unknown };            properties?: { dataCodeMeta?: unknown };          }>;        }      | undefined  )?.children;  const codeNode = children?.find((child) => child?.type === "element" && child.tagName === "code");  const meta = codeNode?.properties?.dataCodeMeta ?? codeNode?.data?.meta;  return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined;} type MarkdownAstNode = {  type?: string;  meta?: unknown;  data?: {    hProperties?: Record<string, unknown>;  };  children?: MarkdownAstNode[];}; function remarkPreserveCodeMeta() {  return (tree: MarkdownAstNode) => {    const visit = (node: MarkdownAstNode) => {      if (node.type === "code" && typeof node.meta === "string" && node.meta.trim().length > 0) {        node.data = {          ...node.data,          hProperties: {            ...node.data?.hProperties,            dataCodeMeta: node.meta.trim(),          },        };      }      node.children?.forEach(visit);    };     visit(tree);  };} /** * Fenced code also lands on the `code` component, and inline vs block is no * longer distinguishable there once both render `<code>` — so inline spans are * tagged on the mdast, where the distinction still exists. Code inside a link * label stays untagged: linkifying it would nest an anchor inside the link's * anchor and steal its clicks. */function remarkTagInlineCode() {  return (tree: MarkdownAstNode) => {    const visit = (node: MarkdownAstNode, insideLink: boolean) => {      if (node.type === "inlineCode" && !insideLink) {        node.data = {          ...node.data,          hProperties: {            ...node.data?.hProperties,            dataInlineCode: "",          },        };      }      const childInsideLink = insideLink || node.type === "link" || node.type === "linkReference";      node.children?.forEach((child) => visit(child, childInsideLink));    };     visit(tree, false);  };} function nodeToPlainText(node: ReactNode): string {  if (typeof node === "string" || typeof node === "number") {    return String(node);  }  if (Array.isArray(node)) {    return node.map((child) => nodeToPlainText(child)).join("");  }  if (isValidElement<{ children?: ReactNode }>(node)) {    return nodeToPlainText(node.props.children);  }  return "";} function extractCodeBlock(  children: ReactNode,): { className: string | undefined; code: string } | null {  const childNodes = Children.toArray(children);  if (childNodes.length !== 1) {    return null;  }   const onlyChild = childNodes[0];  if (    !isValidElement<{ className?: string; children?: ReactNode; node?: { tagName?: string } }>(      onlyChild,    )  ) {    return null;  }  // With a custom `code` component the child's type is that component, not  // the "code" tag — the hast node react-markdown attaches still names it.  if (onlyChild.type !== "code" && onlyChild.props.node?.tagName !== "code") {    return null;  }   return {    className: onlyChild.props.className,    code: nodeToPlainText(onlyChild.props.children),  };} function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string {  return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`;} function estimateHighlightedSize(html: string, code: string): number {  return Math.max(html.length * 2, code.length * 3);} function readInitialWordWrapSetting(): boolean {  return getClientSettings().wordWrap;} function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) {  const containerRef = useRef<HTMLDivElement | null>(null);  const tableRef = useRef<HTMLTableElement | null>(null);  const [expanded, setExpanded] = useState(readInitialWordWrapSetting);  const [copied, setCopied] = useState(false);  const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);  const expandLabel = expanded ? "Collapse table cells" : "Expand table cells";  const copyLabel = copied ? "Copied" : "Copy table";   function toggleExpanded() {    const table = tableRef.current;    if (!table) return;     if (!expanded) {      const rows = [...table.rows];      const columnWidths = rows.reduce<number[]>((widths, row) => {        [...row.cells].forEach((cell, columnIndex) => {          widths[columnIndex] = Math.max(            widths[columnIndex] ?? 0,            cell.getBoundingClientRect().width,          );        });        return widths;      }, []);       [...(table.tHead?.rows[0]?.cells ?? [])].forEach((cell, columnIndex) => {        cell.style.minWidth = `${columnWidths[columnIndex] ?? cell.getBoundingClientRect().width}px`;      });    }     setExpanded((value) => !value);  }   const handleCopy = useCallback((format: "markdown" | "csv") => {    const table = containerRef.current?.querySelector("table");    if (!table || typeof navigator === "undefined" || navigator.clipboard == null) {      return;    }    const text =      format === "markdown"        ? serializeTableElementToMarkdown(table)        : serializeTableElementToCsv(table);    void navigator.clipboard      .writeText(text)      .then(() => {        if (copiedTimerRef.current != null) {          clearTimeout(copiedTimerRef.current);        }        setCopied(true);        copiedTimerRef.current = setTimeout(() => {          setCopied(false);          copiedTimerRef.current = null;        }, 1200);      })      .catch((cause) => {        reportMarkdownActionFailure({ operation: "copy-table", format }, cause);      });  }, []);   useEffect(    () => () => {      if (copiedTimerRef.current != null) {        clearTimeout(copiedTimerRef.current);        copiedTimerRef.current = null;      }    },    [],  );   return (    <div      ref={containerRef}      className="chat-markdown-table-container"      data-expanded={expanded ? "true" : "false"}    >      <ScrollArea        chainVerticalScroll        scrollFade        hideScrollbars        className="w-full max-w-full rounded-none"      >        <table ref={tableRef} {...props}>          {children}        </table>      </ScrollArea>      <div className="chat-markdown-table-footer select-none">        <Tooltip>          <TooltipTrigger            render={              <Button                type="button"                variant="ghost"                size="icon-xs"                className="chat-markdown-chrome-action"                aria-pressed={expanded}                onClick={toggleExpanded}                aria-label={expandLabel}              />            }          >            {expanded ? <Minimize2Icon className="size-3" /> : <Maximize2Icon className="size-3" />}          </TooltipTrigger>          <TooltipPopup side="top">{expandLabel}</TooltipPopup>        </Tooltip>        <Menu>          <Tooltip>            <TooltipTrigger              render={                <MenuTrigger                  render={                    <Button                      type="button"                      variant="ghost"                      size="icon-xs"                      className="chat-markdown-chrome-action"                      aria-label={copyLabel}                    />                  }                />              }            >              {copied ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />}            </TooltipTrigger>            <TooltipPopup side="top">{copyLabel}</TooltipPopup>          </Tooltip>          <MenuPopup align="end">            <MenuItem onClick={() => handleCopy("markdown")}>Copy as Markdown</MenuItem>            <MenuItem onClick={() => handleCopy("csv")}>Copy as CSV</MenuItem>          </MenuPopup>        </Menu>      </div>    </div>  );}

Lines 1–500 of 1721.