IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

file-manager-entry-actions.ts

Open raw
"use client";

import { useMemo } from "react";
import { toast } from "sonner";
import {
  buildDownloadUrl,
  buildZipUrl,
  toFilePath,
} from "@/modules/files/hooks/useFiles";
import type { FileInfoResponse } from "@/lib/shared/contracts/files";
import type { FileEntry } from "@/modules/files/components/file-manager-presenters";
import type {
  CreateEntryDialogState,
  FileManagerAction,
  OpenFileState,
  RenameDialogState,
} from "@/modules/files/components/manager/file-manager-state";

type AsyncMutation<TArgs, TResult = unknown> = {
  mutateAsync: (args: TArgs) => Promise<TResult>;
};

export type UseFileManagerEntryActionsArgs = {
  createEntryDialog: CreateEntryDialogState | null;
  createFileMutation: AsyncMutation<{ parentPath: string; name: string }, { path: string }>;
  createFolderMutation: AsyncMutation<{ parentPath: string; name: string }, { path: string }>;
  createLocalShareMutation: AsyncMutation<{ path: string }, { sharedPath: string }>;
  currentPath: string[];
  deleteFromTrashMutation: AsyncMutation<{ path: string }>;
  deleteLocalShareMutation: AsyncMutation<string>;
  dispatch: (action: FileManagerAction) => void;
  emptyTrashMutation: AsyncMutation<void, { deletedCount: number }>;
  fileDrafts: Record<string, string>;
  filesRootPath: string;
  getFileInfoMutation: AsyncMutation<string, FileInfoResponse>;
  includeHidden: boolean;
  isEmptyingTrash: boolean;
  isStarredView: boolean;
  isTrashView: boolean;
  moveToTrashMutation: AsyncMutation<{ path: string }, { trashPath: string }>;
  openFile: OpenFileState | null;
  openFileKey: string | null;
  openFileViewer: { mode: string; content?: string | null; mtimeMs: number } | null;
  renameDialog: RenameDialogState | null;
  renameFileEntryMutation: AsyncMutation<{ path: string; newName: string }, { path: string }>;
  restoreFromTrashMutation: AsyncMutation<{ path: string; collision: "keep-both" }, { restoredPath: string }>;
  saveFileContentMutation: AsyncMutation<{ path: string; content: string; expectedMtimeMs: number }>;
  selectedFiles: Set<string>;
  setStatusNotice: (message: string | null) => void;
  sortedEntries: FileEntry[];
  systemHostname: string;
  systemNetworkAddress: string;
  toggleStarMutation: AsyncMutation<string, { starred: boolean }>;
  uploadAbortControllerRef: { current: AbortController | null };
  uploadFilesMutation: AsyncMutation<{
    destinationPath: string;
    files: File[];
    includeHidden: boolean;
    onProgress: (loaded: number, total: number) => void;
    signal: AbortSignal;
  }, { uploaded: unknown[]; skipped: unknown[] }>;
  unzipFileMutation: AsyncMutation<string, { destinationPath: string }>;
};

