Files
Netcatty/components/sftp/SftpContext.tsx
2026-05-09 17:47:45 +08:00

228 lines
8.4 KiB
TypeScript

/**
* SftpContext - Provides stable callback references to SFTP components
*
* This context eliminates props drilling of callback functions through
* the component tree, significantly reducing re-renders caused by
* callback reference changes.
*/
import React, { createContext, useContext, useMemo, useSyncExternalStore } from "react";
import { Host, SftpFileEntry, SftpFilenameEncoding } from "../../types";
export interface SftpTransferSource {
name: string;
isDirectory: boolean;
sourcePath?: string;
sourceConnectionId?: string;
targetPath?: string;
}
// Types for the context
export interface SftpPaneCallbacks {
onConnect: (host: Host | "local") => void;
/** Resolves true if disconnect completed, false if the user canceled the
* dirty-editor prompt. Callers that follow up with a replacement connect
* must gate on the result. */
onDisconnect: () => Promise<boolean>;
onPrepareSelection: () => void;
onNavigateTo: (path: string) => void;
onNavigateUp: () => void;
onRefresh: () => void;
onRefreshTab: (tabId: string) => void;
onSetFilenameEncoding: (encoding: SftpFilenameEncoding) => void;
onOpenEntry: (entry: SftpFileEntry, fullPath?: string) => void;
onToggleSelection: (fileName: string, multiSelect: boolean) => void;
onRangeSelect: (fileNames: string[]) => void;
onClearSelection: () => void;
onSetFilter: (filter: string) => void;
onCreateDirectory: (name: string) => Promise<void>;
onCreateDirectoryAtPath: (path: string, name: string) => Promise<void>;
onCreateFile: (name: string) => Promise<void>;
onCreateFileAtPath: (path: string, name: string) => Promise<void>;
onDeleteFiles: (fileNames: string[]) => Promise<void>;
onDeleteFilesAtPath: (connectionId: string, path: string, fileNames: string[]) => Promise<void>;
onRenameFile: (oldName: string, newName: string) => Promise<void>;
onRenameFileAtPath: (oldPath: string, newName: string) => Promise<void>;
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
onReceiveFromOtherPane: (files: SftpTransferSource[]) => void;
onEditPermissions?: (file: SftpFileEntry, fullPath?: string) => void;
// File operations
onEditFile?: (entry: SftpFileEntry, fullPath?: string) => void;
onOpenFile?: (entry: SftpFileEntry, fullPath?: string) => void;
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void; // Always show opener dialog
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void; // Download to local filesystem
onDownloadFiles?: (entries: SftpFileEntry[]) => void; // Batch download — picks one target directory for remote panes
// External file upload (supports folders via DataTransfer)
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
// External file upload from <input type="file" multiple> picker (FileList).
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void>;
// External folder upload from native directory picker.
onUploadExternalFolder?: (targetPath?: string) => Promise<void>;
onListDirectory: (path: string) => Promise<SftpFileEntry[]>;
}
export interface SftpDragCallbacks {
onDragStart: (files: SftpTransferSource[], side: "left" | "right") => void;
onDragEnd: () => void;
}
// Store for activeTabId - allows subscription without re-rendering parent
type ActiveTabStore = {
left: string | null;
right: string | null;
};
type ActiveTabListener = () => void;
let activeTabState: ActiveTabStore = { left: null, right: null };
const activeTabListeners = new Set<ActiveTabListener>();
export const activeTabStore = {
getSnapshot: () => activeTabState,
getLeftActiveTabId: () => activeTabState.left,
getRightActiveTabId: () => activeTabState.right,
setActiveTabId: (side: "left" | "right", tabId: string | null) => {
if (activeTabState[side] !== tabId) {
activeTabState = { ...activeTabState, [side]: tabId };
activeTabListeners.forEach((listener) => listener());
}
},
subscribe: (listener: ActiveTabListener) => {
activeTabListeners.add(listener);
return () => activeTabListeners.delete(listener);
},
};
// Hook to subscribe to active tab changes for a specific side
export const useActiveTabId = (side: "left" | "right"): string | null => {
return useSyncExternalStore(
activeTabStore.subscribe,
() => (side === "left" ? activeTabStore.getLeftActiveTabId() : activeTabStore.getRightActiveTabId()),
() => (side === "left" ? activeTabStore.getLeftActiveTabId() : activeTabStore.getRightActiveTabId()),
);
};
// Hook to check if a specific pane is active (for CSS control)
export const useIsPaneActive = (side: "left" | "right", paneId: string): boolean => {
const activeTabId = useActiveTabId(side);
return activeTabId === paneId || (activeTabId === null && paneId !== null);
};
export interface SftpContextValue {
// Hosts list for connection picker
hosts: Host[];
// Raw hosts list for bookmark persistence and other host writes.
writableHosts: Host[];
// Host updater for bookmark persistence
updateHosts: (hosts: Host[]) => void;
// Callbacks for each side
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
}
export interface SftpDragContextValue {
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
dragCallbacks: SftpDragCallbacks;
}
const SftpContext = createContext<SftpContextValue | null>(null);
const SftpDragContext = createContext<SftpDragContextValue | null>(null);
export const useSftpContext = () => {
const context = useContext(SftpContext);
if (!context) {
throw new Error("useSftpContext must be used within SftpContextProvider");
}
return context;
};
// Hook to get callbacks for a specific side
export const useSftpPaneCallbacks = (side: "left" | "right"): SftpPaneCallbacks => {
const context = useSftpContext();
return side === "left" ? context.leftCallbacks : context.rightCallbacks;
};
// Hook to get drag-related values (reads from separate SftpDragContext)
export const useSftpDrag = () => {
const context = useContext(SftpDragContext);
if (!context) {
throw new Error("useSftpDrag must be used within SftpContextProvider");
}
return useMemo(
() => ({
draggedFiles: context.draggedFiles,
...context.dragCallbacks,
}),
[context.draggedFiles, context.dragCallbacks],
);
};
// Hook to get hosts
export const useSftpHosts = () => {
const context = useSftpContext();
return context.hosts;
};
// Hook to get raw hosts for writeback
export const useSftpWritableHosts = () => {
const context = useSftpContext();
return context.writableHosts;
};
// Hook to get host updater
export const useSftpUpdateHosts = () => {
const context = useSftpContext();
return context.updateHosts;
};
interface SftpContextProviderProps {
hosts: Host[];
writableHosts?: Host[];
updateHosts: (hosts: Host[]) => void;
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
dragCallbacks: SftpDragCallbacks;
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
children: React.ReactNode;
}
export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
hosts,
writableHosts,
updateHosts,
draggedFiles,
dragCallbacks,
leftCallbacks,
rightCallbacks,
children,
}) => {
// Memoize the main context value (no drag state, so drag changes won't cause re-renders here)
const value = useMemo<SftpContextValue>(
() => ({
hosts,
writableHosts: writableHosts ?? hosts,
updateHosts,
leftCallbacks,
rightCallbacks,
}),
[hosts, writableHosts, updateHosts, leftCallbacks, rightCallbacks],
);
// Memoize drag context separately so only drag consumers re-render on drag state changes
const dragValue = useMemo<SftpDragContextValue>(
() => ({
draggedFiles,
dragCallbacks,
}),
[draggedFiles, dragCallbacks],
);
return (
<SftpContext.Provider value={value}>
<SftpDragContext.Provider value={dragValue}>{children}</SftpDragContext.Provider>
</SftpContext.Provider>
);
};