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
289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
import type { SftpFileEntry } from "../../../types";
|
|
import type { SftpPaneCallbacks, SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
|
|
import { isNavigableDirectory } from "../utils";
|
|
import { joinPath } from "../../../application/state/sftp/utils";
|
|
|
|
interface UseSftpPaneDragAndSelectParams {
|
|
side: "left" | "right";
|
|
pane: {
|
|
selectedFiles: Set<string>;
|
|
connection?: { currentPath: string; id: string } | null;
|
|
};
|
|
sortedDisplayFiles: SftpFileEntry[];
|
|
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
|
|
onDragStart: SftpDragCallbacks["onDragStart"];
|
|
onReceiveFromOtherPane: SftpPaneCallbacks["onReceiveFromOtherPane"];
|
|
onMoveEntriesToPath: SftpPaneCallbacks["onMoveEntriesToPath"];
|
|
onUploadExternalFiles?: SftpPaneCallbacks["onUploadExternalFiles"];
|
|
onOpenEntry: SftpPaneCallbacks["onOpenEntry"];
|
|
onRangeSelect: SftpPaneCallbacks["onRangeSelect"];
|
|
onToggleSelection: SftpPaneCallbacks["onToggleSelection"];
|
|
}
|
|
|
|
interface UseSftpPaneDragAndSelectResult {
|
|
dragOverEntry: string | null;
|
|
isDragOverPane: boolean;
|
|
paneContainerRef: React.RefObject<HTMLDivElement>;
|
|
handlePaneDragOver: (e: React.DragEvent) => void;
|
|
handlePaneDragLeave: (e: React.DragEvent) => void;
|
|
handlePaneDrop: (e: React.DragEvent) => Promise<void>;
|
|
handleFileDragStart: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
|
handleEntryDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
|
handleEntryDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
|
handleRowDragLeave: () => void;
|
|
handleRowSelect: (entry: SftpFileEntry, index: number, e: React.MouseEvent) => void;
|
|
handleRowOpen: (entry: SftpFileEntry) => void;
|
|
}
|
|
|
|
export const useSftpPaneDragAndSelect = ({
|
|
side,
|
|
pane,
|
|
sortedDisplayFiles,
|
|
draggedFiles,
|
|
onDragStart,
|
|
onReceiveFromOtherPane,
|
|
onMoveEntriesToPath,
|
|
onUploadExternalFiles,
|
|
onOpenEntry,
|
|
onRangeSelect,
|
|
onToggleSelection,
|
|
}: UseSftpPaneDragAndSelectParams): UseSftpPaneDragAndSelectResult => {
|
|
const [dragOverEntry, setDragOverEntry] = useState<string | null>(null);
|
|
const [isDragOverPane, setIsDragOverPane] = useState(false);
|
|
const paneContainerRef = useRef<HTMLDivElement>(null);
|
|
const lastSelectedIndexRef = useRef<number | null>(null);
|
|
|
|
const selectedFilesRef = useRef(pane.selectedFiles);
|
|
selectedFilesRef.current = pane.selectedFiles;
|
|
const sortedFilesRef = useRef(sortedDisplayFiles);
|
|
sortedFilesRef.current = sortedDisplayFiles;
|
|
const draggedFilesRef = useRef(draggedFiles);
|
|
draggedFilesRef.current = draggedFiles;
|
|
const onReceiveRef = useRef(onReceiveFromOtherPane);
|
|
onReceiveRef.current = onReceiveFromOtherPane;
|
|
const onMoveEntriesToPathRef = useRef(onMoveEntriesToPath);
|
|
onMoveEntriesToPathRef.current = onMoveEntriesToPath;
|
|
const onUploadRef = useRef(onUploadExternalFiles);
|
|
onUploadRef.current = onUploadExternalFiles;
|
|
|
|
useEffect(() => {
|
|
if (pane.selectedFiles.size === 0) {
|
|
lastSelectedIndexRef.current = null;
|
|
}
|
|
}, [pane.selectedFiles.size]);
|
|
|
|
const getSamePaneDragPaths = useCallback((): string[] | null => {
|
|
const dragged = draggedFilesRef.current;
|
|
if (!dragged || dragged.length === 0) return null;
|
|
if (dragged[0]?.side !== side) return null;
|
|
|
|
const currentConnectionId = pane.connection?.id;
|
|
const paths = dragged
|
|
.filter((file) => file.sourceConnectionId === currentConnectionId && file.sourcePath)
|
|
.map((file) => joinPath(file.sourcePath!, file.name));
|
|
|
|
return paths.length > 0 ? paths : null;
|
|
}, [pane.connection?.id, side]);
|
|
|
|
const handlePaneDragOver = useCallback((e: React.DragEvent) => {
|
|
const hasFiles = e.dataTransfer.types.includes("Files");
|
|
|
|
if (hasFiles) {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "copy";
|
|
setIsDragOverPane(true);
|
|
return;
|
|
}
|
|
|
|
if (!draggedFilesRef.current || draggedFilesRef.current[0]?.side === side) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "copy";
|
|
setIsDragOverPane(true);
|
|
}, [side]);
|
|
|
|
const handlePaneDragLeave = useCallback((e: React.DragEvent) => {
|
|
const relatedTarget = e.relatedTarget as Node | null;
|
|
if (relatedTarget && paneContainerRef.current?.contains(relatedTarget)) return;
|
|
setIsDragOverPane(false);
|
|
setDragOverEntry(null);
|
|
}, []);
|
|
|
|
const handlePaneDrop = useCallback(async (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragOverPane(false);
|
|
setDragOverEntry(null);
|
|
|
|
if (draggedFilesRef.current && draggedFilesRef.current.length > 0) {
|
|
if (draggedFilesRef.current[0]?.side !== side) {
|
|
onReceiveRef.current(draggedFilesRef.current);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (e.dataTransfer.items.length > 0 && onUploadRef.current) {
|
|
await onUploadRef.current(e.dataTransfer);
|
|
}
|
|
}, [side]);
|
|
|
|
const handleFileDragStart = useCallback(
|
|
(entry: SftpFileEntry, e: React.DragEvent) => {
|
|
if (entry.name === "..") {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
const selectedNames = new Set(selectedFilesRef.current);
|
|
const files = selectedNames.has(entry.name)
|
|
? sortedFilesRef.current
|
|
.filter((f) => selectedNames.has(f.name))
|
|
.map((f) => ({
|
|
name: f.name,
|
|
isDirectory: isNavigableDirectory(f),
|
|
sourceConnectionId: pane.connection?.id,
|
|
sourcePath: pane.connection?.currentPath,
|
|
side,
|
|
}))
|
|
: [
|
|
{
|
|
name: entry.name,
|
|
isDirectory: isNavigableDirectory(entry),
|
|
sourceConnectionId: pane.connection?.id,
|
|
sourcePath: pane.connection?.currentPath,
|
|
side,
|
|
},
|
|
];
|
|
e.dataTransfer.effectAllowed = "copyMove";
|
|
e.dataTransfer.setData("text/plain", files.map((f) => f.name).join("\n"));
|
|
onDragStart(files, side);
|
|
},
|
|
[onDragStart, pane.connection?.currentPath, pane.connection?.id, side],
|
|
);
|
|
|
|
const handleEntryDragOver = useCallback(
|
|
(entry: SftpFileEntry, e: React.DragEvent) => {
|
|
const samePaneDragPaths = getSamePaneDragPaths();
|
|
if (samePaneDragPaths && isNavigableDirectory(entry) && entry.name !== "..") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.dataTransfer.dropEffect = "move";
|
|
setDragOverEntry(entry.name);
|
|
return;
|
|
}
|
|
|
|
// Handle cross-pane internal drag
|
|
if (draggedFilesRef.current && draggedFilesRef.current[0]?.side !== side) {
|
|
if (isNavigableDirectory(entry) && entry.name !== "..") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragOverEntry(entry.name);
|
|
}
|
|
return;
|
|
}
|
|
// Handle external file drag (from OS file explorer)
|
|
const hasFiles = e.dataTransfer.types.includes("Files");
|
|
if (hasFiles && isNavigableDirectory(entry) && entry.name !== "..") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.dataTransfer.dropEffect = "copy";
|
|
setDragOverEntry(entry.name);
|
|
}
|
|
},
|
|
[getSamePaneDragPaths, side],
|
|
);
|
|
|
|
const handleEntryDrop = useCallback(
|
|
async (entry: SftpFileEntry, e: React.DragEvent) => {
|
|
const samePaneDragPaths = getSamePaneDragPaths();
|
|
if (samePaneDragPaths && isNavigableDirectory(entry) && entry.name !== "..") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragOverEntry(null);
|
|
setIsDragOverPane(false);
|
|
const targetPath = pane.connection?.currentPath
|
|
? joinPath(pane.connection.currentPath, entry.name)
|
|
: undefined;
|
|
if (targetPath) {
|
|
await onMoveEntriesToPathRef.current(samePaneDragPaths, targetPath);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle cross-pane internal drag
|
|
if (draggedFilesRef.current && draggedFilesRef.current[0]?.side !== side) {
|
|
if (isNavigableDirectory(entry) && entry.name !== "..") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragOverEntry(null);
|
|
setIsDragOverPane(false);
|
|
const targetPath = pane.connection?.currentPath
|
|
? joinPath(pane.connection.currentPath, entry.name)
|
|
: undefined;
|
|
onReceiveRef.current(
|
|
draggedFilesRef.current.map((file) => ({ ...file, targetPath })),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
// Handle external file drop on a directory entry
|
|
const hasFiles = e.dataTransfer.types.includes("Files");
|
|
if (hasFiles && isNavigableDirectory(entry) && entry.name !== "..") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragOverEntry(null);
|
|
setIsDragOverPane(false);
|
|
if (onUploadRef.current && pane.connection?.currentPath) {
|
|
const targetPath = joinPath(pane.connection.currentPath, entry.name);
|
|
void onUploadRef.current(e.dataTransfer, targetPath);
|
|
}
|
|
}
|
|
},
|
|
[getSamePaneDragPaths, side, pane.connection?.currentPath],
|
|
);
|
|
|
|
const handleRowSelect = useCallback(
|
|
(entry: SftpFileEntry, index: number, e: React.MouseEvent) => {
|
|
if (entry.name === "..") return;
|
|
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
|
|
const start = Math.min(lastSelectedIndexRef.current, index);
|
|
const end = Math.max(lastSelectedIndexRef.current, index);
|
|
const selectedFileNames = sortedFilesRef.current
|
|
.slice(start, end + 1)
|
|
.filter((f) => f.name !== "..")
|
|
.map((f) => f.name);
|
|
onRangeSelect(selectedFileNames);
|
|
} else {
|
|
onToggleSelection(entry.name, e.ctrlKey || e.metaKey);
|
|
lastSelectedIndexRef.current = index;
|
|
}
|
|
},
|
|
[onRangeSelect, onToggleSelection],
|
|
);
|
|
|
|
const handleRowOpen = useCallback(
|
|
(entry: SftpFileEntry) => {
|
|
onOpenEntry(entry);
|
|
},
|
|
[onOpenEntry],
|
|
);
|
|
|
|
const handleRowDragLeave = useCallback(() => {
|
|
setDragOverEntry(null);
|
|
}, []);
|
|
|
|
return {
|
|
dragOverEntry,
|
|
isDragOverPane,
|
|
paneContainerRef,
|
|
handlePaneDragOver,
|
|
handlePaneDragLeave,
|
|
handlePaneDrop,
|
|
handleFileDragStart,
|
|
handleEntryDragOver,
|
|
handleEntryDrop,
|
|
handleRowDragLeave,
|
|
handleRowSelect,
|
|
handleRowOpen,
|
|
};
|
|
};
|