export function useFileManagerEntryActions({
  createEntryDialog,
  createFileMutation,
  createFolderMutation,
  createLocalShareMutation,
  currentPath,
  deleteFromTrashMutation,
  deleteLocalShareMutation,
  dispatch,
  emptyTrashMutation,
  fileDrafts,
  filesRootPath,
  getFileInfoMutation,
  includeHidden,
  isEmptyingTrash,
  isStarredView,
  isTrashView,
  moveToTrashMutation,
  openFile,
  openFileKey,
  openFileViewer,
  renameDialog,
  renameFileEntryMutation,
  restoreFromTrashMutation,
  saveFileContentMutation,
  selectedFiles,
  setStatusNotice,
  sortedEntries,
  systemHostname,
  systemNetworkAddress,
  toggleStarMutation,
  uploadAbortControllerRef,
  uploadFilesMutation,
  unzipFileMutation,
}: UseFileManagerEntryActionsArgs) {
  return useMemo(() => {
    async function handleTrashSelected() {
      const names = [...selectedFiles];
      if (names.length === 0) return;
      dispatch({ type: "CLEAR_SELECTION" });
      for (const name of names) {
        const entry = sortedEntries.find((candidate) => candidate.name === name);
        if (!entry) continue;
        try {
          await moveToTrashMutation.mutateAsync({ path: entry.path });
        } catch {
          // Errors surface through mutation state.
        }
      }
    }

    async function handleMoveSelectedToTrash(entry: FileEntry) {
      dispatch({ type: "SET_PENDING_ENTRY_PATH", path: entry.path });
      try {
        const result = await moveToTrashMutation.mutateAsync({ path: entry.path });
        if (openFile && toFilePath(openFile.path) === entry.path) dispatch({ type: "CLOSE_FILE" });
        dispatch({ type: "SELECT_FILE", name: null });
        setStatusNotice(`Moved to Trash: ${result.trashPath}`);
      } catch (error) {
        setStatusNotice(error instanceof Error ? error.message : "Failed to move item to Trash");
      } finally {
        dispatch({ type: "SET_PENDING_ENTRY_PATH", path: null });
      }
    }

    async function handleRestoreFromTrash(entry: FileEntry) {
      dispatch({ type: "SET_PENDING_ENTRY_PATH", path: entry.path });
      try {
        const result = await restoreFromTrashMutation.mutateAsync({
          path: entry.path,
          collision: "keep-both",
        });
        dispatch({ type: "SELECT_FILE", name: null });
        setStatusNotice(`Restored: ${result.restoredPath}`);
      } catch (error) {
        setStatusNotice(error instanceof Error ? error.message : "Failed to restore item from Trash");
      } finally {
        dispatch({ type: "SET_PENDING_ENTRY_PATH", path: null });
      }
    }

    async function handleDeleteFromTrash(entry: FileEntry) {
      dispatch({ type: "SET_PENDING_ENTRY_PATH", path: entry.path });
      try {
        await deleteFromTrashMutation.mutateAsync({ path: entry.path });
        dispatch({ type: "SELECT_FILE", name: null });
        setStatusNotice(`Deleted permanently: ${entry.name}`);
      } catch (error) {
        setStatusNotice(error instanceof Error ? error.message : "Failed to permanently delete item");
      } finally {
        dispatch({ type: "SET_PENDING_ENTRY_PATH", path: null });
      }
    }

    function handleEmptyTrash() {
      if (!isTrashView || sortedEntries.length === 0 || isEmptyingTrash) return;
      dispatch({ type: "SHOW_EMPTY_TRASH_CONFIRM" });
    }

    async function confirmEmptyTrash() {
      dispatch({ type: "HIDE_EMPTY_TRASH_CONFIRM" });
      dispatch({ type: "SET_IS_EMPTYING_TRASH", value: true });
      try {
        const result = await emptyTrashMutation.mutateAsync(undefined);
        setStatusNotice(`Trash emptied (${result.deletedCount} item${result.deletedCount !== 1 ? "s" : ""})`);
      } catch (error) {
        setStatusNotice(error instanceof Error ? error.message : "Failed to empty Trash");
      } finally {
        dispatch({ type: "SET_IS_EMPTYING_TRASH", value: false });
      }
    }

    async function submitCreateEntryDialog() {
      if (!createEntryDialog) return;
      const name = createEntryDialog.name.trim();
      if (!name) {
        dispatch({ type: "SET_CREATE_ENTRY_ERROR", error: `Invalid ${createEntryDialog.kind} name` });
        return;
      }
      const parentPath = toFilePath(currentPath);
      try {
        const result =
          createEntryDialog.kind === "folder"
            ? await createFolderMutation.mutateAsync({ parentPath, name })
            : await createFileMutation.mutateAsync({ parentPath, name });
        dispatch({ type: "CLOSE_CREATE_ENTRY_DIALOG" });
        setStatusNotice(`${createEntryDialog.kind === "folder" ? "Folder" : "File"} created: ${result.path}`);
      } catch (error) {
        const message =
          error instanceof Error ? error.message : `Failed to create ${createEntryDialog.kind}`;
        dispatch({ type: "SET_CREATE_ENTRY_ERROR", error: message });
        setStatusNotice(message);
      }
    }

    function handleRenameEntry(entry: FileEntry) {
      if (isTrashView) {
        setStatusNotice("Rename is disabled in Trash");
        return;
      }
      dispatch({ type: "OPEN_RENAME_DIALOG", entry });
    }

    async function submitRenameDialog() {
      if (!renameDialog) return;
      const newName = renameDialog.name.trim();
      if (!newName || newName === renameDialog.entry.name) {
        dispatch({ type: "CLOSE_RENAME_DIALOG" });
        return;
      }
      try {
        const result = await renameFileEntryMutation.mutateAsync({
          path: renameDialog.entry.path,
          newName,
        });
        dispatch({ type: "CLOSE_RENAME_DIALOG" });
        dispatch({ type: "SELECT_FILE", name: null });
        setStatusNotice(`Renamed to ${result.path.split("/").pop() ?? newName}`);
      } catch (error) {
        dispatch({
          type: "SET_RENAME_ERROR",
          error: error instanceof Error ? error.message : "Failed to rename item",
        });
      }
    }

    async function handleGetInfo(entry: FileEntry) {
      try {
        const info = await getFileInfoMutation.mutateAsync(entry.path);
        dispatch({ type: "OPEN_FILE_INFO_DIALOG", info });
      } catch (error) {
        setStatusNotice(error instanceof Error ? error.message : "Failed to get item info");
      }
    }

    async function handleToggleStar(entry: FileEntry) {
      try {
        const result = await toggleStarMutation.mutateAsync(entry.path);
        setStatusNotice(result.starred ? `Starred ${entry.name}` : `Unstarred ${entry.name}`);
      } catch (error) {
        setStatusNotice(error instanceof Error ? error.message : "Failed to toggle star");
      }
    }

    async function handleUploadFiles(files: File[]) {
      if (files.length === 0 || isTrashView || isStarredView) return;
      const totalSize = files.reduce((sum, file) => sum + file.size, 0);
      uploadAbortControllerRef.current?.abort();
      const controller = new AbortController();
      uploadAbortControllerRef.current = controller;
      dispatch({ type: "SET_UPLOAD_PROGRESS", loaded: 0, total: totalSize });
      const isCurrentUpload = () => uploadAbortControllerRef.current === controller;
      try {
        const result = await uploadFilesMutation.mutateAsync({
          destinationPath: toFilePath(currentPath),
          files,
          includeHidden,
          onProgress: (loaded, total) => {
            dispatch({ type: "SET_UPLOAD_PROGRESS", loaded, total: total || totalSize });
          },
          signal: controller.signal,
        });
        if (!isCurrentUpload()) return;
        dispatch({ type: "CLEAR_UPLOAD_PROGRESS" });
        const uploaded = result.uploaded.length;
        const skipped = result.skipped.length;
        if (uploaded > 0 && skipped === 0) setStatusNotice(`Uploaded ${uploaded} file${uploaded !== 1 ? "s" : ""}`);
        else if (uploaded > 0) setStatusNotice(`Uploaded ${uploaded}, skipped ${skipped} (already exist)`);
        else setStatusNotice(`Skipped ${skipped} file${skipped !== 1 ? "s" : ""} (already exist)`);
      } catch (error) {
        if (!isCurrentUpload()) return;
        dispatch({ type: "CLEAR_UPLOAD_PROGRESS" });
        if (error instanceof Error && error.name === "AbortError") {
          setStatusNotice("Upload canceled");
        } else {
          setStatusNotice(error instanceof Error ? error.message : "Upload failed");
        }
      } finally {
        if (uploadAbortControllerRef.current === controller) {
          uploadAbortControllerRef.current = null;
        }
      }
    }

    function handleDownloadEntry(entry: FileEntry) {
      window.open(entry.type === "folder" ? buildZipUrl(entry.path) : buildDownloadUrl(entry.path), "_blank", "noopener,noreferrer");
    }

    async function handleUnzipEntry(entry: FileEntry) {
      try {
        await unzipFileMutation.mutateAsync(entry.path);
        setStatusNotice("Unzipped successfully");
      } catch (error) {
        setStatusNotice(error instanceof Error ? error.message : "Failed to unzip");
      }
    }

    async function handleShareFolder(entry: FileEntry) {
      try {
        const result = await createLocalShareMutation.mutateAsync({ path: entry.path });
        setStatusNotice(`Shared over network: /${result.sharedPath}`);
      } catch (error) {
        setStatusNotice(error instanceof Error ? error.message : "Failed to share folder");
      }
    }

    async function handleUnshareFolder(shareId: string) {
      try {
        await deleteLocalShareMutation.mutateAsync(shareId);
        setStatusNotice("Shared folder removed");
      } catch (error) {
        setStatusNotice(error instanceof Error ? error.message : "Failed to remove shared folder");
      }
    }

    async function handleCopyEntryPath(entry: FileEntry) {
      const basePath = filesRootPath ? `${filesRootPath}/${entry.path}` : `/${entry.path}`;
      const networkBase = systemNetworkAddress || systemHostname;
      const fullPath =
        entry.path === "Shared" || entry.path.startsWith("Shared/")
          ? networkBase
            ? `smb://${networkBase}${basePath}`
            : basePath
          : basePath;
      try {
        if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(fullPath);
        else {
          const el = document.createElement("textarea");
          el.value = fullPath;
          el.style.position = "fixed";
          el.style.opacity = "0";
          document.body.appendChild(el);
          el.select();
          const ok = document.execCommand("copy");
          document.body.removeChild(el);
          if (!ok) throw new Error("execCommand failed");
        }
        toast.success("Path copied to clipboard");
      } catch {
        toast.error("Could not copy automatically", { description: fullPath });
      }
    }

    async function handleSaveOpenFile() {
      if (!openFileKey || openFileViewer?.mode !== "text") return;
      let nextContent = fileDrafts[openFileKey] ?? openFileViewer.content ?? "";

      const ext = openFileKey.split(".").pop()?.toLowerCase();
      if (ext === "json") {
        try {
          nextContent = JSON.stringify(JSON.parse(nextContent), null, 2);
          dispatch({ type: "SET_FILE_DRAFT", key: openFileKey, value: nextContent });
        } catch {
          // malformed JSON — save as-is
        }
      }

      try {
        await saveFileContentMutation.mutateAsync({
          path: openFileKey,
          content: nextContent,
          expectedMtimeMs: openFileViewer.mtimeMs,
        });
        dispatch({ type: "SET_EDITOR_NOTICE", notice: "Saved" });
      } catch (error) {
        dispatch({
          type: "SET_EDITOR_NOTICE",
          notice: error instanceof Error ? error.message : "Failed to save file",
        });
      }
    }

    return {
      confirmEmptyTrash,
      handleCopyEntryPath,
      handleDeleteFromTrash,
      handleDownloadEntry,
      handleEmptyTrash,
      handleGetInfo,
      handleMoveSelectedToTrash,
      handleRenameEntry,
      handleRestoreFromTrash,
      handleSaveOpenFile,
      handleShareFolder,
      handleToggleStar,
      handleTrashSelected,
      handleUnshareFolder,
      handleUnzipEntry,
      handleUploadFiles,
      submitCreateEntryDialog,
      submitRenameDialog,
    };
  }, [
    createEntryDialog,
    createFileMutation,
    createFolderMutation,
    createLocalShareMutation,
    currentPath,
    deleteFromTrashMutation,
    deleteLocalShareMutation,
    dispatch,
    emptyTrashMutation,
    fileDrafts,
    filesRootPath,
    getFileInfoMutation,
    includeHidden,
    isEmptyingTrash,
    isStarredView,
    isTrashView,
    moveToTrashMutation,
    openFile,
    openFileKey,
    openFileViewer,
    renameDialog,
    renameFileEntryMutation,
    restoreFromTrashMutation,
    saveFileContentMutation,
    selectedFiles,
    setStatusNotice,
    sortedEntries,
    systemHostname,
    systemNetworkAddress,
    toggleStarMutation,
    uploadAbortControllerRef,
    uploadFilesMutation,
    unzipFileMutation,
  ]);
}
bytes
15,619
lines
433
Type
ts
Latest commit
16 October 2026