[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
373
components/sftp/hooks/useSftpPaneDialogs.ts
Normal file
373
components/sftp/hooks/useSftpPaneDialogs.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { SftpPaneCallbacks } from "../SftpContext";
|
||||
import type { SftpPane } from "../../../application/state/sftp/types";
|
||||
import { getFileName, getParentPath } from "../../../application/state/sftp/utils";
|
||||
import { logger } from "../../../lib/logger";
|
||||
|
||||
const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/;
|
||||
const RESERVED_NAMES = new Set([
|
||||
"CON",
|
||||
"PRN",
|
||||
"AUX",
|
||||
"NUL",
|
||||
"COM1",
|
||||
"COM2",
|
||||
"COM3",
|
||||
"COM4",
|
||||
"COM5",
|
||||
"COM6",
|
||||
"COM7",
|
||||
"COM8",
|
||||
"COM9",
|
||||
"LPT1",
|
||||
"LPT2",
|
||||
"LPT3",
|
||||
"LPT4",
|
||||
"LPT5",
|
||||
"LPT6",
|
||||
"LPT7",
|
||||
"LPT8",
|
||||
"LPT9",
|
||||
]);
|
||||
|
||||
interface UseSftpPaneDialogsParams {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
pane: SftpPane;
|
||||
onCreateDirectory: SftpPaneCallbacks["onCreateDirectory"];
|
||||
onCreateDirectoryAtPath: SftpPaneCallbacks["onCreateDirectoryAtPath"];
|
||||
onCreateFile: SftpPaneCallbacks["onCreateFile"];
|
||||
onCreateFileAtPath: SftpPaneCallbacks["onCreateFileAtPath"];
|
||||
onRenameFileAtPath: SftpPaneCallbacks["onRenameFileAtPath"];
|
||||
onDeleteFilesAtPath: SftpPaneCallbacks["onDeleteFilesAtPath"];
|
||||
onClearSelection: SftpPaneCallbacks["onClearSelection"];
|
||||
onMutateSuccess?: (paths?: string[]) => void;
|
||||
}
|
||||
|
||||
interface UseSftpPaneDialogsResult {
|
||||
showHostPicker: boolean;
|
||||
hostSearch: string;
|
||||
showNewFolderDialog: boolean;
|
||||
newFolderName: string;
|
||||
showNewFileDialog: boolean;
|
||||
newFileName: string;
|
||||
fileNameError: string | null;
|
||||
showOverwriteConfirm: boolean;
|
||||
overwriteTarget: string | null;
|
||||
showRenameDialog: boolean;
|
||||
renameTarget: string | null;
|
||||
renameName: string;
|
||||
showDeleteConfirm: boolean;
|
||||
deleteTargets: string[];
|
||||
isCreating: boolean;
|
||||
isCreatingFile: boolean;
|
||||
isRenaming: boolean;
|
||||
isDeleting: boolean;
|
||||
setShowHostPicker: (open: boolean) => void;
|
||||
setHostSearch: (value: string) => void;
|
||||
setShowNewFolderDialog: (open: boolean) => void;
|
||||
setNewFolderName: (value: string) => void;
|
||||
setShowNewFileDialog: (open: boolean) => void;
|
||||
setNewFileName: (value: string) => void;
|
||||
setFileNameError: (value: string | null) => void;
|
||||
setShowOverwriteConfirm: (open: boolean) => void;
|
||||
setShowRenameDialog: (open: boolean) => void;
|
||||
setRenameName: (value: string) => void;
|
||||
setShowDeleteConfirm: (open: boolean) => void;
|
||||
handleCreateFolder: () => Promise<void>;
|
||||
handleCreateFile: (forceOverwrite?: boolean) => Promise<void>;
|
||||
handleConfirmOverwrite: () => Promise<void>;
|
||||
handleRename: () => Promise<void>;
|
||||
handleDelete: () => Promise<void>;
|
||||
openNewFolderDialogAtPath: (path: string) => void;
|
||||
openNewFileDialogAtPath: (path: string) => void;
|
||||
openRenameDialog: (name: string) => void;
|
||||
openDeleteConfirm: (names: string[]) => void;
|
||||
getNextUntitledName: (existingFiles: string[]) => string;
|
||||
}
|
||||
|
||||
export const useSftpPaneDialogs = ({
|
||||
t,
|
||||
pane,
|
||||
onCreateDirectory,
|
||||
onCreateDirectoryAtPath,
|
||||
onCreateFile,
|
||||
onCreateFileAtPath,
|
||||
onRenameFileAtPath,
|
||||
onDeleteFilesAtPath,
|
||||
onClearSelection,
|
||||
onMutateSuccess,
|
||||
}: UseSftpPaneDialogsParams): UseSftpPaneDialogsResult => {
|
||||
const [showHostPicker, setShowHostPicker] = useState(false);
|
||||
const [hostSearch, setHostSearch] = useState("");
|
||||
const [showNewFolderDialogState, setShowNewFolderDialogState] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [showNewFileDialogState, setShowNewFileDialogState] = useState(false);
|
||||
const [newFileName, setNewFileName] = useState("");
|
||||
const [createTargetPath, setCreateTargetPath] = useState<string | null>(null);
|
||||
const [fileNameError, setFileNameError] = useState<string | null>(null);
|
||||
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false);
|
||||
const [overwriteTarget, setOverwriteTarget] = useState<string | null>(null);
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null);
|
||||
const [renameName, setRenameName] = useState("");
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deleteTargets, setDeleteTargets] = useState<string[]>([]);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isCreatingFile, setIsCreatingFile] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Refs for values accessed inside useCallback to avoid stale closures
|
||||
const newFolderNameRef = useRef(newFolderName);
|
||||
newFolderNameRef.current = newFolderName;
|
||||
const newFileNameRef = useRef(newFileName);
|
||||
newFileNameRef.current = newFileName;
|
||||
const createTargetPathRef = useRef(createTargetPath);
|
||||
createTargetPathRef.current = createTargetPath;
|
||||
const renameTargetRef = useRef(renameTarget);
|
||||
renameTargetRef.current = renameTarget;
|
||||
const renameNameRef = useRef(renameName);
|
||||
renameNameRef.current = renameName;
|
||||
const deleteTargetsRef = useRef(deleteTargets);
|
||||
deleteTargetsRef.current = deleteTargets;
|
||||
const paneRef = useRef(pane);
|
||||
paneRef.current = pane;
|
||||
|
||||
const validateFileName = useCallback(
|
||||
(name: string): string | null => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const invalidMatch = trimmed.match(INVALID_FILENAME_CHARS);
|
||||
if (invalidMatch) {
|
||||
return t("sftp.error.invalidFileName", { chars: invalidMatch[0] });
|
||||
}
|
||||
|
||||
const baseName = trimmed.split(".")[0].toUpperCase();
|
||||
if (RESERVED_NAMES.has(baseName)) {
|
||||
return t("sftp.error.reservedName");
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const getNextUntitledName = useCallback((existingFiles: string[]): string => {
|
||||
const existingSet = new Set(existingFiles.map((f) => f.toLowerCase()));
|
||||
|
||||
if (!existingSet.has("untitled.txt")) {
|
||||
return "untitled.txt";
|
||||
}
|
||||
|
||||
let counter = 1;
|
||||
while (counter < 1000) {
|
||||
const name = `untitled (${counter}).txt`;
|
||||
if (!existingSet.has(name.toLowerCase())) {
|
||||
return name;
|
||||
}
|
||||
counter++;
|
||||
}
|
||||
|
||||
return `untitled_${Date.now()}.txt`;
|
||||
}, []);
|
||||
|
||||
const handleCreateFolder = useCallback(async () => {
|
||||
if (!newFolderNameRef.current.trim() || isCreating) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
if (createTargetPathRef.current) {
|
||||
await onCreateDirectoryAtPath(createTargetPathRef.current, newFolderNameRef.current.trim());
|
||||
} else {
|
||||
await onCreateDirectory(newFolderNameRef.current.trim());
|
||||
}
|
||||
const affectedPath = createTargetPathRef.current ?? paneRef.current.connection?.currentPath;
|
||||
onMutateSuccess?.(affectedPath ? [affectedPath] : undefined);
|
||||
setShowNewFolderDialogState(false);
|
||||
setCreateTargetPath(null);
|
||||
setNewFolderName("");
|
||||
} catch (err) {
|
||||
logger.warn("Failed to create folder", err);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [isCreating, onCreateDirectory, onCreateDirectoryAtPath, onMutateSuccess]);
|
||||
|
||||
const handleCreateFile = useCallback(async (forceOverwrite = false) => {
|
||||
const trimmedName = newFileNameRef.current.trim();
|
||||
if (!trimmedName || isCreatingFile) return;
|
||||
|
||||
const error = validateFileName(trimmedName);
|
||||
if (error) {
|
||||
setFileNameError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPane = paneRef.current;
|
||||
if (!forceOverwrite && (!createTargetPathRef.current || createTargetPathRef.current === currentPane.connection?.currentPath)) {
|
||||
const existingFile = currentPane.files.find(
|
||||
(f) =>
|
||||
f.name.toLowerCase() === trimmedName.toLowerCase() && f.type === "file",
|
||||
);
|
||||
if (existingFile) {
|
||||
setOverwriteTarget(trimmedName);
|
||||
setShowOverwriteConfirm(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsCreatingFile(true);
|
||||
try {
|
||||
if (createTargetPathRef.current) {
|
||||
await onCreateFileAtPath(createTargetPathRef.current, trimmedName);
|
||||
} else {
|
||||
await onCreateFile(trimmedName);
|
||||
}
|
||||
const affectedPath = createTargetPathRef.current ?? paneRef.current.connection?.currentPath;
|
||||
onMutateSuccess?.(affectedPath ? [affectedPath] : undefined);
|
||||
setShowNewFileDialogState(false);
|
||||
setShowOverwriteConfirm(false);
|
||||
setOverwriteTarget(null);
|
||||
setCreateTargetPath(null);
|
||||
setNewFileName("");
|
||||
setFileNameError(null);
|
||||
} catch (err) {
|
||||
logger.warn("Failed to create file", err);
|
||||
} finally {
|
||||
setIsCreatingFile(false);
|
||||
}
|
||||
}, [isCreatingFile, validateFileName, onCreateFile, onCreateFileAtPath, onMutateSuccess]);
|
||||
|
||||
const handleConfirmOverwrite = useCallback(async () => {
|
||||
await handleCreateFile(true);
|
||||
}, [handleCreateFile]);
|
||||
|
||||
const handleRename = useCallback(async () => {
|
||||
if (!renameTargetRef.current || !renameNameRef.current.trim() || isRenaming) return;
|
||||
setIsRenaming(true);
|
||||
try {
|
||||
// renameTarget is always a full path; use the path-aware variant
|
||||
await onRenameFileAtPath(renameTargetRef.current, renameNameRef.current.trim());
|
||||
onMutateSuccess?.([getParentPath(renameTargetRef.current)]);
|
||||
setShowRenameDialog(false);
|
||||
setRenameTarget(null);
|
||||
setRenameName("");
|
||||
} catch (err) {
|
||||
logger.warn("Failed to rename file", err);
|
||||
} finally {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}, [isRenaming, onRenameFileAtPath, onMutateSuccess]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (deleteTargetsRef.current.length === 0 || isDeleting) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
// deleteTargets are full paths; group by parent dir and use path-aware variant
|
||||
const byDir = new Map<string, string[]>();
|
||||
for (const fullPath of deleteTargetsRef.current) {
|
||||
const dir = getParentPath(fullPath);
|
||||
const name = getFileName(fullPath);
|
||||
const list = byDir.get(dir) ?? [];
|
||||
list.push(name);
|
||||
byDir.set(dir, list);
|
||||
}
|
||||
const connectionId = paneRef.current.connection?.id;
|
||||
if (!connectionId) {
|
||||
throw new Error("Pane connection is no longer available");
|
||||
}
|
||||
for (const [dir, names] of byDir) {
|
||||
await onDeleteFilesAtPath(connectionId, dir, names);
|
||||
}
|
||||
onMutateSuccess?.(Array.from(byDir.keys()));
|
||||
setShowDeleteConfirm(false);
|
||||
setDeleteTargets([]);
|
||||
onClearSelection();
|
||||
} catch (err) {
|
||||
logger.warn("Failed to delete files", err);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [isDeleting, onDeleteFilesAtPath, onMutateSuccess, onClearSelection]);
|
||||
|
||||
// entryPath is the full path; renameName is initialized to the basename
|
||||
const openRenameDialog = useCallback((entryPath: string) => {
|
||||
setRenameTarget(entryPath);
|
||||
setRenameName(getFileName(entryPath) || entryPath);
|
||||
setShowRenameDialog(true);
|
||||
}, []);
|
||||
|
||||
const setShowNewFolderDialog = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setCreateTargetPath(null);
|
||||
}
|
||||
setShowNewFolderDialogState(open);
|
||||
}, []);
|
||||
|
||||
const setShowNewFileDialog = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setCreateTargetPath(null);
|
||||
}
|
||||
setShowNewFileDialogState(open);
|
||||
}, []);
|
||||
|
||||
const openNewFolderDialogAtPath = useCallback((path: string) => {
|
||||
setCreateTargetPath(path);
|
||||
setNewFolderName("");
|
||||
setShowNewFolderDialogState(true);
|
||||
}, []);
|
||||
|
||||
const openNewFileDialogAtPath = useCallback((path: string) => {
|
||||
setCreateTargetPath(path);
|
||||
setNewFileName("");
|
||||
setFileNameError(null);
|
||||
setShowNewFileDialogState(true);
|
||||
}, []);
|
||||
|
||||
const openDeleteConfirm = useCallback((names: string[]) => {
|
||||
setDeleteTargets(names);
|
||||
setShowDeleteConfirm(true);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
showHostPicker,
|
||||
hostSearch,
|
||||
showNewFolderDialog: showNewFolderDialogState,
|
||||
newFolderName,
|
||||
showNewFileDialog: showNewFileDialogState,
|
||||
newFileName,
|
||||
fileNameError,
|
||||
showOverwriteConfirm,
|
||||
overwriteTarget,
|
||||
showRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
showDeleteConfirm,
|
||||
deleteTargets,
|
||||
isCreating,
|
||||
isCreatingFile,
|
||||
isRenaming,
|
||||
isDeleting,
|
||||
setShowHostPicker,
|
||||
setHostSearch,
|
||||
setShowNewFolderDialog,
|
||||
setNewFolderName,
|
||||
setShowNewFileDialog,
|
||||
setNewFileName,
|
||||
setFileNameError,
|
||||
setShowOverwriteConfirm,
|
||||
setShowRenameDialog,
|
||||
setRenameName,
|
||||
setShowDeleteConfirm,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
handleConfirmOverwrite,
|
||||
handleRename,
|
||||
handleDelete,
|
||||
openNewFolderDialogAtPath,
|
||||
openNewFileDialogAtPath,
|
||||
openRenameDialog,
|
||||
openDeleteConfirm,
|
||||
getNextUntitledName,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user