import { IconCode, IconPlus, IconTrash } from "@tabler/icons-react"; import { useMemo, useRef, useState } from "react"; import { cn } from "../../utils.js"; import { ltrCodeBlockProps } from "../code-block-direction.js"; import type { BlockEditProps, BlockReadProps } from "../types.js"; import type { AnnotatedCodeAnnotation, AnnotatedCodeData, } from "./annotated-code.config.js"; import { AnnotationHiddenStack, AnnotationHoverCard, AnnotationInlineOverlayStack, AnnotationGutterMarker, anchorFromElements, buildLineMarkerMap, hasRailAnnotations, resolveAnnotations, useAnnotationMarginNotesAvailable, useAnnotationHover, type ResolvedAnnotation, } from "./annotation-rail.js"; import { useBlockCopy } from "./block-copy.js"; import { CodeFilenameLabel } from "./code-filename-label.js"; import { highlightCode, inferLanguageFromFilename, normalizeCodeLanguage, } from "./code-highlight.js"; import { DevInput, DevLabel, DevTextarea } from "./dev-doc-ui.js"; /** * "Explain this code" walkthrough block: a standard syntax-highlighted code * surface on the left with line-anchored annotation cards on the right (the * Stripe-docs / Sourcegraph layout). Each annotated line range gets a subtle * highlight band + an accent rail down the gutter; its card shows the `lines` * range, optional `label`, and the always-visible markdown `note` (via * `ctx.renderMarkdown`). Hovering a card highlights its lines and vice-versa. * * Syntax highlighting reuses the shared `highlightCode` lowlight helper (the same * colorful palette as the `code-tabs` block) per line, so it matches the app's * standard code styling and supports per-line bands without an async loader. The * surface uses the plan `--plan-code*`/`--plan-*` tokens and Tailwind `dark:` * pairs, so it reads correctly in BOTH light and dark mode. Code lines render as * ``s (never one `
` per line) so they don't pick up document
 * code/pre chrome. Lives in core so any app can register the dev-doc block.
 * Each annotated range also gets a numbered marker in the left gutter on the
 * first line, matching the diff block's annotation affordance without adding a
 * persistent note column.
 *
 * Editing is panel-driven (config-style, like the diff/HTML blocks): a monospace
 * code Textarea, filename/language Inputs, and add/remove-able annotation rows.
 */

/* ── Collapse helpers ──────────────────────────────────────────────────────── */

/**
 * Minimum total line count before collapse is considered. Short files render
 * fully expanded regardless of annotation coverage.
 */
const COLLAPSE_MIN_TOTAL_LINES = 40;

/**
 * Number of unannotated lines in a run that triggers collapse. Runs at or
 * below this threshold always stay expanded (no expander button).
 */
const COLLAPSE_THRESHOLD = 16;

/**
 * Context lines kept visible at each edge of a collapsed run (8 lines of
 * breathing room so the collapsed region is clearly framed).
 */
const COLLAPSE_CONTEXT_EDGE = 8;

type CollapsedSegment = {
  kind: "collapsed";
  startLine: number;
  endLine: number;
};
type VisibleSegment = { kind: "visible"; startLine: number; endLine: number };
type LineSegment = VisibleSegment | CollapsedSegment;

/**
 * Partition line numbers [1..lineCount] into visible and collapsed segments.
 * Annotated lines (and COLLAPSE_CONTEXT_EDGE lines on either side of them) are
 * always visible. Runs of unannotated lines longer than COLLAPSE_THRESHOLD are
 * collapsed. The file header (first COLLAPSE_CONTEXT_EDGE lines) is always
 * visible so context is preserved.
 */
