Repository Public
1'use client';2import { useEffect, useState } from 'react';3import { useQueryClient } from '@tanstack/react-query';4import { api, ApiRequestError, errorMessage, uploadFile } from '@/lib/api';5import { basename, dirname, joinPath } from '@/lib/format';6import { Dialog, ConfirmDialog } from '@/components/ui/dialog';7import { Button } from '@/components/ui/button';8import { Field, Input } from '@/components/ui/input';9import { toast } from '@/stores/toasts';10import { useEditor } from '@/components/editor/editor-store';1112export type FileOp =13 | { type: 'new-file'; dir: string }14 | { type: 'new-folder'; dir: string }15 | { type: 'rename'; path: string }16 | { type: 'move'; path: string }17 | { type: 'delete'; path: string; isDir: boolean };1819const validName = (n: string) => n.length > 0 && !n.includes('/') && n !== '.' && n !== '..';20const validRel = (p: string) => p.length > 0 && !p.startsWith('/') && !p.split('/').some((s) => s === '..' || s === '');2122export function FileOpDialogs({ projectId, op, onClose, onCreated }: { projectId: string; op: FileOp | null; onClose: () => void; onCreated?: (path: string) => void }) {23 const qc = useQueryClient();24 const [value, setValue] = useState('');25 const [busy, setBusy] = useState(false);26 const [error, setError] = useState<string | null>(null);27 useEffect(() => {28 setError(null);29 setValue(op?.type === 'rename' ? basename(op.path) : op?.type === 'move' ? op.path : '');30 }, [op]);31 const refresh = () => {32 void qc.invalidateQueries({ queryKey: ['files', projectId] });33 void qc.invalidateQueries({ queryKey: ['git', projectId] });34 };35 if (!op) return null;36 if (op.type === 'delete')37 return (38 <ConfirmDialog39 open40 onOpenChange={(o) => !o && onClose()}41 title={`Delete ${op.isDir ? 'folder' : 'file'}?`}42 description={<><span className="break-all font-mono text-text">{op.path}</span> will be permanently deleted from the server{op.isDir ? ', including everything inside it' : ''}. This cannot be undone.</>}43 confirmLabel="Delete"44 danger45 onConfirm={async () => {46 try {47 await api.post(`/projects/${projectId}/fs`, { op: 'delete', path: op.path, confirm: true });48 useEditor.getState().removePath(projectId, op.path);49 refresh();50 } catch (e) {51 toast.error('Delete failed', errorMessage(e));52 }53 }}54 />55 );56 const meta = {57 'new-file': { title: 'New file', label: 'File name', cta: 'Create' },58 'new-folder': { title: 'New folder', label: 'Folder name', cta: 'Create' },59 rename: { title: 'Rename', label: 'New name', cta: 'Rename' },60 move: { title: 'Move', label: 'Destination path (relative to the project root)', cta: 'Move' },61 }[op.type];62 const submit = async () => {63 const v = value.trim();64 if (op.type === 'move' ? !validRel(v) : !validName(v)) return setError(op.type === 'move' ? 'Enter a relative path without “..”.' : 'Enter a name without “/”.');65 setBusy(true);66 setError(null);67 try {68 if (op.type === 'new-file') {69 const path = joinPath(op.dir, v);70 await api.put(`/projects/${projectId}/file`, { path, content: '', create: true });71 onCreated?.(path);72 } else if (op.type === 'new-folder') await api.post(`/projects/${projectId}/fs`, { op: 'mkdir', path: joinPath(op.dir, v) });73 else {74 const to = op.type === 'rename' ? joinPath(dirname(op.path), v) : v;75 if (to !== op.path) {76 await api.post(`/projects/${projectId}/fs`, { op: 'rename', from: op.path, to });77 useEditor.getState().renamePath(projectId, op.path, to);78 }79 }80 refresh();81 onClose();82 } catch (e) {83 setError(errorMessage(e));84 } finally {85 setBusy(false);86 }87 };88 return (89 <Dialog90 open91 onOpenChange={(o) => !o && onClose()}92 title={meta.title}93 description={'dir' in op ? `In ${op.dir || 'the project root'}` : op.path}94 size="sm"95 footer={96 <>97 <Button variant="ghost" onClick={onClose}>Cancel</Button>98 <Button variant="primary" loading={busy} onClick={submit} data-testid="file-op-submit">{meta.cta}</Button>99 </>100 }101 >102 <form onSubmit={(e) => { e.preventDefault(); void submit(); }}>103 <Field label={meta.label} error={error}>104 <Input autoFocus value={value} onChange={(e) => setValue(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" data-testid="file-op-input" />105 </Field>106 </form>107 </Dialog>108 );109}110111export interface UploadItem {112 id: number;113 name: string;114 fraction: number;115 error: string | null;116 done: boolean;117 /** Set when the destination already exists (409): the user may replace it explicitly. */118 replace?: () => void;119}120let uploadSeq = 0;121/** Sequential uploads with progress into `dir`. */122export function useUploads(projectId: string | null): { items: UploadItem[]; upload: (files: FileList | File[], dir: string) => Promise<void>; clear: () => void } {123 const qc = useQueryClient();124 const [items, setItems] = useState<UploadItem[]>([]);125 const refresh = () => {126 void qc.invalidateQueries({ queryKey: ['files', projectId] });127 void qc.invalidateQueries({ queryKey: ['git', projectId] });128 };129 const upload = async (files: FileList | File[], dir: string) => {130 if (!projectId) return;131 for (const file of Array.from(files)) {132 const id = ++uploadSeq;133 const set = (p: Partial<UploadItem>) => setItems((l) => l.map((i) => (i.id === id ? { ...i, ...p } : i)));134 setItems((l) => [...l, { id, name: file.name, fraction: 0, error: null, done: false }]);135 if (file.size > 200 * 1024 * 1024) {136 set({ error: 'Larger than the 200 MiB upload limit', done: true });137 continue;138 }139 const attempt = async (overwrite: boolean): Promise<void> => {140 set({ error: null, done: false, fraction: 0, replace: undefined });141 try {142 await uploadFile(projectId, joinPath(dir, file.name), file, (f) => set({ fraction: f }), overwrite);143 set({ fraction: 1, done: true });144 } catch (e) {145 const exists = e instanceof ApiRequestError && e.status === 409;146 set({ error: exists ? 'Already exists' : errorMessage(e), done: true, replace: exists ? () => void attempt(true).then(refresh) : undefined });147 }148 };149 await attempt(false);150 }151 refresh();152 };153 return { items, upload, clear: () => setItems((l) => l.filter((i) => !i.done)) };154}155