Compare commits

...

28 Commits

Author SHA1 Message Date
陈大猫
b8de9ce2b6 Merge pull request #571 from binaricat/ui/compact-host-select-panel
Some checks failed
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
2026-03-29 22:34:08 +08:00
bincxz
2c7bce31d4 style: reduce border-radius on distro avatars
sm: rounded-md → rounded (4px), md: rounded-xl → rounded-lg (8px),
SelectHostPanel inline: rounded-lg → rounded-md (6px)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:29:36 +08:00
bincxz
004a5f18de fix: use rounded square distro avatar in port forwarding wizard
Use size="sm" (rounded-md) instead of className override that kept
the rounded-xl from the default md size, which appeared circular.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:29:03 +08:00
bincxz
731d57d355 fix: add missing TooltipProvider import
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:25:36 +08:00
bincxz
8c6ff1a6a4 fix: wrap tooltips with TooltipProvider in SelectHostPanel
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:24:36 +08:00
bincxz
f7630b3574 ui: compact host selection panel with smaller icons and text truncation
- Reduce item padding, gaps, icon sizes, and font sizes for a denser list
- Use rounded square (rounded-lg) avatars instead of circles, remove border
- Add tooltip on host label and connection string for long text overflow
- Shrink section headers and group items to match compact style
- Remove border from selected host items for cleaner look

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:23:43 +08:00
陈大猫
76bfe26561 Merge pull request #570 from binaricat/fix/sftp-keyboard-action-repeated-across-tabs
fix: isolate SFTP actions and selection state across panes and tabs
2026-03-29 22:13:47 +08:00
bincxz
7079ea66aa fix sftp cross-pane tab focus selection retention 2026-03-29 21:53:11 +08:00
bincxz
6562351955 fix: scope dialog actions and refine selection clearing
- Add dialogActionScopeId to distinguish SftpView and SftpSidePanel
  dialog actions, preventing cross-instance interference
- Refine selectionScope to clear tree selections per-pane instead of
  using clearAllExcept, avoiding side effects on other SFTP surfaces
- Remove selection clearing from tab switch/move/add handlers; clearing
  now only happens on focus side change and file interaction
- Reset keyboard selection and lastSelectedIndex when selections are
  externally cleared

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:44:15 +08:00
bincxz
986fdda008 fix sftp selection clearing across panes and tabs 2026-03-29 21:15:28 +08:00
bincxz
af2dc66113 fix: clear all selections when focus side changes
When the user switches focus between left and right panes, clear all
pane selections. Combined with the per-interaction clearing in
toggleSelection/rangeSelect, this ensures:
- Selecting files clears other panes' selections
- Switching sides clears all selections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:15:01 +08:00
bincxz
cca4a3a37e fix: clear other selections on file interaction, not tab switch
Move selection clearing from tab switch and pane focus handlers into
toggleSelection/rangeSelect. This means:
- Switching tabs just to look around preserves all selections
- Actually clicking/selecting files clears other tabs' selections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:13:24 +08:00
bincxz
75ec050c31 revert: restore clearSelectionsExcept to clear all tabs except target
Clearing same-side inactive tab selections on tab switch is intentional
UX — stale selections on hidden tabs would be confusing when switching
back. Reverts the "preserve same-side" change from 05c48b3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:04:08 +08:00
bincxz
db604e4c41 fix: localize delete dialog labels and preserve moved tab tree selection
- Add i18n keys for "Host" and "Path" labels in delete confirmation
  dialog (was hardcoded English, broken under zh-CN)
- Pass moved tab ID as extra keepId when clearing tree selections after
  moveTabToOtherSide, since the ref still has pre-move state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:50:18 +08:00
bincxz
05c48b3d28 fix: preserve selections in same-side inactive tabs
clearSelectionsExcept was clearing all tabs including same-side inactive
ones, causing users to lose file selections when switching between tabs
on the same side. Now only the opposite side's selections are cleared.

