Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8de9ce2b6 | ||
|
|
2c7bce31d4 | ||
|
|
004a5f18de | ||
|
|
731d57d355 | ||
|
|
8c6ff1a6a4 | ||
|
|
f7630b3574 | ||
|
|
76bfe26561 | ||
|
|
7079ea66aa | ||
|
|
6562351955 | ||
|
|
986fdda008 | ||
|
|
af2dc66113 | ||
|
|
cca4a3a37e | ||
|
|
75ec050c31 | ||
|
|
db604e4c41 | ||
|
|
05c48b3d28 | ||
|
|
3bb98c9c27 | ||
|
|
7f4dcce3cb | ||
|
|
766451d9bb | ||
|
|
6f5a2181b2 | ||
|
|
297adbb818 | ||
|
|
13eeb2cf6d | ||
|
|
e9ad65fef6 | ||
|
|
ddb6b5af1e | ||
|
|
c1171d4c7b | ||
|
|
21daccf6ed | ||
|
|
2eed15b4b2 | ||
|
|
de7fdfc4b4 | ||
|
|
709ed12259 |
@@ -699,6 +699,9 @@ const en: Messages = {
|
||||
'sftp.deleteConfirm.single': 'Delete "{name}"?',
|
||||
'sftp.deleteConfirm.title': 'Delete {count} item(s)?',
|
||||
'sftp.deleteConfirm.desc': 'This action cannot be undone. The following will be deleted:',
|
||||
'sftp.deleteConfirm.descSingle': 'This action cannot be undone.',
|
||||
'sftp.deleteConfirm.host': 'Host',
|
||||
'sftp.deleteConfirm.path': 'Path',
|
||||
'sftp.error.loadFailed': 'Failed to load directory',
|
||||
'sftp.error.downloadFailed': 'Download failed',
|
||||
'sftp.error.uploadFailed': 'Upload failed',
|
||||
|
||||
@@ -507,6 +507,9 @@ const zhCN: Messages = {
|
||||
'sftp.deleteConfirm.single': '删除 "{name}"?',
|
||||
'sftp.deleteConfirm.title': '删除 {count} 个项目?',
|
||||
'sftp.deleteConfirm.desc': '此操作不可撤销,将删除以下内容:',
|
||||
'sftp.deleteConfirm.descSingle': '此操作不可撤销。',
|
||||
'sftp.deleteConfirm.host': '主机',
|
||||
'sftp.deleteConfirm.path': '路径',
|
||||
'sftp.error.loadFailed': '加载目录失败',
|
||||
'sftp.error.downloadFailed': '下载失败',
|
||||
'sftp.error.uploadFailed': '上传失败',
|
||||
|
||||
@@ -28,6 +28,7 @@ interface UseSftpPaneActionsParams {
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
handleSessionError: (side: "left" | "right", error: Error) => void;
|
||||
isSessionError: (err: unknown) => boolean;
|
||||
clearSelectionsExcept: (target: { side: "left" | "right"; tabId: string } | null) => void;
|
||||
dirCacheTtlMs: number;
|
||||
}
|
||||
|
||||
@@ -78,6 +79,7 @@ export const useSftpPaneActions = ({
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
isSessionError,
|
||||
clearSelectionsExcept,
|
||||
dirCacheTtlMs,
|
||||
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
|
||||
const normalizePathForCompare = useCallback((path: string): string => {
|
||||
@@ -465,6 +467,10 @@ export const useSftpPaneActions = ({
|
||||
|
||||
const toggleSelection = useCallback(
|
||||
(side: "left" | "right", fileName: string, multiSelect: boolean) => {
|
||||
const activeTabId = (side === "left" ? leftTabsRef : rightTabsRef).current.activeTabId;
|
||||
if (activeTabId) {
|
||||
clearSelectionsExcept({ side, tabId: activeTabId });
|
||||
}
|
||||
updateActiveTab(side, (prev) => {
|
||||
const newSelection = new Set(multiSelect ? prev.selectedFiles : []);
|
||||
if (newSelection.has(fileName)) {
|
||||
@@ -475,11 +481,15 @@ export const useSftpPaneActions = ({
|
||||
return { ...prev, selectedFiles: newSelection };
|
||||
});
|
||||
},
|
||||
[updateActiveTab],
|
||||
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
|
||||
);
|
||||
|
||||
const rangeSelect = useCallback(
|
||||
(side: "left" | "right", fileNames: string[]) => {
|
||||
const activeTabId = (side === "left" ? leftTabsRef : rightTabsRef).current.activeTabId;
|
||||
if (activeTabId) {
|
||||
clearSelectionsExcept({ side, tabId: activeTabId });
|
||||
}
|
||||
const newSelection = new Set<string>();
|
||||
for (const name of fileNames) {
|
||||
if (name && name !== "..") {
|
||||
@@ -489,7 +499,7 @@ export const useSftpPaneActions = ({
|
||||
|
||||
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: newSelection }));
|
||||
},
|
||||
[updateActiveTab],
|
||||
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
|
||||
);
|
||||
|
||||
const clearSelection = useCallback((side: "left" | "right") => {
|
||||
|
||||
@@ -14,6 +14,7 @@ interface SftpTabsState {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
|
||||
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
|
||||
clearSelectionsExcept: (target: { side: "left" | "right"; tabId: string } | null) => void;
|
||||
setTabShowHiddenFiles: (side: "left" | "right", tabId: string, showHiddenFiles: boolean) => void;
|
||||
addTab: (side: "left" | "right") => string;
|
||||
closeTab: (side: "left" | "right", tabId: string) => void;
|
||||
@@ -34,6 +35,8 @@ interface SftpTabsState {
|
||||
getActiveTabId: (side: "left" | "right") => string | null;
|
||||
}
|
||||
|
||||
const EMPTY_SELECTION = new Set<string>();
|
||||
|
||||
export const useSftpTabsState = ({
|
||||
defaultShowHiddenFiles = false,
|
||||
}: {
|
||||
@@ -95,6 +98,31 @@ export const useSftpTabsState = ({
|
||||
[updateTab],
|
||||
);
|
||||
|
||||
const clearSelectionsExcept = useCallback(
|
||||
(target: { side: "left" | "right"; tabId: string } | null) => {
|
||||
const clearSideSelections = (
|
||||
prev: SftpSideTabs,
|
||||
side: "left" | "right",
|
||||
): SftpSideTabs => {
|
||||
let changed = false;
|
||||
const tabs = prev.tabs.map((tab) => {
|
||||
const shouldKeepSelection =
|
||||
target?.side === side && target.tabId === tab.id;
|
||||
if (shouldKeepSelection || tab.selectedFiles.size === 0) {
|
||||
return tab;
|
||||
}
|
||||
changed = true;
|
||||
return { ...tab, selectedFiles: EMPTY_SELECTION };
|
||||
});
|
||||
return changed ? { ...prev, tabs } : prev;
|
||||
};
|
||||
|
||||
setLeftTabs((prev) => clearSideSelections(prev, "left"));
|
||||
setRightTabs((prev) => clearSideSelections(prev, "right"));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setTabShowHiddenFiles = useCallback(
|
||||
(side: "left" | "right", tabId: string, showHiddenFiles: boolean) => {
|
||||
updateTab(side, tabId, (prev) => {
|
||||
@@ -258,6 +286,7 @@ export const useSftpTabsState = ({
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
clearSelectionsExcept,
|
||||
setTabShowHiddenFiles,
|
||||
addTab,
|
||||
closeTab,
|
||||
|
||||
@@ -57,6 +57,7 @@ export const useSftpState = (
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
clearSelectionsExcept,
|
||||
setTabShowHiddenFiles,
|
||||
addTab,
|
||||
closeTab,
|
||||
@@ -235,6 +236,7 @@ export const useSftpState = (
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
isSessionError,
|
||||
clearSelectionsExcept,
|
||||
dirCacheTtlMs: DIR_CACHE_TTL_MS,
|
||||
});
|
||||
|
||||
@@ -339,6 +341,7 @@ export const useSftpState = (
|
||||
toggleSelection,
|
||||
rangeSelect,
|
||||
clearSelection,
|
||||
clearSelectionsExcept,
|
||||
selectAll,
|
||||
setFilter,
|
||||
setFilenameEncoding,
|
||||
@@ -392,6 +395,7 @@ export const useSftpState = (
|
||||
toggleSelection,
|
||||
rangeSelect,
|
||||
clearSelection,
|
||||
clearSelectionsExcept,
|
||||
selectAll,
|
||||
setFilter,
|
||||
setFilenameEncoding,
|
||||
@@ -448,6 +452,8 @@ export const useSftpState = (
|
||||
toggleSelection: (...args: Parameters<typeof toggleSelection>) => methodsRef.current.toggleSelection(...args),
|
||||
rangeSelect: (...args: Parameters<typeof rangeSelect>) => methodsRef.current.rangeSelect(...args),
|
||||
clearSelection: (...args: Parameters<typeof clearSelection>) => methodsRef.current.clearSelection(...args),
|
||||
clearSelectionsExcept: (...args: Parameters<typeof clearSelectionsExcept>) =>
|
||||
methodsRef.current.clearSelectionsExcept(...args),
|
||||
selectAll: (...args: Parameters<typeof selectAll>) => methodsRef.current.selectAll(...args),
|
||||
setFilter: (...args: Parameters<typeof setFilter>) => methodsRef.current.setFilter(...args),
|
||||
setFilenameEncoding: (...args: Parameters<typeof setFilenameEncoding>) =>
|
||||
|
||||
@@ -65,8 +65,8 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
|
||||
// Size variants - all use rounded corners for consistency
|
||||
const sizeClasses = {
|
||||
sm: "h-6 w-6 rounded-md",
|
||||
md: "h-11 w-11 rounded-xl",
|
||||
sm: "h-6 w-6 rounded",
|
||||
md: "h-11 w-11 rounded-lg",
|
||||
lg: "h-14 w-14 rounded-xl",
|
||||
};
|
||||
const iconSizes = {
|
||||
@@ -98,7 +98,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
containerClass,
|
||||
"flex items-center justify-center border border-border/40 overflow-hidden",
|
||||
"flex items-center justify-center overflow-hidden",
|
||||
bg,
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,12 @@ import { Input } from "./ui/input";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
|
||||
import { TagFilterDropdown } from "./ui/tag-filter-dropdown";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "./ui/tooltip";
|
||||
|
||||
interface SelectHostPanelProps {
|
||||
hosts: Host[];
|
||||
@@ -198,6 +204,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
}, [currentPath]);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-0 bottom-0 w-[380px] border-l border-border/60 bg-background z-40 flex flex-col app-no-drag",
|
||||
@@ -271,7 +278,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Breadcrumbs */}
|
||||
{currentPath && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
@@ -301,20 +308,20 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
)}
|
||||
{groupsWithCounts.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">{t("vault.groups.title")}</h4>
|
||||
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t("vault.groups.title")}</h4>
|
||||
<div className="space-y-1">
|
||||
{groupsWithCounts.map((group) => (
|
||||
<div
|
||||
key={group.path}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-muted/70 cursor-pointer transition-colors"
|
||||
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg hover:bg-muted/70 cursor-pointer transition-colors"
|
||||
onClick={() => setCurrentPath(group.path)}
|
||||
>
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/15 text-primary flex items-center justify-center">
|
||||
<LayoutGrid size={18} />
|
||||
<div className="h-8 w-8 rounded-lg bg-primary/15 text-primary flex items-center justify-center shrink-0">
|
||||
<LayoutGrid size={15} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{group.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-[13px] font-medium truncate">{group.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("vault.groups.hostsCount", { count: group.count })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,18 +334,19 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
{/* Hosts Section */}
|
||||
{filteredHosts.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">{t("vault.nav.hosts")}</h4>
|
||||
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t("vault.nav.hosts")}</h4>
|
||||
<div className="space-y-1">
|
||||
{filteredHosts.map((host) => {
|
||||
const isSelected = selectedHostIds.includes(host.id);
|
||||
const connectionStr = `${host.username}@${host.hostname}:${host.port || 22}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={host.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors",
|
||||
"flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-colors",
|
||||
isSelected
|
||||
? "bg-muted border border-border"
|
||||
? "bg-muted"
|
||||
: "hover:bg-muted/70",
|
||||
)}
|
||||
onClick={() => onSelect(host)}
|
||||
@@ -346,16 +354,32 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.os[0].toUpperCase()}
|
||||
className="h-10 w-10"
|
||||
className="h-8 w-8 rounded-md"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{host.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{host.username}@{host.hostname}:{host.port || 22}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-[13px] font-medium truncate">
|
||||
{host.label}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
<p>{host.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
{connectionStr}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
<p>{connectionStr}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check size={16} className="text-primary" />
|
||||
<Check size={14} className="text-primary shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -413,6 +437,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks"
|
||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
|
||||
import { sftpFocusStore } from "./sftp/hooks/useSftpFocusedPane";
|
||||
import { keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
|
||||
import { KeyBinding, HotkeyScheme } from "../domain/models";
|
||||
|
||||
interface SftpSidePanelProps {
|
||||
@@ -130,12 +131,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const autoSyncRef = useRef(sftpAutoSync);
|
||||
autoSyncRef.current = sftpAutoSync;
|
||||
const panelRootRef = useRef<HTMLDivElement>(null);
|
||||
const dialogActionScopeIdRef = useRef(`sftp-side-panel:${crypto.randomUUID()}`);
|
||||
const [hasPaneFocus, setHasPaneFocus] = useState(false);
|
||||
|
||||
useSftpKeyboardShortcuts({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
dialogActionScopeId: dialogActionScopeIdRef.current,
|
||||
isActive: isVisible && hasPaneFocus,
|
||||
});
|
||||
|
||||
@@ -149,10 +152,19 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
|
||||
}, []);
|
||||
|
||||
const syncFocusedSelection = useCallback((tabId: string | null) => {
|
||||
if (tabId) {
|
||||
keepOnlyPaneSelections(sftpRef.current, { side: "left", tabId });
|
||||
return;
|
||||
}
|
||||
keepOnlyPaneSelections(sftpRef.current, null);
|
||||
}, []);
|
||||
|
||||
const handlePaneFocus = useCallback(() => {
|
||||
sftpFocusStore.setFocusedSide("left");
|
||||
setHasPaneFocus(true);
|
||||
}, []);
|
||||
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
|
||||
}, [syncFocusedSelection]);
|
||||
|
||||
// NOTE: We intentionally do NOT sync to activeTabStore here.
|
||||
// activeTabStore is a global singleton shared with SftpView.
|
||||
@@ -161,19 +173,30 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
setHasPaneFocus(false);
|
||||
syncFocusedSelection(null);
|
||||
}
|
||||
}, [isVisible]);
|
||||
}, [isVisible, syncFocusedSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
const elementTarget = target instanceof Element ? target : null;
|
||||
const isPortalInteraction = !!elementTarget?.closest(
|
||||
'#netcatty-context-menu-root, [role="dialog"], [data-radix-popper-content-wrapper]',
|
||||
);
|
||||
if (isPortalInteraction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (panelRootRef.current?.contains(target)) {
|
||||
sftpFocusStore.setFocusedSide("left");
|
||||
setHasPaneFocus(true);
|
||||
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
|
||||
} else {
|
||||
setHasPaneFocus(false);
|
||||
syncFocusedSelection(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -181,7 +204,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
};
|
||||
}, [isVisible]);
|
||||
}, [isVisible, syncFocusedSelection]);
|
||||
|
||||
const {
|
||||
leftCallbacks,
|
||||
@@ -599,10 +622,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
<SftpPaneView
|
||||
side="left"
|
||||
pane={pane}
|
||||
dialogActionScopeId={dialogActionScopeIdRef.current}
|
||||
isPaneFocused={isVisible && hasPaneFocus}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
showHeader
|
||||
showEmptyHeader
|
||||
forceActive
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
|
||||
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
|
||||
/>
|
||||
|
||||
@@ -40,6 +40,8 @@ import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks"
|
||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
|
||||
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "./sftp/hooks/useSftpFocusedPane";
|
||||
import { keepOnlyActivePaneSelections, keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
|
||||
|
||||
|
||||
// Wrapper component that subscribes to activeTabId for CSS visibility
|
||||
// This isolates the activeTabId subscription - only this component re-renders on tab switch
|
||||
@@ -79,6 +81,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
const { t } = useI18n();
|
||||
const isActive = useIsSftpActive();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const dialogActionScopeIdRef = useRef("sftp-main-view");
|
||||
|
||||
useInstantThemeSwitch(rootRef);
|
||||
|
||||
@@ -132,6 +135,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
dialogActionScopeId: dialogActionScopeIdRef.current,
|
||||
isActive,
|
||||
});
|
||||
|
||||
@@ -139,8 +143,18 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
const focusedSide = useSftpFocusedSide();
|
||||
|
||||
// Handle pane focus when clicking on a pane container
|
||||
const handlePaneFocus = useCallback((side: SftpFocusedSide) => {
|
||||
// Clear the opposite side's selection so file operations only affect the focused pane
|
||||
const handlePaneFocus = useCallback((side: SftpFocusedSide, targetTabId?: string) => {
|
||||
const prevSide = sftpFocusStore.getFocusedSide();
|
||||
sftpFocusStore.setFocusedSide(side);
|
||||
if (prevSide !== side) {
|
||||
if (targetTabId) {
|
||||
keepOnlyPaneSelections(sftpRef.current, { side, tabId: targetTabId });
|
||||
} else {
|
||||
// Focus side changed — clear other panes but keep the newly focused pane intact.
|
||||
keepOnlyActivePaneSelections(sftpRef.current, side);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleHiddenFiles = useCallback((side: "left" | "right", paneId: string) => {
|
||||
@@ -255,6 +269,26 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
handleHostSelectRight,
|
||||
} = useSftpViewTabs({ sftp, sftpRef });
|
||||
|
||||
const handleAddTabLeftWithFocus = useCallback(() => {
|
||||
const tabId = handleAddTabLeft();
|
||||
handlePaneFocus("left", tabId);
|
||||
}, [handleAddTabLeft, handlePaneFocus]);
|
||||
|
||||
const handleAddTabRightWithFocus = useCallback(() => {
|
||||
const tabId = handleAddTabRight();
|
||||
handlePaneFocus("right", tabId);
|
||||
}, [handleAddTabRight, handlePaneFocus]);
|
||||
|
||||
const handleSelectTabLeftWithFocus = useCallback((tabId: string) => {
|
||||
handleSelectTabLeft(tabId);
|
||||
handlePaneFocus("left", tabId);
|
||||
}, [handlePaneFocus, handleSelectTabLeft]);
|
||||
|
||||
const handleSelectTabRightWithFocus = useCallback((tabId: string) => {
|
||||
handleSelectTabRight(tabId);
|
||||
handlePaneFocus("right", tabId);
|
||||
}, [handlePaneFocus, handleSelectTabRight]);
|
||||
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={hosts}
|
||||
@@ -295,9 +329,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
<SftpTabBar
|
||||
tabs={leftTabsInfo}
|
||||
side="left"
|
||||
onSelectTab={handleSelectTabLeft}
|
||||
onSelectTab={handleSelectTabLeftWithFocus}
|
||||
onCloseTab={handleCloseTabLeft}
|
||||
onAddTab={handleAddTabLeft}
|
||||
onAddTab={handleAddTabLeftWithFocus}
|
||||
onReorderTabs={handleReorderTabsLeft}
|
||||
onMoveTabToOtherSide={handleMoveTabFromRightToLeft}
|
||||
/>
|
||||
@@ -313,6 +347,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
<SftpPaneView
|
||||
side="left"
|
||||
pane={pane}
|
||||
dialogActionScopeId={dialogActionScopeIdRef.current}
|
||||
isPaneFocused={focusedSide === "left"}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
showHeader
|
||||
@@ -354,9 +389,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
<SftpTabBar
|
||||
tabs={rightTabsInfo}
|
||||
side="right"
|
||||
onSelectTab={handleSelectTabRight}
|
||||
onSelectTab={handleSelectTabRightWithFocus}
|
||||
onCloseTab={handleCloseTabRight}
|
||||
onAddTab={handleAddTabRight}
|
||||
onAddTab={handleAddTabRightWithFocus}
|
||||
onReorderTabs={handleReorderTabsRight}
|
||||
onMoveTabToOtherSide={handleMoveTabFromLeftToRight}
|
||||
/>
|
||||
@@ -372,6 +407,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
<SftpPaneView
|
||||
side="right"
|
||||
pane={pane}
|
||||
dialogActionScopeId={dialogActionScopeIdRef.current}
|
||||
isPaneFocused={focusedSide === "right"}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
showHeader
|
||||
|
||||
@@ -123,7 +123,7 @@ export const WizardContent: React.FC<WizardContentProps> = ({
|
||||
>
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} className="h-6 w-6" />
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
|
||||
<span>{selectedHost.label}</span>
|
||||
<Check size={14} className="ml-auto" />
|
||||
</div>
|
||||
@@ -228,7 +228,7 @@ export const WizardContent: React.FC<WizardContentProps> = ({
|
||||
>
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} className="h-6 w-6" />
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
|
||||
<span>{selectedHost.label}</span>
|
||||
<Check size={14} className="ml-auto" />
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface SftpTransferSource {
|
||||
export interface SftpPaneCallbacks {
|
||||
onConnect: (host: Host | "local") => void;
|
||||
onDisconnect: () => void;
|
||||
onPrepareSelection: () => void;
|
||||
onNavigateTo: (path: string) => void;
|
||||
onNavigateUp: () => void;
|
||||
onRefresh: () => void;
|
||||
|
||||
@@ -76,8 +76,10 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
onClick={handleSelect}
|
||||
onDoubleClick={handleOpen}
|
||||
className={cn(
|
||||
"px-4 py-2 items-center cursor-pointer text-sm hover:bg-accent/50",
|
||||
isSelectionVisible && "bg-accent text-accent-foreground",
|
||||
"px-4 py-2 items-center cursor-pointer text-sm",
|
||||
isSelectionVisible
|
||||
? "bg-accent text-accent-foreground hover:bg-accent"
|
||||
: "hover:bg-accent/50",
|
||||
isDragOver && isNavDir && "bg-primary/25 ring-1 ring-primary/50"
|
||||
)}
|
||||
style={{ display: 'grid', gridTemplateColumns: buildSftpColumnTemplate(columnWidths) }}
|
||||
@@ -130,6 +132,8 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
const areEqual = (prev: SftpFileRowProps, next: SftpFileRowProps): boolean => {
|
||||
if (prev.index !== next.index) return false;
|
||||
if (prev.isSelected !== next.isSelected) return false;
|
||||
// Only re-render for showSelectionHighlight changes when the row is actually selected
|
||||
if (prev.isSelected && prev.showSelectionHighlight !== next.showSelectionHighlight) return false;
|
||||
if (prev.isDragOver !== next.isDragOver) return false;
|
||||
if (prev.columnWidths.name !== next.columnWidths.name) return false;
|
||||
if (prev.columnWidths.modified !== next.columnWidths.modified) return false;
|
||||
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
} from "../ui/dialog";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { getFileName, getParentPath } from "../../application/state/sftp/utils";
|
||||
import { SftpHostPicker } from "./index";
|
||||
import type { Host } from "../../types";
|
||||
|
||||
interface SftpPaneDialogsProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
hostLabel?: string;
|
||||
currentPath?: string;
|
||||
// New folder
|
||||
showNewFolderDialog: boolean;
|
||||
setShowNewFolderDialog: (open: boolean) => void;
|
||||
@@ -61,8 +64,15 @@ interface SftpPaneDialogsProps {
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
const HostHint: React.FC<{ label?: string }> = ({ label }) =>
|
||||
label ? (
|
||||
<div className="text-xs text-muted-foreground truncate mb-1">{label}</div>
|
||||
) : null;
|
||||
|
||||
export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
t,
|
||||
hostLabel,
|
||||
currentPath,
|
||||
showNewFolderDialog,
|
||||
setShowNewFolderDialog,
|
||||
newFolderName,
|
||||
@@ -100,12 +110,36 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
setHostSearch,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
}) => (
|
||||
}) => {
|
||||
const isSingleDeleteTarget = deleteTargets.length === 1;
|
||||
const deletePath = (() => {
|
||||
if (isSingleDeleteTarget) {
|
||||
return deleteTargets[0];
|
||||
}
|
||||
|
||||
const uniquePaths = Array.from(new Set(deleteTargets.map((target) => getParentPath(target)).filter(Boolean)));
|
||||
if (uniquePaths.length === 1) return uniquePaths[0];
|
||||
if (uniquePaths.length > 1) return "Multiple locations";
|
||||
return currentPath;
|
||||
})();
|
||||
const showDeleteList = deleteTargets.length > 1;
|
||||
const deleteListItems = (() => {
|
||||
if (!showDeleteList) return [];
|
||||
|
||||
const uniquePaths = Array.from(new Set(deleteTargets.map((target) => getParentPath(target)).filter(Boolean)));
|
||||
if (uniquePaths.length === 1) {
|
||||
return deleteTargets.map((target) => getFileName(target) || target);
|
||||
}
|
||||
return deleteTargets;
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Dialogs */}
|
||||
<Dialog open={showNewFolderDialog} onOpenChange={setShowNewFolderDialog}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<HostHint label={hostLabel} />
|
||||
<DialogTitle>{t("sftp.newFolder")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
@@ -148,6 +182,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<HostHint label={hostLabel} />
|
||||
<DialogTitle>{t("sftp.newFile")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
@@ -192,6 +227,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
<Dialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<HostHint label={hostLabel} />
|
||||
<DialogTitle>{t("sftp.overwrite.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sftp.overwrite.desc", { name: overwriteTarget || "" })}
|
||||
@@ -217,6 +253,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<HostHint label={hostLabel} />
|
||||
<DialogTitle>{t("sftp.rename.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
@@ -258,19 +295,39 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
{t("sftp.deleteConfirm.title", { count: deleteTargets.length })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sftp.deleteConfirm.desc")}
|
||||
{t(showDeleteList ? "sftp.deleteConfirm.desc" : "sftp.deleteConfirm.descSingle")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-32 overflow-auto text-sm space-y-1">
|
||||
{deleteTargets.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
<span className="truncate">{name}</span>
|
||||
<div className="space-y-3">
|
||||
{hostLabel || deletePath ? (
|
||||
<div className="text-xs text-muted-foreground space-y-1.5">
|
||||
{hostLabel ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-foreground/80 shrink-0">{t("sftp.deleteConfirm.host")}:</span>
|
||||
<span className="break-all">{hostLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{deletePath ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-foreground/80 shrink-0">{t("sftp.deleteConfirm.path")}:</span>
|
||||
<span className="break-all">{deletePath}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
) : null}
|
||||
{showDeleteList ? (
|
||||
<div className="max-h-32 overflow-auto text-sm space-y-1">
|
||||
{deleteListItems.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -310,4 +367,5 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ import { getParentPath, joinPath } from '../../application/state/sftp/utils';
|
||||
import { buildSftpColumnTemplate, filterHiddenFiles, formatBytes, formatDate, getFileIcon, isNavigableDirectory, sortSftpEntries, type ColumnWidths, type SortField, type SortOrder } from './utils';
|
||||
import type { SftpTransferSource } from './SftpContext';
|
||||
import { sftpTreeSelectionStore, useSftpTreeSelectionState } from './hooks/useSftpTreeSelectionStore';
|
||||
import { sftpTreeEnterStore } from './hooks/useSftpKeyboardShortcuts';
|
||||
import { sftpKeyboardSelectionStore, sftpTreeEnterStore } from './hooks/useSftpKeyboardShortcuts';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { isKnownBinaryFile } from '../../lib/sftpFileUtils';
|
||||
|
||||
@@ -55,6 +55,7 @@ type NodeDescriptor =
|
||||
interface SftpPaneTreeViewProps {
|
||||
pane: SftpPane;
|
||||
side: 'left' | 'right';
|
||||
onPrepareSelection: () => void;
|
||||
onLoadChildren: (path: string) => Promise<SftpFileEntry[]>;
|
||||
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
|
||||
onNavigateUp: () => void;
|
||||
@@ -126,8 +127,10 @@ const TreeNode = React.memo<TreeNodeProps>(({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid items-center gap-x-1 px-2 cursor-pointer select-none hover:bg-accent/50 text-sm',
|
||||
isSelected && 'bg-accent text-accent-foreground',
|
||||
'grid items-center gap-x-1 px-2 cursor-pointer select-none text-sm',
|
||||
isSelected
|
||||
? 'bg-accent text-accent-foreground hover:bg-accent'
|
||||
: 'hover:bg-accent/50',
|
||||
isDragOver && 'ring-2 ring-primary/50 ring-inset bg-primary/10',
|
||||
)}
|
||||
style={{ gridTemplateColumns: columnTemplate, height: TREE_ROW_HEIGHT }}
|
||||
@@ -257,6 +260,7 @@ interface ContextTarget {
|
||||
export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
pane,
|
||||
side,
|
||||
onPrepareSelection,
|
||||
onLoadChildren,
|
||||
onMoveEntriesToPath,
|
||||
onNavigateUp,
|
||||
@@ -368,12 +372,21 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
const [rootEntries, setRootEntries] = useState<SftpFileEntry[]>(pane.files ?? []);
|
||||
const [resolvedRootPath, setResolvedRootPath] = useState(pane.connection?.currentPath ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPaths.size === 0) {
|
||||
lastClickedPathRef.current = null;
|
||||
sftpKeyboardSelectionStore.clear(pane.id);
|
||||
}
|
||||
}, [pane.id, selectedPaths.size]);
|
||||
|
||||
const onOpenEntryRef = useRef(onOpenEntry);
|
||||
onOpenEntryRef.current = onOpenEntry;
|
||||
const onNavigateUpRef = useRef(onNavigateUp);
|
||||
onNavigateUpRef.current = onNavigateUp;
|
||||
const onNavigateToRef = useRef(onNavigateTo);
|
||||
onNavigateToRef.current = onNavigateTo;
|
||||
const onPrepareSelectionRef = useRef(onPrepareSelection);
|
||||
onPrepareSelectionRef.current = onPrepareSelection;
|
||||
const onMoveEntriesToPathRef = useRef(onMoveEntriesToPath);
|
||||
onMoveEntriesToPathRef.current = onMoveEntriesToPath;
|
||||
const onDragStartRef = useRef(onDragStart);
|
||||
@@ -508,6 +521,7 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
invalidateTreeCache();
|
||||
dispatchTreePaths({ type: 'RESET' });
|
||||
sftpTreeSelectionStore.clearSelection(pane.id);
|
||||
sftpKeyboardSelectionStore.clear(pane.id);
|
||||
lastClickedPathRef.current = null;
|
||||
}
|
||||
}, [pane.connection?.currentPath, pane.connection?.id, pane.id, invalidateTreeCache]);
|
||||
@@ -556,11 +570,11 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
focusTreeContainer();
|
||||
|
||||
const state = treeSelectionStateRef.current;
|
||||
const currentIdx = state.visibleIndexByPath.get(entryPath) ?? -1;
|
||||
const nextSelection: string[] = (() => {
|
||||
if (e.shiftKey && lastClickedPathRef.current) {
|
||||
const items = state.visibleItems;
|
||||
const lastIdx = state.visibleIndexByPath.get(lastClickedPathRef.current) ?? -1;
|
||||
const currentIdx = state.visibleIndexByPath.get(entryPath) ?? -1;
|
||||
if (lastIdx !== -1 && currentIdx !== -1) {
|
||||
const parentPath = getParentPath(entryPath);
|
||||
const start = Math.min(lastIdx, currentIdx);
|
||||
@@ -582,7 +596,16 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
return [entryPath];
|
||||
})();
|
||||
|
||||
onPrepareSelectionRef.current();
|
||||
sftpTreeSelectionStore.setSelection(pane.id, nextSelection);
|
||||
if (currentIdx !== -1) {
|
||||
if (e.shiftKey && lastClickedPathRef.current) {
|
||||
const anchorIdx = state.visibleIndexByPath.get(lastClickedPathRef.current) ?? currentIdx;
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, currentIdx);
|
||||
} else {
|
||||
sftpKeyboardSelectionStore.set(pane.id, currentIdx, currentIdx);
|
||||
}
|
||||
}
|
||||
|
||||
lastClickedPathRef.current = entryPath;
|
||||
}, [focusTreeContainer, pane.id]);
|
||||
@@ -610,23 +633,33 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
|
||||
const delta = e.key === 'ArrowDown' ? 1 : -1;
|
||||
const currentSelected = [...selectedPathsRef.current];
|
||||
let currentIdx = -1;
|
||||
if (currentSelected.length === 1) {
|
||||
currentIdx = state.visibleIndexByPath.get(currentSelected[0]) ?? -1;
|
||||
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
|
||||
if (currentSelected.length === 0) {
|
||||
anchorIdx = e.shiftKey ? 0 : -1;
|
||||
focusIdx = -1;
|
||||
} else {
|
||||
const focusPath = items[focusIdx]?.path;
|
||||
if (!focusPath || !state.selectedPaths.has(focusPath)) {
|
||||
focusIdx = state.visibleIndexByPath.get(currentSelected[currentSelected.length - 1]) ?? 0;
|
||||
anchorIdx = focusIdx;
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx);
|
||||
}
|
||||
}
|
||||
|
||||
let nextIdx = currentIdx + delta;
|
||||
let nextIdx = focusIdx + delta;
|
||||
if (nextIdx < 0) nextIdx = 0;
|
||||
if (nextIdx >= items.length) nextIdx = items.length - 1;
|
||||
|
||||
onPrepareSelectionRef.current();
|
||||
if (e.shiftKey && currentSelected.length > 0) {
|
||||
const anchorIdx = currentIdx >= 0 ? currentIdx : 0;
|
||||
const start = Math.min(anchorIdx, nextIdx);
|
||||
const end = Math.max(anchorIdx, nextIdx);
|
||||
const paths = items.slice(start, end + 1).map((item) => item.path);
|
||||
sftpTreeSelectionStore.setSelection(pane.id, paths);
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
|
||||
} else {
|
||||
sftpTreeSelectionStore.setSelection(pane.id, [items[nextIdx].path]);
|
||||
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
|
||||
}
|
||||
|
||||
lastClickedPathRef.current = items[nextIdx].path;
|
||||
|
||||
@@ -67,25 +67,31 @@ SftpPaneWrapper.displayName = "SftpPaneWrapper";
|
||||
interface SftpPaneViewProps {
|
||||
side: "left" | "right";
|
||||
pane: SftpPane;
|
||||
dialogActionScopeId: string;
|
||||
isPaneFocused: boolean;
|
||||
sftpDefaultViewMode: 'list' | 'tree';
|
||||
showHeader?: boolean;
|
||||
showEmptyHeader?: boolean;
|
||||
onToggleShowHiddenFiles?: () => void;
|
||||
onGoToTerminalCwd?: () => void;
|
||||
/** When true, treat this pane as always active (used by SftpSidePanel which manages visibility itself) */
|
||||
forceActive?: boolean;
|
||||
}
|
||||
|
||||
const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
side,
|
||||
pane,
|
||||
dialogActionScopeId,
|
||||
isPaneFocused,
|
||||
sftpDefaultViewMode,
|
||||
showHeader = true,
|
||||
showEmptyHeader = true,
|
||||
onToggleShowHiddenFiles,
|
||||
onGoToTerminalCwd,
|
||||
forceActive,
|
||||
}) => {
|
||||
const isActive = true;
|
||||
const activeTabId = useActiveTabId(side);
|
||||
const isActive = forceActive || (activeTabId ? pane.id === activeTabId : true);
|
||||
|
||||
const callbacks = useSftpPaneCallbacks(side);
|
||||
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
|
||||
@@ -354,7 +360,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
],
|
||||
);
|
||||
|
||||
useSftpDialogActionHandler(side, dialogActionHandlers);
|
||||
useSftpDialogActionHandler(side, dialogActionScopeId, dialogActionHandlers, isActive);
|
||||
|
||||
const handleSortWithTransition = (field: typeof sortField) => {
|
||||
startTransition(() => handleSort(field));
|
||||
@@ -495,6 +501,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
<SftpPaneTreeView
|
||||
pane={pane}
|
||||
side={side}
|
||||
onPrepareSelection={callbacks.onPrepareSelection}
|
||||
onLoadChildren={callbacks.onListDirectory}
|
||||
onMoveEntriesToPath={handleMoveEntriesToPath}
|
||||
onNavigateUp={callbacks.onNavigateUp}
|
||||
@@ -573,6 +580,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
|
||||
<SftpPaneDialogs
|
||||
t={t}
|
||||
hostLabel={pane.connection?.hostLabel}
|
||||
currentPath={pane.connection?.currentPath}
|
||||
showNewFolderDialog={showNewFolderDialog}
|
||||
setShowNewFolderDialog={setShowNewFolderDialog}
|
||||
newFolderName={newFolderName}
|
||||
@@ -621,6 +630,7 @@ const sftpPaneViewAreEqual = (
|
||||
): boolean => {
|
||||
if (prev.pane !== next.pane) return false;
|
||||
if (prev.side !== next.side) return false;
|
||||
if (prev.dialogActionScopeId !== next.dialogActionScopeId) return false;
|
||||
if (prev.isPaneFocused !== next.isPaneFocused) return false;
|
||||
if (prev.showHeader !== next.showHeader) return false;
|
||||
if (prev.showEmptyHeader !== next.showEmptyHeader) return false;
|
||||
|
||||
@@ -214,6 +214,22 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
[onCloseTab],
|
||||
);
|
||||
|
||||
const handleSelectTabClick = useCallback(
|
||||
(e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation();
|
||||
onSelectTab(tabId);
|
||||
},
|
||||
[onSelectTab],
|
||||
);
|
||||
|
||||
const handleAddTabClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onAddTab();
|
||||
},
|
||||
[onAddTab],
|
||||
);
|
||||
|
||||
// Cross-pane drag handlers
|
||||
const handleCrossPaneDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
@@ -302,7 +318,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
<div
|
||||
key={tab.id}
|
||||
data-tab-id={tab.id}
|
||||
onClick={() => onSelectTab(tab.id)}
|
||||
onClick={(e) => handleSelectTabClick(e, tab.id)}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, tab.id)}
|
||||
onDragEnd={handleTabDragEnd}
|
||||
@@ -379,7 +395,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
{/* Add tab button */}
|
||||
<button
|
||||
className="px-2 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-[linear-gradient(135deg,_hsl(var(--accent)_/_0.18),_hsl(var(--primary)_/_0.18))] transition-all duration-150 border-l border-border/40 cursor-pointer"
|
||||
onClick={onAddTab}
|
||||
onClick={handleAddTabClick}
|
||||
title={t("sftp.tabs.addTab")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
@@ -418,4 +434,3 @@ const sftpTabBarAreEqual = (
|
||||
|
||||
export const SftpTabBar = memo(SftpTabBarInner, sftpTabBarAreEqual);
|
||||
SftpTabBar.displayName = "SftpTabBar";
|
||||
|
||||
|
||||
37
components/sftp/hooks/selectionScope.ts
Normal file
37
components/sftp/hooks/selectionScope.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
|
||||
|
||||
export interface SftpSelectionTarget {
|
||||
side: "left" | "right";
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
export const keepOnlyPaneSelections = (
|
||||
sftp: SftpStateApi,
|
||||
target: SftpSelectionTarget | null,
|
||||
) => {
|
||||
sftp.clearSelectionsExcept(target);
|
||||
const paneIds = [
|
||||
...sftp.leftTabs.tabs.map((tab) => tab.id),
|
||||
...sftp.rightTabs.tabs.map((tab) => tab.id),
|
||||
];
|
||||
for (const paneId of paneIds) {
|
||||
if (target?.tabId === paneId) continue;
|
||||
sftpTreeSelectionStore.clearSelection(paneId);
|
||||
}
|
||||
};
|
||||
|
||||
export const keepOnlyActivePaneSelections = (
|
||||
sftp: SftpStateApi,
|
||||
side: "left" | "right",
|
||||
): SftpSelectionTarget | null => {
|
||||
const tabId = sftp.getActiveTabId(side);
|
||||
if (!tabId) {
|
||||
keepOnlyPaneSelections(sftp, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = { side, tabId } as const;
|
||||
keepOnlyPaneSelections(sftp, target);
|
||||
return target;
|
||||
};
|
||||
@@ -13,6 +13,7 @@ type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null
|
||||
interface SftpDialogAction {
|
||||
type: SftpDialogActionType;
|
||||
targetSide: SftpFocusedSide;
|
||||
targetScopeId: string;
|
||||
targetFiles?: string[]; // For rename (single file) or delete (multiple files)
|
||||
timestamp: number; // To distinguish different triggers of the same action
|
||||
}
|
||||
@@ -37,13 +38,14 @@ export const sftpDialogActionStore = {
|
||||
/**
|
||||
* Trigger a dialog action
|
||||
*/
|
||||
trigger: (type: SftpDialogActionType, targetFiles?: string[]) => {
|
||||
trigger: (type: SftpDialogActionType, targetScopeId: string, targetFiles?: string[]) => {
|
||||
if (!type) {
|
||||
dialogAction = null;
|
||||
} else {
|
||||
dialogAction = {
|
||||
type,
|
||||
targetSide: sftpFocusStore.getFocusedSide(),
|
||||
targetScopeId,
|
||||
targetFiles,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
@@ -82,17 +84,19 @@ export const useSftpDialogAction = (): SftpDialogAction | null => {
|
||||
*/
|
||||
export const useSftpDialogActionHandler = (
|
||||
side: SftpFocusedSide,
|
||||
scopeId: string,
|
||||
handlers: {
|
||||
onRename?: (fileName: string) => void;
|
||||
onDelete?: (fileNames: string[]) => void;
|
||||
onNewFolder?: () => void;
|
||||
onNewFile?: () => void;
|
||||
}
|
||||
},
|
||||
isActive = true
|
||||
) => {
|
||||
const action = useSftpDialogAction();
|
||||
|
||||
useEffect(() => {
|
||||
if (!action || action.targetSide !== side) return;
|
||||
if (!action || action.targetSide !== side || action.targetScopeId !== scopeId || !isActive) return;
|
||||
|
||||
// Handle the action and clear it
|
||||
switch (action.type) {
|
||||
@@ -116,5 +120,5 @@ export const useSftpDialogActionHandler = (
|
||||
|
||||
// Clear the action after handling
|
||||
sftpDialogActionStore.clear();
|
||||
}, [action, side, handlers]);
|
||||
}, [action, side, scopeId, handlers, isActive]);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import { sftpFocusStore } from "./useSftpFocusedPane";
|
||||
import { sftpDialogActionStore } from "./useSftpDialogAction";
|
||||
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
|
||||
import { sftpListOrderStore } from "./useSftpListOrderStore";
|
||||
import { keepOnlyPaneSelections } from "./selectionScope";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { filterHiddenFiles, isNavigableDirectory } from "../index";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
@@ -72,13 +73,15 @@ export const sftpTreeEnterStore = {
|
||||
// indices per pane so Shift+Arrow extends correctly.
|
||||
const _kbSelectionState = new Map<string, { anchor: number; focus: number }>();
|
||||
|
||||
function getKbSelection(paneId: string) {
|
||||
return _kbSelectionState.get(paneId) ?? { anchor: 0, focus: 0 };
|
||||
}
|
||||
|
||||
function setKbSelection(paneId: string, anchor: number, focus: number) {
|
||||
_kbSelectionState.set(paneId, { anchor, focus });
|
||||
}
|
||||
export const sftpKeyboardSelectionStore = {
|
||||
get: (paneId: string) => _kbSelectionState.get(paneId) ?? { anchor: 0, focus: 0 },
|
||||
set: (paneId: string, anchor: number, focus: number) => {
|
||||
_kbSelectionState.set(paneId, { anchor, focus });
|
||||
},
|
||||
clear: (paneId: string) => {
|
||||
_kbSelectionState.delete(paneId);
|
||||
},
|
||||
};
|
||||
|
||||
// Basic navigation keys that work even when custom hotkeys are disabled.
|
||||
const BASIC_NAV_KEYS: Record<string, string> = {
|
||||
@@ -90,6 +93,7 @@ interface UseSftpKeyboardShortcutsParams {
|
||||
keyBindings: KeyBinding[];
|
||||
hotkeyScheme: "disabled" | "mac" | "pc";
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
dialogActionScopeId: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
@@ -115,6 +119,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
dialogActionScopeId,
|
||||
isActive,
|
||||
}: UseSftpKeyboardShortcutsParams) => {
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -134,6 +139,12 @@ export const useSftpKeyboardShortcuts = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip when a dialog or overlay is open to prevent SFTP shortcuts from
|
||||
// firing while interacting with unrelated dialogs (e.g. settings, confirm).
|
||||
if (document.querySelector('[role="dialog"][data-state="open"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Arrow Up/Down: move selection ────────────────────────────────
|
||||
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
const sftp = sftpRef.current;
|
||||
@@ -155,29 +166,35 @@ export const useSftpKeyboardShortcuts = ({
|
||||
|
||||
// Resolve current focus position from tracked state, falling back
|
||||
// to the actual selection when out of sync (e.g. after mouse click).
|
||||
let { anchor: anchorIdx, focus: focusIdx } = getKbSelection(pane.id);
|
||||
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
|
||||
const currentSelected = Array.from(pane.selectedFiles) as string[];
|
||||
// If the tracked focus doesn't match the actual selection, re-sync
|
||||
if (currentSelected.length >= 1 && !currentSelected.includes(listItems[focusIdx])) {
|
||||
if (currentSelected.length === 0) {
|
||||
// No selection: start from before the list so the first arrow press lands on item 0.
|
||||
// For Shift+Arrow, anchor at 0 so range selection starts from the first item.
|
||||
anchorIdx = e.shiftKey ? 0 : -1;
|
||||
focusIdx = -1;
|
||||
} else if (!currentSelected.includes(listItems[focusIdx])) {
|
||||
// Tracked focus doesn't match actual selection, re-sync
|
||||
focusIdx = listItems.indexOf(currentSelected[currentSelected.length - 1]);
|
||||
if (focusIdx < 0) focusIdx = 0;
|
||||
anchorIdx = focusIdx;
|
||||
setKbSelection(pane.id, anchorIdx, focusIdx);
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx);
|
||||
}
|
||||
|
||||
let nextIdx = focusIdx + delta;
|
||||
if (nextIdx < 0) nextIdx = 0;
|
||||
if (nextIdx >= listItems.length) nextIdx = listItems.length - 1;
|
||||
|
||||
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
|
||||
if (e.shiftKey) {
|
||||
// Shift+Arrow: extend range from anchor to new focus
|
||||
const start = Math.min(anchorIdx, nextIdx);
|
||||
const end = Math.max(anchorIdx, nextIdx);
|
||||
sftp.rangeSelect(focusedSide, listItems.slice(start, end + 1));
|
||||
setKbSelection(pane.id, anchorIdx, nextIdx);
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
|
||||
} else {
|
||||
sftp.rangeSelect(focusedSide, [listItems[nextIdx]]);
|
||||
setKbSelection(pane.id, nextIdx, nextIdx);
|
||||
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -191,26 +208,35 @@ export const useSftpKeyboardShortcuts = ({
|
||||
const currentSelected = [...treeState.selectedPaths];
|
||||
|
||||
// Use tracked state, re-sync if needed
|
||||
let { anchor: anchorIdx, focus: focusIdx } = getKbSelection(pane.id);
|
||||
if (currentSelected.length >= 1 && items[focusIdx]?.path !== currentSelected[currentSelected.length - 1]) {
|
||||
focusIdx = treeState.visibleIndexByPath.get(currentSelected[currentSelected.length - 1]) ?? 0;
|
||||
anchorIdx = focusIdx;
|
||||
setKbSelection(pane.id, anchorIdx, focusIdx);
|
||||
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
|
||||
if (currentSelected.length === 0) {
|
||||
// No selection: start from before the list so the first arrow press lands on item 0.
|
||||
// For Shift+Arrow, anchor at 0 so range selection starts from the first item.
|
||||
anchorIdx = e.shiftKey ? 0 : -1;
|
||||
focusIdx = -1;
|
||||
} else {
|
||||
const focusPath = items[focusIdx]?.path;
|
||||
if (!focusPath || !treeState.selectedPaths.has(focusPath)) {
|
||||
focusIdx = treeState.visibleIndexByPath.get(currentSelected[currentSelected.length - 1]) ?? 0;
|
||||
anchorIdx = focusIdx;
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx);
|
||||
}
|
||||
}
|
||||
|
||||
let nextIdx = focusIdx + delta;
|
||||
if (nextIdx < 0) nextIdx = 0;
|
||||
if (nextIdx >= items.length) nextIdx = items.length - 1;
|
||||
|
||||
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
|
||||
if (e.shiftKey) {
|
||||
const start = Math.min(anchorIdx, nextIdx);
|
||||
const end = Math.max(anchorIdx, nextIdx);
|
||||
const paths = items.slice(start, end + 1).map(item => item.path);
|
||||
sftpTreeSelectionStore.setSelection(pane.id, paths);
|
||||
setKbSelection(pane.id, anchorIdx, nextIdx);
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
|
||||
} else {
|
||||
sftpTreeSelectionStore.setSelection(pane.id, [items[nextIdx].path]);
|
||||
setKbSelection(pane.id, nextIdx, nextIdx);
|
||||
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -350,8 +376,10 @@ export const useSftpKeyboardShortcuts = ({
|
||||
if (!clipboard || clipboard.files.length === 0) return;
|
||||
|
||||
// Use startTransfer to paste files from source to current pane
|
||||
// The transfer direction is determined by clipboard sourceSide and current focusedSide
|
||||
if (clipboard.sourceSide !== focusedSide) {
|
||||
// Allow paste when source and target are different connections, even on the same side
|
||||
const isSameConnection = clipboard.sourceSide === focusedSide
|
||||
&& clipboard.sourceConnectionId === pane.connection.id;
|
||||
if (!isSameConnection) {
|
||||
const sourceTabs = clipboard.sourceSide === "left" ? sftp.leftTabs.tabs : sftp.rightTabs.tabs;
|
||||
const sourcePane = sourceTabs.find((tab) => tab.connection?.id === clipboard.sourceConnectionId);
|
||||
|
||||
@@ -439,6 +467,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
|
||||
case "sftpSelectAll": {
|
||||
if (treeSelectionState.visibleItems.length > 0) {
|
||||
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
|
||||
sftpTreeSelectionStore.selectAllVisible(pane.id);
|
||||
break;
|
||||
}
|
||||
@@ -458,33 +487,38 @@ export const useSftpKeyboardShortcuts = ({
|
||||
const allFileNames = visibleFiles
|
||||
.filter((f) => f.name !== "..")
|
||||
.map((f) => f.name);
|
||||
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
|
||||
sftp.rangeSelect(focusedSide, allFileNames);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpRename": {
|
||||
if (treeActionSelection.length === 1) {
|
||||
sftpDialogActionStore.trigger("rename", [treeActionSelection[0].path]);
|
||||
sftpDialogActionStore.trigger("rename", dialogActionScopeId, [treeActionSelection[0].path]);
|
||||
break;
|
||||
}
|
||||
|
||||
// Trigger rename for the first selected file
|
||||
const selectedFiles = Array.from(pane.selectedFiles) as string[];
|
||||
if (selectedFiles.length !== 1) return;
|
||||
sftpDialogActionStore.trigger("rename", selectedFiles);
|
||||
sftpDialogActionStore.trigger("rename", dialogActionScopeId, selectedFiles);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpDelete": {
|
||||
if (treeActionSelection.length > 0) {
|
||||
sftpDialogActionStore.trigger("delete", treeActionSelection.map((entry) => entry.path));
|
||||
sftpDialogActionStore.trigger(
|
||||
"delete",
|
||||
dialogActionScopeId,
|
||||
treeActionSelection.map((entry) => entry.path),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Delete selected files
|
||||
const selectedFiles = Array.from(pane.selectedFiles) as string[];
|
||||
if (selectedFiles.length === 0) return;
|
||||
sftpDialogActionStore.trigger("delete", selectedFiles);
|
||||
sftpDialogActionStore.trigger("delete", dialogActionScopeId, selectedFiles);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -496,7 +530,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
|
||||
case "sftpNewFolder": {
|
||||
// Create new folder
|
||||
sftpDialogActionStore.trigger("newFolder");
|
||||
sftpDialogActionStore.trigger("newFolder", dialogActionScopeId);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -559,7 +593,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[hotkeyScheme, isActive, keyBindings, sftpRef]
|
||||
[dialogActionScopeId, hotkeyScheme, isActive, keyBindings, sftpRef]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { SftpPaneCallbacks, SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
|
||||
import { isNavigableDirectory } from "../index";
|
||||
@@ -67,6 +67,12 @@ export const useSftpPaneDragAndSelect = ({
|
||||
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;
|
||||
|
||||
@@ -99,6 +99,17 @@ export const sftpTreeSelectionStore = {
|
||||
setPaneState(paneId, (state) => ({ ...state, selectedPaths: EMPTY_PATHS }));
|
||||
},
|
||||
|
||||
clearAllExcept: (paneIdsToKeep?: Iterable<string>) => {
|
||||
const keep = new Set(paneIdsToKeep ?? []);
|
||||
Array.from(paneStates.keys()).forEach((paneId) => {
|
||||
if (keep.has(paneId)) return;
|
||||
setPaneState(paneId, (state) => {
|
||||
if (state.selectedPaths.size === 0) return state;
|
||||
return { ...state, selectedPaths: EMPTY_PATHS };
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
selectAllVisible: (paneId: string) => {
|
||||
setPaneState(paneId, (state) => ({
|
||||
...state,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import type { SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
|
||||
import { keepOnlyActivePaneSelections } from "./selectionScope";
|
||||
|
||||
interface UseSftpViewPaneActionsParams {
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
@@ -14,6 +15,8 @@ interface UseSftpViewPaneActionsResult {
|
||||
onConnectRight: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
|
||||
onDisconnectLeft: () => void;
|
||||
onDisconnectRight: () => void;
|
||||
onPrepareSelectionLeft: () => void;
|
||||
onPrepareSelectionRight: () => void;
|
||||
onNavigateToLeft: (path: string) => void;
|
||||
onNavigateToRight: (path: string) => void;
|
||||
onNavigateUpLeft: () => void;
|
||||
@@ -126,6 +129,12 @@ export const useSftpViewPaneActions = ({
|
||||
);
|
||||
const onDisconnectLeft = useCallback(() => sftpRef.current.disconnect("left"), [sftpRef]);
|
||||
const onDisconnectRight = useCallback(() => sftpRef.current.disconnect("right"), [sftpRef]);
|
||||
const onPrepareSelectionLeft = useCallback(() => {
|
||||
keepOnlyActivePaneSelections(sftpRef.current, "left");
|
||||
}, [sftpRef]);
|
||||
const onPrepareSelectionRight = useCallback(() => {
|
||||
keepOnlyActivePaneSelections(sftpRef.current, "right");
|
||||
}, [sftpRef]);
|
||||
const onNavigateToLeft = useCallback(
|
||||
(path: string) => sftpRef.current.navigateTo("left", path),
|
||||
[sftpRef],
|
||||
@@ -151,20 +160,32 @@ export const useSftpViewPaneActions = ({
|
||||
[sftpRef],
|
||||
);
|
||||
const onToggleSelectionLeft = useCallback(
|
||||
(name: string, multi: boolean) => sftpRef.current.toggleSelection("left", name, multi),
|
||||
[sftpRef],
|
||||
(name: string, multi: boolean) => {
|
||||
onPrepareSelectionLeft();
|
||||
sftpRef.current.toggleSelection("left", name, multi);
|
||||
},
|
||||
[onPrepareSelectionLeft, sftpRef],
|
||||
);
|
||||
const onToggleSelectionRight = useCallback(
|
||||
(name: string, multi: boolean) => sftpRef.current.toggleSelection("right", name, multi),
|
||||
[sftpRef],
|
||||
(name: string, multi: boolean) => {
|
||||
onPrepareSelectionRight();
|
||||
sftpRef.current.toggleSelection("right", name, multi);
|
||||
},
|
||||
[onPrepareSelectionRight, sftpRef],
|
||||
);
|
||||
const onRangeSelectLeft = useCallback(
|
||||
(fileNames: string[]) => sftpRef.current.rangeSelect("left", fileNames),
|
||||
[sftpRef],
|
||||
(fileNames: string[]) => {
|
||||
onPrepareSelectionLeft();
|
||||
sftpRef.current.rangeSelect("left", fileNames);
|
||||
},
|
||||
[onPrepareSelectionLeft, sftpRef],
|
||||
);
|
||||
const onRangeSelectRight = useCallback(
|
||||
(fileNames: string[]) => sftpRef.current.rangeSelect("right", fileNames),
|
||||
[sftpRef],
|
||||
(fileNames: string[]) => {
|
||||
onPrepareSelectionRight();
|
||||
sftpRef.current.rangeSelect("right", fileNames);
|
||||
},
|
||||
[onPrepareSelectionRight, sftpRef],
|
||||
);
|
||||
const onClearSelectionLeft = useCallback(() => sftpRef.current.clearSelection("left"), [sftpRef]);
|
||||
const onClearSelectionRight = useCallback(() => sftpRef.current.clearSelection("right"), [sftpRef]);
|
||||
@@ -266,6 +287,8 @@ export const useSftpViewPaneActions = ({
|
||||
onConnectRight,
|
||||
onDisconnectLeft,
|
||||
onDisconnectRight,
|
||||
onPrepareSelectionLeft,
|
||||
onPrepareSelectionRight,
|
||||
onNavigateToLeft,
|
||||
onNavigateToRight,
|
||||
onNavigateUpLeft,
|
||||
|
||||
@@ -140,6 +140,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
() => ({
|
||||
onConnect: paneActions.onConnectLeft,
|
||||
onDisconnect: paneActions.onDisconnectLeft,
|
||||
onPrepareSelection: paneActions.onPrepareSelectionLeft,
|
||||
onNavigateTo: paneActions.onNavigateToLeft,
|
||||
onNavigateUp: paneActions.onNavigateUpLeft,
|
||||
onRefresh: paneActions.onRefreshLeft,
|
||||
@@ -176,6 +177,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
() => ({
|
||||
onConnect: paneActions.onConnectRight,
|
||||
onDisconnect: paneActions.onDisconnectRight,
|
||||
onPrepareSelection: paneActions.onPrepareSelectionRight,
|
||||
onNavigateTo: paneActions.onNavigateToRight,
|
||||
onNavigateUp: paneActions.onNavigateUpRight,
|
||||
onRefresh: paneActions.onRefreshRight,
|
||||
|
||||
@@ -21,8 +21,8 @@ interface UseSftpViewTabsResult {
|
||||
setShowHostPickerRight: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setHostSearchLeft: React.Dispatch<React.SetStateAction<string>>;
|
||||
setHostSearchRight: React.Dispatch<React.SetStateAction<string>>;
|
||||
handleAddTabLeft: () => void;
|
||||
handleAddTabRight: () => void;
|
||||
handleAddTabLeft: () => string;
|
||||
handleAddTabRight: () => string;
|
||||
handleCloseTabLeft: (tabId: string) => void;
|
||||
handleCloseTabRight: (tabId: string) => void;
|
||||
handleSelectTabLeft: (tabId: string) => void;
|
||||
@@ -42,13 +42,15 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
|
||||
const [hostSearchRight, setHostSearchRight] = useState("");
|
||||
|
||||
const handleAddTabLeft = useCallback(() => {
|
||||
sftpRef.current.addTab("left");
|
||||
const tabId = sftpRef.current.addTab("left");
|
||||
setShowHostPickerLeft(true);
|
||||
return tabId;
|
||||
}, [sftpRef]);
|
||||
|
||||
const handleAddTabRight = useCallback(() => {
|
||||
sftpRef.current.addTab("right");
|
||||
const tabId = sftpRef.current.addTab("right");
|
||||
setShowHostPickerRight(true);
|
||||
return tabId;
|
||||
}, [sftpRef]);
|
||||
|
||||
const handleCloseTabLeft = useCallback((tabId: string) => {
|
||||
|
||||
Reference in New Issue
Block a user