function buildLineSegments(
  lineCount: number,
  lineMarkers: Map>,
): LineSegment[] {
  if (lineCount <= COLLAPSE_MIN_TOTAL_LINES) {
    return [{ kind: "visible", startLine: 1, endLine: lineCount }];
  }

  // Build a boolean array: true if the line must stay visible.
  const mustShow = new Array(lineCount + 1).fill(false);
  // File header always visible.
  for (let i = 1; i <= Math.min(COLLAPSE_CONTEXT_EDGE, lineCount); i += 1) {
    mustShow[i] = true;
  }
  // Annotated lines and their context edges.
  for (const lineNo of lineMarkers.keys()) {
    for (
      let i = Math.max(1, lineNo - COLLAPSE_CONTEXT_EDGE);
      i <= Math.min(lineCount, lineNo + COLLAPSE_CONTEXT_EDGE);
      i += 1
    ) {
      mustShow[i] = true;
    }
  }

  // Compute initial segments.
  const raw: LineSegment[] = [];
  let segStart = 1;
  let visible = mustShow[1] ?? false;
  for (let i = 2; i <= lineCount; i += 1) {
    const nextVisible = mustShow[i] ?? false;
    if (nextVisible !== visible) {
      raw.push(
        visible
          ? { kind: "visible", startLine: segStart, endLine: i - 1 }
          : { kind: "collapsed", startLine: segStart, endLine: i - 1 },
      );
      segStart = i;
      visible = nextVisible;
    }
  }
  raw.push(
    visible
      ? { kind: "visible", startLine: segStart, endLine: lineCount }
      : { kind: "collapsed", startLine: segStart, endLine: lineCount },
  );

  // Don't collapse short hidden runs — expand them in-place.
  return raw.map((seg) => {
    if (
      seg.kind === "collapsed" &&
      seg.endLine - seg.startLine + 1 <= COLLAPSE_THRESHOLD
    ) {
      return {
        kind: "visible",
        startLine: seg.startLine,
        endLine: seg.endLine,
      };
    }
    return seg;
  });
}

/* ── Read ──────────────────────────────────────────────────────────────────── */

