fix: resolve IDE errors, minor UI tweaks, and implement file conflict resolution UI/API
Some checks failed
Automated Container Build / build-and-push (push) Failing after 1m13s
Some checks failed
Automated Container Build / build-and-push (push) Failing after 1m13s
This commit is contained in:
parent
3e34a37c95
commit
10bab4ea5b
7 changed files with 625 additions and 58 deletions
|
|
@ -433,14 +433,18 @@ func (h *FSHandler) Move(c *fiber.Ctx) error {
|
||||||
uniqueName := GetUniquePath(dstFull, filepath.Base(srcFull))
|
uniqueName := GetUniquePath(dstFull, filepath.Base(srcFull))
|
||||||
dstFull = filepath.Join(dstFull, uniqueName)
|
dstFull = filepath.Join(dstFull, uniqueName)
|
||||||
body.DestPath = filepath.Join(body.DestPath, uniqueName)
|
body.DestPath = filepath.Join(body.DestPath, uniqueName)
|
||||||
} else {
|
} else if err == nil {
|
||||||
// If destination is not a directory but the file exists (rename), we don't overwrite if they want `(1)`
|
if body.Resolution == "skip" {
|
||||||
// Wait, if they intentionally rename to an existing file, should it append (1)?
|
return c.JSON(fiber.Map{"message": "skipped"})
|
||||||
// Yes, to prevent accidental overwrites.
|
}
|
||||||
|
if body.Resolution != "overwrite" {
|
||||||
parentDir := filepath.Dir(dstFull)
|
parentDir := filepath.Dir(dstFull)
|
||||||
uniqueName := GetUniquePath(parentDir, filepath.Base(dstFull))
|
uniqueName := GetUniquePath(parentDir, filepath.Base(dstFull))
|
||||||
dstFull = filepath.Join(parentDir, uniqueName)
|
dstFull = filepath.Join(parentDir, uniqueName)
|
||||||
body.DestPath = filepath.Join(filepath.Dir(body.DestPath), uniqueName)
|
body.DestPath = filepath.Join(filepath.Dir(body.DestPath), uniqueName)
|
||||||
|
} else {
|
||||||
|
os.RemoveAll(dstFull)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.Rename(srcFull, dstFull); err != nil {
|
if err := os.Rename(srcFull, dstFull); err != nil {
|
||||||
|
|
@ -457,13 +461,16 @@ func (h *FSHandler) Move(c *fiber.Ctx) error {
|
||||||
return c.JSON(fiber.Map{"message": "moved successfully"})
|
return c.JSON(fiber.Map{"message": "moved successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CopyRequest struct {
|
||||||
|
SourcePath string `json:"source_path"`
|
||||||
|
DestPath string `json:"dest_path"`
|
||||||
|
Resolution string `json:"resolution"` // "overwrite", "rename", "skip"
|
||||||
|
}
|
||||||
|
|
||||||
// Copy copies a file or folder.
|
// Copy copies a file or folder.
|
||||||
// POST /api/files/copy
|
// POST /api/files/copy
|
||||||
func (h *FSHandler) Copy(c *fiber.Ctx) error {
|
func (h *FSHandler) Copy(c *fiber.Ctx) error {
|
||||||
var body struct {
|
var body CopyRequest
|
||||||
SourcePath string `json:"source_path"`
|
|
||||||
DestPath string `json:"dest_path"`
|
|
||||||
}
|
|
||||||
if err := c.BodyParser(&body); err != nil {
|
if err := c.BodyParser(&body); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||||
}
|
}
|
||||||
|
|
@ -483,12 +490,27 @@ func (h *FSHandler) Copy(c *fiber.Ctx) error {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "source not found"})
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "source not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle conflict
|
||||||
|
finalDst := dstFull
|
||||||
|
if _, err := os.Stat(dstFull); err == nil {
|
||||||
|
if body.Resolution == "skip" {
|
||||||
|
return c.JSON(fiber.Map{"message": "skipped"})
|
||||||
|
}
|
||||||
|
if body.Resolution != "overwrite" {
|
||||||
|
parentDir := filepath.Dir(dstFull)
|
||||||
|
uniqueName := GetUniquePath(parentDir, filepath.Base(dstFull))
|
||||||
|
finalDst = filepath.Join(parentDir, uniqueName)
|
||||||
|
} else {
|
||||||
|
os.RemoveAll(dstFull)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
if err := copyDir(srcFull, dstFull); err != nil {
|
if err := copyDir(srcFull, finalDst); err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to copy directory"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to copy directory"})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err := copyFile(srcFull, dstFull); err != nil {
|
if err := copyFile(srcFull, finalDst); err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to copy file"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to copy file"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -516,6 +538,16 @@ func (h *FSHandler) Delete(c *fiber.Ctx) error {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to prepare trash"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to prepare trash"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(resolvedPath)
|
||||||
|
isDir := 0
|
||||||
|
size := int64(0)
|
||||||
|
if err == nil {
|
||||||
|
if info.IsDir() {
|
||||||
|
isDir = 1
|
||||||
|
}
|
||||||
|
size = info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
if err := os.Rename(resolvedPath, trashPath); err != nil {
|
if err := os.Rename(resolvedPath, trashPath); err != nil {
|
||||||
fmt.Printf("Rename error %s -> %s: %v. Attempting copy & delete fallback...\n", resolvedPath, trashPath, err)
|
fmt.Printf("Rename error %s -> %s: %v. Attempting copy & delete fallback...\n", resolvedPath, trashPath, err)
|
||||||
if err := copyFile(resolvedPath, trashPath); err != nil {
|
if err := copyFile(resolvedPath, trashPath); err != nil {
|
||||||
|
|
@ -527,14 +559,14 @@ func (h *FSHandler) Delete(c *fiber.Ctx) error {
|
||||||
// Update DB
|
// Update DB
|
||||||
cleanPath := filepath.ToSlash(relativePath)
|
cleanPath := filepath.ToSlash(relativePath)
|
||||||
h.DB.Exec(
|
h.DB.Exec(
|
||||||
`UPDATE files SET is_trashed = 1, trashed_at = CURRENT_TIMESTAMP, original_path = ? WHERE path = ?`,
|
`UPDATE files SET is_trashed = 1, trashed_at = CURRENT_TIMESTAMP, original_path = ?, is_dir = ?, size = ? WHERE path = ?`,
|
||||||
cleanPath, cleanPath,
|
cleanPath, isDir, size, cleanPath,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Also insert if not tracked
|
// Also insert if not tracked
|
||||||
h.DB.Exec(
|
h.DB.Exec(
|
||||||
`INSERT OR IGNORE INTO files (path, name, is_trashed, trashed_at, original_path) VALUES (?, ?, 1, CURRENT_TIMESTAMP, ?)`,
|
`INSERT OR IGNORE INTO files (path, name, is_trashed, trashed_at, original_path, is_dir, size) VALUES (?, ?, 1, CURRENT_TIMESTAMP, ?, ?, ?)`,
|
||||||
cleanPath, filepath.Base(cleanPath), cleanPath,
|
cleanPath, filepath.Base(cleanPath), cleanPath, isDir, size,
|
||||||
)
|
)
|
||||||
|
|
||||||
h.DB.AddAuditLog("deleted", cleanPath, c.IP())
|
h.DB.AddAuditLog("deleted", cleanPath, c.IP())
|
||||||
|
|
@ -1153,3 +1185,32 @@ func (h *FSHandler) ServeThumbnail(c *fiber.Ctx) error {
|
||||||
|
|
||||||
return c.SendFile(thumbPath)
|
return c.SendFile(thumbPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CheckConflictsRequest struct {
|
||||||
|
DestPath string `json:"dest_path"`
|
||||||
|
Files []string `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckConflicts checks if files exist in the destination.
|
||||||
|
// POST /api/files/check-conflicts
|
||||||
|
func (h *FSHandler) CheckConflicts(c *fiber.Ctx) error {
|
||||||
|
var req CheckConflictsRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"})
|
||||||
|
}
|
||||||
|
|
||||||
|
destFull, err := resolveSafe(h.Config.StorageDir, req.DestPath)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid path"})
|
||||||
|
}
|
||||||
|
|
||||||
|
conflicts := []string{}
|
||||||
|
for _, file := range req.Files {
|
||||||
|
filePath := filepath.Join(destFull, file)
|
||||||
|
if _, err := os.Stat(filePath); err == nil {
|
||||||
|
conflicts = append(conflicts, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"conflicts": conflicts})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ func (h *TusHandler) Create(c *fiber.Ctx) error {
|
||||||
metadata := parseTusMetadata(c.Get("Upload-Metadata"))
|
metadata := parseTusMetadata(c.Get("Upload-Metadata"))
|
||||||
fileName := metadata["filename"]
|
fileName := metadata["filename"]
|
||||||
destPath := metadata["path"]
|
destPath := metadata["path"]
|
||||||
|
overwrite := metadata["overwrite"] == "true"
|
||||||
|
|
||||||
if fileName == "" {
|
if fileName == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "filename metadata is required"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "filename metadata is required"})
|
||||||
|
|
@ -115,13 +116,15 @@ func (h *TusHandler) Create(c *fiber.Ctx) error {
|
||||||
destPath = "."
|
destPath = "."
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve destination directory and ensure unique filename
|
|
||||||
destFull, err := resolveSafe(h.Config.StorageDir, destPath)
|
destFull, err := resolveSafe(h.Config.StorageDir, destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "invalid destination path"})
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "invalid destination path"})
|
||||||
}
|
}
|
||||||
|
|
||||||
uniqueFileName := GetUniquePath(destFull, fileName)
|
uniqueFileName := fileName
|
||||||
|
if !overwrite {
|
||||||
|
uniqueFileName = GetUniquePath(destFull, fileName)
|
||||||
|
}
|
||||||
|
|
||||||
// Generate unique upload ID
|
// Generate unique upload ID
|
||||||
uploadID, err := generateUploadID()
|
uploadID, err := generateUploadID()
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,7 @@ func main() {
|
||||||
protected.Post("/files/move", fsHandler.Move)
|
protected.Post("/files/move", fsHandler.Move)
|
||||||
protected.Post("/files/copy", fsHandler.Copy)
|
protected.Post("/files/copy", fsHandler.Copy)
|
||||||
protected.Post("/files/upload", fsHandler.Upload)
|
protected.Post("/files/upload", fsHandler.Upload)
|
||||||
|
protected.Post("/files/check-conflicts", fsHandler.CheckConflicts)
|
||||||
|
|
||||||
protected.Get("/files/folders-tree", fsHandler.GetFolderTree)
|
protected.Get("/files/folders-tree", fsHandler.GetFolderTree)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,10 +98,13 @@ function FolderTreeNode({
|
||||||
type ViewMode = 'grid' | 'list';
|
type ViewMode = 'grid' | 'list';
|
||||||
type SidebarSection = 'files' | 'trash' | 'storage' | 'settings' | 'shared';
|
type SidebarSection = 'files' | 'trash' | 'storage' | 'settings' | 'shared';
|
||||||
|
|
||||||
interface UploadProgress {
|
interface UploadBatch {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
progress: number;
|
totalBytes: number;
|
||||||
|
uploadedBytes: number;
|
||||||
|
totalFiles: number;
|
||||||
|
filesUploaded: number;
|
||||||
status: 'uploading' | 'complete' | 'error';
|
status: 'uploading' | 'complete' | 'error';
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -242,9 +245,16 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
const [sortBy, setSortBy] = useState<'name' | 'size' | 'date' | 'type'>('name');
|
const [sortBy, setSortBy] = useState<'name' | 'size' | 'date' | 'type'>('name');
|
||||||
const [sortAsc, setSortAsc] = useState(true);
|
const [sortAsc, setSortAsc] = useState(true);
|
||||||
const [draggedFile, setDraggedFile] = useState<string | null>(null);
|
const [draggedFile, setDraggedFile] = useState<string | null>(null);
|
||||||
const [uploads, setUploads] = useState<UploadProgress[]>([]);
|
const [uploads, setUploads] = useState<UploadBatch[]>([]);
|
||||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'single' | 'multiple'; file?: FileItem; count?: number } | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'single' | 'multiple'; file?: FileItem; count?: number } | null>(null);
|
||||||
|
const [conflictState, setConflictState] = useState<{
|
||||||
|
type: 'upload' | 'move' | 'copy';
|
||||||
|
files?: {file: File, path: string}[];
|
||||||
|
sourcePaths?: string[];
|
||||||
|
destPath: string;
|
||||||
|
conflicts: string[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
function showToast(message: string, type: 'success' | 'error' | 'info' = 'success') {
|
function showToast(message: string, type: 'success' | 'error' | 'info' = 'success') {
|
||||||
const id = Date.now() + Math.random();
|
const id = Date.now() + Math.random();
|
||||||
|
|
@ -688,7 +698,14 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
async function handleMoveExecute() {
|
async function handleMoveExecute() {
|
||||||
if (movingFiles.length === 0) return;
|
if (movingFiles.length === 0) return;
|
||||||
try {
|
try {
|
||||||
await Promise.all(movingFiles.map(src => api.move(src, moveModalPath === '.' ? '.' : moveModalPath)));
|
const destPath = moveModalPath === '.' ? '.' : moveModalPath;
|
||||||
|
const fileNames = movingFiles.map(src => src.split('/').pop() || src);
|
||||||
|
const res = await api.checkConflicts(destPath, fileNames);
|
||||||
|
if (res.conflicts?.length) {
|
||||||
|
setConflictState({ type: 'move', sourcePaths: movingFiles, destPath, conflicts: res.conflicts });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.all(movingFiles.map(src => api.move(src, destPath)));
|
||||||
setShowMoveModal(false);
|
setShowMoveModal(false);
|
||||||
setMovingFiles([]);
|
setMovingFiles([]);
|
||||||
loadFiles(undefined, true);
|
loadFiles(undefined, true);
|
||||||
|
|
@ -699,6 +716,59 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDropOnFolder(file: FileItem, targetFolder: FileItem) {
|
||||||
|
if (file.path === targetFolder.path) return;
|
||||||
|
try {
|
||||||
|
const res = await api.checkConflicts(targetFolder.path, [file.name]);
|
||||||
|
if (res.conflicts?.length) {
|
||||||
|
setConflictState({ type: 'move', sourcePaths: [file.path], destPath: targetFolder.path, conflicts: res.conflicts });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api.move(file.path, targetFolder.path);
|
||||||
|
loadFiles();
|
||||||
|
showToast(`Moved ${file.name}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeConflictResolution(resolution: string) {
|
||||||
|
if (!conflictState) return;
|
||||||
|
const { type, destPath, conflicts } = conflictState;
|
||||||
|
|
||||||
|
if (type === 'upload' && conflictState.files) {
|
||||||
|
let filesToUpload = conflictState.files;
|
||||||
|
if (resolution === 'skip') {
|
||||||
|
filesToUpload = filesToUpload.filter(f => {
|
||||||
|
const name = f.path ? `${f.path}/${f.file.name}` : f.file.name;
|
||||||
|
return !conflicts.includes(name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setConflictState(null);
|
||||||
|
if (filesToUpload.length > 0) {
|
||||||
|
processUploadBatch(filesToUpload, destPath, resolution === 'overwrite');
|
||||||
|
}
|
||||||
|
} else if (type === 'move' && conflictState.sourcePaths) {
|
||||||
|
let pathsToMove = conflictState.sourcePaths;
|
||||||
|
if (resolution === 'skip') {
|
||||||
|
pathsToMove = pathsToMove.filter(p => !conflicts.includes(p.split('/').pop()!));
|
||||||
|
}
|
||||||
|
setConflictState(null);
|
||||||
|
if (pathsToMove.length > 0) {
|
||||||
|
try {
|
||||||
|
await Promise.all(pathsToMove.map(src => api.move(src, destPath, resolution)));
|
||||||
|
setShowMoveModal(false);
|
||||||
|
setMovingFiles([]);
|
||||||
|
loadFiles(undefined, true);
|
||||||
|
setSelectedFiles(new Set());
|
||||||
|
showToast(`Moved ${pathsToMove.length} item${pathsToMove.length > 1 ? 's' : ''}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleDragOver(e: React.DragEvent) {
|
function handleDragOver(e: React.DragEvent) {
|
||||||
if (draggedFile) return;
|
if (draggedFile) return;
|
||||||
if (!e.dataTransfer.types.includes('Files')) return;
|
if (!e.dataTransfer.types.includes('Files')) return;
|
||||||
|
|
@ -748,23 +818,72 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadFile(file: File, targetDir?: string) {
|
async function processUploadBatch(entries: {file: File, path: string}[], destPath: string, overwrite: boolean = false) {
|
||||||
const id = `${Date.now()}-${file.name}`;
|
if (!entries.length) return;
|
||||||
setUploads(prev => [...prev, { id, name: file.name, progress: 0, status: 'uploading' }]);
|
const batchId = 'batch-' + Date.now();
|
||||||
|
const totalFiles = entries.length;
|
||||||
|
const totalBytes = entries.reduce((acc, curr) => acc + curr.file.size, 0);
|
||||||
|
const batchName = totalFiles === 1 ? entries[0].file.name : `Uploading ${totalFiles} items`;
|
||||||
|
|
||||||
|
setUploads(prev => [...prev, {
|
||||||
|
id: batchId,
|
||||||
|
name: batchName,
|
||||||
|
totalBytes,
|
||||||
|
uploadedBytes: 0,
|
||||||
|
totalFiles,
|
||||||
|
filesUploaded: 0,
|
||||||
|
status: 'uploading'
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const MAX_CONCURRENT = 4;
|
||||||
|
let currentIndex = 0;
|
||||||
|
let completedFiles = 0;
|
||||||
|
const fileProgressMap = new Map<string, number>();
|
||||||
|
|
||||||
|
const updateProgress = () => {
|
||||||
|
let totalUploaded = 0;
|
||||||
|
fileProgressMap.forEach(bytes => { totalUploaded += bytes; });
|
||||||
|
setUploads(prev => prev.map(b => b.id === batchId ? {
|
||||||
|
...b,
|
||||||
|
uploadedBytes: totalUploaded,
|
||||||
|
filesUploaded: completedFiles,
|
||||||
|
status: completedFiles === totalFiles ? 'complete' : 'uploading'
|
||||||
|
} : b));
|
||||||
|
};
|
||||||
|
|
||||||
|
const worker = async () => {
|
||||||
|
while (currentIndex < entries.length) {
|
||||||
|
const index = currentIndex++;
|
||||||
|
const { file, path } = entries[index];
|
||||||
|
const uploadPath = path ? (destPath === '.' ? path : `${destPath}/${path}`) : destPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const uploadPath = targetDir ? (currentPath === '.' ? targetDir : `${currentPath}/${targetDir}`) : currentPath;
|
|
||||||
await api.upload(file, uploadPath, {
|
await api.upload(file, uploadPath, {
|
||||||
|
overwrite,
|
||||||
onProgress: (percent) => {
|
onProgress: (percent) => {
|
||||||
setUploads(prev => prev.map(u => u.id === id ? { ...u, progress: percent } : u));
|
fileProgressMap.set(file.name + path, (percent / 100) * file.size);
|
||||||
},
|
updateProgress();
|
||||||
});
|
|
||||||
setUploads(prev => prev.map(u => u.id === id ? { ...u, progress: 100, status: 'complete' } : u));
|
|
||||||
setTimeout(() => {
|
|
||||||
setUploads(prev => prev.filter(u => u.id !== id));
|
|
||||||
}, 3000);
|
|
||||||
} catch (err: any) {
|
|
||||||
setUploads(prev => prev.map(u => u.id === id ? { ...u, status: 'error', error: err.message || 'Upload failed' } : u));
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
completedFiles++;
|
||||||
|
fileProgressMap.set(file.name + path, file.size);
|
||||||
|
updateProgress();
|
||||||
|
} catch (err: any) {
|
||||||
|
setUploads(prev => prev.map(b => b.id === batchId ? { ...b, status: 'error', error: err.message || 'Upload failed' } : b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const workers = [];
|
||||||
|
for (let i = 0; i < Math.min(MAX_CONCURRENT, entries.length); i++) {
|
||||||
|
workers.push(worker());
|
||||||
|
}
|
||||||
|
await Promise.all(workers);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setUploads(prev => prev.filter(u => u.id !== batchId));
|
||||||
|
loadFiles(undefined, true);
|
||||||
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDrop(e: React.DragEvent) {
|
async function handleDrop(e: React.DragEvent) {
|
||||||
|
|
@ -779,7 +898,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
const processEntry = async (entry: any, path: string = '') => {
|
const processEntry = async (entry: any, path: string = '') => {
|
||||||
if (entry.isFile) {
|
if (entry.isFile) {
|
||||||
const file = await new Promise<File>((resolve) => entry.file(resolve));
|
const file = await new Promise<File>((resolve) => entry.file(resolve));
|
||||||
filesToUpload.push({ file, path });
|
filesToUpload.push({ file, path: path.replace(/\/$/, '') });
|
||||||
} else if (entry.isDirectory) {
|
} else if (entry.isDirectory) {
|
||||||
const dirReader = entry.createReader();
|
const dirReader = entry.createReader();
|
||||||
let entries: any[] = [];
|
let entries: any[] = [];
|
||||||
|
|
@ -812,24 +931,58 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
|
|
||||||
if (filesToUpload.length > 0) {
|
if (filesToUpload.length > 0) {
|
||||||
await Promise.all(filesToUpload.map(({file, path}) =>
|
// Collect destination paths to create folders
|
||||||
uploadFile(file, path.replace(/\/$/, ''))
|
const uniqueDirs = new Set<string>();
|
||||||
));
|
filesToUpload.forEach(({path}) => {
|
||||||
}
|
if (path) uniqueDirs.add(currentPath === '.' ? path : `${currentPath}/${path}`);
|
||||||
} else {
|
});
|
||||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
|
||||||
await Promise.all(droppedFiles.map(file => uploadFile(file)));
|
for (const dir of Array.from(uniqueDirs)) {
|
||||||
|
try { await api.createFolder(dir); } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFiles(undefined, true);
|
const fileNames = filesToUpload.map(f => f.path ? `${f.path}/${f.file.name}` : f.file.name);
|
||||||
|
try {
|
||||||
|
const res = await api.checkConflicts(currentPath, fileNames);
|
||||||
|
if (res.conflicts && res.conflicts.length > 0) {
|
||||||
|
setConflictState({ type: 'upload', files: filesToUpload, destPath: currentPath, conflicts: res.conflicts });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
processUploadBatch(filesToUpload, currentPath);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const droppedFiles = Array.from(e.dataTransfer.files).map(f => ({ file: f, path: '' }));
|
||||||
|
const fileNames = droppedFiles.map(f => f.file.name);
|
||||||
|
try {
|
||||||
|
const res = await api.checkConflicts(currentPath, fileNames);
|
||||||
|
if (res.conflicts && res.conflicts.length > 0) {
|
||||||
|
setConflictState({ type: 'upload', files: droppedFiles, destPath: currentPath, conflicts: res.conflicts });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
processUploadBatch(droppedFiles, currentPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const uploadFiles = e.target.files;
|
const uploadFiles = e.target.files;
|
||||||
if (!uploadFiles) return;
|
if (!uploadFiles) return;
|
||||||
await Promise.all(Array.from(uploadFiles).map(file => uploadFile(file)));
|
const filesArray = Array.from(uploadFiles).map(f => ({ file: f, path: '' }));
|
||||||
|
|
||||||
|
const fileNames = filesArray.map(f => f.file.name);
|
||||||
|
try {
|
||||||
|
const res = await api.checkConflicts(currentPath, fileNames);
|
||||||
|
if (res.conflicts && res.conflicts.length > 0) {
|
||||||
|
setConflictState({ type: 'upload', files: filesArray, destPath: currentPath, conflicts: res.conflicts });
|
||||||
|
e.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
processUploadBatch(filesArray, currentPath);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
loadFiles(undefined, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathSegments = currentPath === '.' ? [] : currentPath.split('/');
|
const pathSegments = currentPath === '.' ? [] : currentPath.split('/');
|
||||||
|
|
@ -1998,6 +2151,44 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
{conflictState && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm" onClick={() => setConflictState(null)}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="w-full max-w-md rounded-2xl shadow-xl overflow-hidden flex flex-col"
|
||||||
|
style={{ backgroundColor: 'var(--color-bg-primary)', border: '1px solid var(--color-border)', maxHeight: '90vh' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b" style={{ borderColor: 'var(--color-border)' }}>
|
||||||
|
<h2 className="text-xl font-semibold" style={{ color: 'var(--color-text-primary)' }}>Name Conflict Detected</h2>
|
||||||
|
<p className="mt-1 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
|
The following files already exist in the destination. How would you like to proceed?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto p-6 text-sm flex-1" style={{ backgroundColor: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)' }}>
|
||||||
|
<ul className="list-disc pl-4 space-y-1">
|
||||||
|
{conflictState.conflicts.map(c => <li key={c} className="break-all">{c}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 flex flex-col gap-3 border-t" style={{ borderColor: 'var(--color-border)' }}>
|
||||||
|
<button onClick={() => executeConflictResolution('overwrite')} className="w-full py-2.5 rounded-xl font-medium text-white shadow-sm transition-opacity hover:opacity-90 bg-red-500">
|
||||||
|
Replace
|
||||||
|
</button>
|
||||||
|
<button onClick={() => executeConflictResolution('rename')} className="w-full py-2.5 rounded-xl font-medium text-white shadow-sm transition-opacity hover:opacity-90" style={{ backgroundColor: 'var(--color-accent)' }}>
|
||||||
|
Auto-Rename (Keep Both)
|
||||||
|
</button>
|
||||||
|
<button onClick={() => executeConflictResolution('skip')} className="w-full py-2.5 rounded-xl font-medium shadow-sm transition-opacity hover:opacity-90" style={{ backgroundColor: 'var(--color-bg-elevated)', color: 'var(--color-text-primary)' }}>
|
||||||
|
Skip Conflicting
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setConflictState(null)} className="w-full py-2.5 rounded-xl font-medium shadow-sm transition-opacity hover:opacity-90 border" style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-primary)' }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showMoveModal && (
|
{showMoveModal && (
|
||||||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ export default function LoginPage({ onSuccess }: LoginPageProps) {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
Remember me for 3 weeks
|
Remember me
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,14 @@ class ApiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Files ---
|
// --- Files ---
|
||||||
|
async checkConflicts(destPath: string, files: string[]) {
|
||||||
|
const res = await this.request('/api/files/check-conflicts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { dest_path: destPath, files },
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
async listDirectory(path: string = '.') {
|
async listDirectory(path: string = '.') {
|
||||||
const res = await this.request(`/api/files/?path=${encodeURIComponent(path)}`);
|
const res = await this.request(`/api/files/?path=${encodeURIComponent(path)}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|
@ -177,18 +185,18 @@ class ApiClient {
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async move(sourcePath: string, destPath: string) {
|
async move(sourcePath: string, destPath: string, resolution: string = "rename") {
|
||||||
const res = await this.request('/api/files/move', {
|
const res = await this.request('/api/files/move', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { source_path: sourcePath, dest_path: destPath },
|
body: { source_path: sourcePath, dest_path: destPath, resolution },
|
||||||
});
|
});
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async copy(sourcePath: string, destPath: string) {
|
async copy(sourcePath: string, destPath: string, resolution: string = "rename") {
|
||||||
const res = await this.request('/api/files/copy', {
|
const res = await this.request('/api/files/copy', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { source_path: sourcePath, dest_path: destPath },
|
body: { source_path: sourcePath, dest_path: destPath, resolution },
|
||||||
});
|
});
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
@ -200,7 +208,7 @@ class ApiClient {
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
upload(file: File, destPath: string, callbacks?: { onProgress?: (percent: number) => void }): Promise<void> {
|
upload(file: File, destPath: string, options?: { onProgress?: (percent: number) => void, overwrite?: boolean }): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const upload = new tus.Upload(file, {
|
const upload = new tus.Upload(file, {
|
||||||
endpoint: `${API_BASE}/api/tus/`,
|
endpoint: `${API_BASE}/api/tus/`,
|
||||||
|
|
@ -211,13 +219,14 @@ class ApiClient {
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
filetype: file.type,
|
filetype: file.type,
|
||||||
path: destPath,
|
path: destPath,
|
||||||
|
overwrite: options?.overwrite ? "true" : "false",
|
||||||
},
|
},
|
||||||
onError: function (error) {
|
onError: function (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
},
|
},
|
||||||
onProgress: function (bytesUploaded, bytesTotal) {
|
onProgress: function (bytesUploaded, bytesTotal) {
|
||||||
const percentage = (bytesUploaded / bytesTotal) * 100;
|
const percentage = (bytesUploaded / bytesTotal) * 100;
|
||||||
callbacks?.onProgress?.(percentage);
|
options?.onProgress?.(percentage);
|
||||||
},
|
},
|
||||||
onSuccess: function () {
|
onSuccess: function () {
|
||||||
resolve();
|
resolve();
|
||||||
|
|
|
||||||
302
refactor.js
Normal file
302
refactor.js
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
let content = fs.readFileSync('frontend/src/components/FileExplorer.tsx', 'utf-8');
|
||||||
|
|
||||||
|
// 1. Add ConflictState to the component state
|
||||||
|
const stateInsertPos = content.indexOf(' const [deleteConfirm');
|
||||||
|
const conflictStateCode = ` const [conflictState, setConflictState] = useState<{
|
||||||
|
type: 'upload' | 'move' | 'copy';
|
||||||
|
files?: {file: File, path: string}[];
|
||||||
|
sourcePaths?: string[];
|
||||||
|
destPath: string;
|
||||||
|
conflicts: string[];
|
||||||
|
} | null>(null);\n`;
|
||||||
|
content = content.slice(0, stateInsertPos) + conflictStateCode + content.slice(stateInsertPos);
|
||||||
|
|
||||||
|
// 2. Replace handleDrop and uploadFile with processUploadBatch and updated handleDrop
|
||||||
|
const uploadFileStart = content.indexOf(' async function uploadFile');
|
||||||
|
const handleCreateFolderStart = content.indexOf(' async function handleCreateFolder');
|
||||||
|
|
||||||
|
const newUploadCode = ` async function processUploadBatch(entries: {file: File, path: string}[], destPath: string, overwrite: boolean = false) {
|
||||||
|
if (!entries.length) return;
|
||||||
|
const batchId = 'batch-' + Date.now();
|
||||||
|
const totalFiles = entries.length;
|
||||||
|
const totalBytes = entries.reduce((acc, curr) => acc + curr.file.size, 0);
|
||||||
|
const batchName = totalFiles === 1 ? entries[0].file.name : \`Uploading \${totalFiles} items\`;
|
||||||
|
|
||||||
|
setUploads(prev => [...prev, {
|
||||||
|
id: batchId,
|
||||||
|
name: batchName,
|
||||||
|
totalBytes,
|
||||||
|
uploadedBytes: 0,
|
||||||
|
totalFiles,
|
||||||
|
filesUploaded: 0,
|
||||||
|
status: 'uploading'
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const MAX_CONCURRENT = 4;
|
||||||
|
let currentIndex = 0;
|
||||||
|
let completedFiles = 0;
|
||||||
|
let currentUploadedBytes = 0;
|
||||||
|
const fileProgressMap = new Map<string, number>();
|
||||||
|
|
||||||
|
const updateProgress = () => {
|
||||||
|
let totalUploaded = 0;
|
||||||
|
fileProgressMap.forEach(bytes => { totalUploaded += bytes; });
|
||||||
|
setUploads(prev => prev.map(b => b.id === batchId ? {
|
||||||
|
...b,
|
||||||
|
uploadedBytes: totalUploaded,
|
||||||
|
filesUploaded: completedFiles,
|
||||||
|
status: completedFiles === totalFiles ? 'complete' : 'uploading'
|
||||||
|
} : b));
|
||||||
|
};
|
||||||
|
|
||||||
|
const worker = async () => {
|
||||||
|
while (currentIndex < entries.length) {
|
||||||
|
const index = currentIndex++;
|
||||||
|
const { file, path } = entries[index];
|
||||||
|
const uploadPath = path ? (destPath === '.' ? path : \`\${destPath}/\${path}\`) : destPath;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.upload(file, uploadPath, {
|
||||||
|
overwrite,
|
||||||
|
onProgress: (percent) => {
|
||||||
|
fileProgressMap.set(file.name + path, (percent / 100) * file.size);
|
||||||
|
updateProgress();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
completedFiles++;
|
||||||
|
fileProgressMap.set(file.name + path, file.size);
|
||||||
|
updateProgress();
|
||||||
|
} catch (err: any) {
|
||||||
|
setUploads(prev => prev.map(b => b.id === batchId ? { ...b, status: 'error', error: err.message || 'Upload failed' } : b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const workers = [];
|
||||||
|
for (let i = 0; i < Math.min(MAX_CONCURRENT, entries.length); i++) {
|
||||||
|
workers.push(worker());
|
||||||
|
}
|
||||||
|
await Promise.all(workers);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setUploads(prev => prev.filter(u => u.id !== batchId));
|
||||||
|
loadFiles(undefined, true);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(e: React.DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
if (draggedFile) return;
|
||||||
|
|
||||||
|
if (e.dataTransfer.items) {
|
||||||
|
const filesToUpload: { file: File; path: string }[] = [];
|
||||||
|
const promises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
const processEntry = async (entry: any, path: string = '') => {
|
||||||
|
if (entry.isFile) {
|
||||||
|
const file = await new Promise<File>((resolve) => entry.file(resolve));
|
||||||
|
filesToUpload.push({ file, path: path.replace(/\\/$/, '') });
|
||||||
|
} else if (entry.isDirectory) {
|
||||||
|
const dirReader = entry.createReader();
|
||||||
|
let entries: any[] = [];
|
||||||
|
|
||||||
|
const readAllEntries = async () => {
|
||||||
|
const batch = await new Promise<any[]>((resolve) => dirReader.readEntries(resolve));
|
||||||
|
if (batch.length > 0) {
|
||||||
|
entries = entries.concat(batch);
|
||||||
|
await readAllEntries();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await readAllEntries();
|
||||||
|
|
||||||
|
for (const child of entries) {
|
||||||
|
await processEntry(child, \`\${path}\${entry.name}/\`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < e.dataTransfer.items.length; i++) {
|
||||||
|
const item = e.dataTransfer.items[i];
|
||||||
|
if (item.kind === 'file') {
|
||||||
|
const entry = item.webkitGetAsEntry();
|
||||||
|
if (entry) {
|
||||||
|
promises.push(processEntry(entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
if (filesToUpload.length > 0) {
|
||||||
|
// Collect destination paths to create folders
|
||||||
|
const uniqueDirs = new Set<string>();
|
||||||
|
filesToUpload.forEach(({path}) => {
|
||||||
|
if (path) uniqueDirs.add(currentPath === '.' ? path : \`\${currentPath}/\${path}\`);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const dir of Array.from(uniqueDirs)) {
|
||||||
|
try { await api.createFolder(dir); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileNames = filesToUpload.map(f => f.path ? \`\${f.path}/\${f.file.name}\` : f.file.name);
|
||||||
|
try {
|
||||||
|
const res = await api.checkConflicts(currentPath, fileNames);
|
||||||
|
if (res.conflicts && res.conflicts.length > 0) {
|
||||||
|
setConflictState({ type: 'upload', files: filesToUpload, destPath: currentPath, conflicts: res.conflicts });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
processUploadBatch(filesToUpload, currentPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const uploadFiles = e.target.files;
|
||||||
|
if (!uploadFiles) return;
|
||||||
|
const filesArray = Array.from(uploadFiles).map(f => ({ file: f, path: '' }));
|
||||||
|
|
||||||
|
const fileNames = filesArray.map(f => f.file.name);
|
||||||
|
try {
|
||||||
|
const res = await api.checkConflicts(currentPath, fileNames);
|
||||||
|
if (res.conflicts && res.conflicts.length > 0) {
|
||||||
|
setConflictState({ type: 'upload', files: filesArray, destPath: currentPath, conflicts: res.conflicts });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
processUploadBatch(filesArray, currentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
content = content.slice(0, uploadFileStart) + newUploadCode + content.slice(handleCreateFolderStart);
|
||||||
|
|
||||||
|
// Add Conflict Modal rendering
|
||||||
|
const renderModalsPos = content.lastIndexOf('{showMoveModal && (');
|
||||||
|
const conflictModalCode = `
|
||||||
|
{conflictState && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm" onClick={() => setConflictState(null)}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="w-full max-w-md rounded-2xl shadow-xl overflow-hidden flex flex-col"
|
||||||
|
style={{ backgroundColor: 'var(--color-bg-primary)', border: '1px solid var(--color-border)', maxHeight: '90vh' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b" style={{ borderColor: 'var(--color-border)' }}>
|
||||||
|
<h2 className="text-xl font-semibold" style={{ color: 'var(--color-text-primary)' }}>Name Conflict Detected</h2>
|
||||||
|
<p className="mt-1 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
|
The following files already exist in the destination. How would you like to proceed?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto p-6 text-sm flex-1" style={{ backgroundColor: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)' }}>
|
||||||
|
<ul className="list-disc pl-4 space-y-1">
|
||||||
|
{conflictState.conflicts.map(c => <li key={c} className="break-all">{c}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 flex flex-col gap-3 border-t" style={{ borderColor: 'var(--color-border)' }}>
|
||||||
|
<button onClick={() => executeConflictResolution('overwrite')} className="w-full py-2.5 rounded-xl font-medium text-white shadow-sm transition-opacity hover:opacity-90 bg-red-500">
|
||||||
|
Replace
|
||||||
|
</button>
|
||||||
|
<button onClick={() => executeConflictResolution('rename')} className="w-full py-2.5 rounded-xl font-medium text-white shadow-sm transition-opacity hover:opacity-90" style={{ backgroundColor: 'var(--color-accent)' }}>
|
||||||
|
Auto-Rename (Keep Both)
|
||||||
|
</button>
|
||||||
|
<button onClick={() => executeConflictResolution('skip')} className="w-full py-2.5 rounded-xl font-medium shadow-sm transition-opacity hover:opacity-90" style={{ backgroundColor: 'var(--color-bg-elevated)', color: 'var(--color-text-primary)' }}>
|
||||||
|
Skip Conflicting
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setConflictState(null)} className="w-full py-2.5 rounded-xl font-medium shadow-sm transition-opacity hover:opacity-90 border" style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-primary)' }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
`;
|
||||||
|
content = content.slice(0, renderModalsPos) + conflictModalCode + content.slice(renderModalsPos);
|
||||||
|
|
||||||
|
// Add executeConflictResolution function
|
||||||
|
const execFunctionCode = `
|
||||||
|
async function executeConflictResolution(resolution: string) {
|
||||||
|
if (!conflictState) return;
|
||||||
|
const { type, destPath, conflicts } = conflictState;
|
||||||
|
|
||||||
|
if (type === 'upload' && conflictState.files) {
|
||||||
|
let filesToUpload = conflictState.files;
|
||||||
|
if (resolution === 'skip') {
|
||||||
|
filesToUpload = filesToUpload.filter(f => {
|
||||||
|
const name = f.path ? \`\${f.path}/\${f.file.name}\` : f.file.name;
|
||||||
|
return !conflicts.includes(name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setConflictState(null);
|
||||||
|
if (filesToUpload.length > 0) {
|
||||||
|
processUploadBatch(filesToUpload, destPath, resolution === 'overwrite');
|
||||||
|
}
|
||||||
|
} else if (type === 'move' && conflictState.sourcePaths) {
|
||||||
|
let pathsToMove = conflictState.sourcePaths;
|
||||||
|
if (resolution === 'skip') {
|
||||||
|
pathsToMove = pathsToMove.filter(p => !conflicts.includes(p.split('/').pop()!));
|
||||||
|
}
|
||||||
|
setConflictState(null);
|
||||||
|
if (pathsToMove.length > 0) {
|
||||||
|
try {
|
||||||
|
await Promise.all(pathsToMove.map(src => api.move(src, destPath, resolution)));
|
||||||
|
setShowMoveModal(false);
|
||||||
|
setMovingFiles([]);
|
||||||
|
loadFiles(undefined, true);
|
||||||
|
setSelectedFiles(new Set());
|
||||||
|
showToast(\`Moved \${pathsToMove.length} item\${pathsToMove.length > 1 ? 's' : ''}\`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
const handleDropOnFolderStart = content.indexOf(' async function handleDropOnFolder');
|
||||||
|
content = content.slice(0, handleDropOnFolderStart) + execFunctionCode + content.slice(handleDropOnFolderStart);
|
||||||
|
|
||||||
|
// Update handleMoveExecute
|
||||||
|
content = content.replace(
|
||||||
|
\` async function handleMoveExecute() {
|
||||||
|
if (movingFiles.length === 0) return;
|
||||||
|
try {
|
||||||
|
await Promise.all(movingFiles.map(src => api.move(src, moveModalPath === '.' ? '.' : moveModalPath)));\`,
|
||||||
|
\` async function handleMoveExecute() {
|
||||||
|
if (movingFiles.length === 0) return;
|
||||||
|
try {
|
||||||
|
const destPath = moveModalPath === '.' ? '.' : moveModalPath;
|
||||||
|
const fileNames = movingFiles.map(src => src.split('/').pop() || src);
|
||||||
|
const res = await api.checkConflicts(destPath, fileNames);
|
||||||
|
if (res.conflicts && res.conflicts.length > 0) {
|
||||||
|
setConflictState({ type: 'move', sourcePaths: movingFiles, destPath, conflicts: res.conflicts });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.all(movingFiles.map(src => api.move(src, destPath)));\`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update handleDropOnFolder
|
||||||
|
content = content.replace(
|
||||||
|
\` async function handleDropOnFolder(file: FileItem, targetFolder: FileItem) {
|
||||||
|
if (file.path === targetFolder.path) return;
|
||||||
|
try {
|
||||||
|
await api.move(file.path, targetFolder.path);\`,
|
||||||
|
\` async function handleDropOnFolder(file: FileItem, targetFolder: FileItem) {
|
||||||
|
if (file.path === targetFolder.path) return;
|
||||||
|
try {
|
||||||
|
const res = await api.checkConflicts(targetFolder.path, [file.name]);
|
||||||
|
if (res.conflicts && res.conflicts.length > 0) {
|
||||||
|
setConflictState({ type: 'move', sourcePaths: [file.path], destPath: targetFolder.path, conflicts: res.conflicts });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api.move(file.path, targetFolder.path);\`
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync('frontend/src/components/FileExplorer.tsx', content);
|
||||||
Reference in a new issue