useFileUpload.ts5.5 KBView on GitHub
/**
 * Hook used by drop zones to enqueue a file upload. Returns callbacks that
 * accept a File + scope/parent (or threadId for chat) and drive it through
 * `runUpload` while updating the documentsSlice for UI reads.
 */

import { useCallback } from 'react';
import { useTRPCClient } from '@/providers/query-provider';
import { useCedarStore } from '@/modules/store';

import type { FileScope } from '../store/documentsSlice';
import type { DroppedFile } from './dropTraversal';
import { runUpload } from './uploadRunner';

export function useFileUpload() {
  const trpcClient = useTRPCClient();

  const uploadToFolder = useCallback(
    (args: {
      scope: FileScope;
      parentId: string | null;
      file: File;
      documentType?: 'attachment' | 'custom' | 'kb_item';
    }) => {
      const { opId, tempId, rollback } = useCedarStore.getState().optimisticCreateAttachment({
        scope: args.scope,
        parentId: args.parentId,
        documentType: args.documentType,
        filename: args.file.name,
        mimeType: args.file.type || guessMimeType(args.file.name),
        sizeBytes: args.file.size,
      });
      void runUpload({ opId, file: args.file, trpc: trpcClient as never });
      return { opId, tempId, rollback };
    },
    [trpcClient],
  );

  const uploadToChat = useCallback(
    (args: { threadId: string; file: File }) => {
      const { opId, rollback } = useCedarStore.getState().attachToChat({
        threadId: args.threadId,
        filename: args.file.name,
        mimeType: args.file.type || guessMimeType(args.file.name),
        sizeBytes: args.file.size,
      });
      void runUpload({ opId, file: args.file, trpc: trpcClient as never });
      return { opId, rollback };
    },
    [trpcClient],
  );

  /**
   * Upload an entire dropped folder tree into the explorer. Creates folder
   * documents for each subdirectory (idempotent — pre-existing folders are
   * reused) and uploads each file into its corresponding parent folder.
   */
  const uploadDroppedTree = useCallback(
    async (args: { scope: FileScope; rootParentId: string | null; entries: DroppedFile[] }) => {
      // Build the unique set of folder paths needed and create them top-down.
      const folderPaths = new Set<string>();
      for (const e of args.entries) {
        const segments = e.relativePath.split('/');
        segments.pop(); // drop the filename
        let acc = '';
        for (const seg of segments) {
          acc = acc ? `${acc}/${seg}` : seg;
          folderPaths.add(acc);
        }
      }
      // Sort by depth so parents are created before children.
      const sortedPaths = Array.from(folderPaths).sort(
        (a, b) => a.split('/').length - b.split('/').length || a.localeCompare(b),
      );

      const folderIdByPath = new Map<string, string | null>();
      folderIdByPath.set('', args.rootParentId);

      for (const path of sortedPaths) {
        const lastSlash = path.lastIndexOf('/');
        const parentPath = lastSlash === -1 ? '' : path.slice(0, lastSlash);
        const name = lastSlash === -1 ? path : path.slice(lastSlash + 1);
        const parentId = folderIdByPath.get(parentPath) ?? args.rootParentId;
        try {
          const created = await trpcClient.files.createFolder.mutate({
            scope: args.scope,
            parentId,
            name,
          });
          folderIdByPath.set(path, (created as { id: string }).id);
        } catch {
          // Folder may already exist — try resolving by listing children.
          const siblings = (await trpcClient.files.listChildren.query({
            scope: args.scope,
            parentId,
          })) as Array<{ id: string; title: string | null; path: string; documentType: string }>;
          const slug = name
            .trim()
            .toLowerCase()
            .replace(/[^a-z0-9]+/g, '-')
            .replace(/^-+|-+$/g, '');
          const match = siblings.find(
            (s) =>
              s.documentType === 'folder' &&
              (s.title?.trim().toLowerCase() === name.toLowerCase() ||
                s.path.split('/').pop() === slug),
          );
          if (match) folderIdByPath.set(path, match.id);
        }
      }

      // Now upload each file into its computed parent folder.
      for (const entry of args.entries) {
        const segments = entry.relativePath.split('/');
        segments.pop();
        const parentPath = segments.join('/');
        const parentId = folderIdByPath.get(parentPath) ?? args.rootParentId;
        uploadToFolder({ scope: args.scope, parentId, file: entry.file });
      }
    },
    [trpcClient, uploadToFolder],
  );

  return { uploadToFolder, uploadToChat, uploadDroppedTree };
}

function guessMimeType(filename: string): string {
  const lower = filename.toLowerCase();
  if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'text/markdown';
  if (lower.endsWith('.pdf')) return 'application/pdf';
  if (lower.endsWith('.docx'))
    return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
  if (lower.endsWith('.doc')) return 'application/msword';
  if (lower.endsWith('.pptx'))
    return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
  if (lower.endsWith('.ppt')) return 'application/vnd.ms-powerpoint';
  if (lower.endsWith('.png')) return 'image/png';
  if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
  if (lower.endsWith('.gif')) return 'image/gif';
  if (lower.endsWith('.webp')) return 'image/webp';
  if (lower.endsWith('.svg')) return 'image/svg+xml';
  return 'application/octet-stream';
}