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';
}