811 lines · 23.1 KB · TypeScript
RawDownload
import {  LexicalComposer,  type InitialConfigType,} from "@lexical/react/LexicalComposer";import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";import { ContentEditable } from "@lexical/react/LexicalContentEditable";import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";import {  $applyNodeReplacement,  $createRangeSelection,  $getSelection,  $setSelection,  $isElementNode,  $isLineBreakNode,  $isRangeSelection,  $isTextNode,  $createLineBreakNode,  $createParagraphNode,  $createTextNode,  KEY_ARROW_DOWN_COMMAND,  KEY_ARROW_LEFT_COMMAND,  KEY_ARROW_RIGHT_COMMAND,  KEY_ARROW_UP_COMMAND,  KEY_ENTER_COMMAND,  KEY_TAB_COMMAND,  COMMAND_PRIORITY_HIGH,  KEY_BACKSPACE_COMMAND,  $getRoot,  type ElementNode,  type LexicalNode,  TextNode,  type EditorConfig,  type EditorState,  type NodeKey,  type SerializedTextNode,  type Spread,} from "lexical";import {  forwardRef,  useCallback,  useEffect,  useImperativeHandle,  useLayoutEffect,  useMemo,  useRef,  type ClipboardEventHandler,  type Ref,} from "react"; import { isCollapsedCursorAdjacentToMention } from "~/composer-logic";import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";import { cn } from "~/lib/utils";import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons"; const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; type SerializedComposerMentionNode = Spread<  {    path: string;    type: "composer-mention";    version: 1;  },  SerializedTextNode>; class ComposerMentionNode extends TextNode {  __path: string;   static override getType(): string {    return "composer-mention";  }   static override clone(node: ComposerMentionNode): ComposerMentionNode {    return new ComposerMentionNode(node.__path, node.__key);  }   static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode {    return $createComposerMentionNode(serializedNode.path);  }   constructor(path: string, key?: NodeKey) {    const normalizedPath = path.startsWith("@") ? path.slice(1) : path;    super(`@${normalizedPath}`, key);    this.__path = normalizedPath;  }   override exportJSON(): SerializedComposerMentionNode {    return {      ...super.exportJSON(),      path: this.__path,      type: "composer-mention",      version: 1,    };  }   override createDOM(_config: EditorConfig): HTMLElement {    const dom = document.createElement("span");    dom.className =      "inline-flex select-none items-center gap-1 rounded-md border border-border/70 bg-accent/40 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-foreground align-middle";    dom.contentEditable = "false";    dom.setAttribute("spellcheck", "false");    renderMentionChipDom(dom, this.__path);    return dom;  }   override updateDOM(    prevNode: ComposerMentionNode,    dom: HTMLElement,    _config: EditorConfig,  ): boolean {    dom.contentEditable = "false";    if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) {      renderMentionChipDom(dom, this.__path);    }    return false;  }   override canInsertTextBefore(): false {    return false;  }   override canInsertTextAfter(): false {    return false;  }   override isTextEntity(): true {    return true;  }   override isToken(): true {    return true;  }} function $createComposerMentionNode(path: string): ComposerMentionNode {  return $applyNodeReplacement(new ComposerMentionNode(path));} function inferMentionPathKind(pathValue: string): "file" | "directory" {  const base = basenameOfPath(pathValue);  if (base.startsWith(".") && !base.slice(1).includes(".")) {    return "directory";  }  if (base.includes(".")) {    return "file";  }  return "directory";} function resolvedThemeFromDocument(): "light" | "dark" {  return document.documentElement.classList.contains("dark") ? "dark" : "light";} function renderMentionChipDom(container: HTMLElement, pathValue: string): void {  container.textContent = "";  container.style.setProperty("user-select", "none");  container.style.setProperty("-webkit-user-select", "none");   const theme = resolvedThemeFromDocument();  const icon = document.createElement("img");  icon.alt = "";  icon.ariaHidden = "true";  icon.className = "size-3.5 shrink-0 opacity-85";  icon.loading = "lazy";  icon.src = getVscodeIconUrlForEntry(pathValue, inferMentionPathKind(pathValue), theme);   const label = document.createElement("span");  label.className = "truncate select-none leading-tight";  label.textContent = basenameOfPath(pathValue);   container.append(icon, label);} function clampCursor(value: string, cursor: number): number {  if (!Number.isFinite(cursor)) return value.length;  return Math.max(0, Math.min(value.length, Math.floor(cursor)));} function getComposerNodeTextLength(node: LexicalNode): number {  if (node instanceof ComposerMentionNode) {    return 1;  }  if ($isTextNode(node)) {    return node.getTextContentSize();  }  if ($isLineBreakNode(node)) {    return 1;  }  if ($isElementNode(node)) {    return node.getChildren().reduce((total, child) => total + getComposerNodeTextLength(child), 0);  }  return 0;} function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number {  let offset = 0;  let current: LexicalNode | null = node;   while (current) {    const nextParent = current.getParent() as LexicalNode | null;    if (!nextParent || !$isElementNode(nextParent)) {      break;    }    const siblings = nextParent.getChildren();    const index = current.getIndexWithinParent();    for (let i = 0; i < index; i += 1) {      const sibling = siblings[i];      if (!sibling) continue;      offset += getComposerNodeTextLength(sibling);    }    current = nextParent;  }   if ($isTextNode(node)) {    if (node instanceof ComposerMentionNode) {      return offset + (pointOffset > 0 ? 1 : 0);    }    return offset + Math.min(pointOffset, node.getTextContentSize());  }   if ($isLineBreakNode(node)) {    return offset + Math.min(pointOffset, 1);  }   if ($isElementNode(node)) {    const children = node.getChildren();    const clampedOffset = Math.max(0, Math.min(pointOffset, children.length));    for (let i = 0; i < clampedOffset; i += 1) {      const child = children[i];      if (!child) continue;      offset += getComposerNodeTextLength(child);    }    return offset;  }   return offset;} function findSelectionPointAtOffset(  node: LexicalNode,  remainingRef: { value: number },): { key: string; offset: number; type: "text" | "element" } | null {  if (node instanceof ComposerMentionNode) {    const parent = node.getParent();    if (!parent || !$isElementNode(parent)) return null;    const index = node.getIndexWithinParent();    if (remainingRef.value === 0) {      return {        key: parent.getKey(),        offset: index,        type: "element",      };    }    if (remainingRef.value === 1) {      return {        key: parent.getKey(),        offset: index + 1,        type: "element",      };    }    remainingRef.value -= 1;    return null;  }   if ($isTextNode(node)) {    const size = node.getTextContentSize();    if (remainingRef.value <= size) {      return {        key: node.getKey(),        offset: remainingRef.value,        type: "text",      };    }    remainingRef.value -= size;    return null;  }   if ($isLineBreakNode(node)) {    const parent = node.getParent();    if (!parent) return null;    const index = node.getIndexWithinParent();    if (remainingRef.value === 0) {      return {        key: parent.getKey(),        offset: index,        type: "element",      };    }    if (remainingRef.value === 1) {      return {        key: parent.getKey(),        offset: index + 1,        type: "element",      };    }    remainingRef.value -= 1;    return null;  }   if ($isElementNode(node)) {    const children = node.getChildren();    for (const child of children) {      const point = findSelectionPointAtOffset(child, remainingRef);      if (point) {        return point;      }    }    if (remainingRef.value === 0) {      return {        key: node.getKey(),        offset: children.length,        type: "element",      };    }  }   return null;} function $getComposerRootLength(): number {  const root = $getRoot();  const children = root.getChildren();  return children.reduce((sum, child) => sum + getComposerNodeTextLength(child), 0);} function $setSelectionAtComposerOffset(nextOffset: number): void {  const root = $getRoot();  const composerLength = $getComposerRootLength();  const boundedOffset = Math.max(0, Math.min(nextOffset, composerLength));  const remainingRef = { value: boundedOffset };  const point =    findSelectionPointAtOffset(root, remainingRef) ?? {      key: root.getKey(),      offset: root.getChildren().length,      type: "element" as const,    };  const selection = $createRangeSelection();  selection.anchor.set(point.key, point.offset, point.type);  selection.focus.set(point.key, point.offset, point.type);  $setSelection(selection);} function $readSelectionOffsetFromEditorState(fallback: number): number {  const selection = $getSelection();  if (!$isRangeSelection(selection) || !selection.isCollapsed()) {    return fallback;  }  const anchorNode = selection.anchor.getNode();  const offset = getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset);  const composerLength = $getComposerRootLength();  return Math.max(0, Math.min(offset, composerLength));} function $appendTextWithLineBreaks(parent: ElementNode, text: string): void {  const lines = text.split("\n");  for (let index = 0; index < lines.length; index += 1) {    const line = lines[index] ?? "";    if (line.length > 0) {      parent.append($createTextNode(line));    }    if (index < lines.length - 1) {      parent.append($createLineBreakNode());    }  }} function $setComposerEditorPrompt(prompt: string): void {  const root = $getRoot();  root.clear();  const paragraph = $createParagraphNode();  root.append(paragraph);   const segments = splitPromptIntoComposerSegments(prompt);  for (const segment of segments) {    if (segment.type === "mention") {      paragraph.append($createComposerMentionNode(segment.path));      continue;    }    $appendTextWithLineBreaks(paragraph, segment.text);  }} export interface ComposerPromptEditorHandle {  focus: () => void;  focusAt: (cursor: number) => void;  focusAtEnd: () => void;  readSnapshot: () => { value: string; cursor: number };} interface ComposerPromptEditorProps {  value: string;  cursor: number;  disabled: boolean;  placeholder: string;  className?: string;  onChange: (nextValue: string, nextCursor: number, cursorAdjacentToMention: boolean) => void;  onCommandKeyDown?: (    key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",    event: KeyboardEvent,  ) => boolean;  onPaste: ClipboardEventHandler<HTMLElement>;} interface ComposerPromptEditorInnerProps extends ComposerPromptEditorProps {  editorRef: Ref<ComposerPromptEditorHandle>;} function ComposerCommandKeyPlugin(props: {  onCommandKeyDown?: (    key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",    event: KeyboardEvent,  ) => boolean;}) {  const [editor] = useLexicalComposerContext();   useEffect(() => {    const handleCommand = (      key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",      event: KeyboardEvent | null,    ): boolean => {      if (!props.onCommandKeyDown || !event) {        return false;      }      const handled = props.onCommandKeyDown(key, event);      if (handled) {        event.preventDefault();        event.stopPropagation();      }      return handled;    };     const unregisterArrowDown = editor.registerCommand(      KEY_ARROW_DOWN_COMMAND,      (event) => handleCommand("ArrowDown", event),      COMMAND_PRIORITY_HIGH,    );    const unregisterArrowUp = editor.registerCommand(      KEY_ARROW_UP_COMMAND,      (event) => handleCommand("ArrowUp", event),      COMMAND_PRIORITY_HIGH,    );    const unregisterEnter = editor.registerCommand(      KEY_ENTER_COMMAND,      (event) => handleCommand("Enter", event),      COMMAND_PRIORITY_HIGH,    );    const unregisterTab = editor.registerCommand(      KEY_TAB_COMMAND,      (event) => handleCommand("Tab", event),      COMMAND_PRIORITY_HIGH,    );     return () => {      unregisterArrowDown();      unregisterArrowUp();      unregisterEnter();      unregisterTab();    };  }, [editor, props]);   return null;} function ComposerMentionArrowPlugin() {  const [editor] = useLexicalComposerContext();   useEffect(() => {    const unregisterLeft = editor.registerCommand(      KEY_ARROW_LEFT_COMMAND,      (event) => {        let nextOffset: number | null = null;        editor.getEditorState().read(() => {          const selection = $getSelection();          if (!$isRangeSelection(selection) || !selection.isCollapsed()) return;          const currentOffset = $readSelectionOffsetFromEditorState(0);          if (currentOffset <= 0) return;          const promptValue = $getRoot().getTextContent();          if (!isCollapsedCursorAdjacentToMention(promptValue, currentOffset, "left")) {            return;          }          nextOffset = currentOffset - 1;        });        if (nextOffset === null) return false;        const selectionOffset = nextOffset;        event?.preventDefault();        event?.stopPropagation();        editor.update(() => {          $setSelectionAtComposerOffset(selectionOffset);        });        return true;      },      COMMAND_PRIORITY_HIGH,    );    const unregisterRight = editor.registerCommand(      KEY_ARROW_RIGHT_COMMAND,      (event) => {        let nextOffset: number | null = null;

Lines 1–500 of 811.