FileLinkMenu.tsx3.2 KBView on GitHub
'use client';

import { forwardRef, useImperativeHandle, useMemo, useState } from 'react';
import { FileText, Table2 } from 'lucide-react';
import { formatTableSummary } from '@/modules/documents/table/table-metadata';
import { cn } from '@/lib/utils';
import type { FileLinkSuggestionItem } from './FileLinkSuggestion';

interface FileLinkMenuProps {
  items: FileLinkSuggestionItem[];
  command: (item: FileLinkSuggestionItem) => void;
}

export interface FileLinkMenuHandle {
  onKeyDown: (data: { event: KeyboardEvent }) => boolean;
}

export const FileLinkMenu = forwardRef<FileLinkMenuHandle, FileLinkMenuProps>(
  function FileLinkMenu({ items, command }, ref) {
    const [selectedIndex, setSelectedIndex] = useState(0);

    const safeSelectedIndex = useMemo(() => {
      if (items.length === 0) return 0;
      return Math.min(selectedIndex, items.length - 1);
    }, [items.length, selectedIndex]);

    useImperativeHandle(ref, () => ({
      onKeyDown: ({ event }) => {
        if (event.key === 'ArrowDown') {
          setSelectedIndex((index) => (items.length ? (index + 1) % items.length : 0));
          return true;
        }
        if (event.key === 'ArrowUp') {
          setSelectedIndex((index) => (items.length ? (index + items.length - 1) % items.length : 0));
          return true;
        }
        if (event.key === 'Enter') {
          const item = items[safeSelectedIndex];
          if (item) command(item);
          return true;
        }
        return false;
      },
    }));

    return (
      <div className="max-h-72 w-[min(32rem,calc(100vw-2rem))] overflow-x-hidden overflow-y-auto rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg">
        {items.length === 0 ? (
          <div className="px-3 py-2 text-xs text-muted-foreground">No matching files</div>
        ) : (
          items.map((item, index) => (
            <button
              key=[redacted]
              type="button"
              className={cn(
                'flex w-full items-start gap-2 rounded-md px-2 py-2 text-left text-sm',
                index === safeSelectedIndex ? 'bg-muted' : 'hover:bg-muted/70',
              )}
              onMouseEnter={() => setSelectedIndex(index)}
              onMouseDown={(event) => {
                event.preventDefault();
                command(item);
              }}
            >
              {item.documentType === 'table' ? (
                <Table2 className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
              ) : (
                <FileText className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
              )}
              <span className="min-w-0 flex-1">
                <span className="block truncate font-medium">{item.label}</span>
                {/* Prefer the conversation name for conversation-scoped files — the raw
                    path is `conversation/<uuid>/...`, which is unreadable. A table adds its
                    shape ("38 rows × 6 columns") when the search surfaced its metadata. */}
                <span className="block truncate text-xs text-muted-foreground">
                  {formatTableSummary(item.metadata) ?? item.conversationName ?? item.path}
                </span>
              </span>
            </button>
          ))
        )}
      </div>
    );
  },
);