function AnnotatedCodeRead({
  data,
  blockId,
  title,
  summary,
  ctx,
}: BlockReadProps) {
  const copy = useBlockCopy();
  // On-hover popover (anchored to the right of the code) replaces the old
  // persistent rail: nothing is visible when idle. `codeRef` measures the code
  // block's right edge; `hover` carries the active index + captured geometry.
  const hover = useAnnotationHover();
  const { activeIndex } = hover;
  const codeRef = useRef(null);
  const lineRefs = useRef(new Map());

  const setLineRef = (lineNo: number, node: HTMLDivElement | null) => {
    if (node) lineRefs.current.set(lineNo, node);
    else lineRefs.current.delete(lineNo);
  };

  const lines = useMemo(
    () => data.code.replace(/\n$/, "").split("\n"),
    [data.code],
  );
  const lineCount = lines.length;

  const language = useMemo(
    () =>
      normalizeCodeLanguage(data.language) ??
      inferLanguageFromFilename(data.filename),
    [data.language, data.filename],
  );

  // Highlight each line once; empty lines keep their height with a NBSP.
  const highlightedLines = useMemo(
    () =>
      lines.map((text) => (text.length ? highlightCode(text, language) : " ")),
    [lines, language],
  );

  const resolved = useMemo(
    () => resolveAnnotations(data.annotations, () => lineCount),
    [data.annotations, lineCount],
  );

  // line number (1-based) → resolved annotations covering it.
  const lineMarkers = useMemo(() => buildLineMarkerMap(resolved), [resolved]);

  const hasAnnotations = hasRailAnnotations(resolved);
  const showAnnotationOverlays = Boolean(ctx.showCodeAnnotationOverlays);
  const annotationLayout = ctx.codeAnnotationLayout;
  const annotationHoverSide = annotationLayout?.hoverSide ?? "right";
  const annotationHoverFallbackSide =
    annotationLayout?.hoverFallbackSide ?? "right";
  const annotationMarginSide = annotationLayout?.marginSide ?? "auto";
  const defaultVisibleAnnotations =
    annotationLayout?.defaultVisibleAnnotations ??
    (annotationLayout?.showByDefaultWhenRoom ? "all" : undefined);
  const showMarginAnnotations = useAnnotationMarginNotesAvailable({
    containerRef: codeRef,
    enabled: Boolean(
      hasAnnotations && !showAnnotationOverlays && defaultVisibleAnnotations,
    ),
    side: annotationMarginSide,
    preferredSide: annotationHoverSide,
  });
  const showPersistentAnnotations =
    showAnnotationOverlays || showMarginAnnotations;
  const persistentAnnotationIndexes = useMemo(() => {
    if (!showMarginAnnotations || !defaultVisibleAnnotations) {
      return new Set();
    }
    const visible = resolved.filter((item) => item.range);
    if (defaultVisibleAnnotations === "first") {
      const first = visible[0];
      return first ? new Set([first.index]) : new Set();
    }
    return new Set(visible.map((item) => item.index));
  }, [defaultVisibleAnnotations, resolved, showMarginAnnotations]);
  const captureOverlayAnnotationIndex = useMemo(
    () => resolved.find((item) => item.range)?.index ?? null,
    [resolved],
  );
  const langChip = data.language?.trim();
  const hasFilename = Boolean(data.filename?.trim());
  const showLangChip = Boolean(langChip && !hasFilename);

  // The resolved annotation whose card is currently shown on hover.
  const activeItem =
    useMemo | null>(
      () =>
        activeIndex == null
          ? null
          : (resolved.find((item) => item.index === activeIndex) ?? null),
      [activeIndex, resolved],
    );
  const activeItemIsPersistentlyVisible = Boolean(
    activeItem &&
    !showAnnotationOverlays &&
    persistentAnnotationIndexes.has(activeItem.index),
  );

  // Line-collapse state: a set of collapsed segment start lines that have been
  // expanded by the reader. Starts empty (all segments in their default state).
  const [expandedCollapsed, setExpandedCollapsed] = useState>(
    () => new Set(),
  );

  const segments = useMemo(
    () => buildLineSegments(lineCount, lineMarkers),
    [lineCount, lineMarkers],
  );

  const renderLine = (lineNo: number) => {
    const markers = lineMarkers.get(lineNo);
    const isAnnotated = !!markers?.length;
    const isActive =
      activeIndex != null && !!markers?.some((m) => m.index === activeIndex);
    const overlayItems =
      showPersistentAnnotations && markers
        ? markers.filter(
            (item) =>
              item.range?.start === lineNo &&
              (showAnnotationOverlays
                ? item.index === captureOverlayAnnotationIndex
                : persistentAnnotationIndexes.has(item.index)),
          )
        : [];
    const rowHasPersistentAnnotation = Boolean(
      markers?.some((item) => persistentAnnotationIndexes.has(item.index)),
    );
    const rangeStartMarkers =
      markers?.filter((item) => item.range?.start === lineNo) ?? [];
    const showMarkerColumn = hasAnnotations;

    const buildAnchorForItem = (
      item: ResolvedAnnotation,
      fallbackRow: HTMLElement,
    ) => {
      const anchorLine = item.range?.start ?? lineNo;
      const anchorRow = lineRefs.current.get(anchorLine) ?? fallbackRow;
      return anchorFromElements(codeRef.current, anchorRow);
    };

    const openAnnotation = (
      item: ResolvedAnnotation,
      row: HTMLElement,
    ) => {
      const anchor = buildAnchorForItem(item, row);
      if (anchor) hover.open(item.index, anchor);
    };

    return (
      
setLineRef(lineNo, node)} data-code-line={lineNo} data-annot-row={isAnnotated ? markers?.[0].index : undefined} tabIndex={isAnnotated ? 0 : undefined} role={isAnnotated ? "button" : undefined} aria-expanded={isAnnotated ? isActive : undefined} aria-label={ isAnnotated ? copy.lineAnnotation.replace("{{line}}", String(lineNo)) : undefined } className={cn( "relative flex w-full", isAnnotated && "cursor-pointer", isActive ? "bg-amber-400/[0.12] dark:bg-amber-300/[0.10]" : rowHasPersistentAnnotation ? "bg-amber-300/[0.14] dark:bg-amber-300/[0.10]" : isAnnotated && showAnnotationOverlays ? "bg-amber-300/[0.14] dark:bg-amber-300/[0.10]" : isAnnotated ? "bg-amber-400/[0.045] dark:bg-amber-300/[0.045]" : null, )} onMouseEnter={ isAnnotated && markers ? (event) => { openAnnotation(markers[0], event.currentTarget); } : undefined } onMouseLeave={isAnnotated ? () => hover.scheduleClose() : undefined} onClick={ isAnnotated && markers ? (event) => { openAnnotation(markers[0], event.currentTarget); } : undefined } onKeyDown={ isAnnotated && markers ? (event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); openAnnotation(markers[0], event.currentTarget); } : undefined } onFocus={ isAnnotated && markers ? (event) => { openAnnotation(markers[0], event.currentTarget); } : undefined } onBlur={isAnnotated ? () => hover.scheduleClose() : undefined} > {lineNo} {showMarkerColumn && ( 0 ? "" : undefined } > {rangeStartMarkers.map((item) => ( { event.stopPropagation(); const row = event.currentTarget.closest( "[data-code-line]", ) ?? event.currentTarget; openAnnotation(item, row); }} onClick={(event) => { event.stopPropagation(); const row = event.currentTarget.closest( "[data-code-line]", ) ?? event.currentTarget; openAnnotation(item, row); }} className="inline-flex cursor-pointer outline-none transition-transform hover:scale-110" > ))} )} {highlightedLines[lineNo - 1]} {overlayItems.length > 0 && ( )}
); }; const codeSurface = (
{(hasFilename || showLangChip) && (
{showLangChip && ( {langChip} )}
)}
{segments.map((seg) => { if (seg.kind === "visible") { const lineNos = Array.from( { length: seg.endLine - seg.startLine + 1 }, (_, i) => seg.startLine + i, ); return lineNos.map(renderLine); } // Collapsed segment — show an expander row. const isExpanded = expandedCollapsed.has(seg.startLine); const hiddenCount = seg.endLine - seg.startLine + 1; if (isExpanded) { const lineNos = Array.from( { length: hiddenCount }, (_, i) => seg.startLine + i, ); return lineNos.map(renderLine); } return ( ); })}
); return (
{title &&
{title}
} {/* The code keeps its full width — no persistent annotation column. Notes live in a visually-hidden stack (a11y + tests) and surface ONE at a time as an on-hover popover anchored to the right of the code. */} {codeSurface} {hasAnnotations && ( )} {hasAnnotations && !showAnnotationOverlays && !activeItemIsPersistentlyVisible && activeItem && hover.anchor && ( )} {summary &&

{summary}

}
); } /* ── Edit (panel) ──────────────────────────────────────────────────────────── */ const codeAreaClass = "min-h-[160px] font-mono [font-size:var(--plan-code-size)] leading-5"; function AnnotatedCodeEdit({ data, onChange, editable, }: BlockEditProps) { const annotations = data.annotations ?? []; const patch = (next: Partial) => onChange({ ...data, ...next }); const updateAnnotation = ( index: number, next: Partial, ) => patch({ annotations: annotations.map((annotation, i) => i === index ? { ...annotation, ...next } : annotation, ), }); const removeAnnotation = (index: number) => patch({ annotations: annotations.filter((_, i) => i !== index) }); const addAnnotation = () => { if (annotations.length >= 80) return; // schema max patch({ annotations: [...annotations, { lines: "1", label: "", note: "" }], }); }; return (
Filename patch({ filename: event.target.value || undefined }) } />
Language patch({ language: event.target.value || undefined }) } />
Code patch({ code: event.target.value })} />
Annotations {editable && annotations.length < 80 && ( )}
{annotations.length === 0 && (

No annotations yet. Add one to anchor a note to a line range.

)} {annotations.map((annotation, index) => (
updateAnnotation(index, { lines: event.target.value }) } /> updateAnnotation(index, { label: event.target.value || undefined, }) } /> {editable && ( )}
updateAnnotation(index, { note: event.target.value }) } />
))}
); } export { AnnotatedCodeRead, AnnotatedCodeEdit };