Also scoped tree selection clearing to only affect opposite-side pane
IDs, preventing mounted but hidden SFTP surfaces from losing state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:39:39 +08:00
bincxz
3bb98c9c27 fix: allow paste between different tabs on the same side
The paste check only compared sourceSide vs focusedSide, treating all
tabs on the same side as "same pane". Now it also compares connectionId
so copying from one tab and pasting to a different tab on the same side
works correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:24:11 +08:00
bincxz
7f4dcce3cb fix: don't clear dialog action from inactive panes
Revert the stale action clearing in inactive panes (e9ad65f). When
multiple tabs exist on the same side, the inactive tab's effect could
fire before the active tab's, clearing the action and causing it to
be handled by the wrong pane or not at all.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:19:34 +08:00
bincxz
766451d9bb fix: handle empty selection in tree view container keyboard navigation
The tree view's own onKeyDown handler had the same issue as the global
keyboard shortcuts: pressing ArrowDown with no selection would skip the
first item. Apply the same fix (reset focus to -1 for empty selection).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:01:58 +08:00
bincxz
6f5a2181b2 fix: suppress SFTP keyboard shortcuts when a dialog is open
Prevents SFTP shortcuts (Delete, Enter, etc.) from firing while
unrelated dialogs are open, which could cause unintended file
operations from outside the SFTP panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:55:01 +08:00
bincxz
297adbb818 fix: clamp anchor for Shift+Arrow from empty selection
When no files are selected, Shift+Arrow would use anchor=-1 causing
invalid slice ranges. Now anchor is set to 0 when Shift is held, so
range selection starts from the first item correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:47:47 +08:00
bincxz
13eeb2cf6d fix: ArrowDown from cleared selection now lands on first item
When selections are cleared (e.g. by switching panes), pressing
ArrowDown would skip the first item because the keyboard focus
defaulted to index 0 and then moved to 1. Now an empty selection
resets focus to -1 so the first arrow press selects item 0.
Applies to both list and tree views.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:38:24 +08:00
bincxz
e9ad65fef6 fix: clear stale dialog actions when target pane is inactive
When a dialog action's targetSide matched but the pane was inactive,
the action was left in the store. If the pane later became active, it
would fire the stale action unexpectedly. Now inactive panes clear the
action to prevent this.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:25:55 +08:00
bincxz
ddb6b5af1e perf: only re-render selected rows on focus change
The showSelectionHighlight check in SftpFileRow's areEqual was causing
all rows to re-render when switching focus between panes. Now only rows
that are actually selected re-render on highlight changes, avoiding
unnecessary work for large file lists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:23:35 +08:00
bincxz
c1171d4c7b fix sftp shift selection upward expansion 2026-03-29 18:19:04 +08:00
bincxz
21daccf6ed fix: enforce cross-pane selection mutual exclusivity and improve delete dialog
- Add clearSelectionsExcept to clear all file/tree selections except the
  target pane, called on focus change, tab switch, tab add, and tab move
- Fix SftpFileRow areEqual to include showSelectionHighlight so highlight
  updates when focus changes between panes
- Improve delete confirmation dialog with host/path context and separate
  single vs multi-delete descriptions
- Fix hover style on selected rows to prevent flicker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:05:54 +08:00
bincxz
2eed15b4b2 feat: show host label in SFTP operation dialogs
Display the connection's host label at the top of new folder, new file,
rename, overwrite, and delete confirmation dialogs so users can see
which machine the operation targets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:50:37 +08:00
bincxz
de7fdfc4b4 fix: ensure SftpSidePanel panes remain active for keyboard shortcuts
SftpSidePanel doesn't sync with the global activeTabStore, so
useActiveTabId would return the main SftpView's tab id, causing
side panel panes to be treated as inactive. Add forceActive prop
to bypass the activeTabId check for contexts that manage pane
visibility themselves.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:36:11 +08:00
bincxz
709ed12259 fix: prevent SFTP keyboard actions from repeating across all tabs (#569)
When multiple SFTP connections were open as tabs on the same side,
keyboard-triggered actions (delete, rename, new folder, new file) were
executed on every mounted tab instead of just the active one. This was
because all hidden SftpPaneView instances shared the same dialog action
handler and React batched their effects before clear() could prevent
duplicates.

- Add isActive parameter to useSftpDialogActionHandler so only the
  active tab responds to keyboard shortcut actions
- Compute real isActive state in SftpPaneView using useActiveTabId
  instead of hardcoding true
- Clear opposite side's file selection on pane focus change to prevent
  cross-pane selection leaking into actions

Closes #569

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:31:12 +08:00
24 changed files with 481 additions and 104 deletions

View File

@@ -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',

View File

@@ -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': '上传失败',

View File

@@ -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") => {

View File

@@ -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,

View File

@@ -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>) =>

View File

@@ -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,
)}

View File

@@ -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>
);
};

View File

@@ -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}
/>

View File

@@ -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

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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> = ({
}}
/>
</>
);
);
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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";

View 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;
};

View File

@@ -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]);
};

View File

@@ -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(() => {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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) => {