SPB Git

Repository Public

file-ops.tsx 6.4 KB 155 lines tsx

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
Files main
Menu

Theme