[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,274 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { getSftpBreadcrumbSegments } from "../../application/state/sftp/utils.ts";
import {
normalizeSftpBreadcrumbMaxVisibleParts,
resolveSftpBreadcrumbVisibleParts,
scrollSftpBreadcrumbViewportToTail,
shouldShowSftpBreadcrumbEllipsis,
splitSftpBreadcrumbPinnedParts,
} from "./SftpBreadcrumb.tsx";
const breadcrumbSource = fs.readFileSync(
path.join(path.dirname(fileURLToPath(import.meta.url)), "SftpBreadcrumb.tsx"),
"utf8",
);
test("deep unix paths keep the first segment and trailing segments", () => {
const { segments } = getSftpBreadcrumbSegments(
"/var/www/apps/netcatty/releases/current/public",
);
const resolved = resolveSftpBreadcrumbVisibleParts({
segments,
maxVisibleParts: 4,
});
assert.equal(resolved.needsTruncation, true);
assert.deepEqual(
resolved.visibleParts.map((part) => part.segment.label),
["var", "releases", "current", "public"],
);
assert.deepEqual(
resolved.hiddenParts.map((part) => part.segment.label),
["www", "apps", "netcatty"],
);
const split = splitSftpBreadcrumbPinnedParts(resolved.visibleParts);
assert.equal(split.leadingPart?.segment.label, "var");
assert.deepEqual(
split.trailingParts.map((part) => part.segment.label),
["releases", "current", "public"],
);
});
test("windows drive paths keep the drive letter while preferring the tail", () => {
const { segments } = getSftpBreadcrumbSegments(
"C:\\Users\\alice\\projects\\netcatty\\src\\components",
);
const resolved = resolveSftpBreadcrumbVisibleParts({
segments,
maxVisibleParts: 4,
});
assert.equal(resolved.needsTruncation, true);
assert.equal(resolved.visibleParts[0]?.segment.label, "C:");
assert.deepEqual(
resolved.visibleParts.slice(1).map((part) => part.segment.label),
["netcatty", "src", "components"],
);
});
test("windows UNC paths keep the share root while preferring the tail", () => {
const { segments } = getSftpBreadcrumbSegments(
"\\\\wsl.localhost\\Ubuntu-22.04\\home\\alice\\projects\\netcatty\\src",
);
const resolved = resolveSftpBreadcrumbVisibleParts({
segments,
maxVisibleParts: 4,
});
assert.equal(resolved.needsTruncation, true);
assert.equal(
resolved.visibleParts[0]?.segment.label,
"\\\\wsl.localhost\\Ubuntu-22.04",
);
assert.deepEqual(
resolved.visibleParts.slice(1).map((part) => part.segment.label),
["projects", "netcatty", "src"],
);
});
test("budget of one keeps the leading root and still exposes hidden segments via ellipsis", () => {
assert.equal(normalizeSftpBreadcrumbMaxVisibleParts(0), 1);
assert.equal(normalizeSftpBreadcrumbMaxVisibleParts(1.9), 1);
const { segments } = getSftpBreadcrumbSegments(
"C:\\Users\\alice\\projects\\netcatty\\src",
);
const resolved = resolveSftpBreadcrumbVisibleParts({
segments,
maxVisibleParts: 1,
});
assert.equal(resolved.needsTruncation, true);
assert.deepEqual(
resolved.visibleParts.map((part) => part.segment.label),
["C:"],
);
assert.ok(resolved.hiddenParts.length >= 3);
assert.equal(
shouldShowSftpBreadcrumbEllipsis({
needsTruncation: resolved.needsTruncation,
hiddenPartsCount: resolved.hiddenParts.length,
}),
true,
);
assert.deepEqual(splitSftpBreadcrumbPinnedParts(resolved.visibleParts).trailingParts, []);
});
test("breadcrumb pins leading chrome and only scrolls trailing chips", () => {
assert.doesNotMatch(breadcrumbSource, /dir="rtl"/);
assert.match(breadcrumbSource, /splitSftpBreadcrumbPinnedParts/);
assert.match(breadcrumbSource, /shouldShowSftpBreadcrumbEllipsis/);
assert.match(breadcrumbSource, /scrollSftpBreadcrumbViewportToTail/);
assert.match(breadcrumbSource, /shrink-0/);
assert.match(breadcrumbSource, /flex-1 overflow-hidden/);
const calls: Array<{ left: number }> = [];
const viewport = {
scrollWidth: 400,
clientWidth: 120,
set scrollLeft(value: number) {
calls.push({ left: value });
},
get scrollLeft() {
return calls.at(-1)?.left ?? 0;
},
} as HTMLElement;
scrollSftpBreadcrumbViewportToTail(viewport);
assert.deepEqual(calls, [{ left: 280 }]);
const shortCalls: number[] = [];
scrollSftpBreadcrumbViewportToTail({
scrollWidth: 100,
clientWidth: 120,
set scrollLeft(value: number) {
shortCalls.push(value);
},
get scrollLeft() {
return shortCalls.at(-1) ?? 0;
},
} as HTMLElement);
assert.deepEqual(shortCalls, [0]);
});
test("breadcrumb root button navigates to the filesystem root", async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: "http://localhost",
});
const window = dom.window;
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const installGlobal = (key: string, value: unknown) => {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value,
});
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
installGlobal("window", window);
installGlobal("document", window.document);
installGlobal("navigator", window.navigator);
installGlobal("HTMLElement", window.HTMLElement);
installGlobal("Element", window.Element);
installGlobal("SVGElement", window.SVGElement);
installGlobal("Node", window.Node);
installGlobal("NodeFilter", window.NodeFilter);
installGlobal("MutationObserver", window.MutationObserver);
installGlobal("CustomEvent", window.CustomEvent);
installGlobal("Event", window.Event);
installGlobal("getComputedStyle", window.getComputedStyle.bind(window));
installGlobal("requestAnimationFrame", window.requestAnimationFrame.bind(window));
installGlobal("cancelAnimationFrame", window.cancelAnimationFrame.bind(window));
installGlobal("ResizeObserver", ResizeObserverStub);
installGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const { default: React, act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { SftpBreadcrumb } = await import("./SftpBreadcrumb.tsx");
const { TooltipProvider } = await import("../ui/tooltip.tsx");
const rootNode = window.document.getElementById("root");
assert.ok(rootNode);
const root = createRoot(rootNode);
const navigatedPaths: string[] = [];
try {
await act(async () => {
root.render(
React.createElement(
TooltipProvider,
null,
React.createElement(SftpBreadcrumb, {
path: "/var/www/apps",
onNavigate: (path: string) => navigatedPaths.push(path),
onHome: () => {},
}),
),
);
});
const rootButton = Array.from(window.document.querySelectorAll("button")).find(
(button) => button.textContent === "/",
);
assert.ok(rootButton, "root button should be rendered next to the home button");
assert.equal(rootButton.disabled, false);
await act(async () => rootButton.click());
assert.deepEqual(navigatedPaths, ["/"]);
// A preserved double-slash POSIX path must still be able to navigate to /.
await act(async () => {
root.render(
React.createElement(
TooltipProvider,
null,
React.createElement(SftpBreadcrumb, {
path: "//",
onNavigate: (path: string) => navigatedPaths.push(path),
onHome: () => {},
}),
),
);
});
const rootButtonAtDoubleSlash = Array.from(window.document.querySelectorAll("button")).find(
(button) => button.textContent === "/",
);
assert.ok(rootButtonAtDoubleSlash, "root button should stay visible at //");
assert.equal(rootButtonAtDoubleSlash.disabled, false, "// must not disable navigation to /");
await act(async () => rootButtonAtDoubleSlash.click());
assert.deepEqual(navigatedPaths, ["/", "/"]);
// Already at the root: the button is disabled so it stays a no-op.
await act(async () => {
root.render(
React.createElement(
TooltipProvider,
null,
React.createElement(SftpBreadcrumb, {
path: "/",
onNavigate: (path: string) => navigatedPaths.push(path),
onHome: () => {},
}),
),
);
});
const rootButtonAtRoot = Array.from(window.document.querySelectorAll("button")).find(
(button) => button.textContent === "/",
);
assert.ok(rootButtonAtRoot, "root button should stay visible at /");
assert.equal(rootButtonAtRoot.disabled, true);
await act(async () => rootButtonAtRoot.click());
assert.deepEqual(navigatedPaths, ["/", "/"]);
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete (globalThis as Record<string, unknown>)[key];
}
}
});

View File

@@ -0,0 +1,338 @@
/**
* SFTP Breadcrumb navigation component
*/
import { ChevronDown, ChevronRight, Home, MoreHorizontal } from 'lucide-react';
import React, { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { getSftpBreadcrumbSegments, getSftpPathRoot, isWindowsPath, isWindowsRoot } from '../../application/state/sftp/utils';
import type { SftpWindowsPathOptions } from '../../application/state/sftp/utils';
import { Dropdown, DropdownContent, DropdownTrigger } from '../ui/dropdown';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { cn } from '../../lib/utils';
interface SftpBreadcrumbProps {
path: string;
onNavigate: (path: string) => void;
onHome: () => void;
/** Maximum number of visible path segments before truncation (default: 4) */
maxVisibleParts?: number;
isLocal?: boolean;
onListDrives?: () => Promise<string[]>;
/** When true, treat //host/share as Windows UNC (Windows-style panes). */
acceptForwardSlashUnc?: boolean;
}
type BreadcrumbSegment = ReturnType<typeof getSftpBreadcrumbSegments>['segments'][number];
export type SftpBreadcrumbVisiblePart = {
segment: BreadcrumbSegment;
originalIndex: number;
};
/** Clamp the visible-segment budget to a positive integer. */
export function normalizeSftpBreadcrumbMaxVisibleParts(maxVisibleParts: number): number {
if (!Number.isFinite(maxVisibleParts)) return 1;
return Math.max(1, Math.floor(maxVisibleParts));
}
/**
* Prefer the path tail when truncating, but always keep the first segment so the
* root / drive / UNC share stays clickable. Budget of 1 shows only the first segment.
*/
export function resolveSftpBreadcrumbVisibleParts({
segments,
maxVisibleParts,
}: {
segments: BreadcrumbSegment[];
maxVisibleParts: number;
}): {
visibleParts: SftpBreadcrumbVisiblePart[];
hiddenParts: SftpBreadcrumbVisiblePart[];
needsTruncation: boolean;
} {
const budget = normalizeSftpBreadcrumbMaxVisibleParts(maxVisibleParts);
if (segments.length <= budget) {
return {
visibleParts: segments.map((segment, idx) => ({ segment, originalIndex: idx })),
hiddenParts: [],
needsTruncation: false,
};
}
if (budget === 1) {
return {
visibleParts: [{ segment: segments[0], originalIndex: 0 }],
hiddenParts: segments.slice(1).map((segment, idx) => ({
segment,
originalIndex: idx + 1,
})),
needsTruncation: true,
};
}
const lastPartsCount = budget - 1;
const lastParts = segments.slice(-lastPartsCount).map((segment, idx) => ({
segment,
originalIndex: segments.length - lastPartsCount + idx,
}));
const hiddenParts = segments.slice(1, -lastPartsCount).map((segment, idx) => ({
segment,
originalIndex: idx + 1,
}));
return {
visibleParts: [{ segment: segments[0], originalIndex: 0 }, ...lastParts],
hiddenParts,
needsTruncation: true,
};
}
/** Split pinned leading chrome from the scrollable trailing chips. */
export function splitSftpBreadcrumbPinnedParts(visibleParts: SftpBreadcrumbVisiblePart[]): {
leadingPart: SftpBreadcrumbVisiblePart | null;
trailingParts: SftpBreadcrumbVisiblePart[];
} {
if (visibleParts.length === 0) {
return { leadingPart: null, trailingParts: [] };
}
return {
leadingPart: visibleParts[0],
trailingParts: visibleParts.slice(1),
};
}
/** True when truncated middle segments need an ellipsis affordance. */
export function shouldShowSftpBreadcrumbEllipsis({
needsTruncation,
hiddenPartsCount,
}: {
needsTruncation: boolean;
hiddenPartsCount: number;
}): boolean {
return needsTruncation && hiddenPartsCount > 0;
}
/** Scroll a breadcrumb viewport so overflow keeps the trailing path visible. */
export function scrollSftpBreadcrumbViewportToTail(viewport: HTMLElement | null): void {
if (!viewport) return;
viewport.scrollLeft = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
}
const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({
path,
onNavigate,
onHome,
maxVisibleParts = 4,
isLocal,
onListDrives,
acceptForwardSlashUnc = false,
}) => {
const { t } = useI18n();
const [drives, setDrives] = useState<string[]>([]);
const [driveDropdownOpen, setDriveDropdownOpen] = useState(false);
const viewportRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const handleDriveDropdownOpen = useCallback(async (open: boolean) => {
setDriveDropdownOpen(open);
if (open && onListDrives) {
const result = await onListDrives();
setDrives(result);
}
}, [onListDrives]);
const pathOptions = useMemo<SftpWindowsPathOptions>(
() => ({ acceptForwardSlashUnc }),
[acceptForwardSlashUnc],
);
const { segments, isWindowsDrive } = useMemo(
() => getSftpBreadcrumbSegments(path, pathOptions),
[path, pathOptions],
);
const { visibleParts, hiddenParts, needsTruncation } = useMemo(
() =>
resolveSftpBreadcrumbVisibleParts({
segments,
maxVisibleParts,
}),
[segments, maxVisibleParts],
);
const { leadingPart, trailingParts } = useMemo(
() => splitSftpBreadcrumbPinnedParts(visibleParts),
[visibleParts],
);
const showEllipsis = shouldShowSftpBreadcrumbEllipsis({
needsTruncation,
hiddenPartsCount: hiddenParts.length,
});
const syncTailScroll = useCallback(() => {
scrollSftpBreadcrumbViewportToTail(viewportRef.current);
}, []);
useLayoutEffect(() => {
syncTailScroll();
const viewport = viewportRef.current;
if (!viewport || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver(() => syncTailScroll());
ro.observe(viewport);
const track = trackRef.current;
if (track) ro.observe(track);
return () => ro.disconnect();
}, [syncTailScroll, path, trailingParts, showEllipsis]);
const showDriveDropdown = isWindowsDrive && isLocal && !!onListDrives;
// Dedicated "go to filesystem root" target: "/" on POSIX, drive / share root on Windows.
const rootPath = useMemo(
() => getSftpPathRoot(path, pathOptions),
[path, pathOptions],
);
const atRoot = useMemo(() => {
if (rootPath === null) return false;
return isWindowsPath(path, pathOptions)
? isWindowsRoot(path, pathOptions)
: path === rootPath;
}, [path, pathOptions, rootPath]);
const renderSegmentButton = (
part: SftpBreadcrumbVisiblePart,
{ showTrailingChevron }: { showTrailingChevron: boolean },
) => {
const { segment, originalIndex } = part;
const isLast = originalIndex === segments.length - 1;
const node = originalIndex === 0 && showDriveDropdown ? (
<Dropdown open={driveDropdownOpen} onOpenChange={handleDriveDropdownOpen}>
<DropdownTrigger asChild>
<button className="hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary/60 shrink-0 flex items-center gap-0.5">
{segment.label}
<ChevronDown size={10} className="opacity-60" />
</button>
</DropdownTrigger>
<DropdownContent align="start" className="w-16 p-1">
{drives.map(drive => (
<button
key={drive}
onClick={() => { onNavigate(drive + '\\'); setDriveDropdownOpen(false); }}
className={cn(
"w-full text-left px-2 py-1 text-xs rounded hover:bg-secondary/60",
drive === segment.label && "bg-secondary font-medium"
)}
>
{drive}
</button>
))}
</DropdownContent>
</Dropdown>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onNavigate(segment.path)}
className={cn(
"hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary/60 truncate max-w-[160px] shrink-0",
isLast && "text-foreground font-medium"
)}
>
{segment.label}
</button>
</TooltipTrigger>
<TooltipContent>{segment.label}</TooltipContent>
</Tooltip>
);
return (
<React.Fragment key={segment.path}>
{node}
{showTrailingChevron && <ChevronRight size={12} className="opacity-40 shrink-0" />}
</React.Fragment>
);
};
// Pin Home + leading root + ellipsis outside the scrollport so narrow panes
// can still navigate prefixes while the trailing chips scroll toward the end.
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex w-full min-w-0 items-center gap-1 text-xs text-muted-foreground cursor-default">
<div className="flex items-center gap-1 shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onHome}
className="hover:text-foreground p-1 rounded hover:bg-secondary/60 shrink-0"
>
<Home size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t("sftp.goHome")}</TooltipContent>
</Tooltip>
{rootPath && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onNavigate(rootPath)}
disabled={atRoot}
className="hover:text-foreground p-1 rounded hover:bg-secondary/60 shrink-0 text-[10px] leading-none font-semibold disabled:pointer-events-none disabled:opacity-40"
>
/
</button>
</TooltipTrigger>
<TooltipContent>{t("sftp.goRoot")}</TooltipContent>
</Tooltip>
)}
<ChevronRight size={12} className="opacity-40 shrink-0" />
{leadingPart && renderSegmentButton(leadingPart, {
showTrailingChevron: showEllipsis || trailingParts.length > 0,
})}
{showEllipsis && (
<>
<Tooltip>
<TooltipTrigger asChild>
<span className="px-1 py-0.5 shrink-0 flex items-center text-muted-foreground cursor-default">
<MoreHorizontal size={14} />
</span>
</TooltipTrigger>
<TooltipContent>
{`${t("sftp.showHiddenPaths")}: ${hiddenParts.map(h => h.segment.label).join(' > ')}`}
</TooltipContent>
</Tooltip>
{trailingParts.length > 0 && (
<ChevronRight size={12} className="opacity-40 shrink-0" />
)}
</>
)}
</div>
{trailingParts.length > 0 && (
<div
ref={viewportRef}
className="min-w-0 flex-1 overflow-hidden"
>
<div
ref={trackRef}
className="flex w-max max-w-none items-center gap-1"
>
{trailingParts.map((part, idx) =>
renderSegmentButton(part, {
showTrailingChevron: idx < trailingParts.length - 1,
}),
)}
</div>
</div>
)}
</div>
</TooltipTrigger>
<TooltipContent>{path}</TooltipContent>
</Tooltip>
);
};
export const SftpBreadcrumb = memo(SftpBreadcrumbInner);
SftpBreadcrumb.displayName = 'SftpBreadcrumb';

View File

@@ -0,0 +1,114 @@
import React, { useRef } from "react";
import { Button } from "../ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import type { SftpClipboardUploadRequest } from "./clipboardUpload";
import {
confirmSftpClipboardUpload,
shouldStartClipboardUploadConfirm,
sftpClipboardUploadStore,
} from "./clipboardUpload";
interface SftpClipboardUploadDialogProps {
request: SftpClipboardUploadRequest | null;
currentPath?: string;
onUploaded?: (targetPath: string) => void;
}
/**
* Confirmation dialog for OS-clipboard / path-backed paste uploads.
*
* Important: clear the store request *before* awaiting the transfer so the
* modal overlay does not block the app for the entire upload (issue #2478).
* Progress and cancellation live in the transfer queue after handoff.
*/
export const SftpClipboardUploadDialog: React.FC<SftpClipboardUploadDialogProps> = ({
request,
currentPath,
onUploaded,
}) => {
// Double-click guard is scoped to the request identity so a later paste can
// confirm while an earlier background transfer is still running.
const confirmStartedForRef = useRef<SftpClipboardUploadRequest | null>(null);
const open = !!request;
const fileCount = request?.files.length ?? 0;
const previewFiles = request?.files.slice(0, 5) ?? [];
const remainingCount = Math.max(0, fileCount - previewFiles.length);
const handleClose = (nextOpen: boolean) => {
if (nextOpen) return;
sftpClipboardUploadStore.clear(request);
};
const handleConfirm = async () => {
if (!request || !shouldStartClipboardUploadConfirm(request, confirmStartedForRef.current)) {
return;
}
const confirmedRequest = request;
confirmStartedForRef.current = confirmedRequest;
try {
// Close immediately so side-panel / standalone SFTP stay interactive while
// the existing background transfer path runs.
await confirmSftpClipboardUpload({ request: confirmedRequest, onUploaded });
} catch {
// Transfer handlers toast failures; keep this path free of unhandled
// rejections after the dialog has already closed.
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-md">
<DialogHeader className="min-w-0 pr-6">
<DialogTitle>Upload clipboard files?</DialogTitle>
<DialogDescription>
Upload {fileCount} item{fileCount === 1 ? "" : "s"} to:
</DialogDescription>
</DialogHeader>
<div className="min-w-0 space-y-3">
<div className="min-w-0 rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-sm font-mono break-all [overflow-wrap:anywhere]">
{request?.targetPath ?? currentPath}
</div>
{previewFiles.length > 0 && (
<div className="max-h-40 min-w-0 overflow-auto rounded-md border border-border/60">
{previewFiles.map((file) => (
<div
key={file.path}
className="flex min-w-0 items-center border-b border-border/40 px-3 py-2 text-sm last:border-b-0"
>
<span className="min-w-0 truncate" title={file.name}>
{file.name}
</span>
</div>
))}
{remainingCount > 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground">
and {remainingCount} more...
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => sftpClipboardUploadStore.clear(request)}
>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!request}>
Upload
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
ContextMenuCheckboxItem,
ContextMenuSeparator,
} from '../ui/context-menu';
import type { ColumnWidths, SftpColumnVisibility } from './utils';
interface SftpColumnMenuItemsProps {
visibleColumns: SftpColumnVisibility;
directoriesFirst: boolean;
toggleColumnVisibility: (field: keyof ColumnWidths) => void;
toggleDirectoriesFirst: () => void;
}
export const SftpColumnMenuItems: React.FC<SftpColumnMenuItemsProps> = ({
visibleColumns,
directoriesFirst,
toggleColumnVisibility,
toggleDirectoriesFirst,
}) => {
const { t } = useI18n();
return (
<>
<ContextMenuCheckboxItem checked disabled>
{t('sftp.columns.name')}
</ContextMenuCheckboxItem>
{(['modified', 'size', 'type', 'owner'] as const).map((field) => (
<ContextMenuCheckboxItem
key={field}
checked={visibleColumns[field]}
onCheckedChange={() => toggleColumnVisibility(field)}
>
{t(field === 'type' ? 'sftp.columns.kind' : `sftp.columns.${field}`)}
</ContextMenuCheckboxItem>
))}
<ContextMenuSeparator />
<ContextMenuCheckboxItem
checked={directoriesFirst}
onCheckedChange={toggleDirectoriesFirst}
>
{t('sftp.sort.directoriesFirst')}
</ContextMenuCheckboxItem>
</>
);
};

View File

@@ -0,0 +1,237 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import {
createDomRenderer,
dispatchDomEvent,
flushEffects,
installDomEnvironment,
} from "../test-support/renderReactDom.tsx";
test("announces folder replacement risk and refocuses Merge for each queued conflict", async (t) => {
const env = installDomEnvironment();
const previousMutationObserver = globalThis.MutationObserver;
const previousNodeFilter = globalThis.NodeFilter;
const previousHTMLInputElement = globalThis.HTMLInputElement;
Object.defineProperty(globalThis, "MutationObserver", {
configurable: true,
writable: true,
value: env.window.MutationObserver,
});
Object.defineProperty(globalThis, "NodeFilter", {
configurable: true,
writable: true,
value: env.window.NodeFilter,
});
Object.defineProperty(globalThis, "HTMLInputElement", {
configurable: true,
writable: true,
value: env.window.HTMLInputElement,
});
const { I18nProvider } = await import("../../application/i18n/I18nProvider.tsx");
const { SftpConflictDialog } = await import("./SftpConflictDialog.tsx");
const renderer = await createDomRenderer(env.document);
const resolvedActions: string[] = [];
t.after(async () => {
await renderer.unmount();
await new Promise((resolve) => setTimeout(resolve, 20));
Object.defineProperty(globalThis, "MutationObserver", {
configurable: true,
writable: true,
value: previousMutationObserver,
});
Object.defineProperty(globalThis, "NodeFilter", {
configurable: true,
writable: true,
value: previousNodeFilter,
});
Object.defineProperty(globalThis, "HTMLInputElement", {
configurable: true,
writable: true,
value: previousHTMLInputElement,
});
env.cleanup();
});
const queuedConflicts = [
{
transferId: "folder-conflict",
fileName: "docs",
sourcePath: "/source/docs",
targetPath: "/destination/docs",
isDirectory: true,
existingType: "directory",
existingSize: 4096,
newSize: 4096,
existingModified: 1,
newModified: 2,
},
{
transferId: "next-folder-conflict",
fileName: "photos",
sourcePath: "/source/photos",
targetPath: "/destination/photos",
isDirectory: true,
existingType: "directory",
existingSize: 4096,
newSize: 4096,
existingModified: 3,
newModified: 4,
},
{
transferId: "symlink-folder-conflict",
fileName: "shortcut",
sourcePath: "/source/shortcut",
targetPath: "/destination/shortcut",
isDirectory: true,
existingType: "symlink",
existingSize: 0,
newSize: 4096,
existingModified: 5,
newModified: 6,
},
{
transferId: "file-folder-conflict",
fileName: "archive",
sourcePath: "/source/archive",
targetPath: "/destination/archive",
isDirectory: true,
existingType: "file",
existingSize: 1024,
newSize: 4096,
existingModified: 7,
newModified: 8,
},
{
transferId: "legacy-folder-conflict",
fileName: "legacy",
sourcePath: "/source/legacy",
targetPath: "/destination/legacy",
isDirectory: true,
existingType: undefined,
existingSize: 0,
newSize: 4096,
existingModified: 9,
newModified: 10,
},
{
transferId: "file-conflict",
fileName: "notes.txt",
sourcePath: "/source/notes.txt",
targetPath: "/destination/notes.txt",
isDirectory: false,
existingType: "file",
existingSize: 128,
newSize: 256,
existingModified: 11,
newModified: 12,
},
] satisfies React.ComponentProps<typeof SftpConflictDialog>["conflicts"];
const QueueHarness = () => {
const [conflicts, setConflicts] = React.useState(queuedConflicts);
return React.createElement(SftpConflictDialog, {
conflicts,
onResolve: (_id, action) => {
resolvedActions.push(action);
setConflicts((current) => current.slice(1));
},
formatFileSize: (size: number) => `${size} B`,
});
};
await renderer.render(React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(QueueHarness),
));
await flushEffects();
const dialog = env.document.querySelector<HTMLElement>("[role=dialog]");
assert.ok(dialog, "folder conflict dialog should render");
const describedBy = dialog.getAttribute("aria-describedby");
assert.ok(describedBy, "dialog should expose its safety description");
const describedText = describedBy
.split(/\s+/)
.map((id) => env.document.getElementById(id)?.textContent ?? "")
.join(" ");
assert.match(describedText, /Merge: keeps destination-only content/);
assert.match(describedText, /Replace: deletes destination-only content and cannot be undone/);
assert.doesNotMatch(env.document.body.textContent ?? "", /A folder with the same name already exists/);
const dialogTitle = env.document.querySelector("[role=dialog] h2");
assert.equal(dialogTitle?.textContent, "docs already exists");
assert.equal(dialogTitle?.getAttribute("aria-label"), "Folder Conflict: docs already exists");
const warning = env.document.getElementById(describedBy.split(/\s+/).at(-1) ?? "");
assert.ok(warning, "folder action guidance should render");
assert.equal(warning.querySelectorAll("svg").length, 2, "both guidance rows should use matching icons");
assert.equal(warning.querySelectorAll("p.text-xs").length, 0, "guidance rows should use the same text size");
const replaceButton = Array.from(env.document.querySelectorAll("button"))
.find((button) => button.textContent === "Replace");
assert.ok(replaceButton, "folder replacement action should render");
const mergeButton = Array.from(env.document.querySelectorAll("button"))
.find((button) => button.textContent === "Merge");
assert.ok(mergeButton, "folder merge action should render");
assert.match(mergeButton.className, /bg-primary/);
assert.match(mergeButton.className, /border/);
assert.doesNotMatch(replaceButton.className, /(^|\s)bg-destructive(?:\s|$)/);
assert.match(replaceButton.className, /text-destructive/);
assert.match(replaceButton.className, /border/);
assert.equal(replaceButton.getAttribute("aria-describedby"), describedBy.split(/\s+/).at(-1));
for (const label of ["Stop", "Skip", "Duplicate", "Merge", "Replace"]) {
const action = Array.from(env.document.querySelectorAll("button"))
.find((button) => button.textContent === label);
assert.ok(action, `${label} action should render`);
assert.match(action.className, /h-9/);
assert.match(action.className, /min-w-24/);
assert.match(action.className, /border/);
}
assert.equal(
Array.from(env.document.querySelectorAll("button"))
.filter((button) => button.className.includes("bg-primary"))
.length,
1,
"Merge should be the only visually primary action",
);
await dispatchDomEvent(replaceButton, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
await new Promise((resolve) => setTimeout(resolve, 20));
assert.deepEqual(resolvedActions, ["replace"]);
assert.match(env.document.body.textContent ?? "", /photos/);
assert.equal(env.document.activeElement?.textContent, "Merge");
const clickFocusedAction = async (expectedLabel: string) => {
const focusedButton = env.document.activeElement;
assert.equal(focusedButton?.textContent, expectedLabel);
assert.ok(focusedButton, `${expectedLabel} should be focused`);
await dispatchDomEvent(focusedButton, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
await new Promise((resolve) => setTimeout(resolve, 20));
};
await clickFocusedAction("Merge");
assert.match(env.document.body.textContent ?? "", /shortcut/);
assert.equal(env.document.activeElement?.textContent, "Replace");
await clickFocusedAction("Replace");
assert.match(env.document.body.textContent ?? "", /archive/);
assert.equal(env.document.activeElement?.textContent, "Duplicate");
await clickFocusedAction("Duplicate");
assert.match(env.document.body.textContent ?? "", /legacy/);
assert.match(env.document.body.textContent ?? "", /type could not be confirmed/);
assert.equal(env.document.activeElement?.textContent, "Duplicate");
assert.equal(
Array.from(env.document.querySelectorAll("button")).some((button) => button.textContent === "Replace"),
false,
);
await clickFocusedAction("Duplicate");
assert.match(env.document.body.textContent ?? "", /notes\.txt/);
assert.equal(env.document.activeElement?.textContent, "Replace");
await clickFocusedAction("Replace");
assert.deepEqual(resolvedActions, ["replace", "merge", "replace", "duplicate", "duplicate", "replace"]);
assert.equal(env.document.querySelector("[role=dialog]"), null);
});

View File

@@ -0,0 +1,109 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
canReplaceConflict,
getSftpConflictDialogPresentation,
} from "./SftpConflictDialog.tsx";
test("does not offer replace when a file upload conflicts with an existing directory", () => {
assert.equal(canReplaceConflict({
isDirectory: false,
existingType: "directory",
}), false);
});
test("does not offer replace when a directory upload conflicts with an existing file", () => {
assert.equal(canReplaceConflict({
isDirectory: true,
existingType: "file",
}), false);
});
test("offers replace when a file upload conflicts with an existing file", () => {
assert.equal(canReplaceConflict({
isDirectory: false,
existingType: "file",
}), true);
});
test("offers replace when a directory upload conflicts with an existing symlink", () => {
assert.equal(canReplaceConflict({
isDirectory: true,
existingType: "symlink",
}), true);
});
test("offers replace when a file upload conflicts with an existing symlink", () => {
assert.equal(canReplaceConflict({
isDirectory: false,
existingType: "symlink",
}), true);
});
test("does not offer replace when a folder conflict has an unknown destination type", () => {
assert.equal(canReplaceConflict({
isDirectory: true,
existingType: undefined,
}), false);
const presentation = getSftpConflictDialogPresentation({
isDirectory: true,
existingType: undefined,
});
assert.equal(presentation.descriptionKey, "sftp.conflict.folderUnknownDesc");
assert.equal(presentation.showDirectoryReplaceWarning, false);
assert.equal(presentation.replaceVariant, "default");
});
test("makes merge the safe primary action for a same-named folder conflict", () => {
assert.deepEqual(getSftpConflictDialogPresentation({
isDirectory: true,
existingType: "directory",
}), {
titleKey: "sftp.conflict.folderTitle",
descriptionKey: "sftp.conflict.folderDesc",
showFileMetadata: false,
showDirectoryReplaceWarning: true,
mergeVariant: "default",
replaceVariant: "outline",
});
});
test("keeps the existing file conflict presentation unchanged", () => {
assert.deepEqual(getSftpConflictDialogPresentation({
isDirectory: false,
existingType: "file",
}), {
titleKey: "sftp.conflict.title",
descriptionKey: "sftp.conflict.desc",
showFileMetadata: true,
showDirectoryReplaceWarning: false,
mergeVariant: "outline",
replaceVariant: "default",
});
});
test("does not show the directory deletion warning when replacing a symlink", () => {
const presentation = getSftpConflictDialogPresentation({
isDirectory: true,
existingType: "symlink",
});
assert.equal(presentation.titleKey, "sftp.conflict.folderTitle");
assert.equal(presentation.descriptionKey, "sftp.conflict.folderSymlinkDesc");
assert.equal(presentation.showDirectoryReplaceWarning, false);
assert.equal(presentation.replaceVariant, "default");
});
test("explains why a folder cannot merge with an existing file", () => {
const presentation = getSftpConflictDialogPresentation({
isDirectory: true,
existingType: "file",
});
assert.equal(presentation.titleKey, "sftp.conflict.folderTitle");
assert.equal(presentation.descriptionKey, "sftp.conflict.folderFileDesc");
assert.equal(presentation.showDirectoryReplaceWarning, false);
assert.equal(presentation.mergeVariant, "outline");
});

View File

@@ -0,0 +1,293 @@
/**
* SFTP Conflict Resolution Dialog
*/
import { AlertTriangle, GitMerge } from 'lucide-react';
import React, { memo, useEffect, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { canReplaceSftpConflict, getSftpConflictTypeKey } from '../../domain/sftpConflict';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import type { FileConflictAction } from '../../domain/models';
interface ConflictItem {
transferId: string;
fileName: string;
sourcePath: string;
targetPath: string;
isDirectory: boolean;
existingType?: 'file' | 'directory' | 'symlink';
applyToAllCount?: number;
existingSize: number;
newSize: number;
existingModified: number;
newModified: number;
}
export const canReplaceConflict = (conflict: Pick<ConflictItem, 'isDirectory' | 'existingType'>): boolean => {
return canReplaceSftpConflict(conflict.isDirectory, conflict.existingType);
};
export const getSftpConflictDialogPresentation = (
conflict: Pick<ConflictItem, 'isDirectory' | 'existingType'>,
) => {
const isDestructiveDirectoryReplace = conflict.isDirectory && conflict.existingType === 'directory';
const descriptionKey = !conflict.isDirectory
? 'sftp.conflict.desc'
: conflict.existingType === 'file'
? 'sftp.conflict.folderFileDesc'
: conflict.existingType === 'symlink'
? 'sftp.conflict.folderSymlinkDesc'
: conflict.existingType === 'directory'
? 'sftp.conflict.folderDesc'
: 'sftp.conflict.folderUnknownDesc';
return {
titleKey: conflict.isDirectory ? 'sftp.conflict.folderTitle' : 'sftp.conflict.title',
descriptionKey,
showFileMetadata: !conflict.isDirectory,
showDirectoryReplaceWarning: isDestructiveDirectoryReplace,
mergeVariant: isDestructiveDirectoryReplace ? 'default' : 'outline',
replaceVariant: isDestructiveDirectoryReplace ? 'outline' : 'default',
} as const;
};
const getConflictTypeKey = (conflict: Pick<ConflictItem, 'isDirectory' | 'existingType'>): string =>
getSftpConflictTypeKey(conflict.isDirectory, conflict.existingType);
interface SftpConflictDialogProps {
conflicts: ConflictItem[];
onResolve: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
formatFileSize: (size: number) => string;
}
interface ConflictFileSummaryProps {
title: string;
sizeLabel: string;
modifiedLabel: string;
size: string;
modified: string;
}
const ConflictFileSummary: React.FC<ConflictFileSummaryProps> = ({
title,
sizeLabel,
modifiedLabel,
size,
modified,
}) => (
<div className="rounded-md border border-border/60 bg-secondary/25 px-4 py-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-medium text-foreground">
{title}
</div>
</div>
<dl className="space-y-2 text-sm">
<div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
<dt className="text-muted-foreground">{sizeLabel}</dt>
<dd className="min-w-0 text-foreground">{size}</dd>
</div>
<div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
<dt className="text-muted-foreground">{modifiedLabel}</dt>
<dd className="min-w-0 break-words leading-relaxed text-foreground">{modified}</dd>
</div>
</dl>
</div>
);
const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts, onResolve, formatFileSize }) => {
const { t } = useI18n();
const [applyToAll, setApplyToAll] = useState(false);
const duplicateButtonRef = useRef<HTMLButtonElement>(null);
const mergeButtonRef = useRef<HTMLButtonElement>(null);
const replaceButtonRef = useRef<HTMLButtonElement>(null);
const previousConflictIdRef = useRef<string | undefined>(undefined);
const descriptionId = React.useId();
const directoryWarningId = React.useId();
const conflict = conflicts[0]; // Handle first conflict
const currentCanMerge = conflict?.isDirectory === true && conflict.existingType === 'directory';
const currentCanReplace = conflict ? canReplaceConflict(conflict) : false;
useEffect(() => {
const currentConflictId = conflict?.transferId;
const previousConflictId = previousConflictIdRef.current;
previousConflictIdRef.current = currentConflictId;
if (!currentConflictId || !previousConflictId || currentConflictId === previousConflictId) return;
const nextAction = currentCanMerge
? mergeButtonRef.current
: currentCanReplace
? replaceButtonRef.current
: duplicateButtonRef.current;
if (!nextAction) return;
// If the previously focused action disappears or becomes disabled,
// Radix may restore focus to the first button after this effect. Focus
// on the next frame so the current conflict's safe action wins.
if (typeof globalThis.requestAnimationFrame === 'function') {
const frame = globalThis.requestAnimationFrame(() => nextAction.focus());
return () => globalThis.cancelAnimationFrame(frame);
}
const timer = globalThis.setTimeout(() => nextAction.focus(), 0);
return () => globalThis.clearTimeout(timer);
}, [conflict?.transferId, currentCanMerge, currentCanReplace]);
if (!conflict) return null;
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
};
const sameTypeConflictCount = Math.max(
conflict.applyToAllCount ?? 1,
conflicts.filter((item) => getConflictTypeKey(item) === getConflictTypeKey(conflict)).length,
);
const canMerge = currentCanMerge;
const canReplace = currentCanReplace;
const presentation = getSftpConflictDialogPresentation(conflict);
const showConflictDescription = !presentation.showDirectoryReplaceWarning;
const describedBy = presentation.showDirectoryReplaceWarning
? `${descriptionId} ${directoryWarningId}`
: descriptionId;
const handleAction = (action: FileConflictAction) => {
onResolve(conflict.transferId, action, applyToAll);
setApplyToAll(false);
};
return (
<Dialog open={!!conflict} onOpenChange={() => handleAction('skip')}>
<DialogContent
className="gap-4 p-5 sm:max-w-[600px] sm:p-6"
aria-describedby={describedBy}
>
<DialogHeader className="space-y-1.5 pr-8">
<DialogTitle
className="flex min-w-0 flex-wrap items-baseline gap-x-2 text-lg leading-tight"
aria-label={`${t(presentation.titleKey)}: ${conflict.fileName} ${t('sftp.conflict.alreadyExistsSuffix')}`}
>
<span className="min-w-0 break-words">{conflict.fileName}</span>
{' '}
<span className="text-base font-normal text-muted-foreground">
{t('sftp.conflict.alreadyExistsSuffix')}
</span>
</DialogTitle>
<div id={descriptionId} className={showConflictDescription ? undefined : 'sr-only'}>
<DialogDescription className="leading-5">
{t(presentation.descriptionKey)}
</DialogDescription>
</div>
</DialogHeader>
<div className="space-y-3">
{presentation.showFileMetadata && (
<div className="space-y-3">
<ConflictFileSummary
title={t('sftp.conflict.existingFile')}
sizeLabel={t('sftp.conflict.size')}
modifiedLabel={t('sftp.conflict.modified')}
size={formatFileSize(conflict.existingSize)}
modified={formatDate(conflict.existingModified)}
/>
<ConflictFileSummary
title={t('sftp.conflict.newFile')}
sizeLabel={t('sftp.conflict.size')}
modifiedLabel={t('sftp.conflict.modified')}
size={formatFileSize(conflict.newSize)}
modified={formatDate(conflict.newModified)}
/>
</div>
)}
{presentation.showDirectoryReplaceWarning && (
<div
id={directoryWarningId}
className="space-y-1.5 text-sm leading-5"
>
<div className="flex items-start gap-2 text-muted-foreground">
<GitMerge className="mt-0.5 h-4 w-4 shrink-0" />
<p>{t('sftp.conflict.folderMergeHint')}</p>
</div>
<div className="flex items-start gap-2 text-sm font-normal text-destructive/90">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p>{t('sftp.conflict.folderReplaceWarning')}</p>
</div>
</div>
)}
{sameTypeConflictCount > 1 && (
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={applyToAll}
onChange={(e) => setApplyToAll(e.target.checked)}
className="rounded border-border"
/>
{t('sftp.conflict.applyToAll', { count: sameTypeConflictCount })}
</label>
)}
</div>
<DialogFooter className="flex flex-col-reverse gap-3 border-t border-border/50 pt-4 sm:flex-row sm:items-center sm:justify-between sm:space-x-0">
<Button
variant="outline"
size="sm"
onClick={() => handleAction('stop')}
className="min-w-24 self-start border-border/70 text-muted-foreground hover:text-destructive"
>
{t('sftp.conflict.action.stop')}
</Button>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleAction('skip')}
className="min-w-24 border-border/70"
>
{t('sftp.conflict.action.skip')}
</Button>
<Button
ref={duplicateButtonRef}
variant="outline"
size="sm"
onClick={() => handleAction('duplicate')}
className="min-w-24 border-border/70"
>
{t('sftp.conflict.action.duplicate')}
</Button>
{conflict.isDirectory && (
<Button
ref={mergeButtonRef}
variant={presentation.mergeVariant}
size="sm"
onClick={() => handleAction('merge')}
disabled={!canMerge}
autoFocus={presentation.showDirectoryReplaceWarning}
className="min-w-24 border border-primary"
>
{t('sftp.conflict.action.merge')}
</Button>
)}
{canReplace && (
<Button
ref={replaceButtonRef}
variant={presentation.replaceVariant}
size="sm"
onClick={() => handleAction('replace')}
aria-describedby={presentation.showDirectoryReplaceWarning ? directoryWarningId : undefined}
className={presentation.showDirectoryReplaceWarning
? 'min-w-24 border-destructive/50 text-destructive hover:bg-destructive/10 hover:text-destructive'
: 'min-w-24'}
>
{t('sftp.conflict.action.replace')}
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export const SftpConflictDialog = memo(SftpConflictDialogInner);
SftpConflictDialog.displayName = 'SftpConflictDialog';

View File

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

View File

@@ -0,0 +1,197 @@
/**
* SFTP File row component for file list
*/
import { Folder, Link } from 'lucide-react';
import React, { memo, useCallback } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { cn } from '../../lib/utils';
import { SftpFileEntry } from '../../types';
import {
sftpFileRowDensityClass,
sftpFileRowIconDensityClass,
type SftpListDensity,
} from '../../domain/sftpListDensity';
import { buildSftpColumnTemplate, formatBytes, formatDate, getFileIcon, isNavigableDirectory, type ColumnWidths, type SftpColumnVisibility } from './utils';
interface SftpFileRowProps {
entry: SftpFileEntry;
index: number;
isSelected: boolean;
showSelectionHighlight: boolean;
isDragOver: boolean;
columnWidths: ColumnWidths;
visibleColumns: SftpColumnVisibility;
onSelect: (entry: SftpFileEntry, index: number, e: React.MouseEvent) => void;
onOpen: (entry: SftpFileEntry) => void;
onDragStart: (entry: SftpFileEntry, e: React.DragEvent) => void;
onDragEnd: () => void;
onDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
onDragLeave: () => void;
onDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
density?: SftpListDensity;
}
const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
entry,
index,
isSelected,
showSelectionHighlight,
isDragOver,
columnWidths,
visibleColumns,
onSelect,
onOpen,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop,
density = "comfortable",
}) => {
const isParentDir = entry.name === '..';
// A symlink pointing to a directory behaves like a directory (navigable, accepts drops)
const isNavDir = isNavigableDirectory(entry);
const isSymlinkToDirectory = entry.type === 'symlink' && entry.linkTarget === 'directory';
const modifiedLabel = entry.lastModifiedFormatted || formatDate(entry.lastModified);
const sizeLabel = entry.sizeFormatted || formatBytes(entry.size);
const handleSelect = useCallback((e: React.MouseEvent) => {
onSelect(entry, index, e);
}, [entry, index, onSelect]);
const handleOpen = useCallback(() => {
onOpen(entry);
}, [entry, onOpen]);
const handleDragStart = useCallback((e: React.DragEvent) => {
onDragStart(entry, e);
}, [entry, onDragStart]);
const handleDragOver = useCallback((e: React.DragEvent) => {
onDragOver(entry, e);
}, [entry, onDragOver]);
const handleDrop = useCallback((e: React.DragEvent) => {
onDrop(entry, e);
}, [entry, onDrop]);
const isSelectionVisible = isSelected && showSelectionHighlight;
return (
<div
data-sftp-row="true"
data-section="terminal-sftp-list-row"
data-entry-name={entry.name}
data-selected={isSelected ? "true" : "false"}
data-entry-type={isNavDir ? "directory" : entry.type}
data-drag-over={isDragOver ? "true" : "false"}
draggable={!isParentDir}
onDragStart={handleDragStart}
onDragEnd={onDragEnd}
onDragOver={handleDragOver}
onDragLeave={onDragLeave}
onDrop={handleDrop}
onClick={handleSelect}
onDoubleClick={handleOpen}
data-sftp-list-density={density}
className={cn(
"items-center cursor-pointer",
sftpFileRowDensityClass(density),
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, visibleColumns) }}
>
<div className={cn("flex items-center min-w-0", density === "compact" ? "gap-2" : "gap-3")}>
<div className={cn(
"rounded flex items-center justify-center shrink-0 relative",
sftpFileRowIconDensityClass(density),
isSelectionVisible
? "bg-accent-foreground/10 text-accent-foreground"
: isNavDir
? "bg-primary/10 text-primary"
: "bg-secondary/60 text-muted-foreground"
)}>
{isNavDir ? <Folder size={14} /> : getFileIcon(entry)}
{/* Show link indicator for symlinks */}
{entry.type === 'symlink' && (
<Link
size={8}
className={cn(
"absolute -bottom-0.5 -right-0.5",
isSelectionVisible ? "text-accent-foreground/80" : "text-muted-foreground",
)}
aria-hidden="true"
/>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"truncate cursor-default",
entry.type === 'symlink' && "italic pr-1",
isSelectionVisible && "font-medium",
)}
>
{entry.name}
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
</span>
</TooltipTrigger>
<TooltipContent>{entry.name}</TooltipContent>
</Tooltip>
</div>
{visibleColumns.modified && (
<span className={cn("text-xs truncate", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>{modifiedLabel}</span>
)}
{visibleColumns.size && (
<span className={cn("text-xs truncate text-right", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>
{isNavDir ? '--' : sizeLabel}
</span>
)}
{visibleColumns.type && (
<span className={cn("text-xs truncate capitalize text-right", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>
{isSymlinkToDirectory ? 'link → folder' : entry.type === 'directory' ? 'folder' : entry.type === 'symlink' ? 'link' : entry.name.split('.').pop()?.toLowerCase() || 'file'}
</span>
)}
{visibleColumns.owner && (
<span className={cn("text-xs truncate text-right", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>
{isParentDir ? '' : (entry.owner || '--')}
</span>
)}
</div>
);
};
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;
if (prev.columnWidths.size !== next.columnWidths.size) return false;
if (prev.columnWidths.type !== next.columnWidths.type) return false;
if (prev.columnWidths.owner !== next.columnWidths.owner) return false;
if (prev.visibleColumns.modified !== next.visibleColumns.modified) return false;
if (prev.visibleColumns.size !== next.visibleColumns.size) return false;
if (prev.visibleColumns.type !== next.visibleColumns.type) return false;
if (prev.visibleColumns.owner !== next.visibleColumns.owner) return false;
// Compare callbacks - important for ".." entry which has static properties
if (prev.onOpen !== next.onOpen) return false;
if (prev.onSelect !== next.onSelect) return false;
if (prev.density !== next.density) return false;
const prevEntry = prev.entry;
const nextEntry = next.entry;
return (
prevEntry.name === nextEntry.name &&
prevEntry.type === nextEntry.type &&
prevEntry.size === nextEntry.size &&
prevEntry.lastModified === nextEntry.lastModified &&
prevEntry.linkTarget === nextEntry.linkTarget &&
prevEntry.sizeFormatted === nextEntry.sizeFormatted &&
prevEntry.lastModifiedFormatted === nextEntry.lastModifiedFormatted &&
prevEntry.owner === nextEntry.owner
);
};
export const SftpFileRow = memo(SftpFileRowInner, areEqual);
SftpFileRow.displayName = 'SftpFileRow';

View File

@@ -0,0 +1,329 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import type { SftpStateApi } from "../../application/state/useSftpState.ts";
import type { HotkeyScheme, KeyBinding } from "../../domain/models/keyBindings.ts";
test("Ctrl+F opens and refocuses the active SFTP pane filter without handling inactive panes", async () => {
const dom = new JSDOM(
'<!doctype html><html><body><div id="root"></div><textarea id="terminal-focus" class="xterm"></textarea></body></html>',
{ pretendToBeVisual: true, url: "http://localhost" },
);
const window = dom.window;
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const installGlobal = (key: string, value: unknown) => {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value,
});
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
installGlobal("window", window);
installGlobal("document", window.document);
installGlobal("navigator", window.navigator);
installGlobal("HTMLElement", window.HTMLElement);
installGlobal("HTMLInputElement", window.HTMLInputElement);
installGlobal("HTMLTextAreaElement", window.HTMLTextAreaElement);
installGlobal("Element", window.Element);
installGlobal("SVGElement", window.SVGElement);
installGlobal("Node", window.Node);
installGlobal("NodeFilter", window.NodeFilter);
installGlobal("MutationObserver", window.MutationObserver);
installGlobal("CustomEvent", window.CustomEvent);
installGlobal("Event", window.Event);
installGlobal("KeyboardEvent", window.KeyboardEvent);
installGlobal("StorageEvent", window.StorageEvent);
installGlobal("localStorage", window.localStorage);
installGlobal("sessionStorage", window.sessionStorage);
installGlobal("getComputedStyle", window.getComputedStyle.bind(window));
installGlobal("requestAnimationFrame", window.requestAnimationFrame.bind(window));
installGlobal("cancelAnimationFrame", window.cancelAnimationFrame.bind(window));
installGlobal("ResizeObserver", ResizeObserverStub);
installGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const { default: React, act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { DEFAULT_KEY_BINDINGS } = await import("../../domain/models/keyBindings.ts");
const { sftpFocusStore } = await import("../../application/state/sftp/sftpFocusStore.ts");
const { useSftpKeyboardShortcuts } = await import("./hooks/useSftpKeyboardShortcuts.ts");
const { SftpPaneToolbar } = await import("./SftpPaneToolbar.tsx");
const pane = {
id: "pane-1",
connection: {
id: "conn-1",
hostId: "host-1",
name: "Example",
currentPath: "/home/app",
homeDir: "/home/app",
isLocal: false,
},
files: [],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set<string>(),
filter: "",
filenameEncoding: "auto" as const,
showHiddenFiles: false,
transferMutationToken: 0,
};
const Harness = ({
isActive,
hotkeyScheme = "pc",
keyBindings = DEFAULT_KEY_BINDINGS,
}: {
isActive: boolean;
hotkeyScheme?: HotkeyScheme;
keyBindings?: KeyBinding[];
}) => {
const [showFilterBar, setShowFilterBar] = React.useState(false);
const filterInputRef = React.useRef<HTMLInputElement>(null);
const sftpRef = React.useRef({
leftTabs: { tabs: [pane], activeTabId: pane.id },
rightTabs: { tabs: [], activeTabId: null },
} as unknown as SftpStateApi);
useSftpKeyboardShortcuts({
keyBindings,
hotkeyScheme,
sftpRef,
dialogActionScopeId: "test-scope",
isActive,
});
return React.createElement(
React.Fragment,
null,
React.createElement("button", { id: "sftp-focus-target" }, "files"),
React.createElement(
"div",
{ "data-section": "terminal-sftp-path" },
React.createElement("input", { id: "sftp-path-input", defaultValue: "/home/app" }),
),
React.createElement(SftpPaneToolbar, {
t: (key: string) => ({
"sftp.filter": "Filter files",
"sftp.filter.placeholder": "Filter files",
"sftp.viewMode.switchToTree": "Switch to tree view",
"sftp.bookmark.add": "Bookmark current path",
"common.refresh": "Refresh",
"common.close": "Close",
}[key] ?? key),
pane,
onNavigateTo: () => {},
onSetFilter: () => {},
onSetFilenameEncoding: () => {},
onRefresh: () => {},
showFilterBar,
setShowFilterBar,
filterInputRef,
isEditingPath: false,
editingPathValue: "",
setEditingPathValue: () => {},
setShowPathSuggestions: () => {},
showPathSuggestions: false,
setPathSuggestionIndex: () => {},
pathSuggestions: [],
pathSuggestionIndex: -1,
pathInputRef: { current: null },
pathDropdownRef: { current: null },
handlePathBlur: () => {},
handlePathKeyDown: () => {},
handlePathDoubleClick: () => {},
handlePathSubmit: () => {},
getNextUntitledName: () => "untitled",
setNewFileName: () => {},
setFileNameError: () => {},
setShowNewFileDialog: () => {},
setShowNewFolderDialog: () => {},
setNewFolderName: () => {},
bookmarks: [],
isCurrentPathBookmarked: false,
onToggleBookmark: () => {},
onAddGlobalBookmark: () => {},
isCurrentPathGlobalBookmarked: false,
onNavigateToBookmark: () => {},
onDeleteBookmark: () => {},
showHiddenFiles: false,
onToggleShowHiddenFiles: () => {},
viewMode: "list",
onSetViewMode: () => {},
}),
);
};
const rootNode = window.document.getElementById("root");
assert.ok(rootNode);
const root = createRoot(rootNode);
const pressShortcut = async (
target: Element,
init: Pick<KeyboardEventInit, "key" | "code" | "ctrlKey" | "metaKey">,
) => {
const event = new window.KeyboardEvent("keydown", {
...init,
bubbles: true,
cancelable: true,
});
await act(async () => {
target.dispatchEvent(event);
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
return event;
};
const pressCtrlF = (target: Element) => pressShortcut(target, {
key: "f",
code: "KeyF",
ctrlKey: true,
metaKey: false,
});
try {
sftpFocusStore.setFocusedSide("left");
await act(async () => root.render(React.createElement(Harness, { isActive: true })));
const sftpTarget = window.document.getElementById("sftp-focus-target");
assert.ok(sftpTarget);
sftpTarget.focus();
const activeSftpEvent = await pressCtrlF(sftpTarget);
assert.equal(activeSftpEvent.defaultPrevented, true, "active SFTP should consume its search shortcut");
const filterInput = window.document.querySelector<HTMLInputElement>(
'[data-section="terminal-sftp-filter-bar"] input',
);
assert.ok(filterInput, "Ctrl+F should open the SFTP filter bar");
assert.equal(window.document.activeElement, filterInput, "Ctrl+F should focus the SFTP filter input");
assert.equal((await pressCtrlF(filterInput)).defaultPrevented, true);
assert.equal(window.document.activeElement, filterInput, "Ctrl+F in the filter should keep it focused");
sftpTarget.focus();
await pressCtrlF(sftpTarget);
assert.equal(window.document.activeElement, filterInput, "repeated Ctrl+F should refocus the open filter");
const closeButton = window.document.querySelector<HTMLButtonElement>(
'[data-section="terminal-sftp-filter-bar"] button',
);
assert.ok(closeButton);
await act(async () => closeButton.click());
assert.equal(
window.document.querySelector('[data-section="terminal-sftp-filter-bar"]'),
null,
"the filter should close normally",
);
const pathInput = window.document.getElementById("sftp-path-input");
assert.ok(pathInput);
pathInput.focus();
assert.equal((await pressCtrlF(pathInput)).defaultPrevented, true);
const reopenedFilterInput = window.document.querySelector<HTMLInputElement>(
'[data-section="terminal-sftp-filter-bar"] input',
);
assert.ok(reopenedFilterInput, "Ctrl+F should reopen the filter after it was closed");
assert.equal(
window.document.activeElement,
reopenedFilterInput,
"a reopened filter should receive focus",
);
const reopenedCloseButton = window.document.querySelector<HTMLButtonElement>(
'[data-section="terminal-sftp-filter-bar"] button',
);
assert.ok(reopenedCloseButton);
await act(async () => reopenedCloseButton.click());
const terminalTarget = window.document.getElementById("terminal-focus");
assert.ok(terminalTarget);
terminalTarget.focus();
let terminalReceiverCalled = false;
let terminalReceiverSawPrevented = false;
const terminalReceiver = (event: KeyboardEvent) => {
terminalReceiverCalled = true;
terminalReceiverSawPrevented = event.defaultPrevented;
};
window.addEventListener("keydown", terminalReceiver);
const staleActiveSftpEvent = await pressCtrlF(terminalTarget);
assert.equal(staleActiveSftpEvent.defaultPrevented, false, "SFTP must not intercept terminal input");
assert.equal(
window.document.querySelector('[data-section="terminal-sftp-filter-bar"]'),
null,
"terminal input must not reopen the SFTP filter when SFTP focus state is stale",
);
assert.equal(terminalReceiverCalled, true, "terminal listeners should receive Ctrl+F with stale SFTP focus");
assert.equal(terminalReceiverSawPrevented, false);
terminalReceiverCalled = false;
terminalReceiverSawPrevented = false;
await act(async () => root.render(React.createElement(Harness, { isActive: false })));
const inactiveSftpEvent = await pressCtrlF(terminalTarget);
window.removeEventListener("keydown", terminalReceiver);
assert.equal(
window.document.querySelector('[data-section="terminal-sftp-filter-bar"]'),
null,
"an inactive SFTP pane must not consume terminal Ctrl+F",
);
assert.equal(inactiveSftpEvent.defaultPrevented, false, "inactive SFTP must not prevent terminal Ctrl+F");
assert.equal(terminalReceiverCalled, true, "terminal listeners should still receive Ctrl+F");
assert.equal(terminalReceiverSawPrevented, false, "terminal listeners should receive an unhandled Ctrl+F");
const customKeyBindings = DEFAULT_KEY_BINDINGS.map((binding) => (
binding.action === "searchTerminal" ? { ...binding, pc: "Ctrl + G" } : binding
));
await act(async () => root.render(React.createElement(Harness, {
isActive: true,
keyBindings: customKeyBindings,
})));
sftpTarget.focus();
assert.equal((await pressCtrlF(sftpTarget)).defaultPrevented, false);
assert.equal(window.document.querySelector('[data-section="terminal-sftp-filter-bar"]'), null);
assert.equal((await pressShortcut(sftpTarget, {
key: "g",
code: "KeyG",
ctrlKey: true,
metaKey: false,
})).defaultPrevented, true, "the configured PC search shortcut should open the filter");
assert.ok(window.document.querySelector('[data-section="terminal-sftp-filter-bar"]'));
await act(async () => root.render(React.createElement(Harness, {
isActive: true,
hotkeyScheme: "disabled",
})));
const disabledCloseButton = window.document.querySelector<HTMLButtonElement>(
'[data-section="terminal-sftp-filter-bar"] button',
);
assert.ok(disabledCloseButton);
await act(async () => disabledCloseButton.click());
assert.equal((await pressCtrlF(sftpTarget)).defaultPrevented, false);
assert.equal(window.document.querySelector('[data-section="terminal-sftp-filter-bar"]'), null);
await act(async () => root.render(React.createElement(Harness, {
isActive: true,
hotkeyScheme: "mac",
})));
assert.equal((await pressShortcut(sftpTarget, {
key: "f",
code: "KeyF",
ctrlKey: false,
metaKey: true,
})).defaultPrevented, true, "the configured Mac search shortcut should open the filter");
assert.ok(window.document.querySelector('[data-section="terminal-sftp-filter-bar"]'));
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete (globalThis as Record<string, unknown>)[key];
}
}
});

View File

@@ -0,0 +1,80 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const source = fs.readFileSync(
path.join(path.dirname(fileURLToPath(import.meta.url)), "SftpHostPicker.tsx"),
"utf8",
);
test("sftp host picker rows reuse quick switcher selection classes", () => {
assert.match(source, /getQuickSwitcherRowStateClass/);
assert.match(source, /shouldUseQuickSwitcherPointerNavigation/);
assert.match(source, /isKeyboardNavigating/);
assert.match(source, /onMouseMove=\{\(event\) => handlePointerHover\(event\.movementX, event\.movementY\)\}/);
assert.match(
source,
/const handlePointerHover = useCallback\(\(movementX: number, movementY: number\) => \{[\s\S]*if \(!isKeyboardNavigatingRef\.current\) return;[\s\S]*setIsKeyboardNavigating\(false\);[\s\S]*\}, \[\]\);/,
);
assert.doesNotMatch(
source,
/const handlePointerHover = useCallback\(\(itemIndex: number, movementX: number, movementY: number\) => \{[\s\S]*setSelectedIndex\(itemIndex\);/,
);
});
test("sftp host picker uses single-line quick switcher row layout", () => {
assert.match(
source,
/className=\{`flex items-center justify-between px-4 py-2\.5 cursor-pointer transition-colors \$\{getQuickSwitcherRowStateClass/,
);
assert.match(
source,
/className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground"/,
);
assert.match(
source,
/<span className="text-sm font-medium truncate">\{t\('sftp\.picker\.local\.title'\)\}<\/span>/,
);
assert.match(
source,
/<span className="text-sm font-medium truncate">\{host\.label\}<\/span>/,
);
assert.match(
source,
/className="ml-3 shrink-0 text-\[11px\] text-muted-foreground truncate max-w-\[12rem\]"/,
);
assert.match(source, /formatHostMeta\(host\)/);
assert.doesNotMatch(source, /bg-primary\/10 border border-primary\/30/);
assert.doesNotMatch(source, /text-xs text-muted-foreground truncate/);
});
test("sftp host picker passes sourceSessionId for sudo connected hosts", () => {
assert.match(source, /sftpSourceSessionIdForHost/);
assert.match(
source,
/const sourceSessionId = sftpSourceSessionIdForHost\(\s*item\.entry\.host,\s*item\.entry\.sessionId,\s*\);/,
);
assert.match(
source,
/onSelectHost\(\s*item\.entry\.host,\s*sourceSessionId \? \{ sourceSessionId \} : undefined,\s*\)/,
);
});
test("sftp host picker virtualizes the host list", () => {
assert.match(source, /VariableSizeVirtualList/);
assert.match(source, /data-host-picker-virtual="sftp"/);
assert.match(source, /itemIndexToVisualIndex/);
assert.doesNotMatch(source, /filteredHosts\.map\(\(host\) =>/);
// Connected-only results must not leave a dangling empty Hosts header.
assert.match(source, /if \(filteredHosts\.length > 0\) \{\s*pushHeader\('header:hosts'/);
assert.match(
source,
/else if \(filteredConnectedHosts\.length === 0\) \{\s*pushHeader\('header:hosts'/,
);
// Short lists shrink; long lists cap at 360px (not a forced blank 360px dialog).
assert.match(source, /Math\.min\(360, Math\.max\(total, 1\)\)/);
assert.match(source, /listViewportHeight/);
assert.doesNotMatch(source, /className="h-\[360px\]"/);
});

View File

@@ -0,0 +1,361 @@
/**
* SFTP Host Picker Dialog
*/
import { Monitor, Search } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
sftpHostEndpointsEqual,
sftpSourceSessionIdForHost,
type SftpConnectedHostEntry,
} from '../../domain/sftpConnectedHosts';
import { isPluginHostProtocol } from '../../domain/pluginConnection';
import { Host } from '../../types';
import { DistroAvatar } from '../DistroAvatar';
import { getQuickSwitcherRowStateClass, shouldUseQuickSwitcherPointerNavigation } from '../QuickSwitcher';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
import { Input } from '../ui/input';
import {
VariableSizeVirtualList,
type VariableSizeVirtualListHandle,
} from '../ui/VariableSizeVirtualList';
import { clampListIndex, stepListIndex } from '../ui/virtualListMath';
const SFTP_PICKER_ROW_HEIGHT = 44;
const SFTP_PICKER_HEADER_HEIGHT = 32;
const SFTP_PICKER_EMPTY_HEIGHT = 56;
interface SftpHostPickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
hosts: Host[];
connectedHosts?: SftpConnectedHostEntry[];
side: 'left' | 'right';
hostSearch: string;
onHostSearchChange: (search: string) => void;
onSelectLocal: () => void;
onSelectHost: (host: Host, options?: { sourceSessionId?: string }) => void;
}
const StatusDot: React.FC = () => (
<span className="h-1.5 w-1.5 rounded-full shrink-0 bg-emerald-500" aria-hidden />
);
function formatHostMeta(host: Host): string {
const endpoint = host.username ? `${host.username}@${host.hostname}` : host.hostname;
return host.group ? `${endpoint} · ${host.group}` : endpoint;
}
type PickerItem =
| { type: 'local'; id: string }
| { type: 'connected'; id: string; entry: SftpConnectedHostEntry }
| { type: 'host'; id: string; host: Host };
type VisualRow =
| { kind: 'header'; key: string; label: string }
| { kind: 'item'; key: string; item: PickerItem; itemIndex: number }
| { kind: 'empty'; key: string; message: string };
const SftpHostPickerInner: React.FC<SftpHostPickerProps> = ({
open,
onOpenChange,
hosts,
connectedHosts = [],
side,
hostSearch,
onHostSearchChange,
onSelectLocal,
onSelectHost,
}) => {
const { t } = useI18n();
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<VariableSizeVirtualListHandle>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(true);
const isKeyboardNavigatingRef = useRef(true);
const term = hostSearch.trim().toLowerCase();
const filteredConnectedHosts = useMemo(() => {
return connectedHosts.filter(({ host }) =>
!term ||
host.label.toLowerCase().includes(term) ||
host.hostname.toLowerCase().includes(term) ||
host.username.toLowerCase().includes(term),
);
}, [connectedHosts, term]);
const connectedByHostId = useMemo(() => {
const map = new Map<string, SftpConnectedHostEntry>();
for (const entry of filteredConnectedHosts) {
map.set(entry.host.id, entry);
}
return map;
}, [filteredConnectedHosts]);
const filteredHosts = useMemo(() => {
return hosts.filter((h) => {
// SFTP is an SSH-specific host capability. Plugin protocols may
// provide arbitrary transports and cannot be treated as SSH.
if (h.protocol === "serial" || isPluginHostProtocol(h.protocol)) return false;
// Hide a saved host only when Connected already shows the same endpoint.
// If the vault host was edited after connect, keep both: Live (old) + Saved (new).
const connected = connectedByHostId.get(h.id);
if (connected && sftpHostEndpointsEqual(h, connected.host)) return false;
return !term
|| h.label.toLowerCase().includes(term)
|| h.hostname.toLowerCase().includes(term);
}).sort((a, b) => a.label.localeCompare(b.label));
}, [hosts, term, connectedByHostId]);
const sideLabel = side === 'left' ? t('common.left') : t('common.right');
const { items, visualRows, itemIndexToVisualIndex } = useMemo(() => {
const nextItems: PickerItem[] = [];
const nextVisual: VisualRow[] = [];
const nextMap = new Map<number, number>();
const pushHeader = (key: string, label: string) => {
nextVisual.push({ kind: 'header', key, label });
};
const pushItem = (item: PickerItem) => {
const itemIndex = nextItems.length;
nextItems.push(item);
nextMap.set(itemIndex, nextVisual.length);
nextVisual.push({ kind: 'item', key: item.id, item, itemIndex });
};
pushHeader('header:local', t('sftp.picker.local.badge'));
pushItem({ type: 'local', id: 'local' });
if (filteredConnectedHosts.length > 0) {
pushHeader('header:connected', t('sftp.picker.connected.section'));
for (const entry of filteredConnectedHosts) {
pushItem({
type: 'connected',
id: `connected:${entry.sessionId}`,
entry,
});
}
}
// Only show the Hosts section when there are saved hosts to list, or when
// nothing matched at all (no connected + no saved). Avoid a dangling
// "Hosts" header after connected-only results hide the saved inventory.
if (filteredHosts.length > 0) {
pushHeader('header:hosts', t('vault.nav.hosts'));
for (const host of filteredHosts) {
pushItem({ type: 'host', id: host.id, host });
}
} else if (filteredConnectedHosts.length === 0) {
pushHeader('header:hosts', t('vault.nav.hosts'));
nextVisual.push({
kind: 'empty',
key: 'empty:hosts',
message: t('sftp.picker.noMatch'),
});
}
return {
items: nextItems,
visualRows: nextVisual,
itemIndexToVisualIndex: nextMap,
};
}, [filteredConnectedHosts, filteredHosts, t]);
useEffect(() => {
if (!open) return;
setSelectedIndex(0);
isKeyboardNavigatingRef.current = true;
setIsKeyboardNavigating(true);
const focusTimer = setTimeout(() => inputRef.current?.focus(), 50);
return () => clearTimeout(focusTimer);
}, [open]);
useEffect(() => {
if (!open) return;
setSelectedIndex(0);
isKeyboardNavigatingRef.current = true;
setIsKeyboardNavigating(true);
}, [hostSearch, open]);
useEffect(() => {
if (!open) return;
setSelectedIndex((prev) => clampListIndex(prev, items.length));
}, [items.length, open]);
useEffect(() => {
if (!open) return;
const visualIndex = itemIndexToVisualIndex.get(selectedIndex);
if (visualIndex === undefined) return;
listRef.current?.scrollToIndex(visualIndex, 'auto');
}, [itemIndexToVisualIndex, open, selectedIndex]);
const handleSelect = useCallback((item: PickerItem) => {
if (item.type === 'local') {
onSelectLocal();
} else if (item.type === 'connected') {
// Sudo SFTP cannot reuse the terminal shell; omit the hint so connect
// UI (reusedConnection / spinner) matches the dedicated open path.
const sourceSessionId = sftpSourceSessionIdForHost(
item.entry.host,
item.entry.sessionId,
);
onSelectHost(
item.entry.host,
sourceSessionId ? { sourceSessionId } : undefined,
);
} else {
onSelectHost(item.host);
}
onOpenChange(false);
}, [onOpenChange, onSelectHost, onSelectLocal]);
// Match Quick Switcher: pointer movement only leaves keyboard-nav mode.
// It must not rewrite the keyboard-selected index until the user clicks.
const handlePointerHover = useCallback((movementX: number, movementY: number) => {
if (!shouldUseQuickSwitcherPointerNavigation(movementX, movementY)) return;
if (!isKeyboardNavigatingRef.current) return;
isKeyboardNavigatingRef.current = false;
setIsKeyboardNavigating(false);
}, []);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
isKeyboardNavigatingRef.current = true;
setIsKeyboardNavigating(true);
setSelectedIndex((prev) => stepListIndex(prev, items.length, 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
isKeyboardNavigatingRef.current = true;
setIsKeyboardNavigating(true);
setSelectedIndex((prev) => stepListIndex(prev, items.length, -1));
} else if (e.key === 'Enter' && items.length > 0) {
e.preventDefault();
const item = items[clampListIndex(selectedIndex, items.length)];
if (!item) return;
handleSelect(item);
}
};
const getRowHeight = useCallback((row: VisualRow) => {
if (row.kind === 'header') return SFTP_PICKER_HEADER_HEIGHT;
if (row.kind === 'empty') return SFTP_PICKER_EMPTY_HEIGHT;
return SFTP_PICKER_ROW_HEIGHT;
}, []);
// Cap at 360px for large inventories, but shrink to content for short lists
// (Local-only / few hosts) so the dialog does not leave a large blank region.
const listViewportHeight = useMemo(() => {
let total = 0;
for (const row of visualRows) total += getRowHeight(row);
return Math.min(360, Math.max(total, 1));
}, [getRowHeight, visualRows]);
const renderRow = useCallback((row: VisualRow) => {
if (row.kind === 'header') {
return (
<div className="flex h-full items-end px-4 pb-1.5">
<span className="text-xs font-medium text-muted-foreground">{row.label}</span>
</div>
);
}
if (row.kind === 'empty') {
return (
<div className="px-4 py-6 text-xs text-muted-foreground text-center">
{row.message}
</div>
);
}
const { item, itemIndex } = row;
const isSelected = selectedIndex === itemIndex;
if (item.type === 'local') {
return (
<div
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors ${getQuickSwitcherRowStateClass(isSelected, isKeyboardNavigating)}`}
onClick={() => handleSelect(item)}
onMouseMove={(event) => handlePointerHover(event.movementX, event.movementY)}
>
<div className="flex items-center gap-3 min-w-0">
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
<Monitor size={16} />
</div>
<span className="text-sm font-medium truncate">{t('sftp.picker.local.title')}</span>
</div>
<div className="ml-3 shrink-0 text-[11px] text-muted-foreground truncate max-w-[12rem]">
{t('sftp.picker.local.desc')}
</div>
</div>
);
}
const host = item.type === 'connected' ? item.entry.host : item.host;
const showStatus = item.type === 'connected';
return (
<div
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors ${getQuickSwitcherRowStateClass(isSelected, isKeyboardNavigating)}`}
onClick={() => handleSelect(item)}
onMouseMove={(event) => handlePointerHover(event.movementX, event.movementY)}
>
<div className="flex items-center gap-3 min-w-0">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} size="sm" />
<div className="flex min-w-0 items-center gap-1.5">
{showStatus ? <StatusDot /> : null}
<span className="text-sm font-medium truncate">{host.label}</span>
</div>
</div>
<div className="ml-3 shrink-0 text-[11px] text-muted-foreground truncate max-w-[12rem]">
{formatHostMeta(host)}
</div>
</div>
);
}, [handlePointerHover, handleSelect, isKeyboardNavigating, selectedIndex, t]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg p-0 overflow-hidden gap-0">
<DialogHeader className="sr-only">
<DialogTitle>{t('sftp.picker.title')}</DialogTitle>
<DialogDescription>
{t('sftp.picker.desc', { side: side === 'left' ? t('common.left') : t('common.right') })}
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-3 px-4 py-3 pr-12 border-b border-border">
<Search size={16} className="text-muted-foreground" />
<Input
ref={inputRef}
value={hostSearch}
onChange={e => onHostSearchChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('sftp.picker.searchPlaceholder')}
className="flex-1 h-8 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 px-0 text-sm"
/>
<span className="ml-auto mr-1 text-[11px] text-muted-foreground bg-muted px-2 py-0.5 rounded whitespace-nowrap">
{sideLabel}
</span>
</div>
<div
className="max-h-[360px]"
style={{ height: listViewportHeight }}
data-host-picker-virtual="sftp"
>
<VariableSizeVirtualList<VisualRow>
ref={listRef}
items={visualRows}
getItemHeight={getRowHeight}
className="h-full"
overscan={8}
getItemKey={(row) => row.key}
renderItem={renderRow}
/>
</div>
</DialogContent>
</Dialog>
);
};
export const SftpHostPicker = memo(SftpHostPickerInner);
SftpHostPicker.displayName = 'SftpHostPicker';

View File

@@ -0,0 +1,114 @@
import React from 'react';
import { Folder, Loader2 } from 'lucide-react';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Input } from '../ui/input';
import { cn } from '../../lib/utils';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type SftpMoveToDialogProps = Record<string, any>;
export const SftpMoveToDialog: React.FC<SftpMoveToDialogProps> = ({
showMoveToDialog, setShowMoveToDialog, setMoveToPath, setMoveToError, setMoveToSuggestions,
setMoveToSuggestionIndex, t, moveToInputRef, moveToPath, fetchMoveToSuggestions,
moveToSuggestions, moveToSuggestionIndex, moveToError, isMoving, handleMoveToSubmit,
}) => (
<Dialog open={showMoveToDialog} onOpenChange={(open) => {
if (!open) {
setShowMoveToDialog(false);
setMoveToSuggestions([]);
setMoveToSuggestionIndex(-1);
}
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('sftp.moveTo.title')}</DialogTitle>
</DialogHeader>
<div className="relative">
<Input
ref={moveToInputRef}
value={moveToPath}
onChange={(e) => {
const val = e.target.value;
setMoveToPath(val);
setMoveToError(null);
setMoveToSuggestionIndex(-1);
fetchMoveToSuggestions(val);
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown' && moveToSuggestions.length > 0) {
e.preventDefault();
setMoveToSuggestionIndex((i) => i < moveToSuggestions.length - 1 ? i + 1 : 0);
} else if (e.key === 'ArrowUp' && moveToSuggestions.length > 0) {
e.preventDefault();
setMoveToSuggestionIndex((i) => i > 0 ? i - 1 : moveToSuggestions.length - 1);
} else if (e.key === 'Tab' && moveToSuggestionIndex >= 0) {
e.preventDefault();
const selected = moveToSuggestions[moveToSuggestionIndex];
setMoveToPath(selected);
setMoveToError(null);
fetchMoveToSuggestions(selected);
} else if (e.key === 'Enter') {
e.preventDefault();
if (moveToSuggestionIndex >= 0 && moveToSuggestions[moveToSuggestionIndex]) {
const selected = moveToSuggestions[moveToSuggestionIndex];
setMoveToPath(selected);
setMoveToSuggestionIndex(-1);
setMoveToSuggestions([]);
setMoveToError(null);
} else {
void handleMoveToSubmit();
}
} else if (e.key === 'Escape') {
if (moveToSuggestions.length > 0) {
e.preventDefault();
e.stopPropagation();
setMoveToSuggestions([]);
setMoveToSuggestionIndex(-1);
}
// When no suggestions, let the Dialog handle ESC to close itself
}
}}
placeholder={t('sftp.moveTo.placeholder')}
autoFocus
className={moveToError ? 'border-destructive' : undefined}
/>
{moveToSuggestions.length > 0 && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 rounded-md border bg-popover shadow-md max-h-48 overflow-y-auto">
{moveToSuggestions.map((suggestion, i) => (
<div
key={suggestion}
className={cn(
'px-3 py-1.5 text-sm cursor-pointer truncate',
i === moveToSuggestionIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50',
)}
onMouseDown={(e) => {
e.preventDefault();
setMoveToPath(suggestion);
setMoveToSuggestions([]);
setMoveToSuggestionIndex(-1);
setMoveToError(null);
}}
>
<Folder size={12} className="inline mr-2 text-yellow-500" />
{suggestion}
</div>
))}
</div>
)}
</div>
{moveToError && (
<p className="text-xs text-destructive">{moveToError}</p>
)}
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setShowMoveToDialog(false)}>
{t('common.cancel')}
</Button>
<Button size="sm" disabled={!moveToPath.trim() || isMoving} onClick={() => void handleMoveToSubmit()}>
{isMoving && <Loader2 size={14} className="mr-2 animate-spin" />}
{t('sftp.moveTo.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);

View File

@@ -0,0 +1,290 @@
import React, { lazy, Suspense } from "react";
import type { Host, SftpFileEntry } from "../../types";
import type { FileOpenerType, SystemAppInfo } from "../../lib/sftpFileUtils";
import type { useSftpState } from "../../application/state/useSftpState";
import type { HotkeyScheme, KeyBinding } from "../../domain/models";
import type { TransferTask } from "../../types";
import FileOpenerDialog from "../FileOpenerDialog";
import type { TextEditorModalSnapshot } from "../TextEditorModal";
import { TerminalHostKeyVerification } from "../terminal/TerminalHostKeyVerification";
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
import { LazyLoadBoundary } from "../ui/lazy-load-boundary";
import { SftpConflictDialog } from "./SftpConflictDialog";
import { SftpHostPicker } from "./SftpHostPicker";
import { SftpPermissionsDialog } from "./SftpPermissionsDialog";
import { SftpTransferQueue } from "./SftpTransferQueue";
const LazyTextEditorModal = lazy(() => import("../TextEditorModal"));
type SftpState = ReturnType<typeof useSftpState>;
const TextEditorModalLoading: React.FC<{
open: boolean;
fileName: string;
onClose: () => void;
}> = ({ open, fileName, onClose }) => (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0 overflow-hidden">
<DialogTitle className="sr-only">{fileName || "Text editor"}</DialogTitle>
<div className="netcatty-lazy-fade-in h-full min-h-0" aria-hidden="true" />
</DialogContent>
</Dialog>
);
const TextEditorModalUnavailable: React.FC<{
open: boolean;
fileName: string;
onClose: () => void;
}> = ({ open, fileName, onClose }) => (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-md">
<DialogTitle>Text editor could not load.</DialogTitle>
<div className="text-sm text-muted-foreground">
{fileName ? `${fileName} cannot be opened until the editor reloads.` : "The editor needs to reload before it can open this file."}
</div>
<div className="flex justify-end gap-2">
<button
type="button"
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted"
onClick={onClose}
>
Close
</button>
<button
type="button"
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
onClick={() => window.location.reload()}
>
Reload
</button>
</div>
</DialogContent>
</Dialog>
);
interface SftpOverlaysProps {
hosts: Host[];
connectedHosts?: import("../../domain/sftpConnectedHosts").SftpConnectedHostEntry[];
sftp: SftpState;
visibleTransfers: SftpState["transfers"];
showTransferQueue?: boolean;
canRevealTransferTarget?: (task: TransferTask) => boolean;
onRevealTransferTarget?: (task: TransferTask) => void | Promise<void>;
canCopyTransferTargetPath?: (task: TransferTask) => boolean;
onCopyTransferTargetPath?: (task: TransferTask) => void | Promise<void>;
showHostPickerLeft: boolean;
showHostPickerRight: boolean;
hostSearchLeft: string;
hostSearchRight: string;
setShowHostPickerLeft: (open: boolean) => void;
setShowHostPickerRight: (open: boolean) => void;
setHostSearchLeft: (value: string) => void;
setHostSearchRight: (value: string) => void;
handleHostSelectLeft: (host: Host | "local", options?: { sourceSessionId?: string }) => void;
handleHostSelectRight: (host: Host | "local", options?: { sourceSessionId?: string }) => void;
permissionsState: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
setPermissionsState: (state: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null) => void;
showTextEditor: boolean;
setShowTextEditor: (open: boolean) => void;
textEditorTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
setTextEditorTarget: (target: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null) => void;
textEditorContent: string;
setTextEditorContent: (content: string) => void;
handleSaveTextFile: (content: string) => Promise<void>;
editorWordWrap: boolean;
setEditorWordWrap: (enabled: boolean) => void;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
showFileOpenerDialog: boolean;
setShowFileOpenerDialog: (open: boolean) => void;
fileOpenerTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
setFileOpenerTarget: (target: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null) => void;
handleFileOpenerSelect: (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => void;
handleSelectSystemApp: (systemApp: { path: string; name: string }) => void;
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
onRequestTerminalFocus?: () => void;
}
export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
hosts,
connectedHosts = [],
sftp,
visibleTransfers,
showTransferQueue = true,
canRevealTransferTarget,
onRevealTransferTarget,
canCopyTransferTargetPath,
onCopyTransferTargetPath,
showHostPickerLeft,
showHostPickerRight,
hostSearchLeft,
hostSearchRight,
setShowHostPickerLeft,
setShowHostPickerRight,
setHostSearchLeft,
setHostSearchRight,
handleHostSelectLeft,
handleHostSelectRight,
permissionsState,
setPermissionsState,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
handleSaveTextFile,
editorWordWrap,
setEditorWordWrap,
hotkeyScheme,
keyBindings,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
handleFileOpenerSelect,
handleSelectSystemApp,
onPromoteToTab,
onRequestTerminalFocus,
}) => {
const textEditorFileName = textEditorTarget?.file.name || "";
const closeTextEditor = () => {
setShowTextEditor(false);
setTextEditorTarget(null);
setTextEditorContent("");
onRequestTerminalFocus?.();
};
return (
<>
{/* Host pickers for adding new tabs */}
<SftpHostPicker
open={showHostPickerLeft}
onOpenChange={setShowHostPickerLeft}
hosts={hosts}
connectedHosts={connectedHosts}
side="left"
hostSearch={hostSearchLeft}
onHostSearchChange={setHostSearchLeft}
onSelectLocal={() => handleHostSelectLeft("local")}
onSelectHost={handleHostSelectLeft}
/>
<SftpHostPicker
open={showHostPickerRight}
onOpenChange={setShowHostPickerRight}
hosts={hosts}
connectedHosts={connectedHosts}
side="right"
hostSearch={hostSearchRight}
onHostSearchChange={setHostSearchRight}
onSelectLocal={() => handleHostSelectRight("local")}
onSelectHost={handleHostSelectRight}
/>
{showTransferQueue && (
<SftpTransferQueue
sftp={sftp}
visibleTransfers={visibleTransfers}
allTransfers={sftp.transfers}
canRevealTransferTarget={canRevealTransferTarget}
onRevealTransferTarget={onRevealTransferTarget}
canCopyTransferTargetPath={canCopyTransferTargetPath}
onCopyTransferTargetPath={onCopyTransferTargetPath}
/>
)}
<SftpConflictDialog
conflicts={sftp.conflicts}
onResolve={sftp.resolveConflict}
formatFileSize={sftp.formatFileSize}
/>
<Dialog
open={!!sftp.hostKeyVerification}
onOpenChange={(open) => {
if (!open) sftp.rejectHostKeyVerification();
}}
>
<DialogContent className="max-w-lg" hideCloseButton>
<DialogTitle className="sr-only">Confirm host key</DialogTitle>
{sftp.hostKeyVerification && (
<TerminalHostKeyVerification
hostKeyInfo={sftp.hostKeyVerification.hostKeyInfo}
showLogs={sftp.hostKeyVerification.progressLogs.length > 0}
progressLogs={sftp.hostKeyVerification.progressLogs}
onClose={sftp.rejectHostKeyVerification}
onContinue={sftp.acceptHostKeyVerification}
onAddAndContinue={sftp.acceptAndSaveHostKeyVerification}
/>
)}
</DialogContent>
</Dialog>
<SftpPermissionsDialog
open={!!permissionsState}
onOpenChange={(open) => !open && setPermissionsState(null)}
file={permissionsState?.file ?? null}
onSave={(_file, permissions) => {
if (permissionsState) {
sftp.changePermissions(
permissionsState.side,
permissionsState.fullPath,
permissions,
);
}
setPermissionsState(null);
}}
/>
{/* Text Editor Modal */}
{showTextEditor && (
<LazyLoadBoundary
name="Text editor"
resetKey={textEditorTarget?.fullPath || "text-editor"}
fallback={
<TextEditorModalUnavailable
open={showTextEditor}
fileName={textEditorFileName}
onClose={closeTextEditor}
/>
}
>
<Suspense
fallback={
<TextEditorModalLoading
open={showTextEditor}
fileName={textEditorFileName}
onClose={closeTextEditor}
/>
}
>
<LazyTextEditorModal
open={showTextEditor}
onClose={closeTextEditor}
fileName={textEditorFileName}
initialContent={textEditorContent}
onSave={handleSaveTextFile}
editorWordWrap={editorWordWrap}
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
onPromoteToTab={onPromoteToTab}
/>
</Suspense>
</LazyLoadBoundary>
)}
{/* File Opener Dialog */}
<FileOpenerDialog
open={showFileOpenerDialog}
onClose={() => {
setShowFileOpenerDialog(false);
setFileOpenerTarget(null);
}}
fileName={fileOpenerTarget?.file.name || ""}
onSelect={handleFileOpenerSelect}
onSelectSystemApp={handleSelectSystemApp}
/>
</>
);
});

View File

@@ -0,0 +1,399 @@
import React from "react";
import { Loader2, Trash2 } from "lucide-react";
import { Button } from "../ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { getFileName, getParentPath } from "../../application/state/sftp/utils";
import { SftpHostPicker } from "./SftpHostPicker";
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;
newFolderName: string;
setNewFolderName: (value: string) => void;
handleCreateFolder: () => void;
isCreating: boolean;
// New file
showNewFileDialog: boolean;
setShowNewFileDialog: (open: boolean) => void;
newFileName: string;
setNewFileName: (value: string) => void;
fileNameError: string | null;
setFileNameError: (value: string | null) => void;
handleCreateFile: () => void;
isCreatingFile: boolean;
// Overwrite confirm
showOverwriteConfirm: boolean;
setShowOverwriteConfirm: (open: boolean) => void;
overwriteTarget: string | null;
handleOverwriteConfirm: () => void;
// Rename
showRenameDialog: boolean;
setShowRenameDialog: (open: boolean) => void;
renameName: string;
setRenameName: (value: string) => void;
handleRename: () => void;
isRenaming: boolean;
// Delete
showDeleteConfirm: boolean;
setShowDeleteConfirm: (open: boolean) => void;
deleteTargets: string[];
handleDelete: () => void;
isDeleting: boolean;
// Host picker (connected view)
showHostPicker: boolean;
setShowHostPicker: (open: boolean) => void;
hosts: Host[];
connectedHosts?: import("../../domain/sftpConnectedHosts").SftpConnectedHostEntry[];
side: "left" | "right";
hostSearch: string;
setHostSearch: (value: string) => void;
onConnect: (
host: Host | "local",
options?: { sourceSessionId?: string },
) => void;
onDisconnect: () => Promise<boolean>;
}
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,
setNewFolderName,
handleCreateFolder,
isCreating,
showNewFileDialog,
setShowNewFileDialog,
newFileName,
setNewFileName,
fileNameError,
setFileNameError,
handleCreateFile,
isCreatingFile,
showOverwriteConfirm,
setShowOverwriteConfirm,
overwriteTarget,
handleOverwriteConfirm,
showRenameDialog,
setShowRenameDialog,
renameName,
setRenameName,
handleRename,
isRenaming,
showDeleteConfirm,
setShowDeleteConfirm,
deleteTargets,
handleDelete,
isDeleting,
showHostPicker,
setShowHostPicker,
hosts,
connectedHosts = [],
side,
hostSearch,
setHostSearch,
onConnect,
onDisconnect,
}) => {
// Focus the confirm button when a confirmation dialog opens so Enter confirms it.
// These dialogs are opened from a context menu, whose focus-return can otherwise
// leave focus outside the dialog, making Enter do nothing.
const deleteConfirmButtonRef = React.useRef<HTMLButtonElement>(null);
const overwriteConfirmButtonRef = React.useRef<HTMLButtonElement>(null);
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">
<div className="space-y-2">
<Label>{t("sftp.folderName")}</Label>
<Input
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder={t("sftp.folderName.placeholder")}
onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowNewFolderDialog(false)}
>
{t("common.cancel")}
</Button>
<Button
onClick={handleCreateFolder}
disabled={!newFolderName.trim() || isCreating}
>
{isCreating && (
<Loader2 size={14} className="mr-2 animate-spin" />
)}
{t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showNewFileDialog} onOpenChange={(open) => {
setShowNewFileDialog(open);
if (!open) {
setFileNameError(null);
}
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<HostHint label={hostLabel} />
<DialogTitle>{t("sftp.newFile")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("sftp.fileName")}</Label>
<Input
value={newFileName}
onChange={(e) => {
setNewFileName(e.target.value);
setFileNameError(null);
}}
placeholder={t("sftp.fileName.placeholder")}
onKeyDown={(e) => e.key === "Enter" && handleCreateFile()}
autoFocus
/>
{fileNameError && (
<div className="text-xs text-destructive">{fileNameError}</div>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowNewFileDialog(false)}
>
{t("common.cancel")}
</Button>
<Button
onClick={handleCreateFile}
disabled={!newFileName.trim() || isCreatingFile}
>
{isCreatingFile && (
<Loader2 size={14} className="mr-2 animate-spin" />
)}
{t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Overwrite Confirmation Dialog */}
<Dialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
<DialogContent
className="max-w-sm"
onOpenAutoFocus={(e) => {
e.preventDefault();
overwriteConfirmButtonRef.current?.focus();
}}
>
<DialogHeader>
<HostHint label={hostLabel} />
<DialogTitle>{t("sftp.overwrite.title")}</DialogTitle>
<DialogDescription>
{t("sftp.overwrite.desc", { name: overwriteTarget || "" })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowOverwriteConfirm(false)}
>
{t("common.cancel")}
</Button>
<Button
ref={overwriteConfirmButtonRef}
variant="destructive"
onClick={handleOverwriteConfirm}
>
{t("sftp.overwrite.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<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">
<div className="space-y-2">
<Label>{t("sftp.rename.newName")}</Label>
<Input
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
placeholder={t("sftp.rename.placeholder")}
onKeyDown={(e) => e.key === "Enter" && handleRename()}
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowRenameDialog(false)}
>
{t("common.cancel")}
</Button>
<Button
onClick={handleRename}
disabled={!renameName.trim() || isRenaming}
>
{isRenaming && (
<Loader2 size={14} className="mr-2 animate-spin" />
)}
{t("common.rename")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent
className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-sm"
onOpenAutoFocus={(e) => {
e.preventDefault();
deleteConfirmButtonRef.current?.focus();
}}
>
<DialogHeader className="min-w-0 pr-6">
<DialogTitle className="truncate">
{t("sftp.deleteConfirm.title", { count: deleteTargets.length })}
</DialogTitle>
<DialogDescription className="break-words [overflow-wrap:anywhere]">
{t(showDeleteList ? "sftp.deleteConfirm.desc" : "sftp.deleteConfirm.descSingle")}
</DialogDescription>
</DialogHeader>
<div className="min-w-0 space-y-3">
{hostLabel || deletePath ? (
<div className="min-w-0 space-y-1.5 text-xs text-muted-foreground">
{hostLabel ? (
<div className="flex min-w-0 items-start gap-2">
<span className="font-medium text-foreground/80 shrink-0">{t("sftp.deleteConfirm.host")}:</span>
<span className="min-w-0 break-words [overflow-wrap:anywhere]">{hostLabel}</span>
</div>
) : null}
{deletePath ? (
<div className="flex min-w-0 items-start gap-2">
<span className="font-medium text-foreground/80 shrink-0">{t("sftp.deleteConfirm.path")}:</span>
<span className="min-w-0 break-words [overflow-wrap:anywhere]">{deletePath}</span>
</div>
) : null}
</div>
) : null}
{showDeleteList ? (
<div className="max-h-32 min-w-0 space-y-1 overflow-auto text-sm">
{deleteListItems.map((name) => (
<div
key={name}
className="flex min-w-0 items-center gap-2 text-muted-foreground"
>
<Trash2 size={12} className="shrink-0" />
<span className="min-w-0 truncate">{name}</span>
</div>
))}
</div>
) : null}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t("common.cancel")}
</Button>
<Button
ref={deleteConfirmButtonRef}
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting && (
<Loader2 size={14} className="mr-2 animate-spin" />
)}
{t("action.delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SftpHostPicker
open={showHostPicker}
onOpenChange={setShowHostPicker}
hosts={hosts}
connectedHosts={connectedHosts}
side={side}
hostSearch={hostSearch}
onHostSearchChange={setHostSearch}
onSelectLocal={async () => {
// Only connect to the new target if the disconnect actually happened.
// A cancel on the dirty-editor prompt must keep the user on the
// current host instead of silently switching and stranding tabs.
const ok = await onDisconnect();
if (ok) onConnect("local");
}}
onSelectHost={async (host, options) => {
const ok = await onDisconnect();
if (ok) onConnect(host, options);
}}
/>
</>
);
};

View File

@@ -0,0 +1,85 @@
import React from "react";
import { HardDrive, Monitor, Plus } from "lucide-react";
import { Button } from "../ui/button";
import { SftpHostPicker } from "./SftpHostPicker";
import type { Host } from "../../domain/models";
import type { SftpConnectedHostEntry } from "../../domain/sftpConnectedHosts";
import type { SftpConnectHostOptions, SftpConnectTarget } from "./SftpContext";
interface SftpPaneEmptyStateProps {
side: "left" | "right";
showEmptyHeader: boolean;
t: (key: string, params?: Record<string, unknown>) => string;
showHostPicker: boolean;
setShowHostPicker: (open: boolean) => void;
hostSearch: string;
setHostSearch: (value: string) => void;
hosts: Host[];
connectedHosts?: SftpConnectedHostEntry[];
onConnect: (host: SftpConnectTarget, options?: SftpConnectHostOptions) => void;
}
export const SftpPaneEmptyState: React.FC<SftpPaneEmptyStateProps> = ({
side,
showEmptyHeader,
t,
showHostPicker,
setShowHostPicker,
hostSearch,
setHostSearch,
hosts,
connectedHosts = [],
onConnect,
}) => {
return (
<div className="absolute inset-0 flex flex-col">
{showEmptyHeader && (
<div className="h-12 px-4 border-b border-border/60 flex items-center gap-3 shrink-0">
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground">
{side === "left" ? <Monitor size={14} /> : <HardDrive size={14} />}
<span>
{side === "left" ? t("sftp.pane.local") : t("sftp.pane.remote")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="h-8 px-3"
onClick={() => setShowHostPicker(true)}
>
<Plus size={14} className="mr-2" /> {t("sftp.pane.selectHost")}
</Button>
</div>
)}
<div className="flex-1 flex flex-col items-center justify-center text-center gap-4 p-6">
<div className="h-14 w-14 rounded-xl bg-secondary/60 text-primary flex items-center justify-center">
{side === "left" ? <Monitor size={24} /> : <HardDrive size={24} />}
</div>
<div>
<div className="text-sm font-semibold mb-1">
{t("sftp.pane.selectHostToStart")}
</div>
<div className="text-xs text-muted-foreground">
{t("sftp.pane.chooseFilesystem")}
</div>
</div>
<Button onClick={() => setShowHostPicker(true)}>
<Plus size={14} className="mr-2" /> {t("sftp.pane.selectHost")}
</Button>
</div>
<SftpHostPicker
open={showHostPicker}
onOpenChange={setShowHostPicker}
hosts={hosts}
connectedHosts={connectedHosts}
side={side}
hostSearch={hostSearch}
onHostSearchChange={setHostSearch}
onSelectLocal={() => onConnect("local")}
onSelectHost={onConnect}
/>
</div>
);
};

View File

@@ -0,0 +1,798 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AppWindow, Archive, ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug, Upload } from "lucide-react";
import { Button } from "../ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "../ui/context-menu";
import { cn } from "../../lib/utils";
import { getParentPath, joinPath } from "../../application/state/sftp/utils";
import type { SftpFileEntry } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
import type { SftpTransferSource } from "./SftpContext";
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
import type { UseSftpPaneSortingResult } from "../../application/state/sftp/useSftpPaneSorting";
import { buildSftpColumnTemplate, isNavigableDirectory, isSftpColumnMenuKey } from "./utils";
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
import { isExtractableArchive } from "../../domain/sftpArchive";
import { SftpFileRow } from "./SftpFileRow";
import type { SftpListDensity } from "../../domain/sftpListDensity";
import { SftpColumnMenuItems } from "./SftpColumnMenuItems";
import { getSftpVirtualListScrollTop } from "../../domain/sftpVirtualList";
import {
getSftpListUploadFilesTargetPath,
getSftpUploadFilesLabelKey,
getSftpUploadFolderLabelKey,
shouldShowSftpUploadFolderMenu,
shouldShowSftpUploadFilesMenu,
} from "./sftpUploadMenu";
interface SftpPaneFileListProps {
t: (key: string, params?: Record<string, unknown>) => string;
pane: SftpPane;
side: "left" | "right";
isPaneFocused: boolean;
sorting: UseSftpPaneSortingResult;
fileListRef: React.RefObject<HTMLDivElement>;
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
shouldVirtualize: boolean;
totalHeight: number;
sortedDisplayFiles: SftpFileEntry[];
isDragOverPane: boolean;
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
onRefresh: () => void;
onNavigateTo: (path: string) => void;
onClearSelection: () => void;
setShowNewFolderDialog: (open: boolean) => void;
setShowNewFileDialog: (open: boolean) => void;
getNextUntitledName: (existingNames: string[]) => string;
setNewFileName: (value: string) => void;
setFileNameError: (value: string | null) => void;
// Row rendering
dragOverEntry: string | null;
handleRowSelect: (entry: SftpFileEntry, index: number, e: React.MouseEvent) => void;
handleRowOpen: (entry: SftpFileEntry) => void;
handleFileDragStart: (entry: SftpFileEntry, e: React.DragEvent) => void;
onDragEnd: () => void;
handleEntryDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
handleRowDragLeave: () => void;
handleEntryDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
onOpenFileWithSystemDefault?: (entry: SftpFileEntry) => void;
onOpenFileWith?: (entry: SftpFileEntry) => void;
onEditFile?: (entry: SftpFileEntry) => void;
onDownloadFile?: (entry: SftpFileEntry) => void;
onDownloadFiles?: (entries: SftpFileEntry[]) => void;
onExtractArchive?: (entry: SftpFileEntry) => void;
onEditPermissions?: (entry: SftpFileEntry) => void;
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void> | void;
onUploadExternalFolder?: (targetPath?: string) => Promise<void> | void;
// Whether this pane is rendering a local filesystem. Upload menu items only
// make sense for remote (SFTP) panes, so they are suppressed when isLocal.
isLocal?: boolean;
openRenameDialog: (name: string) => void;
openDeleteConfirm: (targets: string[]) => void;
rowHeight: number;
visibleRows: { entry: SftpFileEntry; index: number; top: number }[];
listDensity?: SftpListDensity;
}
const SftpErrorWithLogs: React.FC<{
error: string;
connectionLogs: string[];
onRetry: () => void;
t: (key: string) => string;
}> = ({ error, connectionLogs, onRetry, t }) => {
const [showLogs, setShowLogs] = useState(connectionLogs.length > 0);
return (
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground">
<Unplug size={28} className="text-destructive/70" />
<span className="text-xs text-center px-6 max-w-xs leading-relaxed">{t(error)}</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={onRetry}>
{t("sftp.retry")}
</Button>
{connectionLogs.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
onClick={() => setShowLogs(!showLogs)}
>
<ChevronDown size={14} className={`mr-1 transition-transform ${showLogs ? 'rotate-180' : ''}`} />
{showLogs ? "Hide logs" : "Show logs"}
</Button>
)}
</div>
{showLogs && connectionLogs.length > 0 && (
<div className="w-full max-w-sm mt-1 p-2 rounded-md bg-secondary/50 border border-border/60 space-y-0.5 max-h-40 overflow-y-auto">
{connectionLogs.map((log, i) => (
<div key={i} className="text-[11px] text-muted-foreground truncate font-mono">
{log}
</div>
))}
</div>
)}
</div>
);
};
export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
t,
pane,
side,
isPaneFocused,
sorting,
fileListRef,
handleFileListScroll,
shouldVirtualize,
totalHeight,
sortedDisplayFiles,
isDragOverPane,
draggedFiles,
onRefresh,
onNavigateTo,
onClearSelection,
setShowNewFolderDialog,
setShowNewFileDialog,
getNextUntitledName,
setNewFileName,
setFileNameError,
dragOverEntry,
handleRowSelect,
handleRowOpen,
handleFileDragStart,
onDragEnd,
handleEntryDragOver,
handleRowDragLeave,
handleEntryDrop,
onCopyToOtherPane,
onMoveEntriesToPath,
onOpenFileWithSystemDefault,
onOpenFileWith,
onEditFile,
onDownloadFile,
onDownloadFiles,
onExtractArchive,
onEditPermissions,
onUploadExternalFileList,
onUploadExternalFolder,
isLocal = false,
openRenameDialog,
openDeleteConfirm,
rowHeight,
visibleRows,
listDensity = "comfortable",
}) => {
const {
columnWidths,
visibleColumns,
directoriesFirst,
sortField,
sortOrder,
handleSort,
handleResizeStart,
toggleColumnVisibility,
toggleDirectoriesFirst,
} = sorting;
const filesByName = useMemo(() => {
const map = new Map<string, SftpFileEntry>();
sortedDisplayFiles.forEach((entry) => {
map.set(entry.name, entry);
});
return map;
}, [sortedDisplayFiles]);
// Push sorted file names into the list order store for keyboard navigation
useEffect(() => {
const names = sortedDisplayFiles
.filter((f) => f.name !== "..")
.map((f) => f.name);
sftpListOrderStore.setItems(pane.id, names);
return () => sftpListOrderStore.clearPane(pane.id);
}, [sortedDisplayFiles, pane.id]);
useEffect(() => {
if (pane.selectedFiles.size !== 1) return;
const selectedName = Array.from(pane.selectedFiles)[0];
if (!selectedName) return;
const container = fileListRef.current;
if (!container) return;
const row = Array.from(container.querySelectorAll<HTMLElement>('[data-sftp-row="true"]'))
.find((element) => element.dataset.entryName === selectedName);
if (row) {
row.scrollIntoView({ block: "nearest" });
return;
}
if (!shouldVirtualize || rowHeight <= 0) return;
const itemIndex = sortedDisplayFiles.findIndex((entry) => entry.name === selectedName);
if (itemIndex < 0) return;
container.scrollTop = getSftpVirtualListScrollTop({
itemIndex,
rowHeight,
currentScrollTop: container.scrollTop,
viewportHeight: container.clientHeight,
});
}, [fileListRef, pane.selectedFiles, rowHeight, shouldVirtualize, sortedDisplayFiles]);
// Use refs for frequently-changing values in context-menu actions
const selectedFilesRef = useRef(pane.selectedFiles);
selectedFilesRef.current = pane.selectedFiles;
const handleBackgroundClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
if (target.closest('[data-sftp-row="true"]')) return;
if (pane.selectedFiles.size === 0) return;
onClearSelection();
}, [onClearSelection, pane.selectedFiles.size]);
// Hidden file input backing the "Upload File(s)" context menu item. It sends
// the original FileList through uploadFromFileList so Electron can still
// resolve local paths for stream uploads.
const uploadEnabled = shouldShowSftpUploadFilesMenu({
isLocal,
hasFileListUpload: !!onUploadExternalFileList,
});
const folderUploadEnabled = shouldShowSftpUploadFolderMenu({
isLocal,
hasFolderUpload: !!onUploadExternalFolder,
});
const uploadInputRef = useRef<HTMLInputElement>(null);
const uploadTargetPathRef = useRef<string | undefined>(undefined);
const triggerUploadPicker = useCallback((targetPath?: string) => {
if (isLocal || !onUploadExternalFileList) return;
const input = uploadInputRef.current;
if (!input) return;
uploadTargetPathRef.current = targetPath;
// Reset value so selecting the same files twice still fires onChange.
input.value = "";
input.click();
}, [isLocal, onUploadExternalFileList]);
const handleUploadInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) {
uploadTargetPathRef.current = undefined;
return;
}
if (!onUploadExternalFileList) {
uploadTargetPathRef.current = undefined;
return;
}
const targetPath = uploadTargetPathRef.current;
uploadTargetPathRef.current = undefined;
void onUploadExternalFileList(files, targetPath);
}, [onUploadExternalFileList]);
const renderRow = useCallback(
(entry: SftpFileEntry, index: number) => (
<ContextMenu>
<ContextMenuTrigger>
<SftpFileRow
entry={entry}
index={index}
isSelected={pane.selectedFiles.has(entry.name)}
showSelectionHighlight={isPaneFocused}
isDragOver={dragOverEntry === entry.name}
columnWidths={columnWidths}
visibleColumns={visibleColumns}
onSelect={handleRowSelect}
onOpen={handleRowOpen}
onDragStart={handleFileDragStart}
onDragEnd={onDragEnd}
onDragOver={handleEntryDragOver}
onDragLeave={handleRowDragLeave}
onDrop={handleEntryDrop}
density={listDensity}
/>
</ContextMenuTrigger>
{entry.name !== ".." && (
<ContextMenuContent>
<ContextMenuItem onClick={() => handleRowOpen(entry)}>
{isNavigableDirectory(entry) ? (
<>
<Folder size={14} className="mr-2" /> {t("sftp.context.open")}
</>
) : (
<>
<ExternalLink size={14} className="mr-2" />{" "}
{t("sftp.context.open")}
</>
)}
</ContextMenuItem>
{isNavigableDirectory(entry) && (
<ContextMenuItem onClick={() => onNavigateTo(joinPath(pane.connection.currentPath, entry.name))}>
<ArrowRight size={14} className="mr-2" /> {t("sftp.context.navigateTo")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && onOpenFileWithSystemDefault && (
<ContextMenuItem onClick={() => onOpenFileWithSystemDefault(entry)}>
<AppWindow size={14} className="mr-2" />{" "}
{t("sftp.context.openWithDefault")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && onOpenFileWith && (
<ContextMenuItem onClick={() => onOpenFileWith(entry)}>
<ExternalLink size={14} className="mr-2" />{" "}
{t("sftp.context.openWith")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && !isKnownBinaryFile(entry.name) && onEditFile && (
<ContextMenuItem onClick={() => onEditFile(entry)}>
<Edit2 size={14} className="mr-2" />{" "}
{t("sftp.context.edit")}
</ContextMenuItem>
)}
{onDownloadFile &&
(!isNavigableDirectory(entry) || !pane.connection?.isLocal) && (
<ContextMenuItem
onClick={() => {
const currentSelected = selectedFilesRef.current;
if (
onDownloadFiles &&
currentSelected.has(entry.name) &&
currentSelected.size > 1
) {
const entries = Array.from(currentSelected)
.map((name) => filesByName.get(String(name)))
.filter((f): f is SftpFileEntry => !!f);
onDownloadFiles(entries);
} else {
onDownloadFile(entry);
}
}}
>
<Download size={14} className="mr-2" />{" "}
{t("sftp.context.download")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && onExtractArchive && isExtractableArchive(entry.name) && (
<ContextMenuItem onClick={() => onExtractArchive(entry)}>
<Archive size={14} className="mr-2" />{" "}
{t("sftp.context.extract")}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
const currentSelected = selectedFilesRef.current;
const files = currentSelected.has(entry.name)
? Array.from(currentSelected)
: [entry.name];
const fileData = files.map((name) => {
const fileName = String(name);
const file = filesByName.get(fileName);
return {
name: fileName,
isDirectory: file ? isNavigableDirectory(file) : false,
sourceConnectionId: pane.connection?.id,
sourcePath: pane.connection?.currentPath,
};
});
onCopyToOtherPane(fileData);
}}
>
<Copy size={14} className="mr-2" />{" "}
{t("sftp.context.copyToOtherPane")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
navigator.clipboard.writeText(joinPath(pane.connection.currentPath, entry.name));
}}
>
<ClipboardCopy size={14} className="mr-2" />{" "}
{t("sftp.context.copyPath")}
</ContextMenuItem>
<ContextMenuSeparator />
{(() => {
const sourceParent = getParentPath(joinPath(pane.connection?.currentPath ?? "", entry.name));
const targetParent = getParentPath(sourceParent);
if (sourceParent === targetParent) return null;
return (
<ContextMenuItem
onClick={() => {
const currentSelected = selectedFilesRef.current;
const sourcePaths = currentSelected.has(entry.name)
? Array.from(currentSelected as Set<string>).map((n) => joinPath(pane.connection?.currentPath ?? "", n))
: [joinPath(pane.connection?.currentPath ?? "", entry.name)];
void onMoveEntriesToPath(sourcePaths, targetParent);
}}
>
<ArrowUp size={14} className="mr-2" />{" "}
{t("sftp.context.moveToParent")}
</ContextMenuItem>
);
})()}
<ContextMenuItem onClick={() => openRenameDialog(joinPath(pane.connection?.currentPath ?? "", entry.name))}>
<Pencil size={14} className="mr-2" /> {t("common.rename")}
</ContextMenuItem>
{onEditPermissions && pane.connection && !pane.connection.isLocal && (
<ContextMenuItem onClick={() => onEditPermissions(entry)}>
<Shield size={14} className="mr-2" />{" "}
{t("sftp.context.permissions")}
</ContextMenuItem>
)}
<ContextMenuItem
className="text-destructive"
onClick={() => {
const currentSelected = selectedFilesRef.current;
const files = currentSelected.has(entry.name)
? Array.from(currentSelected as Set<string>).map((n) => joinPath(pane.connection?.currentPath ?? "", n))
: [joinPath(pane.connection?.currentPath ?? "", entry.name)];
openDeleteConfirm(files);
}}
>
<Trash2 size={14} className="mr-2" /> {t("action.delete")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={onRefresh}>
<RefreshCw size={14} className="mr-2" /> {t("common.refresh")}
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowNewFolderDialog(true)}>
<FolderPlus size={14} className="mr-2" /> {t("sftp.newFolder")}
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowNewFileDialog(true)}>
<FilePlus size={14} className="mr-2" /> {t("sftp.newFile")}
</ContextMenuItem>
{uploadEnabled && onUploadExternalFileList && (
<ContextMenuItem
onClick={() => {
const target = getSftpListUploadFilesTargetPath(entry, pane.connection?.currentPath ?? "");
triggerUploadPicker(target);
}}
>
<Upload size={14} className="mr-2" />{" "}
{t(getSftpUploadFilesLabelKey(entry))}
</ContextMenuItem>
)}
{folderUploadEnabled && onUploadExternalFolder && (
<ContextMenuItem
onClick={() => {
const target = getSftpListUploadFilesTargetPath(entry, pane.connection?.currentPath ?? "");
void onUploadExternalFolder(target);
}}
>
<Upload size={14} className="mr-2" />{" "}
{t(getSftpUploadFolderLabelKey(entry))}
</ContextMenuItem>
)}
</ContextMenuContent>
)}
</ContextMenu>
),
[
columnWidths,
visibleColumns,
filesByName,
handleEntryDragOver,
handleEntryDrop,
handleFileDragStart,
handleRowDragLeave,
handleRowOpen,
handleRowSelect,
dragOverEntry,
isPaneFocused,
onCopyToOtherPane,
onMoveEntriesToPath,
onDownloadFile,
onDownloadFiles,
onExtractArchive,
onDragEnd,
onEditFile,
onEditPermissions,
onNavigateTo,
onOpenFileWithSystemDefault,
onOpenFileWith,
onRefresh,
onUploadExternalFileList,
onUploadExternalFolder,
uploadEnabled,
folderUploadEnabled,
openDeleteConfirm,
openRenameDialog,
pane.connection,
pane.selectedFiles,
listDensity,
setShowNewFolderDialog,
setShowNewFileDialog,
t,
triggerUploadPicker,
],
);
const fileRows = useMemo(
() =>
shouldVirtualize
? visibleRows.map(({ entry, index, top }) => (
<div
key={entry.name}
className="absolute left-0 right-0 border-b border-border/30"
style={{ top, height: rowHeight }}
>
{renderRow(entry, index)}
</div>
))
: sortedDisplayFiles.map((entry, index) => (
<React.Fragment key={entry.name}>
{renderRow(entry, index)}
</React.Fragment>
)),
[
renderRow,
rowHeight,
shouldVirtualize,
sortedDisplayFiles,
visibleRows,
],
);
return (
<>
{/* File list header */}
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className="text-[11px] uppercase tracking-wide text-muted-foreground px-4 py-2 border-b border-border/40 bg-secondary/10 select-none"
data-section="terminal-sftp-list-header"
tabIndex={0}
aria-label={t("sftp.columns.configure")}
onKeyDown={(e) => {
if (!isSftpColumnMenuKey(e.key, e.shiftKey)) return;
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
e.currentTarget.dispatchEvent(new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: rect.left + 16,
clientY: rect.top + rect.height / 2,
}));
}}
style={{
display: "grid",
gridTemplateColumns: buildSftpColumnTemplate(columnWidths, visibleColumns),
}}
>
<div
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 overflow-hidden"
onClick={() => handleSort("name")}
>
<span className="truncate whitespace-nowrap">{t("sftp.columns.name")}</span>
{sortField === "name" && (
<span className="shrink-0 text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("name", e)}
/>
</div>
{visibleColumns.modified && (
<div
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 overflow-hidden"
onClick={() => handleSort("modified")}
>
<span className="truncate whitespace-nowrap">{t("sftp.columns.modified")}</span>
{sortField === "modified" && (
<span className="shrink-0 text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("modified", e)}
/>
</div>
)}
{visibleColumns.size && (
<div
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 justify-end overflow-hidden"
onClick={() => handleSort("size")}
>
{sortField === "size" && (
<span className="shrink-0 text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<span className="truncate whitespace-nowrap">{t("sftp.columns.size")}</span>
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("size", e)}
/>
</div>
)}
{visibleColumns.type && (
<div
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 justify-end overflow-hidden"
onClick={() => handleSort("type")}
>
{sortField === "type" && (
<span className="shrink-0 text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<span className="truncate whitespace-nowrap">{t("sftp.columns.kind")}</span>
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("type", e)}
/>
</div>
)}
{visibleColumns.owner && (
<div
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground justify-end overflow-hidden"
onClick={() => handleSort("owner")}
>
{sortField === "owner" && (
<span className="shrink-0 text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<span className="truncate whitespace-nowrap">{t("sftp.columns.owner")}</span>
</div>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<SftpColumnMenuItems
visibleColumns={visibleColumns}
directoriesFirst={directoriesFirst}
toggleColumnVisibility={toggleColumnVisibility}
toggleDirectoriesFirst={toggleDirectoriesFirst}
/>
</ContextMenuContent>
</ContextMenu>
{/* File list with empty area context menu */}
<ContextMenu>
<ContextMenuTrigger asChild>
<div
ref={fileListRef}
data-section="terminal-sftp-list"
className={cn(
"flex-1 min-h-0 overflow-y-auto relative",
isDragOverPane && "ring-2 ring-primary/30 ring-inset",
)}
onClick={handleBackgroundClick}
onScroll={handleFileListScroll}
>
{pane.loading && sortedDisplayFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full gap-2">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
{pane.connectionLogs.length > 0 && (
<div className="w-full max-w-sm mt-2 space-y-0.5 px-4">
{pane.connectionLogs.map((log, i) => (
<div key={i} className="text-[11px] text-muted-foreground truncate">
{log}
</div>
))}
</div>
)}
</div>
) : pane.error && !pane.reconnecting ? (
<SftpErrorWithLogs
error={pane.error}
connectionLogs={pane.connectionLogs}
onRetry={onRefresh}
t={t}
/>
) : sortedDisplayFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Folder size={32} className="mb-2 opacity-50" />
<span className="text-sm">{t("sftp.emptyDirectory")}</span>
</div>
) : (
<div
className={cn(
shouldVirtualize ? "relative" : "divide-y divide-border/30",
)}
style={shouldVirtualize ? { height: totalHeight } : undefined}
>
{fileRows}
</div>
)}
{/* Drop overlay */}
{isDragOverPane && draggedFiles && draggedFiles[0]?.side !== side && (
<div className="absolute inset-0 flex items-center justify-center bg-primary/5 pointer-events-none">
<div className="flex flex-col items-center gap-2 text-primary">
<ArrowDown size={32} />
<span className="text-sm font-medium">{t("sftp.dropFilesHere")}</span>
</div>
</div>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onRefresh}>
<RefreshCw size={14} className="mr-2" />{t("sftp.context.refresh")}
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowNewFolderDialog(true)}>
<FolderPlus size={14} className="mr-2" />{t("sftp.newFolder")}
</ContextMenuItem>
<ContextMenuItem onClick={() => {
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
setNewFileName(defaultName);
setFileNameError(null);
setShowNewFileDialog(true);
}}>
<FilePlus size={14} className="mr-2" />{t("sftp.newFile")}
</ContextMenuItem>
{uploadEnabled && onUploadExternalFileList && (
<ContextMenuItem onClick={() => triggerUploadPicker(undefined)}>
<Upload size={14} className="mr-2" />{t("sftp.context.uploadFiles")}
</ContextMenuItem>
)}
{folderUploadEnabled && onUploadExternalFolder && (
<ContextMenuItem onClick={() => void onUploadExternalFolder(undefined)}>
<Upload size={14} className="mr-2" />{t("sftp.context.uploadFolder")}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
{/* Hidden file input backing the "Upload File(s)" context menu item. */}
{uploadEnabled && onUploadExternalFileList && (
<input
ref={uploadInputRef}
type="file"
multiple
className="hidden"
onChange={handleUploadInputChange}
/>
)}
{/* Footer */}
<div className="h-9 shrink-0 px-4 flex items-center justify-between text-[11px] text-muted-foreground border-t border-border/40 bg-secondary/30">
<span>
{t("sftp.itemsCount", {
count: sortedDisplayFiles.length - (sortedDisplayFiles[0]?.name === ".." ? 1 : 0),
})}
{pane.selectedFiles.size > 0 &&
` - ${t("sftp.selectedCount", { count: pane.selectedFiles.size })}`}
</span>
<span className="truncate max-w-[200px]">
{pane.connection.currentPath}
</span>
</div>
{/* Loading overlay - covers entire pane when navigating or reconnecting */}
{pane.loading && !pane.connection?.reusedConnection && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
{pane.connectionLogs.length > 0 && (
<div className="w-full max-w-sm mt-2 space-y-0.5 px-4">
{pane.connectionLogs.map((log, i) => (
<div key={i} className="text-[11px] text-muted-foreground truncate">
{log}
</div>
))}
</div>
)}
</div>
)}
{/* Reconnecting overlay - shows when SFTP connection is lost and reconnecting */}
{pane.reconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
<Loader2 size={32} className="animate-spin text-primary" />
<div className="text-center">
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
<div className="text-xs text-muted-foreground mt-1">{t("sftp.reconnecting.desc")}</div>
</div>
</div>
</div>
)}
</>
);
});

View File

@@ -0,0 +1,316 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
test("inline bookmark actions navigate and confirm current-path removal", async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: "http://localhost",
});
const window = dom.window;
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const installGlobal = (key: string, value: unknown) => {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value,
});
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
installGlobal("window", window);
installGlobal("document", window.document);
installGlobal("navigator", window.navigator);
installGlobal("HTMLElement", window.HTMLElement);
installGlobal("HTMLInputElement", window.HTMLInputElement);
installGlobal("HTMLTextAreaElement", window.HTMLTextAreaElement);
installGlobal("Element", window.Element);
installGlobal("SVGElement", window.SVGElement);
installGlobal("Node", window.Node);
installGlobal("NodeFilter", window.NodeFilter);
installGlobal("MutationObserver", window.MutationObserver);
installGlobal("CustomEvent", window.CustomEvent);
installGlobal("Event", window.Event);
installGlobal("StorageEvent", window.StorageEvent);
installGlobal("localStorage", window.localStorage);
installGlobal("sessionStorage", window.sessionStorage);
installGlobal("getComputedStyle", window.getComputedStyle.bind(window));
installGlobal("requestAnimationFrame", window.requestAnimationFrame.bind(window));
installGlobal("cancelAnimationFrame", window.cancelAnimationFrame.bind(window));
installGlobal("ResizeObserver", ResizeObserverStub);
installGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const { default: React, act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { I18nProvider } = await import("../../application/i18n/I18nProvider.tsx");
const { SftpPaneToolbar } = await import("./SftpPaneToolbar.tsx");
const rootNode = window.document.getElementById("root");
assert.ok(rootNode);
const root = createRoot(rootNode);
const navigatedPaths: string[] = [];
const deletedBookmarkIds: string[] = [];
let toggleBookmarkCalls = 0;
const waitForFocusRestore = async () => {
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 20));
});
};
const setBookmarkPlacement = async (placement: "show" | "collapse") => {
window.localStorage.setItem("netcatty_sftp_toolbar_layout_v1", JSON.stringify({
order: ["bookmark"],
placement: { bookmark: placement },
}));
await act(async () => {
window.dispatchEvent(new window.StorageEvent("storage", {
key: "netcatty_sftp_toolbar_layout_v1",
}));
});
};
const findRemovalDialog = () => Array.from(window.document.querySelectorAll("h2"))
.find((heading) => heading.textContent?.trim() === "Remove bookmark")
?.closest<HTMLElement>('[role="dialog"]') ?? null;
const renderToolbar = async (currentPath: string, currentPathBookmarked: boolean) => {
await act(async () => {
const toolbar = React.createElement(SftpPaneToolbar, {
t: (key, params) => ({
"sftp.bookmark.list": "Bookmarked paths",
"sftp.bookmark.remove": "Remove bookmark",
"sftp.bookmark.removeConfirm": `Remove bookmark ${params?.path ?? ""}?`,
"sftp.viewMode.switchToTree": "Switch to tree view",
}[key] ?? key),
pane: {
id: "pane-1",
connection: {
id: "conn-1",
hostId: "host-1",
name: "Example",
currentPath,
homeDir: "/home/app",
isLocal: false,
},
files: [],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles: false,
transferMutationToken: 0,
},
onNavigateTo: () => {},
onSetFilter: () => {},
onSetFilenameEncoding: () => {},
onRefresh: () => {},
showFilterBar: false,
setShowFilterBar: () => {},
filterInputRef: { current: null },
isEditingPath: false,
editingPathValue: "",
setEditingPathValue: () => {},
setShowPathSuggestions: () => {},
showPathSuggestions: false,
setPathSuggestionIndex: () => {},
pathSuggestions: [],
pathSuggestionIndex: -1,
pathInputRef: { current: null },
pathDropdownRef: { current: null },
handlePathBlur: () => {},
handlePathKeyDown: () => {},
handlePathDoubleClick: () => {},
handlePathSubmit: () => {},
getNextUntitledName: () => "untitled",
setNewFileName: () => {},
setFileNameError: () => {},
setShowNewFileDialog: () => {},
setShowNewFolderDialog: () => {},
setNewFolderName: () => {},
bookmarks: [
{ id: "bm-current", path: "/home/app", label: "App root" },
{ id: "bm-1", path: "/srv/www", label: "Web root" },
],
isCurrentPathBookmarked: currentPathBookmarked,
onToggleBookmark: () => {
toggleBookmarkCalls += 1;
},
onAddGlobalBookmark: () => {},
isCurrentPathGlobalBookmarked: false,
onNavigateToBookmark: (path) => navigatedPaths.push(path),
onDeleteBookmark: (bookmark) => deletedBookmarkIds.push(bookmark.id),
showHiddenFiles: false,
onToggleShowHiddenFiles: () => {},
viewMode: "list",
onSetViewMode: () => {},
});
root.render(React.createElement(I18nProvider, { locale: "en" }, toolbar));
});
};
try {
await renderToolbar("/home/app", true);
let trigger = window.document.querySelector<HTMLButtonElement>(
'button[aria-label="Bookmarked paths"]',
);
assert.ok(trigger);
await act(async () => trigger.click());
const bookmark = Array.from(window.document.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Web root"),
);
assert.ok(bookmark, "bookmark path should be visible after opening the popover");
await act(async () => bookmark.click());
assert.deepEqual(navigatedPaths, ["/srv/www"]);
assert.equal(
Array.from(window.document.querySelectorAll("button")).some((button) =>
button.textContent?.includes("Web root"),
),
false,
"bookmark popover should close after selecting a path",
);
await act(async () => trigger.click());
const removeCurrentPath = Array.from(window.document.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Remove bookmark",
);
assert.ok(removeCurrentPath, "current-path bookmark removal should be visible");
removeCurrentPath.focus();
await act(async () => removeCurrentPath.click());
let dialog = findRemovalDialog();
assert.ok(dialog, "current-path removal should open an in-app dialog");
assert.match(dialog.textContent ?? "", /Remove bookmark \/home\/app\?/);
assert.equal(
Array.from(window.document.querySelectorAll("button")).some((button) =>
button.textContent?.includes("Web root"),
),
false,
"the bookmark popover must close before the dialog opens",
);
assert.equal(toggleBookmarkCalls, 0, "opening the dialog must not remove the bookmark");
const cancel = Array.from(dialog.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Cancel",
);
assert.ok(cancel);
await setBookmarkPlacement("collapse");
const overflowTriggerAfterLayoutChange = window.document.querySelector<HTMLButtonElement>(
'button[data-toolbar-overflow-trigger="true"]',
);
assert.ok(overflowTriggerAfterLayoutChange);
await act(async () => cancel.click());
await waitForFocusRestore();
assert.equal(findRemovalDialog(), null);
assert.equal(toggleBookmarkCalls, 0, "cancelling must preserve the bookmark");
assert.equal(
window.document.activeElement,
overflowTriggerAfterLayoutChange,
"cancel should fall back to the newly mounted overflow trigger",
);
await setBookmarkPlacement("show");
trigger = window.document.querySelector<HTMLButtonElement>(
'button[aria-label="Bookmarked paths"]',
);
assert.ok(trigger);
await act(async () => trigger.click());
const removeCurrentPathAfterCancel = Array.from(
window.document.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Remove bookmark");
assert.ok(removeCurrentPathAfterCancel);
await act(async () => removeCurrentPathAfterCancel.click());
dialog = findRemovalDialog();
assert.ok(dialog, "the removal dialog should reopen after cancellation");
await renderToolbar("/home/other", false);
dialog = findRemovalDialog();
assert.ok(dialog, "changing directory must not replace the pending removal target");
assert.match(dialog.textContent ?? "", /Remove bookmark \/home\/app\?/);
assert.doesNotMatch(dialog.textContent ?? "", /\/home\/other/);
const confirm = Array.from(dialog.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Remove bookmark",
);
assert.ok(confirm);
await act(async () => confirm.click());
assert.deepEqual(deletedBookmarkIds, ["bm-current"]);
assert.equal(toggleBookmarkCalls, 0, "confirmation must not toggle the new current path");
assert.equal(findRemovalDialog(), null);
await waitForFocusRestore();
assert.equal(window.document.activeElement, trigger, "confirm should restore the bookmark trigger");
await act(async () => trigger.click());
const webRootRow = Array.from(
window.document.querySelectorAll<HTMLElement>("[data-bookmark-scope]"),
).find((element) => element.textContent?.includes("Web root"));
const removeWebRoot = webRootRow?.querySelector<HTMLButtonElement>(
'button[aria-label="Remove bookmark"]',
);
assert.ok(removeWebRoot);
await act(async () => removeWebRoot.click());
dialog = findRemovalDialog();
assert.ok(dialog, "row removal should use the shared in-app dialog");
assert.match(dialog.textContent ?? "", /Remove bookmark \/srv\/www\?/);
assert.equal(webRootRow.isConnected, false, "row removal must close the bookmark popover");
const confirmWebRoot = Array.from(dialog.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Remove bookmark",
);
assert.ok(confirmWebRoot);
await act(async () => confirmWebRoot.click());
assert.deepEqual(deletedBookmarkIds, ["bm-current", "bm-1"]);
await waitForFocusRestore();
assert.equal(window.document.activeElement, trigger, "row removal should restore the bookmark trigger");
await setBookmarkPlacement("collapse");
const overflowTrigger = window.document.querySelector<HTMLButtonElement>(
'button[data-toolbar-overflow-trigger="true"]',
);
assert.ok(overflowTrigger, "collapsed bookmark should expose the overflow trigger");
await act(async () => overflowTrigger.click());
const nestedBookmarkTrigger = window.document.querySelector<HTMLButtonElement>(
'button[aria-label="Bookmarked paths"]',
);
assert.ok(nestedBookmarkTrigger, "overflow should expose the nested bookmark trigger");
await act(async () => nestedBookmarkTrigger.click());
const overflowWebRootRow = Array.from(
window.document.querySelectorAll<HTMLElement>("[data-bookmark-scope]"),
).find((element) => element.textContent?.includes("Web root"));
const removeOverflowWebRoot = overflowWebRootRow?.querySelector<HTMLButtonElement>(
'button[aria-label="Remove bookmark"]',
);
assert.ok(removeOverflowWebRoot);
removeOverflowWebRoot.focus();
await act(async () => removeOverflowWebRoot.click());
dialog = findRemovalDialog();
assert.ok(dialog, "overflow row removal should open the shared dialog");
const cancelOverflowRemoval = Array.from(dialog.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Cancel",
);
assert.ok(cancelOverflowRemoval);
await act(async () => cancelOverflowRemoval.click());
await waitForFocusRestore();
assert.equal(
window.document.activeElement,
overflowTrigger,
"overflow removal should restore the persistent overflow trigger",
);
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 300));
});
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete (globalThis as Record<string, unknown>)[key];
}
}
});

View File

@@ -0,0 +1,602 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
getSftpBookmarkButtonLabelKey,
getSftpBookmarkIdentity,
getNextSftpViewMode,
copySftpCurrentPathToClipboard,
canReorderSftpBookmark,
getSftpBookmarkMoveTargets,
getNextSftpToolbarDisplayPath,
getSftpViewModeToggleTarget,
getSftpViewModeToggleLabelKey,
resolveSftpToolbarVisibleIds,
shouldToggleSftpBookmarkFromButton,
SftpBookmarkList,
SftpPaneToolbar,
} from "./SftpPaneToolbar.tsx";
import type { SftpPane } from "../../application/state/sftp/types.ts";
import { Popover } from "../ui/popover.tsx";
import { TooltipProvider } from "../ui/tooltip.tsx";
const toolbarSource = fs.readFileSync(
path.join(path.dirname(fileURLToPath(import.meta.url)), "SftpPaneToolbar.tsx"),
"utf8",
);
test("single SFTP view-mode button toggles to the other mode", () => {
assert.equal(getNextSftpViewMode("list"), "tree");
assert.equal(getNextSftpViewMode("tree"), "list");
});
test("SFTP toolbar includes compact list density next to view mode", () => {
assert.match(toolbarSource, /"listDensity"/);
assert.match(toolbarSource, /getSftpListDensityToggleLabelKey/);
});
test("narrow SFTP toolbar spills non-pinned show items into overflow without changing hide/collapse", () => {
const shown = ["bookmark", "copyPath", "viewMode", "filter", "newFolder", "newFile", "refresh"];
const collapsed = ["encoding"];
const wide = resolveSftpToolbarVisibleIds({ shown, collapsed, narrow: false });
assert.deepEqual(wide.inlineIds, shown);
assert.deepEqual(wide.overflowIds, collapsed);
const narrow = resolveSftpToolbarVisibleIds({ shown, collapsed, narrow: true });
assert.ok(narrow.inlineIds.includes("bookmark"));
assert.ok(narrow.inlineIds.includes("filter"));
assert.ok(!narrow.inlineIds.includes("newFolder"));
assert.ok(narrow.overflowIds.includes("newFolder"));
assert.ok(narrow.overflowIds.includes("encoding"));
// hide is already excluded from shown/collapsed by partition - not reintroduced here
assert.ok(!narrow.inlineIds.includes("encoding"));
});
test("single SFTP view-mode button describes the target mode", () => {
assert.equal(getSftpViewModeToggleLabelKey("list"), "sftp.viewMode.switchToTree");
assert.equal(getSftpViewModeToggleLabelKey("tree"), "sftp.viewMode.switchToList");
});
test("single SFTP view-mode button exposes the mode it will switch to", () => {
assert.deepEqual(getSftpViewModeToggleTarget("list"), {
nextViewMode: "tree",
labelKey: "sftp.viewMode.switchToTree",
});
assert.deepEqual(getSftpViewModeToggleTarget("tree"), {
nextViewMode: "list",
labelKey: "sftp.viewMode.switchToList",
});
});
test("bookmark button keeps one-click add only when there are no saved paths", () => {
assert.equal(shouldToggleSftpBookmarkFromButton({ bookmarkCount: 0, isCurrentPathBookmarked: false }), true);
assert.equal(shouldToggleSftpBookmarkFromButton({ bookmarkCount: 1, isCurrentPathBookmarked: false }), false);
assert.equal(shouldToggleSftpBookmarkFromButton({ bookmarkCount: 1, isCurrentPathBookmarked: true }), false);
});
test("bookmark button label matches whether it opens saved paths or adds current path", () => {
assert.equal(
getSftpBookmarkButtonLabelKey({ bookmarkCount: 0, isCurrentPathBookmarked: false }),
"sftp.bookmark.add",
);
assert.equal(
getSftpBookmarkButtonLabelKey({ bookmarkCount: 1, isCurrentPathBookmarked: false }),
"sftp.bookmark.list",
);
assert.equal(
getSftpBookmarkButtonLabelKey({ bookmarkCount: 1, isCurrentPathBookmarked: true }),
"sftp.bookmark.list",
);
});
test("toolbar renders one view-mode toggle instead of separate list and tree buttons", () => {
const pane: SftpPane = {
id: "pane-1",
connection: {
id: "conn-1",
hostId: "host-1",
name: "Example",
currentPath: "/home/app",
homeDir: "/home/app",
isLocal: false,
},
files: [],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles: false,
transferMutationToken: 0,
};
const t = (key: string) => ({
"sftp.viewMode.switchToTree": "Switch to tree view",
"sftp.viewMode.list": "List view",
"sftp.viewMode.tree": "Tree view",
"sftp.bookmark.list": "Bookmarked paths",
}[key] ?? key);
const markup = renderToStaticMarkup(
React.createElement(SftpPaneToolbar, {
t,
pane,
onNavigateTo: () => {},
onSetFilter: () => {},
onSetFilenameEncoding: () => {},
onRefresh: () => {},
showFilterBar: false,
setShowFilterBar: () => {},
filterInputRef: { current: null },
isEditingPath: false,
editingPathValue: "",
setEditingPathValue: () => {},
setShowPathSuggestions: () => {},
showPathSuggestions: false,
setPathSuggestionIndex: () => {},
pathSuggestions: [],
pathSuggestionIndex: -1,
pathInputRef: { current: null },
pathDropdownRef: { current: null },
handlePathBlur: () => {},
handlePathKeyDown: () => {},
handlePathDoubleClick: () => {},
handlePathSubmit: () => {},
getNextUntitledName: () => "untitled",
setNewFileName: () => {},
setFileNameError: () => {},
setShowNewFileDialog: () => {},
setShowNewFolderDialog: () => {},
setNewFolderName: () => {},
bookmarks: [{ id: "bm-1", path: "/srv/www", label: "/srv/www" }],
isCurrentPathBookmarked: false,
onToggleBookmark: () => {},
onAddGlobalBookmark: () => {},
isCurrentPathGlobalBookmarked: false,
onNavigateToBookmark: () => {},
onDeleteBookmark: () => {},
showHiddenFiles: false,
onToggleShowHiddenFiles: () => {},
viewMode: "list",
onSetViewMode: () => {},
}),
);
assert.match(markup, /aria-label="Switch to tree view"/);
assert.doesNotMatch(markup, /aria-label="List view"/);
assert.doesNotMatch(markup, /aria-label="Tree view"/);
assert.match(markup, /aria-label="Bookmarked paths"/);
});
test("toolbar exposes locate-path-in-terminal when the callback is provided", () => {
const pane: SftpPane = {
id: "pane-1",
connection: {
id: "conn-1",
hostId: "host-1",
name: "Example",
currentPath: "/var/www/app",
homeDir: "/home/app",
isLocal: false,
},
files: [],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles: false,
transferMutationToken: 0,
};
const markup = renderToStaticMarkup(
React.createElement(TooltipProvider, {
children: React.createElement(SftpPaneToolbar, {
t: (key: string) => ({
"sftp.locatePathInTerminal": "Open path in terminal",
"sftp.viewMode.switchToTree": "Switch to tree view",
"sftp.bookmark.add": "Bookmark current path",
}[key] ?? key),
pane,
onNavigateTo: () => {},
onSetFilter: () => {},
onSetFilenameEncoding: () => {},
onRefresh: () => {},
showFilterBar: false,
setShowFilterBar: () => {},
filterInputRef: { current: null },
isEditingPath: false,
editingPathValue: "",
setEditingPathValue: () => {},
setShowPathSuggestions: () => {},
showPathSuggestions: false,
setPathSuggestionIndex: () => {},
pathSuggestions: [],
pathSuggestionIndex: -1,
pathInputRef: { current: null },
pathDropdownRef: { current: null },
handlePathBlur: () => {},
handlePathKeyDown: () => {},
handlePathDoubleClick: () => {},
handlePathSubmit: () => {},
getNextUntitledName: () => "untitled",
setNewFileName: () => {},
setFileNameError: () => {},
setShowNewFileDialog: () => {},
setShowNewFolderDialog: () => {},
setNewFolderName: () => {},
bookmarks: [],
isCurrentPathBookmarked: false,
onToggleBookmark: () => {},
onAddGlobalBookmark: () => {},
isCurrentPathGlobalBookmarked: false,
onNavigateToBookmark: () => {},
onDeleteBookmark: () => {},
showHiddenFiles: false,
onToggleShowHiddenFiles: () => {},
onLocatePathInTerminal: () => {},
viewMode: "list",
onSetViewMode: () => {},
}),
}),
);
assert.match(markup, /aria-label="Open path in terminal"/);
});
test("toolbar exposes copy-current-path action for the active directory", () => {
const pane: SftpPane = {
id: "pane-1",
connection: {
id: "conn-1",
hostId: "host-1",
name: "Example",
currentPath: "/var/www/app",
homeDir: "/home/app",
isLocal: false,
},
files: [],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles: false,
transferMutationToken: 0,
};
const markup = renderToStaticMarkup(
React.createElement(SftpPaneToolbar, {
t: (key: string) => ({
"sftp.copyCurrentPath": "Copy current path",
"sftp.viewMode.switchToTree": "Switch to tree view",
"sftp.bookmark.list": "Bookmarked paths",
}[key] ?? key),
pane,
onNavigateTo: () => {},
onSetFilter: () => {},
onSetFilenameEncoding: () => {},
onRefresh: () => {},
showFilterBar: false,
setShowFilterBar: () => {},
filterInputRef: { current: null },
isEditingPath: false,
editingPathValue: "",
setEditingPathValue: () => {},
setShowPathSuggestions: () => {},
showPathSuggestions: false,
setPathSuggestionIndex: () => {},
pathSuggestions: [],
pathSuggestionIndex: -1,
pathInputRef: { current: null },
pathDropdownRef: { current: null },
handlePathBlur: () => {},
handlePathKeyDown: () => {},
handlePathDoubleClick: () => {},
handlePathSubmit: () => {},
getNextUntitledName: () => "untitled",
setNewFileName: () => {},
setFileNameError: () => {},
setShowNewFileDialog: () => {},
setShowNewFolderDialog: () => {},
setNewFolderName: () => {},
bookmarks: [],
isCurrentPathBookmarked: false,
onToggleBookmark: () => {},
onAddGlobalBookmark: () => {},
isCurrentPathGlobalBookmarked: false,
onNavigateToBookmark: () => {},
onDeleteBookmark: () => {},
showHiddenFiles: false,
onToggleShowHiddenFiles: () => {},
viewMode: "list",
onSetViewMode: () => {},
}),
);
assert.match(markup, /aria-label="Copy current path"/);
});
test("copy-current-path action writes the displayed path and reports success", async () => {
let copiedText = "";
let successMessage = "";
await copySftpCurrentPathToClipboard({
currentPath: "/srv/current",
writeText: async (text) => {
copiedText = text;
},
onSuccess: (message) => {
successMessage = message;
},
onError: () => {},
t: (key) => ({
"sftp.copyCurrentPath.success": "Current path copied",
}[key] ?? key),
});
assert.equal(copiedText, "/srv/current");
assert.equal(successMessage, "Current path copied");
});
test("copy-current-path action reports clipboard failures", async () => {
let errorMessage = "";
await copySftpCurrentPathToClipboard({
currentPath: "/srv/current",
writeText: async () => {
throw new Error("denied");
},
onSuccess: () => {},
onError: (message) => {
errorMessage = message;
},
t: (key) => ({
"sftp.copyCurrentPath.error": "Could not copy current path",
}[key] ?? key),
});
assert.equal(errorMessage, "Could not copy current path");
});
test("SFTP filter input guards CJK IME composition instead of deferring controlled value writes", () => {
assert.match(toolbarSource, /onCompositionStart=\{/);
assert.match(toolbarSource, /onCompositionEnd=\{/);
assert.match(toolbarSource, /shouldCommitImeControlledChange/);
assert.match(toolbarSource, /value=\{filterDraft\}/);
assert.doesNotMatch(
toolbarSource,
/onChange=\{\(e\) => startTransition\(\(\) => onSetFilter\(e\.target\.value\)\)\}/,
);
});
test("SFTP filter honors an external filter change over a stale draft when composition ends", () => {
// If navigation clears pane.filter mid-composition, compositionEnd must adopt the
// external value instead of committing the stale draft, preserving the invariant
// that different-directory navigation clears the filter.
assert.match(toolbarSource, /filterAtComposeStartRef\.current = pane\.filter;/);
assert.match(toolbarSource, /filterPathAtComposeStartRef\.current = pane\.connection\?\.currentPath/);
assert.match(
toolbarSource,
/pane\.filter !== filterAtComposeStartRef\.current\s*\|\|\s*pathChangedDuringCompose\s*\|\|\s*filterCompositionSupersededRef\.current/,
);
assert.match(toolbarSource, /filterCompositionSupersededRef\.current = true;/);
assert.match(toolbarSource, /setFilterDraft\(pane\.filter\);/);
});
test("SFTP filter adopts external navigation-cleared filters during an open IME composition", () => {
// Sync path must pass valueAtComposeStart while composing so pane.filter="" from
// different-directory navigation supersedes the draft mid-composition.
assert.match(toolbarSource, /valueAtComposeStart:\s*composing/);
assert.match(toolbarSource, /filterAtComposeStartRef\.current/);
assert.match(toolbarSource, /pathChangedDuringCompose/);
assert.match(
toolbarSource,
/filterCompositionSupersededRef\.current = true;/,
);
});
test("SFTP filter supersedes IME draft on path navigation even when committed filter was already empty", () => {
// When the filter was already "" at compose start, navigation sets filter to ""
// again - pane.filter does not change - so path-at-compose-start must drive
// supersede; otherwise compositionend commits the stale draft.
assert.match(toolbarSource, /filterPathAtComposeStartRef/);
assert.match(
toolbarSource,
/currentPath !== filterPathAtComposeStartRef\.current/,
);
assert.match(
toolbarSource,
/\[pane\.filter, pane\.connection\?\.currentPath\]/,
);
// Hide-filter reset must still clear composing + supersede and resync draft.
assert.match(
toolbarSource,
/if \(!showFilterBar\) \{\s*filterComposingRef\.current = false;\s*filterCompositionSupersededRef\.current = false;\s*setFilterDraft\(pane\.filter\);/,
);
});
test("SFTP filter suppresses the post-composition onChange after external supersede", () => {
// Browsers may fire onChange with composing=false after compositionend; that event
// must not re-commit the stale composed draft over a navigation-cleared filter.
assert.match(toolbarSource, /resolveSupersededImeInputEvent/);
assert.match(toolbarSource, /superseded\.ignoreEventValue/);
assert.match(toolbarSource, /compositionExternallySuperseded:\s*filterCompositionSupersededRef\.current/);
});
test("SFTP filter clears the supersede latch if no post-composition change arrives", () => {
// Some IME/browser paths never fire the post-compositionend onChange that would
// clear filterCompositionSupersededRef; without a deferred clear, the next
// ordinary keystroke is treated as the stale supersede follow-up and dropped.
// Guard the clear so an intervening onChange clear or new compositionstart wins.
assert.match(
toolbarSource,
/filterCompositionSupersededRef\.current = true;\s*setFilterDraft\(pane\.filter\);\s*window\.setTimeout\(\(\) => \{/,
);
assert.match(
toolbarSource,
/if \(\s*filterCompositionSupersededRef\.current\s*&&\s*!filterComposingRef\.current\s*\) \{\s*filterCompositionSupersededRef\.current = false;/,
);
});
test("SFTP filter commit path clears the IME composing guard so clears/commits can't leave it stuck", () => {
// commitFilterValue backs composition end, Escape, inline clear and close; it must
// drop the guard so a programmatic clear mid-composition isn't blocked or undone.
assert.match(
toolbarSource,
/const commitFilterValue = useCallback\(\(value: string\) => \{[\s\S]*?filterComposingRef\.current = false;[\s\S]*?filterCompositionSupersededRef\.current = false;/,
);
});
test("SFTP filter clears the IME composing guard and resyncs the draft when the filter bar closes", () => {
// If the input unmounts mid-composition, compositionend never fires; the guard
// must be reset (or every later onSetFilter is blocked) and the draft resynced to
// the committed filter so a reopened bar never shows stale, uncommitted text.
assert.match(
toolbarSource,
/if \(!showFilterBar\) \{\s*filterComposingRef\.current = false;\s*filterCompositionSupersededRef\.current = false;\s*setFilterDraft\(pane\.filter\);/,
);
});
test("toolbar keeps path chrome on its own row above the action controls", () => {
assert.match(toolbarSource, /data-section="terminal-sftp-actions"/);
assert.match(toolbarSource, /data-section="terminal-sftp-path-row"/);
assert.match(
toolbarSource,
/data-section="terminal-sftp-path-row"[\s\S]*data-section="terminal-sftp-actions"/,
);
assert.match(
toolbarSource,
/className="ml-auto shrink-0" data-section="terminal-sftp-overflow"/,
);
assert.doesNotMatch(
toolbarSource,
/data-section="terminal-sftp-toolbar"[\s\S]*className="h-7 px-2 flex items-center gap-1 border-b/,
);
});
test("toolbar display path keeps the previous confirmed path while loading the same connection", () => {
assert.equal(
getNextSftpToolbarDisplayPath({
previousDisplayPath: "/srv/old",
previousConnectionId: "conn-1",
connectionId: "conn-1",
currentPath: "/srv/new",
loading: true,
}),
"/srv/old",
);
});
test("bookmark delete asks for confirmation inside the app", () => {
assert.doesNotMatch(toolbarSource, /window\.confirm/);
assert.doesNotMatch(toolbarSource, /confirmRemoveSftpBookmark/);
assert.match(toolbarSource, /import \{ ConfirmDialog \} from "\.\.\/ui\/confirm-dialog"/);
assert.match(toolbarSource, /<ConfirmDialog/);
assert.match(toolbarSource, /sftp\.bookmark\.removeConfirm/);
assert.match(toolbarSource, /path: pendingBookmarkRemoval\.path/);
});
test("bookmark rename does not use window.prompt", () => {
assert.doesNotMatch(toolbarSource, /window\.prompt/);
assert.match(toolbarSource, /event\.nativeEvent\.isComposing/);
});
test("bookmark drag ordering stays within global or location scope", () => {
const bookmarks = [
{ id: "gbm-1", path: "/global-a", label: "Global A", global: true },
{ id: "gbm-2", path: "/global-b", label: "Global B", global: true },
{ id: "bm-1", path: "/host-a", label: "Host A" },
{ id: "bm-2", path: "/host-b", label: "Host B" },
];
assert.equal(canReorderSftpBookmark(bookmarks[0], bookmarks[1]), true);
assert.equal(canReorderSftpBookmark(bookmarks[2], bookmarks[3]), true);
assert.equal(canReorderSftpBookmark(bookmarks[0], bookmarks[2]), false);
assert.equal(canReorderSftpBookmark(undefined, bookmarks[2]), false);
assert.deepEqual(getSftpBookmarkMoveTargets(bookmarks, bookmarks[0]), {
previous: null,
next: bookmarks[1],
});
assert.deepEqual(getSftpBookmarkMoveTargets(bookmarks, bookmarks[2]), {
previous: null,
next: bookmarks[3],
});
});
test("bookmark operations keep global and location entries distinct when ids collide", () => {
const global = { id: "shared-id", path: "/global", label: "Global", global: true };
const location = { id: "shared-id", path: "/location", label: "Location" };
const nextLocation = { id: "next", path: "/next", label: "Next" };
const bookmarks = [global, location, nextLocation];
assert.notEqual(getSftpBookmarkIdentity(global), getSftpBookmarkIdentity(location));
assert.deepEqual(getSftpBookmarkMoveTargets(bookmarks, location), {
previous: null,
next: nextLocation,
});
assert.equal(canReorderSftpBookmark(location, nextLocation), true);
assert.equal(canReorderSftpBookmark(global, location), false);
});
test("bookmark manage mode exposes keyboard reorder controls without a dead path button", () => {
const markup = renderToStaticMarkup(
React.createElement(SftpBookmarkList, {
bookmarks: [
{ id: "bm-1", path: "/srv/a", label: "First" },
{ id: "bm-2", path: "/srv/b", label: "Second" },
],
managing: true,
onNavigateToBookmark: () => {},
onRequestDeleteBookmark: () => {},
onReorderBookmark: () => {},
t: (key: string, params?: Record<string, unknown>) => ({
"sftp.bookmark.moveUp": `Move ${params?.label ?? ""} up`,
"sftp.bookmark.moveDown": `Move ${params?.label ?? ""} down`,
"sftp.bookmark.remove": "Remove bookmark",
}[key] ?? key),
}),
);
assert.match(markup, /aria-label="Move First up"[^>]*disabled/);
assert.match(markup, /aria-label="Move First down"/);
assert.match(markup, /aria-label="Move Second up"/);
assert.match(markup, /aria-label="Move Second down"[^>]*disabled/);
assert.doesNotMatch(markup, /<button[^>]*>\s*<div class="text-xs font-medium truncate">First/);
});
test("bookmark list renders saved paths as selectable rows", () => {
const markup = renderToStaticMarkup(
React.createElement(
TooltipProvider,
null,
React.createElement(
Popover,
null,
React.createElement(SftpBookmarkList, {
bookmarks: [{ id: "bm-1", path: "/srv/www", label: "Web root" }],
onNavigateToBookmark: () => {},
onRequestDeleteBookmark: () => {},
t: (key: string) => ({
"sftp.bookmark.remove": "Remove bookmark",
}[key] ?? key),
}),
),
),
);
assert.match(markup, /Web root/);
assert.match(markup, /\/srv\/www/);
assert.match(markup, /aria-label="Remove bookmark"/);
assert.match(markup, /focus-visible:opacity-100/);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import { getSftpTreeEntryOpenAction } from "./SftpPaneTreeView.tsx";
import type { SftpFileEntry } from "../../types";
const entry = (name: string, type: SftpFileEntry["type"]): SftpFileEntry => ({
name,
type,
size: 0,
lastModified: 0,
});
test("tree activation enters directories and keeps file opening separate", () => {
assert.equal(getSftpTreeEntryOpenAction(entry("..", "directory")), "up");
assert.equal(getSftpTreeEntryOpenAction(entry("docs", "directory")), "navigate");
assert.equal(getSftpTreeEntryOpenAction(entry("notes.txt", "file")), "open");
});
test("tree row double click routes through the open action", () => {
const source = fs.readFileSync(new URL("./SftpPaneTreeNode.tsx", import.meta.url), "utf8");
assert.match(source, /onDoubleClick=\{\(\) => onOpenEntry\(entry, entryPath\)\}/);
});

View File

@@ -0,0 +1,130 @@
import React from 'react';
import { ChevronRight, CornerUpLeft, Folder, FolderOpen, Loader2 } from 'lucide-react';
import type { SftpFileEntry } from '../../types';
import { formatBytes, formatDate, getFileIcon, isNavigableDirectory, type SftpColumnVisibility } from './utils';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
export type NodeDescriptor =
| { type: 'node'; entry: SftpFileEntry; entryPath: string; depth: number; isExpanded: boolean; isLoading: boolean }
| { type: 'loading' | 'error'; key: string; depth: number };
// ── Simplified TreeNode (no per-node ContextMenu) ────────────────────
interface TreeNodeProps {
entry: SftpFileEntry;
entryPath: string;
depth: number;
columnTemplate: string;
visibleColumns: SftpColumnVisibility;
isSelected: boolean;
isExpanded: boolean;
isLoading: boolean;
isDragOver: boolean;
onToggleExpand: (entry: SftpFileEntry, entryPath: string) => void;
onNodeClick: (entry: SftpFileEntry, entryPath: string, e: React.MouseEvent) => void;
onOpenEntry: (entry: SftpFileEntry, entryPath: string) => void;
onDragStart: (entry: SftpFileEntry, entryPath: string, isDir: boolean, e: React.DragEvent) => void;
onDragEnd: () => void;
onDragOverEntry: (entryPath: string, e: React.DragEvent) => void;
onDropEntry: (entryPath: string, e: React.DragEvent) => void;
onDragLeaveEntry: () => void;
onContextMenu: (entry: SftpFileEntry, entryPath: string, e: React.MouseEvent) => void;
}
export const TREE_ROW_HEIGHT = 28;
export const TreeNode = React.memo<TreeNodeProps>(({
entry, entryPath, depth, columnTemplate, visibleColumns, isSelected,
isExpanded, isLoading, isDragOver,
onToggleExpand, onNodeClick, onOpenEntry, onDragStart, onDragEnd,
onDragOverEntry, onDropEntry, onDragLeaveEntry,
onContextMenu,
}) => {
const { t } = useI18n();
const isParentEntry = entry.name === '..';
const isDir = isNavigableDirectory(entry);
const icon = isDir
? (isExpanded
? <FolderOpen size={14} className="shrink-0 text-yellow-500" />
: <Folder size={14} className="shrink-0 text-yellow-500" />)
: getFileIcon(entry);
return (
<div
data-section="terminal-sftp-tree-row"
data-entry-name={entry.name}
data-entry-type={isDir ? 'directory' : entry.type}
data-selected={isSelected ? 'true' : 'false'}
data-expanded={isDir ? (isExpanded ? 'true' : 'false') : undefined}
data-drag-over={isDragOver ? 'true' : 'false'}
className={cn(
'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 }}
onClick={e => onNodeClick(entry, entryPath, e)}
onDoubleClick={() => onOpenEntry(entry, entryPath)}
onContextMenu={e => {
if (!isParentEntry) {
onContextMenu(entry, entryPath, e);
}
}}
draggable={!isParentEntry}
onDragStart={e => { if (!isParentEntry) onDragStart(entry, entryPath, isDir, e); }}
onDragEnd={onDragEnd}
onDragOver={e => onDragOverEntry(entryPath, e)}
onDrop={e => onDropEntry(entryPath, e)}
onDragLeave={onDragLeaveEntry}
>
<div
className="flex min-w-0 items-center gap-1"
style={{ paddingLeft: depth * 16 + 8 }}
>
<span className="shrink-0 w-4 flex items-center justify-center">
{isParentEntry ? (
<CornerUpLeft size={14} className="text-muted-foreground" />
) : isDir ? (
isLoading ? (
<Loader2 size={12} className="animate-spin text-muted-foreground" />
) : (
<ChevronRight
size={14}
className={cn('transition-transform text-muted-foreground', isExpanded && 'rotate-90')}
onClick={e => { e.stopPropagation(); void onToggleExpand(entry, entryPath); }}
/>
)
) : null}
</span>
{!isParentEntry && <span className="shrink-0">{icon}</span>}
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
</div>
{visibleColumns.modified && (
<span className="min-w-0 text-muted-foreground text-xs truncate">
{isParentEntry ? '' : formatDate(entry.lastModified)}
</span>
)}
{visibleColumns.size && (
<span className="min-w-0 text-right text-muted-foreground text-xs truncate">
{isParentEntry ? '' : (isDir ? '--' : formatBytes(entry.size ?? 0))}
</span>
)}
{visibleColumns.type && (
<span className="min-w-0 text-right text-muted-foreground text-xs truncate">
{isParentEntry ? '' : (isDir ? t('sftp.kind.folder') : (entry.name.split('.').pop()?.toUpperCase() ?? '--'))}
</span>
)}
{visibleColumns.owner && (
<span className="min-w-0 text-right text-muted-foreground text-xs truncate">
{isParentEntry ? '' : (entry.owner || '--')}
</span>
)}
</div>
);
});
TreeNode.displayName = 'TreeNode';
// ── Tree paths reducer (unchanged) ──────────────────────────────────

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
import type { SftpFileEntry } from '../../types';
import type { SftpPane } from '../../application/state/sftp/types';
import type { SftpTransferSource } from './SftpContext';
import type { UseSftpPaneSortingResult } from '../../application/state/sftp/useSftpPaneSorting';
export interface SftpPaneTreeViewProps {
pane: SftpPane;
side: 'left' | 'right';
onPrepareSelection: () => void;
onLoadChildren: (path: string) => Promise<SftpFileEntry[]>;
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
onNavigateUp: () => void;
onNavigateTo: (path: string) => void;
onRefresh: () => void;
onOpenEntry: (entry: SftpFileEntry, fullPath?: string) => void;
onDragStart: (files: SftpTransferSource[], side: 'left' | 'right') => void;
onDragEnd: () => void;
openRenameDialog: (entryPath: string) => void;
openDeleteConfirm: (targets: string[]) => void;
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
onReceiveFromOtherPane: (files: SftpTransferSource[]) => void;
onOpenFileWithSystemDefault?: (entry: SftpFileEntry, fullPath?: string) => void;
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void;
onEditFile?: (entry: SftpFileEntry, fullPath?: string) => void;
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void;
onExtractArchive?: (entry: SftpFileEntry, fullPath?: string) => void | Promise<void>;
onEditPermissions?: (entry: SftpFileEntry, fullPath?: string) => void;
draggedFiles: (SftpTransferSource & { side: 'left' | 'right' })[] | null;
openNewFolderDialog: (targetPath: string) => void;
openNewFileDialog: (targetPath: string) => void;
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void>;
onUploadExternalFolder?: (targetPath?: string) => Promise<void>;
sorting: UseSftpPaneSortingResult;
reloadRequest: { token: number; paths?: string[]; full?: boolean };
}

View File

@@ -0,0 +1,788 @@
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, useTransition } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { logger } from "../../lib/logger";
import { useRenderTracker } from "../../lib/useRenderTracker";
import { cn } from "../../lib/utils";
import { SftpPaneDialogs } from "./SftpPaneDialogs";
import { SftpClipboardUploadDialog } from "./SftpClipboardUploadDialog";
import { SftpPaneEmptyState } from "./SftpPaneEmptyState";
import { SftpPaneFileList } from "./SftpPaneFileList";
import { SftpPaneToolbar } from "./SftpPaneToolbar";
import { SftpPaneTreeView } from "./SftpPaneTreeView";
import {
useActiveTabId,
useSftpConnectedHosts,
useSftpDrag,
useSftpHosts,
useSftpPaneCallbacks,
useSftpUpdateHosts,
useSftpWritableHosts,
} from "./SftpContext";
import type { SftpPane } from "../../application/state/sftp/types";
import { joinPath, getParentPath } from "../../application/state/sftp/utils";
import type { Host, SftpBookmark } from "../../domain/models";
import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
import { useSftpPaneFiles } from "./hooks/useSftpPaneFiles";
import { useSftpPanePath } from "./hooks/useSftpPanePath";
import { useSftpPaneSorting, type UseSftpPaneSortingResult } from "../../application/state/sftp/useSftpPaneSorting";
import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
import { sftpPaneViewModeStore } from "../../application/state/sftp/sftpPaneViewModeStore";
import { useSftpDialogActionHandler } from "../../application/state/sftp/sftpDialogActionStore";
import { useSftpBookmarks } from "./hooks/useSftpBookmarks";
import { useLocalSftpBookmarks } from "../../application/state/sftp/localSftpBookmarks";
import { useGlobalSftpBookmarks } from "./hooks/useGlobalSftpBookmarks";
import { useSftpHostViewMode } from "../../application/state/sftp/sftpHostViewModeStore";
import { useSftpListDensity } from "../../application/state/sftp/sftpListDensityStore";
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
import { sftpTreeSelectionStore } from "../../application/state/sftp/sftpTreeSelectionStore";
import { sftpClipboardUploadStore } from "./clipboardUpload";
interface TreeReloadRequest {
token: number;
paths?: string[];
full?: boolean;
}
interface SftpPaneWrapperProps {
side: "left" | "right";
paneId: string;
isFirstPane: boolean;
children: React.ReactNode;
}
const SftpPaneWrapper = memo<SftpPaneWrapperProps>(({ side, paneId, isFirstPane, children }) => {
const activeTabId = useActiveTabId(side);
const isActive = activeTabId ? paneId === activeTabId : isFirstPane;
const containerStyle: React.CSSProperties = isActive
? {}
: { visibility: "hidden", pointerEvents: "none" };
return (
<div
className={cn("absolute inset-0", isActive ? "z-10" : "z-0")}
style={containerStyle}
>
{children}
</div>
);
});
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;
onLocatePathInTerminal?: () => void;
followTerminalCwd?: boolean;
onToggleFollowTerminalCwd?: () => 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,
onLocatePathInTerminal,
followTerminalCwd,
onToggleFollowTerminalCwd,
forceActive,
}) => {
const activeTabId = useActiveTabId(side);
const isActive = forceActive || (activeTabId ? pane.id === activeTabId : true);
const callbacks = useSftpPaneCallbacks(side);
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
const hosts = useSftpHosts();
const connectedHosts = useSftpConnectedHosts();
const writableHosts = useSftpWritableHosts();
const { t } = useI18n();
const hostId = pane.connection?.hostId;
const { hostViewMode, setHostViewMode: saveHostViewMode } = useSftpHostViewMode(hostId);
const [, startTransition] = useTransition();
const [showFilterBar, setShowFilterBar] = useState(false);
const initialViewMode = hostViewMode ?? sftpDefaultViewMode ?? 'list';
const [viewMode, setViewMode] = useState<'list' | 'tree'>(initialViewMode);
const { density: listDensity, setDensity: setListDensity } = useSftpListDensity();
const [treeReloadRequest, setTreeReloadRequest] = useState<TreeReloadRequest>({ token: 0, full: true });
// Lazy-mount: only render the tree component once tree mode has been activated
const [treeEverMounted, setTreeEverMounted] = useState(initialViewMode === 'tree');
useEffect(() => {
if (viewMode === 'tree' && !treeEverMounted) setTreeEverMounted(true);
}, [viewMode, treeEverMounted]);
const filterInputRef = useRef<HTMLInputElement>(null);
const clipboardUploadRequestSnapshot = useSyncExternalStore(
sftpClipboardUploadStore.subscribe,
sftpClipboardUploadStore.getSnapshot,
sftpClipboardUploadStore.getSnapshot,
);
const clipboardUploadRequest =
clipboardUploadRequestSnapshot?.scopeId === dialogActionScopeId
&& clipboardUploadRequestSnapshot.side === side
&& isActive
? clipboardUploadRequestSnapshot
: null;
const requestTreeReload = useCallback((paths?: string[], full = false) => {
setTreeReloadRequest((prev) => ({
token: prev.token + 1,
paths,
full,
}));
}, []);
const requestNestedTreeReload = useCallback((paths?: string[]) => {
const targets = Array.from(new Set((paths ?? []).filter(Boolean)));
if (targets.length > 0) {
requestTreeReload(targets);
}
}, [requestTreeReload]);
useRenderTracker(`SftpPaneView[${side}]`, {
side,
paneId: pane.id,
paneConnected: pane.connected,
panePath: pane.currentPath,
showHeader,
draggedFilesCount: draggedFiles?.length ?? 0,
});
const {
sortField,
sortOrder,
directoriesFirst,
columnWidths,
visibleColumns,
handleSort,
handleResizeStart,
toggleColumnVisibility,
toggleDirectoriesFirst,
} = useSftpPaneSorting();
// Bookmark support
const updateHosts = useSftpUpdateHosts();
const currentHost = useMemo(
() => writableHosts.find((h) => h.id === pane.connection?.hostId),
[writableHosts, pane.connection?.hostId],
);
const onUpdateHost = useCallback(
(updated: Host) => updateHosts(writableHosts.map((h) => (h.id === updated.id ? updated : h))),
[updateHosts, writableHosts],
);
const remoteBookmarks = useSftpBookmarks({
host: currentHost,
currentPath: pane.connection?.currentPath,
onUpdateHost,
});
const localBookmarks = useLocalSftpBookmarks({
currentPath: pane.connection?.currentPath,
});
const globalBookmarks = useGlobalSftpBookmarks({
currentPath: pane.connection?.currentPath,
});
const hostBookmarks = pane.connection?.isLocal ? localBookmarks : remoteBookmarks;
const mergedBookmarks = useMemo(
() => [
...globalBookmarks.bookmarks.map((b) => ({ ...b, global: true as const })),
...hostBookmarks.bookmarks.map((b) => ({ ...b, global: false as const })),
],
[hostBookmarks.bookmarks, globalBookmarks.bookmarks],
);
const isCurrentPathBookmarked = hostBookmarks.isCurrentPathBookmarked || globalBookmarks.isCurrentPathBookmarked;
const toggleBookmark = useCallback(() => {
if (globalBookmarks.isCurrentPathBookmarked && !hostBookmarks.isCurrentPathBookmarked) {
const currentPath = pane.connection?.currentPath;
if (currentPath) {
const bm = globalBookmarks.bookmarks.find((b) => b.path === currentPath);
if (bm) globalBookmarks.deleteBookmark(bm.id);
}
} else {
hostBookmarks.toggleBookmark();
}
}, [hostBookmarks, globalBookmarks, pane.connection?.currentPath]);
const deleteBookmark = useCallback(
(bookmark: SftpBookmark) => {
if (bookmark.global) {
globalBookmarks.deleteBookmark(bookmark.id);
} else {
hostBookmarks.deleteBookmark(bookmark.id);
}
},
[hostBookmarks, globalBookmarks],
);
const reorderBookmark = useCallback(
(from: SftpBookmark, to: SftpBookmark) => {
if (from.global && to.global) {
globalBookmarks.reorderBookmark(from.id, to.id);
} else if (!from.global && !to.global) {
hostBookmarks.reorderBookmark(from.id, to.id);
}
},
[hostBookmarks, globalBookmarks],
);
const renameBookmark = useCallback(
(bookmark: SftpBookmark, label: string) => {
if (bookmark.global) {
globalBookmarks.renameBookmark(bookmark.id, label);
} else {
hostBookmarks.renameBookmark(bookmark.id, label);
}
},
[hostBookmarks, globalBookmarks],
);
const { sortedDisplayFiles } = useSftpPaneFiles({
files: pane.files,
filter: pane.filter,
connection: pane.connection,
showHiddenFiles: pane.showHiddenFiles,
enableListView: viewMode === 'list',
sortField,
sortOrder,
directoriesFirst,
});
const {
isEditingPath,
editingPathValue,
showPathSuggestions,
pathSuggestionIndex,
pathInputRef,
pathDropdownRef,
pathSuggestions,
setEditingPathValue,
setShowPathSuggestions,
setPathSuggestionIndex,
handlePathBlur,
handlePathKeyDown,
handlePathDoubleClick,
handlePathSubmit,
} = useSftpPanePath({
connection: pane.connection,
files: pane.files,
showHiddenFiles: pane.showHiddenFiles,
onNavigateTo: callbacks.onNavigateTo,
});
const {
showHostPicker,
hostSearch,
showNewFolderDialog,
newFolderName,
showNewFileDialog,
newFileName,
fileNameError,
showOverwriteConfirm,
overwriteTarget,
showRenameDialog,
renameTarget: _renameTarget,
renameName,
showDeleteConfirm,
deleteTargets,
isCreating,
isCreatingFile,
isRenaming,
isDeleting,
setShowHostPicker,
setHostSearch,
setShowNewFolderDialog,
setNewFolderName,
setShowNewFileDialog,
setNewFileName,
setFileNameError,
setShowOverwriteConfirm,
setShowRenameDialog,
setRenameName,
setShowDeleteConfirm,
handleCreateFolder,
handleCreateFile,
handleConfirmOverwrite,
handleRename,
handleDelete,
openNewFolderDialogAtPath,
openNewFileDialogAtPath,
openRenameDialog,
openDeleteConfirm,
getNextUntitledName,
} = useSftpPaneDialogs({
t,
pane,
onCreateDirectory: callbacks.onCreateDirectory,
onCreateDirectoryAtPath: callbacks.onCreateDirectoryAtPath,
onCreateFile: callbacks.onCreateFile,
onCreateFileAtPath: callbacks.onCreateFileAtPath,
onRenameFileAtPath: callbacks.onRenameFileAtPath,
onDeleteFilesAtPath: callbacks.onDeleteFilesAtPath,
onClearSelection: callbacks.onClearSelection,
onMutateSuccess: (paths?: string[]) => requestNestedTreeReload(paths),
});
const handleUploadExternalFiles = useCallback(async (dataTransfer: DataTransfer, targetPath?: string) => {
await callbacks.onUploadExternalFiles?.(dataTransfer, targetPath);
const affectedPath = targetPath ?? pane.connection?.currentPath;
if (affectedPath && affectedPath !== pane.connection?.currentPath) {
requestTreeReload([affectedPath]);
}
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
const handleUploadExternalFileList = useCallback(async (fileList: FileList, targetPath?: string) => {
await callbacks.onUploadExternalFileList?.(fileList, targetPath);
const affectedPath = targetPath ?? pane.connection?.currentPath;
if (affectedPath && affectedPath !== pane.connection?.currentPath) {
requestTreeReload([affectedPath]);
}
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
const handleUploadExternalFolder = useCallback(async (targetPath?: string) => {
await callbacks.onUploadExternalFolder?.(targetPath);
const affectedPath = targetPath ?? pane.connection?.currentPath;
if (affectedPath && affectedPath !== pane.connection?.currentPath) {
requestTreeReload([affectedPath]);
}
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
const handleExtractArchive = useCallback((entry: Parameters<NonNullable<typeof callbacks.onExtractArchive>>[0], fullPath?: string) => {
const archivePath = fullPath ?? joinPath(pane.connection?.currentPath ?? "", entry.name);
void Promise.resolve(callbacks.onExtractArchive?.(entry, fullPath)).then(() => {
const parentPath = getParentPath(archivePath);
if (parentPath) requestNestedTreeReload([parentPath]);
});
}, [callbacks, pane.connection?.currentPath, requestNestedTreeReload]);
const handleMoveEntriesToPath = useCallback(async (sourcePaths: string[], targetPath: string) => {
await callbacks.onMoveEntriesToPath(sourcePaths, targetPath);
}, [callbacks]);
const {
dragOverEntry,
isDragOverPane,
paneContainerRef,
handlePaneDragOver,
handlePaneDragLeave,
handlePaneDrop,
handleFileDragStart,
handleEntryDragOver,
handleEntryDrop,
handleRowDragLeave,
handleRowSelect,
handleRowOpen,
} = useSftpPaneDragAndSelect({
side,
pane,
sortedDisplayFiles,
draggedFiles,
onDragStart,
onReceiveFromOtherPane: callbacks.onReceiveFromOtherPane,
onMoveEntriesToPath: callbacks.onMoveEntriesToPath,
onUploadExternalFiles: handleUploadExternalFiles,
onOpenEntry: callbacks.onOpenEntry,
onRangeSelect: callbacks.onRangeSelect,
onToggleSelection: callbacks.onToggleSelection,
});
const {
fileListRef,
rowHeight,
handleFileListScroll,
shouldVirtualize,
totalHeight,
visibleRows,
} = useSftpPaneVirtualList({
isActive,
enabled: viewMode === 'list',
sortedDisplayFiles,
layoutKey: listDensity,
});
const toFullPath = useCallback(
(target: string) => {
const currentPath = pane.connection?.currentPath;
if (!currentPath || target.includes("/") || target.includes("\\")) {
return target;
}
return joinPath(currentPath, target);
},
[pane.connection?.currentPath],
);
// Handle keyboard shortcut dialog actions
const dialogActionHandlers = useMemo(
() => ({
onRename: (fileName: string) => openRenameDialog(toFullPath(fileName)),
onDelete: (fileNames: string[]) => openDeleteConfirm(fileNames.map(toFullPath)),
onNewFolder: () => {
setNewFolderName("");
setShowNewFolderDialog(true);
},
onNewFile: () => {
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
setNewFileName(defaultName);
setFileNameError(null);
setShowNewFileDialog(true);
},
}),
[
getNextUntitledName,
openDeleteConfirm,
openRenameDialog,
pane.files,
toFullPath,
setFileNameError,
setNewFileName,
setNewFolderName,
setShowNewFileDialog,
setShowNewFolderDialog,
],
);
useSftpDialogActionHandler(side, dialogActionScopeId, dialogActionHandlers, isActive);
const handleSortWithTransition = useCallback((field: typeof sortField) => {
startTransition(() => handleSort(field));
}, [handleSort, startTransition]);
const sortingControls = useMemo<UseSftpPaneSortingResult>(() => ({
sortField,
sortOrder,
directoriesFirst,
columnWidths,
visibleColumns,
handleSort: handleSortWithTransition,
handleResizeStart,
toggleColumnVisibility,
toggleDirectoriesFirst,
}), [
columnWidths,
directoriesFirst,
handleResizeStart,
handleSortWithTransition,
sortField,
sortOrder,
toggleColumnVisibility,
toggleDirectoriesFirst,
visibleColumns,
]);
const handleRefresh = useCallback(() => {
callbacks.onRefresh();
if (viewMode === 'tree') {
requestTreeReload(undefined, true);
}
}, [callbacks, requestTreeReload, viewMode]);
const onSetFilterRef = useRef(callbacks.onSetFilter);
onSetFilterRef.current = callbacks.onSetFilter;
const onClearSelectionRef = useRef(callbacks.onClearSelection);
onClearSelectionRef.current = callbacks.onClearSelection;
const handleSetViewMode = useCallback((mode: 'list' | 'tree') => {
setViewMode(mode);
saveHostViewMode(mode);
if (mode === 'tree') {
setShowFilterBar(false);
onSetFilterRef.current('');
onClearSelectionRef.current();
}
}, [saveHostViewMode]);
useEffect(() => {
sftpPaneViewModeStore.set(pane.id, viewMode);
if (viewMode === 'list') {
sftpTreeSelectionStore.clearPane(pane.id);
} else {
sftpListOrderStore.clearPane(pane.id);
}
return () => sftpPaneViewModeStore.clear(pane.id);
}, [pane.id, viewMode]);
// When connecting to a host, restore its saved view mode preference
const prevHostIdRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (hostId && hostId !== prevHostIdRef.current) {
setViewMode(hostViewMode ?? sftpDefaultViewMode);
}
prevHostIdRef.current = hostId;
}, [hostId, hostViewMode, sftpDefaultViewMode]);
useEffect(() => {
logger.debug("SftpPaneView active state", {
side,
paneId: pane.id,
isActive,
});
}, [isActive, pane.id, side]);
const lastHandledTransferMutationTokenRef = useRef(0);
useEffect(() => {
if (!pane.connection || pane.transferMutationToken === 0) return;
if (pane.transferMutationToken === lastHandledTransferMutationTokenRef.current) return;
lastHandledTransferMutationTokenRef.current = pane.transferMutationToken;
callbacks.onRefreshTab(pane.id);
if (viewMode === 'tree') {
requestTreeReload(undefined, true);
}
}, [callbacks, pane.connection, pane.id, pane.transferMutationToken, requestTreeReload, viewMode]);
if (!pane.connection) {
return (
<SftpPaneEmptyState
side={side}
showEmptyHeader={showEmptyHeader}
t={t}
showHostPicker={showHostPicker}
setShowHostPicker={setShowHostPicker}
hostSearch={hostSearch}
setHostSearch={setHostSearch}
hosts={hosts}
connectedHosts={connectedHosts}
onConnect={callbacks.onConnect}
/>
);
}
return (
<div
ref={paneContainerRef}
data-section="terminal-sftp-pane"
data-sftp-pane-side={side}
data-sftp-view-mode={viewMode}
className={cn(
"absolute inset-0 flex flex-col transition-colors",
isDragOverPane && "bg-primary/5",
)}
onDragOver={handlePaneDragOver}
onDragLeave={handlePaneDragLeave}
onDrop={handlePaneDrop}
>
<SftpPaneToolbar
t={t}
pane={pane}
onNavigateTo={callbacks.onNavigateTo}
onSetFilter={callbacks.onSetFilter}
onSetFilenameEncoding={callbacks.onSetFilenameEncoding}
onRefresh={handleRefresh}
showFilterBar={showFilterBar}
setShowFilterBar={setShowFilterBar}
filterInputRef={filterInputRef}
isEditingPath={isEditingPath}
editingPathValue={editingPathValue}
setEditingPathValue={setEditingPathValue}
setShowPathSuggestions={setShowPathSuggestions}
showPathSuggestions={showPathSuggestions}
setPathSuggestionIndex={setPathSuggestionIndex}
pathSuggestions={pathSuggestions}
pathSuggestionIndex={pathSuggestionIndex}
pathInputRef={pathInputRef}
pathDropdownRef={pathDropdownRef}
handlePathBlur={handlePathBlur}
handlePathKeyDown={handlePathKeyDown}
handlePathDoubleClick={handlePathDoubleClick}
handlePathSubmit={handlePathSubmit}
getNextUntitledName={getNextUntitledName}
setNewFileName={setNewFileName}
setFileNameError={setFileNameError}
setShowNewFileDialog={setShowNewFileDialog}
setShowNewFolderDialog={setShowNewFolderDialog}
setNewFolderName={setNewFolderName}
bookmarks={mergedBookmarks}
isCurrentPathBookmarked={isCurrentPathBookmarked}
onToggleBookmark={toggleBookmark}
onAddGlobalBookmark={globalBookmarks.addBookmark}
isCurrentPathGlobalBookmarked={globalBookmarks.isCurrentPathBookmarked}
onNavigateToBookmark={callbacks.onNavigateTo}
onDeleteBookmark={deleteBookmark}
onReorderBookmark={reorderBookmark}
onRenameBookmark={renameBookmark}
showHiddenFiles={pane.showHiddenFiles}
onToggleShowHiddenFiles={onToggleShowHiddenFiles}
onGoToTerminalCwd={onGoToTerminalCwd}
onLocatePathInTerminal={onLocatePathInTerminal}
followTerminalCwd={followTerminalCwd}
onToggleFollowTerminalCwd={onToggleFollowTerminalCwd}
viewMode={viewMode}
onSetViewMode={handleSetViewMode}
listDensity={listDensity}
onSetListDensity={setListDensity}
onListDrives={callbacks.onListDrives}
/>
{treeEverMounted && (
<div
className={viewMode === 'tree' ? 'flex-1 min-h-0 flex flex-col' : 'hidden'}
data-section="terminal-sftp-tree"
>
<SftpPaneTreeView
pane={pane}
side={side}
onPrepareSelection={callbacks.onPrepareSelection}
onLoadChildren={callbacks.onListDirectory}
onMoveEntriesToPath={handleMoveEntriesToPath}
onNavigateUp={callbacks.onNavigateUp}
onNavigateTo={callbacks.onNavigateTo}
onRefresh={handleRefresh}
onOpenEntry={callbacks.onOpenEntry}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
openRenameDialog={openRenameDialog}
openDeleteConfirm={openDeleteConfirm}
onCopyToOtherPane={callbacks.onCopyToOtherPane}
onReceiveFromOtherPane={callbacks.onReceiveFromOtherPane}
onOpenFileWithSystemDefault={callbacks.onOpenFileWithSystemDefault}
onOpenFileWith={callbacks.onOpenFileWith}
onEditFile={callbacks.onEditFile}
onDownloadFile={callbacks.onDownloadFile}
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
onEditPermissions={callbacks.onEditPermissions}
draggedFiles={draggedFiles}
openNewFolderDialog={openNewFolderDialogAtPath}
openNewFileDialog={openNewFileDialogAtPath}
onUploadExternalFiles={handleUploadExternalFiles}
onUploadExternalFileList={handleUploadExternalFileList}
onUploadExternalFolder={handleUploadExternalFolder}
sorting={sortingControls}
reloadRequest={treeReloadRequest}
/>
</div>
)}
<div
className={viewMode === 'list' ? 'flex-1 min-h-0 flex flex-col' : 'hidden'}
>
<SftpPaneFileList
t={t}
pane={pane}
side={side}
isPaneFocused={isPaneFocused}
sorting={sortingControls}
fileListRef={fileListRef}
handleFileListScroll={handleFileListScroll}
shouldVirtualize={shouldVirtualize}
totalHeight={totalHeight}
sortedDisplayFiles={sortedDisplayFiles}
isDragOverPane={isDragOverPane}
draggedFiles={draggedFiles}
onRefresh={handleRefresh}
onNavigateTo={callbacks.onNavigateTo}
onClearSelection={callbacks.onClearSelection}
setShowNewFolderDialog={setShowNewFolderDialog}
setShowNewFileDialog={setShowNewFileDialog}
getNextUntitledName={getNextUntitledName}
setNewFileName={setNewFileName}
setFileNameError={setFileNameError}
dragOverEntry={dragOverEntry}
handleRowSelect={handleRowSelect}
handleRowOpen={handleRowOpen}
handleFileDragStart={handleFileDragStart}
onDragEnd={onDragEnd}
handleEntryDragOver={handleEntryDragOver}
handleRowDragLeave={handleRowDragLeave}
handleEntryDrop={handleEntryDrop}
onCopyToOtherPane={callbacks.onCopyToOtherPane}
onMoveEntriesToPath={handleMoveEntriesToPath}
onOpenFileWithSystemDefault={callbacks.onOpenFileWithSystemDefault}
onOpenFileWith={callbacks.onOpenFileWith}
onEditFile={callbacks.onEditFile}
onDownloadFile={callbacks.onDownloadFile}
onDownloadFiles={callbacks.onDownloadFiles}
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
onEditPermissions={callbacks.onEditPermissions}
onUploadExternalFileList={handleUploadExternalFileList}
onUploadExternalFolder={handleUploadExternalFolder}
isLocal={!!pane.connection?.isLocal}
openRenameDialog={openRenameDialog}
openDeleteConfirm={openDeleteConfirm}
rowHeight={rowHeight}
visibleRows={visibleRows}
listDensity={listDensity}
/>
</div>
<SftpPaneDialogs
t={t}
hostLabel={pane.connection?.hostLabel}
currentPath={pane.connection?.currentPath}
showNewFolderDialog={showNewFolderDialog}
setShowNewFolderDialog={setShowNewFolderDialog}
newFolderName={newFolderName}
setNewFolderName={setNewFolderName}
handleCreateFolder={handleCreateFolder}
isCreating={isCreating}
showNewFileDialog={showNewFileDialog}
setShowNewFileDialog={setShowNewFileDialog}
newFileName={newFileName}
setNewFileName={setNewFileName}
fileNameError={fileNameError}
setFileNameError={setFileNameError}
handleCreateFile={handleCreateFile}
isCreatingFile={isCreatingFile}
showOverwriteConfirm={showOverwriteConfirm}
setShowOverwriteConfirm={setShowOverwriteConfirm}
overwriteTarget={overwriteTarget}
handleOverwriteConfirm={handleConfirmOverwrite}
showRenameDialog={showRenameDialog}
setShowRenameDialog={setShowRenameDialog}
renameName={renameName}
setRenameName={setRenameName}
handleRename={handleRename}
isRenaming={isRenaming}
showDeleteConfirm={showDeleteConfirm}
setShowDeleteConfirm={setShowDeleteConfirm}
deleteTargets={deleteTargets}
handleDelete={handleDelete}
isDeleting={isDeleting}
showHostPicker={showHostPicker}
setShowHostPicker={setShowHostPicker}
hosts={hosts}
connectedHosts={connectedHosts}
side={side}
hostSearch={hostSearch}
setHostSearch={setHostSearch}
onConnect={callbacks.onConnect}
onDisconnect={callbacks.onDisconnect}
/>
<SftpClipboardUploadDialog
request={clipboardUploadRequest}
currentPath={pane.connection?.currentPath}
onUploaded={(targetPath) => {
if (targetPath && targetPath !== pane.connection?.currentPath) {
requestTreeReload([targetPath]);
}
}}
/>
</div>
);
};
const sftpPaneViewAreEqual = (
prev: SftpPaneViewProps,
next: SftpPaneViewProps,
): 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;
if (prev.sftpDefaultViewMode !== next.sftpDefaultViewMode) return false;
if (prev.followTerminalCwd !== next.followTerminalCwd) return false;
if (prev.onToggleFollowTerminalCwd !== next.onToggleFollowTerminalCwd) return false;
if (prev.onGoToTerminalCwd !== next.onGoToTerminalCwd) return false;
if (prev.onLocatePathInTerminal !== next.onLocatePathInTerminal) return false;
if (prev.onToggleShowHiddenFiles !== next.onToggleShowHiddenFiles) return false;
return true;
};
const SftpPaneView = memo(SftpPaneViewInner, sftpPaneViewAreEqual);
SftpPaneView.displayName = "SftpPaneView";
export { SftpPaneView, SftpPaneWrapper };

View File

@@ -0,0 +1,172 @@
/**
* SFTP Permissions Editor Dialog
*/
import React, { memo, useEffect, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { SftpFileEntry } from '../../types';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
interface SftpPermissionsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
file: SftpFileEntry | null;
onSave: (file: SftpFileEntry, permissions: string) => void;
}
const SftpPermissionsDialogInner: React.FC<SftpPermissionsDialogProps> = ({ open, onOpenChange, file, onSave }) => {
const { t } = useI18n();
const [permissions, setPermissions] = useState({
owner: { read: false, write: false, execute: false },
group: { read: false, write: false, execute: false },
others: { read: false, write: false, execute: false },
});
// Parse permissions from file
// Supports both symbolic format (rwxr-xr-x) and octal format (755)
useEffect(() => {
if (file?.permissions) {
const perms = file.permissions;
// Check if it's octal format (e.g., "755", "644")
if (/^[0-7]{3,4}$/.test(perms)) {
const octal = perms.length === 4 ? perms.slice(1) : perms;
const ownerBits = parseInt(octal[0], 10);
const groupBits = parseInt(octal[1], 10);
const othersBits = parseInt(octal[2], 10);
setPermissions({
owner: {
read: (ownerBits & 4) !== 0,
write: (ownerBits & 2) !== 0,
execute: (ownerBits & 1) !== 0,
},
group: {
read: (groupBits & 4) !== 0,
write: (groupBits & 2) !== 0,
execute: (groupBits & 1) !== 0,
},
others: {
read: (othersBits & 4) !== 0,
write: (othersBits & 2) !== 0,
execute: (othersBits & 1) !== 0,
},
});
return;
}
// Parse symbolic rwxrwxrwx format (skip first char for type)
const pStr = perms.length === 10 ? perms.slice(1) : perms;
if (pStr.length >= 9) {
setPermissions({
owner: {
read: pStr[0] === 'r',
write: pStr[1] === 'w',
execute: pStr[2] === 'x' || pStr[2] === 's',
},
group: {
read: pStr[3] === 'r',
write: pStr[4] === 'w',
execute: pStr[5] === 'x' || pStr[5] === 's',
},
others: {
read: pStr[6] === 'r',
write: pStr[7] === 'w',
execute: pStr[8] === 'x' || pStr[8] === 't',
},
});
}
}
}, [file]);
const togglePerm = (role: 'owner' | 'group' | 'others', perm: 'read' | 'write' | 'execute') => {
setPermissions(prev => ({
...prev,
[role]: { ...prev[role], [perm]: !prev[role][perm] }
}));
};
const getOctalPermissions = (): string => {
const getNum = (p: { read: boolean; write: boolean; execute: boolean }) =>
(p.read ? 4 : 0) + (p.write ? 2 : 0) + (p.execute ? 1 : 0);
return `${getNum(permissions.owner)}${getNum(permissions.group)}${getNum(permissions.others)}`;
};
const getSymbolicPermissions = (): string => {
const getSym = (p: { read: boolean; write: boolean; execute: boolean }) =>
`${p.read ? 'r' : '-'}${p.write ? 'w' : '-'}${p.execute ? 'x' : '-'}`;
return getSym(permissions.owner) + getSym(permissions.group) + getSym(permissions.others);
};
const handleSave = () => {
if (file) {
onSave(file, getOctalPermissions());
onOpenChange(false);
}
};
if (!file) return null;
const permLabel = (perm: 'read' | 'write' | 'execute') => (perm === 'read' ? 'R' : perm === 'write' ? 'W' : 'X');
const PermRow = ({ role, label }: { role: 'owner' | 'group' | 'others'; label: string }) => (
<div className="flex items-center gap-4">
<div className="w-16 text-sm font-medium">{label}</div>
<div className="flex gap-3">
{(['read', 'write', 'execute'] as const).map(perm => (
<label key={perm} className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={permissions[role][perm]}
onChange={() => togglePerm(role, perm)}
className="rounded border-border"
/>
<span className="text-xs">{permLabel(perm)}</span>
</label>
))}
</div>
</div>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{t('sftp.permissions.title')}</DialogTitle>
<DialogDescription className="truncate">
{file.name}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-3">
<PermRow role="owner" label={t('sftp.permissions.owner')} />
<PermRow role="group" label={t('sftp.permissions.group')} />
<PermRow role="others" label={t('sftp.permissions.others')} />
</div>
<div className="flex items-center justify-between pt-2 border-t border-border/60">
<div className="text-xs text-muted-foreground">
{t('sftp.permissions.octal')}: <span className="font-mono text-foreground">{getOctalPermissions()}</span>
</div>
<div className="text-xs text-muted-foreground">
{t('sftp.permissions.symbolic')}: <span className="font-mono text-foreground">{getSymbolicPermissions()}</span>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleSave}>
{t('common.apply')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export const SftpPermissionsDialog = memo(SftpPermissionsDialogInner);
SftpPermissionsDialog.displayName = 'SftpPermissionsDialog';

View File

@@ -0,0 +1,518 @@
/**
* SFTP Tab Bar Component
*
* A tab bar for managing multiple SFTP connections in a single pane.
* Features:
* - Tab items with close button
* - Add button (+) to open HostSelectModal
* - Scrollable when many tabs are open
* - Drag-and-drop reordering of tabs
*/
import { Copy, HardDrive, Monitor, Plus, X } from "lucide-react";
import React, {
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { logger } from "../../lib/logger";
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from "../../lib/tabInteractions";
import { useRenderTracker } from "../../lib/useRenderTracker";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { cn } from "../../lib/utils";
import { useActiveTabId } from "./SftpContext";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "../ui/context-menu";
import {
canDuplicateSftpTab,
isSftpTabKeyboardContextMenuShortcut,
isSftpTabKeyboardSelectShortcut,
shouldHandleSftpTabKeyboardEvent,
SFTP_TAB_DUPLICATE_MENU_ITEMS,
type SftpTabDuplicateMode,
} from "./sftpTabDuplication";
export interface SftpTab {
id: string;
label: string;
isLocal: boolean;
hostId: string | null;
canDuplicate?: boolean;
}
interface SftpTabBarProps {
tabs: SftpTab[];
side: "left" | "right";
onSelectTab: (tabId: string) => void;
onCloseTab: (tabId: string) => void;
onAddTab: () => void;
onReorderTabs: (
draggedId: string,
targetId: string,
position: "before" | "after",
) => void;
/** Called when a tab is dragged to the other side */
onMoveTabToOtherSide?: (tabId: string) => void;
onDuplicateTab?: (
tabId: string,
mode: SftpTabDuplicateMode,
) => void | Promise<void>;
}
const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
tabs,
side,
onSelectTab,
onCloseTab,
onAddTab,
onReorderTabs,
onMoveTabToOtherSide,
onDuplicateTab,
}) => {
// Subscribe to activeTabId from store (isolated subscription)
const activeTabId = useActiveTabId(side);
// 渲染追踪 - 追踪所有 props 包括回调函数
useRenderTracker(`SftpTabBar[${side}]`, {
side,
tabsCount: tabs.length,
activeTabId,
// 追踪回调函数引用是否变化
onSelectTab,
onCloseTab,
onAddTab,
onReorderTabs,
onMoveTabToOtherSide,
});
const { t } = useI18n();
// Refs for scrollable tab container
const tabsContainerRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
// Drag state
const [dropIndicator, setDropIndicator] = useState<{
tabId: string;
position: "before" | "after";
} | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isCrossPaneDragOver, setIsCrossPaneDragOver] = useState(false);
const draggedTabIdRef = useRef<string | null>(null);
// Global dragend listener to ensure state is reset even if the dragged element is removed
useEffect(() => {
const handleGlobalDragEnd = () => {
if (draggedTabIdRef.current) {
draggedTabIdRef.current = null;
setDropIndicator(null);
setIsDragging(false);
setIsCrossPaneDragOver(false);
}
};
document.addEventListener("dragend", handleGlobalDragEnd);
return () => document.removeEventListener("dragend", handleGlobalDragEnd);
}, []);
// Check scroll state
const updateScrollState = useCallback(() => {
const container = tabsContainerRef.current;
if (container) {
setCanScrollLeft(container.scrollLeft > 0);
setCanScrollRight(
container.scrollLeft < container.scrollWidth - container.clientWidth - 1,
);
}
}, []);
// Update scroll state on mount and resize
useEffect(() => {
updateScrollState();
const container = tabsContainerRef.current;
if (container) {
container.addEventListener("scroll", updateScrollState);
const resizeObserver = new ResizeObserver(updateScrollState);
resizeObserver.observe(container);
return () => {
container.removeEventListener("scroll", updateScrollState);
resizeObserver.disconnect();
};
}
}, [updateScrollState, tabs]);
// Scroll to active tab when it changes
useLayoutEffect(() => {
if (!activeTabId) return;
const container = tabsContainerRef.current;
if (!container) return;
const activeTabElement = container.querySelector(
`[data-tab-id="${activeTabId}"]`,
) as HTMLElement | null;
if (activeTabElement) {
const containerRect = container.getBoundingClientRect();
const tabRect = activeTabElement.getBoundingClientRect();
if (tabRect.left < containerRect.left) {
container.scrollLeft -= containerRect.left - tabRect.left + 8;
} else if (tabRect.right > containerRect.right) {
container.scrollLeft += tabRect.right - containerRect.right + 8;
}
}
const timer = setTimeout(updateScrollState, 100);
return () => clearTimeout(timer);
}, [activeTabId, updateScrollState]);
// Drag handlers
const handleTabDragStart = useCallback(
(e: React.DragEvent, tabId: string) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("sftp-tab-id", tabId);
e.dataTransfer.setData("sftp-tab-side", side);
draggedTabIdRef.current = tabId;
setTimeout(() => {
setIsDragging(true);
}, 0);
},
[side],
);
const handleTabDragEnd = useCallback(() => {
draggedTabIdRef.current = null;
setDropIndicator(null);
setIsDragging(false);
}, []);
const handleTabDragOver = useCallback(
(e: React.DragEvent, tabId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!draggedTabIdRef.current || draggedTabIdRef.current === tabId) {
return;
}
const rect = e.currentTarget.getBoundingClientRect();
const midpoint = rect.left + rect.width / 2;
const position: "before" | "after" =
e.clientX < midpoint ? "before" : "after";
setDropIndicator({ tabId, position });
},
[],
);
const handleTabDrop = useCallback(
(e: React.DragEvent, targetTabId: string) => {
e.preventDefault();
const draggedId =
e.dataTransfer.getData("sftp-tab-id") || draggedTabIdRef.current;
if (draggedId && draggedId !== targetTabId && dropIndicator) {
onReorderTabs(draggedId, targetTabId, dropIndicator.position);
}
setDropIndicator(null);
setIsDragging(false);
},
[dropIndicator, onReorderTabs],
);
const handleCloseTab = useCallback(
(e: React.MouseEvent, tabId: string) => {
e.stopPropagation();
onCloseTab(tabId);
},
[onCloseTab],
);
const handleSelectTabClick = useCallback(
(e: React.MouseEvent, tabId: string) => {
e.stopPropagation();
onSelectTab(tabId);
},
[onSelectTab],
);
const handleAddTabClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onAddTab();
},
[onAddTab],
);
const handleTabKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>, tabId: string) => {
if (!shouldHandleSftpTabKeyboardEvent(e.target, e.currentTarget)) {
return;
}
if (isSftpTabKeyboardSelectShortcut(e.key)) {
e.preventDefault();
onSelectTab(tabId);
return;
}
if (isSftpTabKeyboardContextMenuShortcut(e.key, e.shiftKey)) {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
e.currentTarget.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
button: 2,
clientX: rect.left + Math.min(rect.width / 2, 24),
clientY: rect.bottom,
}),
);
}
},
[onSelectTab],
);
// Cross-pane drag handlers
const handleCrossPaneDragOver = useCallback(
(e: React.DragEvent) => {
const draggedFromSide = e.dataTransfer.types.includes("sftp-tab-side");
if (!draggedFromSide) return;
// Check if this is from the other side (we can't read the data during dragover due to browser security)
// We'll set the indicator and validate on drop
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setIsCrossPaneDragOver(true);
},
[],
);
const handleCrossPaneDragLeave = useCallback(() => {
setIsCrossPaneDragOver(false);
}, []);
const handleCrossPaneDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsCrossPaneDragOver(false);
const draggedId = e.dataTransfer.getData("sftp-tab-id");
const draggedFromSide = e.dataTransfer.getData("sftp-tab-side");
// Only accept drops from the other side
if (draggedId && draggedFromSide && draggedFromSide !== side && onMoveTabToOtherSide) {
logger.info("[SftpTabBar] Cross-pane drop", {
tabId: draggedId,
fromSide: draggedFromSide,
toSide: side,
});
onMoveTabToOtherSide(draggedId);
}
// Always reset drag state on drop
draggedTabIdRef.current = null;
setDropIndicator(null);
setIsDragging(false);
},
[side, onMoveTabToOtherSide],
);
return (
<div
className={cn(
"flex items-stretch h-8 bg-secondary/30 border-b border-border/40 transition-colors",
isCrossPaneDragOver && "bg-primary/10 ring-1 ring-inset ring-primary/40",
)}
onDragOver={handleCrossPaneDragOver}
onDragLeave={handleCrossPaneDragLeave}
onDrop={handleCrossPaneDrop}
>
{/* Scrollable tabs container */}
<div className="relative flex-1 min-w-0 flex">
{/* Left fade mask */}
{canScrollLeft && (
<div
className="absolute left-0 top-0 bottom-0 w-6 pointer-events-none z-10"
style={{
background:
"linear-gradient(to right, hsl(var(--secondary) / 0.9), transparent)",
}}
/>
)}
<div
ref={tabsContainerRef}
className="flex items-stretch overflow-x-auto scrollbar-none max-w-full"
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
>
{tabs.map((tab) => {
const isActive = activeTabId === tab.id;
const canDuplicateTab = canDuplicateSftpTab(tab, !!onDuplicateTab);
const isBeingDragged =
isDragging && draggedTabIdRef.current === tab.id;
const showDropIndicatorBefore =
dropIndicator?.tabId === tab.id &&
dropIndicator.position === "before";
const showDropIndicatorAfter =
dropIndicator?.tabId === tab.id &&
dropIndicator.position === "after";
return (
<ContextMenu key={tab.id}>
<ContextMenuTrigger asChild>
<div
data-tab-id={tab.id}
data-tab-type="sftp"
data-state={isActive ? 'active' : 'inactive'}
tabIndex={0}
aria-haspopup="menu"
aria-label={tab.label}
onClick={(e) => handleSelectTabClick(e, tab.id)}
onKeyDown={(e) => handleTabKeyDown(e, tab.id)}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseTab(tab.id))}
draggable
onDragStart={(e) => handleTabDragStart(e, tab.id)}
onDragEnd={handleTabDragEnd}
onDragOver={(e) => handleTabDragOver(e, tab.id)}
onDrop={(e) => handleTabDrop(e, tab.id)}
className={cn(
"netcatty-tab relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
"transition-[color,opacity,transform] duration-100 ease-out focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:ring-inset",
isActive
? "text-foreground border-b-2"
: "text-muted-foreground hover:text-foreground",
isBeingDragged && "opacity-50",
)}
style={
isActive
? { borderBottomColor: "hsl(var(--accent))" }
: undefined
}
>
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDragging && (
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDragging && (
<div className="absolute right-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
<div className="flex items-center gap-1.5 min-w-0 flex-1">
{tab.isLocal ? (
<Monitor
size={12}
className={cn(
"shrink-0",
isActive ? "text-primary" : "text-muted-foreground",
)}
/>
) : (
<HardDrive
size={12}
className={cn(
"shrink-0",
isActive ? "text-primary" : "text-muted-foreground",
)}
/>
)}
<span className="truncate">{tab.label}</span>
</div>
<button
onClick={(e) => handleCloseTab(e, tab.id)}
className="p-0.5 hover:bg-destructive/10 hover:text-destructive transition-colors shrink-0"
aria-label={t("common.close")}
>
<X size={12} />
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{SFTP_TAB_DUPLICATE_MENU_ITEMS.map((item) => (
<ContextMenuItem
key={item.mode}
disabled={!canDuplicateTab}
onClick={() => {
void onDuplicateTab?.(tab.id, item.mode);
}}
>
<Copy size={14} className="mr-2" />
{t(item.labelKey)}
</ContextMenuItem>
))}
</ContextMenuContent>
</ContextMenu>
);
})}
</div>
{/* Right fade mask */}
{canScrollRight && (
<div
className="absolute right-0 top-0 bottom-0 w-6 pointer-events-none z-10"
style={{
background:
"linear-gradient(to left, hsl(var(--secondary) / 0.9), transparent)",
}}
/>
)}
</div>
{/* Add tab button */}
<Tooltip>
<TooltipTrigger asChild>
<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={handleAddTabClick}
>
<Plus size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t("sftp.tabs.addTab")}</TooltipContent>
</Tooltip>
</div>
);
};
// Custom comparison - only re-render when data props change, ignore callback refs
// Note: activeTabId is now subscribed internally, not passed as prop
const sftpTabBarAreEqual = (
prev: SftpTabBarProps,
next: SftpTabBarProps,
): boolean => {
// Compare data props only
if (prev.side !== next.side) return false;
if (prev.tabs.length !== next.tabs.length) return false;
// Deep compare tabs array
for (let i = 0; i < prev.tabs.length; i++) {
const prevTab = prev.tabs[i];
const nextTab = next.tabs[i];
if (
prevTab.id !== nextTab.id ||
prevTab.label !== nextTab.label ||
prevTab.isLocal !== nextTab.isLocal ||
prevTab.hostId !== nextTab.hostId ||
prevTab.canDuplicate !== nextTab.canDuplicate
) {
return false;
}
}
// Ignore callback function refs - they may change but behavior is stable
return true;
};
export const SftpTabBar = memo(SftpTabBarInner, sftpTabBarAreEqual);
SftpTabBar.displayName = "SftpTabBar";

View File

@@ -0,0 +1,663 @@
/**
* SFTP Transfer item component for transfer queue
*/
import {
ArrowDown,
ArrowRight,
CheckCircle2,
ChevronDown,
ChevronUp,
ClipboardCopy,
File,
FolderOpen,
FolderUp,
GripVertical,
Loader2,
Pause,
Play,
RefreshCw,
X,
XCircle,
} from 'lucide-react';
import React, { memo } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { getParentPath } from '../../application/state/sftp/utils';
import { useSftpTransferTask, useSftpTransferResuming } from '../../application/state/sftpTransferCenterStore';
import { cn } from '../../lib/utils';
import { TransferTask } from '../../types';
import {
buildGlobalTransferProgressDisplay,
isDirectoryParentTask,
} from '../GlobalSftpTransferCenter';
import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import { formatSpeed, formatTransferBytes } from './utils';
/** Child rows need room for Pause + Cancel (2×24px icons + gap). */
const CHILD_ACTIONS_COLUMN_PX = 56;
interface SftpTransferItemProps {
task: TransferTask;
isChild?: boolean;
childNameColumnWidth?: number;
onResizeNameColumn?: (event: React.MouseEvent<HTMLDivElement>) => void;
onCancel: () => void;
onPause?: () => void;
onResume?: () => void;
onRetry: () => void;
onDismiss: () => void;
canRevealTarget?: boolean;
onRevealTarget?: () => void;
canCopyTargetPath?: boolean;
onCopyTargetPath?: () => void;
canToggleChildren?: boolean;
isExpanded?: boolean;
visibleChildCount?: number;
onToggleChildren?: () => void;
onSetNameColumnWidth?: (width: number) => void;
childNameColumnMinWidth?: number;
childNameColumnMaxWidth?: number;
childListId?: string;
resizeHandleTabIndex?: number;
}
const TruncatedTextWithTooltip: React.FC<{
text: string;
className?: string;
}> = ({ text, className }) => (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("truncate", className)}>
{text}
</span>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-md break-all">
{text}
</TooltipContent>
</Tooltip>
);
const IconButtonWithTooltip: React.FC<{
label: string;
children: React.ReactElement;
}> = ({ label, children }) => (
<Tooltip>
<TooltipTrigger asChild>
{children}
</TooltipTrigger>
<TooltipContent side="top" className="pointer-events-none">{label}</TooltipContent>
</Tooltip>
);
/** Pointer activates on pointerdown (Tooltip/parent may eat click); keyboard uses click detail 0. */
const oncePerActivationHandlers = (activate: () => void) => ({
onPointerDown: (event: React.PointerEvent) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
activate();
},
onClick: (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
// Mouse/touch already ran on pointerdown; only keyboard click (detail 0) remains.
if (event.detail > 0) return;
activate();
},
});
const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
task: propsTask,
isChild = false,
childNameColumnWidth = 260,
onResizeNameColumn,
onCancel,
onPause,
onResume,
onRetry,
onDismiss,
canRevealTarget = false,
onRevealTarget,
canCopyTargetPath = false,
onCopyTargetPath,
canToggleChildren = false,
isExpanded = false,
visibleChildCount: _visibleChildCount = 0,
onToggleChildren,
onSetNameColumnWidth,
childNameColumnMinWidth = 160,
childNameColumnMaxWidth = 480,
childListId,
resizeHandleTabIndex = 0,
}) => {
const { t } = useI18n();
// Progress bytes live in the center store (patchTask). Avoid depending on
// panel setTransfersState for every tick — that re-rendered the whole SFTP
// tree and pegged the renderer during large copies.
const task = useSftpTransferTask(propsTask.id, propsTask);
// Same progress model as the global transfer center (done · found for folders).
const isDirParent = isDirectoryParentTask(task);
const centerProgress = buildGlobalTransferProgressDisplay(task, t);
const hasKnownTotal = isDirParent
? task.totalBytes > 0 && task.transferredBytes > 0 && task.phase !== 'scanning'
: task.totalBytes > 0 || !!task.sourceLastModified;
const progress = isDirParent
? centerProgress.percent
: hasKnownTotal
? Math.min((task.transferredBytes / task.totalBytes) * 100, 100)
: 0;
const isIndeterminate = isDirParent
? centerProgress.indeterminate && (task.status === 'transferring' || task.status === 'pending' || task.status === 'queued' || task.status === 'pausing')
: task.status === 'transferring' && !hasKnownTotal;
const isActiveTransfer = task.status === 'transferring' || task.status === 'pausing';
// Reconnect / dedicated resume window — keep the action slot as a spinner
// until the first real progress clears reconnectRequired.
const storeResuming = task.reconnectRequired === true
&& ['pending', 'queued', 'transferring'].includes(task.status)
&& !task.error;
const sharedResuming = useSftpTransferResuming(task.id);
const isResuming = sharedResuming || storeResuming;
const effectiveSpeed = task.status === 'transferring'
? (Number.isFinite(task.speed) && task.speed > 0 ? task.speed : 0)
: 0;
const isPausedLike = task.status === 'paused' || task.status === 'interrupted';
const bytesDisplay = isDirParent
? ''
: (isActiveTransfer || isPausedLike) && hasKnownTotal
? `${formatTransferBytes(task.transferredBytes)} / ${formatTransferBytes(task.totalBytes)}`
: isActiveTransfer || isPausedLike
? formatTransferBytes(task.transferredBytes)
: task.status === 'completed' && hasKnownTotal
? formatTransferBytes(task.totalBytes)
: '';
// Prefer the transfer-center detail string so the panel never lags behind
// "N done · M found" while status is still pending during progressive walks.
const fileCountDisplay = isDirParent ? centerProgress.detail : '';
const speedFormatted = effectiveSpeed > 0 ? formatSpeed(effectiveSpeed) : '';
const targetDirectoryPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
// Pausing must show explicit copy — spinner-only looked like a no-op while
// the backend drained in-flight chunks ("finish current step").
const pausingLabel = t('sftp.transferCenter.status.pausing');
const resumingLabel = t('sftp.transferCenter.status.resuming');
const isLiveScanning = task.phase === 'scanning'
&& (task.status === 'pending' || task.status === 'queued' || task.status === 'transferring');
const progressOverlayText = isResuming
? resumingLabel
: isLiveScanning
? (fileCountDisplay
? `${t('sftp.transferCenter.phase.scanning')} · ${fileCountDisplay}`
: t('sftp.transferCenter.phase.scanning'))
: task.status === 'pausing'
? pausingLabel
: isDirParent
? (fileCountDisplay
|| (task.status === 'pending' || task.status === 'queued'
? t('sftp.task.waiting')
: isIndeterminate
? '...'
: `${Math.round(progress)}%`))
: task.status === 'pending'
? t('sftp.task.waiting')
: isIndeterminate
? t('sftp.transfer.preparing')
: bytesDisplay
? `${bytesDisplay}${hasKnownTotal ? `${Math.round(progress)}%` : ''}`
: hasKnownTotal
? `${Math.round(progress)}%`
: '...';
const progressBarWidth = isDirParent
? (centerProgress.indeterminate || isLiveScanning
? '100%'
: `${progress}%`)
: task.status === 'pending'
|| (task.status === 'transferring' && !hasKnownTotal)
|| isIndeterminate
? (task.status === 'pending' || !hasKnownTotal ? '100%' : `${progress}%`)
: `${progress}%`;
const statusIcon = isResuming
? <Loader2 size={12} className="animate-spin text-primary" />
: task.status === 'pausing'
? <Loader2 size={12} className="animate-spin text-amber-500" />
: task.status === 'transferring'
? <Loader2 size={12} className="animate-spin text-primary" />
: task.status === 'pending' || task.status === 'queued'
? (task.isDirectory
? <FolderUp size={12} className="text-muted-foreground animate-pulse" />
: <ArrowDown size={12} className="text-muted-foreground animate-bounce" />)
: task.status === 'completed'
? <CheckCircle2 size={12} className="text-green-500" />
: task.status === 'paused' || task.status === 'interrupted' || task.status === 'attention'
? <Pause size={12} className="text-amber-500" />
: <XCircle size={12} className={task.status === 'failed' ? "text-destructive" : "text-muted-foreground"} />;
const childProgressBar = (
<div
className="relative h-full overflow-hidden border border-border/60 bg-secondary/70"
>
<div
className={cn(
"h-full relative overflow-hidden",
task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
? "bg-muted-foreground/35 animate-pulse"
: isIndeterminate
? "bg-primary/60 animate-pulse"
: task.status === 'completed'
? "bg-emerald-500/80"
: task.status === 'failed'
? "bg-destructive/70"
: task.status === 'cancelled'
? "bg-muted-foreground/45"
: task.status === 'paused' || task.status === 'interrupted'
? "bg-amber-500/80"
: "bg-gradient-to-r from-primary via-primary/90 to-primary"
)}
style={{
width: progressBarWidth,
// Match ~200ms IPC ticks so the bar eases between samples.
transition: 'width 220ms linear',
}}
>
</div>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center px-2">
<span className="truncate whitespace-nowrap text-[10px] font-medium text-foreground">
{progressOverlayText}
</span>
</div>
</div>
);
const progressSummaryText = isResuming
|| isActiveTransfer
|| isPausedLike
|| task.status === 'pending'
|| task.status === 'queued'
|| (isDirParent && !!fileCountDisplay)
? [speedFormatted, progressOverlayText].filter(Boolean).join(' • ')
: '';
const showTransferSizeCalculation = task.status === 'transferring' && !hasKnownTotal && !isDirParent;
const showFailedError = task.status === 'failed' && !!task.error;
// Surface hard pause misses (e.g. "cannot be paused yet") so the panel
// pause button never looks dead when the backend refuses.
const showPauseUnavailable = !!task.pauseUnavailableReason
&& (task.status === 'transferring' || task.status === 'queued' || task.status === 'pending');
const hasFooterContent = showTransferSizeCalculation || showFailedError || showPauseUnavailable;
const retryActionLabel = t('sftp.transfers.retryAction');
const cancelActionLabel = t('common.cancel');
const pauseActionLabel = t('sftp.transferCenter.pause');
const resumeActionLabel = t('sftp.transferCenter.resume');
const dismissActionLabel = t('sftp.transfers.dismissAction');
const resizeNameColumnLabel = t('sftp.transfers.resizeNameColumn');
const toggleChildrenLabel = isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList');
const revealTargetLabel = t('sftp.transfers.openTargetFolder');
const copyTargetPathLabel = t('sftp.transfers.copyTargetPath');
const actionButtonClass = "h-6 w-6 focus-visible:ring-1 focus-visible:ring-primary/50";
const actionAriaLabel = (label: string) => `${label}: ${task.fileName}`;
const setNameColumnWidth = (width: number) => {
const nextWidth = Math.max(childNameColumnMinWidth, Math.min(childNameColumnMaxWidth, width));
onSetNameColumnWidth?.(nextWidth);
};
const handleResizeKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!onSetNameColumnWidth) return;
const step = event.shiftKey ? 40 : 10;
if (event.key === 'ArrowLeft') {
event.preventDefault();
setNameColumnWidth(childNameColumnWidth - step);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
setNameColumnWidth(childNameColumnWidth + step);
} else if (event.key === 'Home') {
event.preventDefault();
setNameColumnWidth(childNameColumnMinWidth);
} else if (event.key === 'End') {
event.preventDefault();
setNameColumnWidth(childNameColumnMaxWidth);
}
};
const actionButtons = (
<div className="flex items-center gap-1 shrink-0">
{canRevealTarget && onRevealTarget && (
<IconButtonWithTooltip label={revealTargetLabel}>
<Button variant="ghost" size="icon" className={actionButtonClass} onClick={onRevealTarget} aria-label={actionAriaLabel(revealTargetLabel)}>
<FolderOpen size={12} />
</Button>
</IconButtonWithTooltip>
)}
{canCopyTargetPath && onCopyTargetPath && (
<IconButtonWithTooltip label={copyTargetPathLabel}>
<Button variant="ghost" size="icon" className={actionButtonClass} onClick={onCopyTargetPath} aria-label={actionAriaLabel(copyTargetPathLabel)}>
<ClipboardCopy size={12} />
</Button>
</IconButtonWithTooltip>
)}
{task.status === 'failed' && task.retryable !== false && (
<IconButtonWithTooltip label={retryActionLabel}>
<Button variant="ghost" size="icon" className={actionButtonClass} onClick={onRetry} aria-label={actionAriaLabel(retryActionLabel)}>
<RefreshCw size={12} />
</Button>
</IconButtonWithTooltip>
)}
{task.status === 'transferring' && task.resumable !== false && onPause && !isResuming && (
<IconButtonWithTooltip label={pauseActionLabel}>
<Button
type="button"
variant="ghost"
size="icon"
className={actionButtonClass}
data-action="pause-transfer"
{...oncePerActivationHandlers(onPause)}
aria-label={actionAriaLabel(pauseActionLabel)}
>
<Pause size={12} />
</Button>
</IconButtonWithTooltip>
)}
{task.status === 'pausing' && (
<IconButtonWithTooltip label={pausingLabel}>
<Button
type="button"
variant="ghost"
size="icon"
className={actionButtonClass}
disabled
aria-label={actionAriaLabel(pausingLabel)}
aria-busy="true"
>
<Loader2 size={12} className="animate-spin text-amber-500" />
</Button>
</IconButtonWithTooltip>
)}
{isResuming && (
<IconButtonWithTooltip label={resumingLabel}>
<Button
type="button"
variant="ghost"
size="icon"
className={actionButtonClass}
disabled
aria-label={actionAriaLabel(resumingLabel)}
aria-busy="true"
>
<Loader2 size={12} className="animate-spin text-primary" />
</Button>
</IconButtonWithTooltip>
)}
{(task.status === 'paused' || task.status === 'interrupted') && onResume && !isResuming && (
<IconButtonWithTooltip label={resumeActionLabel}>
<Button
type="button"
variant="ghost"
size="icon"
className={actionButtonClass}
data-action="resume-transfer"
{...oncePerActivationHandlers(() => {
onResume();
})}
aria-label={actionAriaLabel(resumeActionLabel)}
>
<Play size={12} />
</Button>
</IconButtonWithTooltip>
)}
{(['pending', 'queued', 'transferring', 'pausing', 'paused', 'interrupted', 'attention'] as const).includes(task.status as never) && (
<IconButtonWithTooltip label={cancelActionLabel}>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(actionButtonClass, "text-destructive hover:text-destructive")}
data-action="cancel-transfer"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onCancel();
}}
aria-label={actionAriaLabel(cancelActionLabel)}
>
<X size={12} />
</Button>
</IconButtonWithTooltip>
)}
{(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && (
<IconButtonWithTooltip label={dismissActionLabel}>
<Button variant="ghost" size="icon" className={actionButtonClass} onClick={onDismiss} aria-label={actionAriaLabel(dismissActionLabel)}>
<X size={12} />
</Button>
</IconButtonWithTooltip>
)}
</div>
);
const content = isChild ? (
<div
className="grid h-7 items-stretch border-t border-border/20 bg-background/20 px-3"
data-section="terminal-sftp-transfer-row"
data-transfer-status={task.status}
data-transfer-direction={task.direction}
style={{
// Last column reserves space for Pause + Cancel so the
// progress bar never paints under the action buttons.
gridTemplateColumns: `24px ${childNameColumnWidth}px 10px minmax(0, 1fr) ${CHILD_ACTIONS_COLUMN_PX}px`,
}}
>
<div className="flex h-full items-center justify-center text-muted-foreground">
{task.isDirectory ? <FolderUp size={12} /> : <File size={12} />}
</div>
<div className="flex min-w-0 items-center pr-2">
<TruncatedTextWithTooltip
text={task.fileName}
className="min-w-0 text-[11px] font-medium text-foreground/90"
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex h-full cursor-col-resize items-center justify-center text-muted-foreground/35 hover:text-foreground/70 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
onMouseDown={onResizeNameColumn}
onKeyDown={handleResizeKeyDown}
role="separator"
aria-label={resizeNameColumnLabel}
aria-orientation="vertical"
aria-valuemin={childNameColumnMinWidth}
aria-valuemax={childNameColumnMaxWidth}
aria-valuenow={childNameColumnWidth}
tabIndex={resizeHandleTabIndex}
>
<GripVertical size={10} />
</div>
</TooltipTrigger>
<TooltipContent side="top">{resizeNameColumnLabel}</TooltipContent>
</Tooltip>
<div className="min-w-0 overflow-hidden">
{childProgressBar}
</div>
<div className="flex h-full min-w-0 items-center justify-end gap-0.5 pl-1">
{actionButtons}
</div>
</div>
) : (() => {
// Keep the bar visible while paused/interrupted so checkpoint progress
// stays readable; shimmer only runs on active/resuming states.
const showBelowParentProgress = isResuming
|| task.status === 'transferring'
|| task.status === 'pausing'
|| task.status === 'pending'
|| task.status === 'paused'
|| task.status === 'interrupted';
const titleBlock = (
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<TruncatedTextWithTooltip
text={task.fileName}
className="text-[12px] font-medium leading-5"
/>
<ArrowRight size={11} className="shrink-0 text-muted-foreground/70" />
<TruncatedTextWithTooltip
text={targetDirectoryPath}
className={cn(
"min-w-0 text-[11px]",
canRevealTarget ? "text-primary/80" : "text-muted-foreground",
)}
/>
</div>
);
const toggleChildrenButton = canToggleChildren ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded border border-border/60 bg-secondary/60 px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
onClick={onToggleChildren}
aria-label={toggleChildrenLabel}
aria-expanded={isExpanded}
aria-controls={childListId}
>
{toggleChildrenLabel}
{isExpanded ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</button>
</TooltipTrigger>
<TooltipContent side="top">{toggleChildrenLabel}</TooltipContent>
</Tooltip>
) : null;
return (
<div
className="border-t border-border/40 bg-background/60 px-3 py-2.5 supports-[backdrop-filter]:backdrop-blur-sm"
data-section="terminal-sftp-transfer-row"
data-transfer-status={task.status}
data-transfer-direction={task.direction}
>
<div className="flex items-center gap-1">
<div className="flex h-5 w-5 items-center justify-center shrink-0 -translate-y-px">
{statusIcon}
</div>
{canRevealTarget && onRevealTarget ? (
<button
type="button"
className="flex min-w-0 flex-1 rounded-sm text-left transition-colors hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
onClick={onRevealTarget}
aria-label={actionAriaLabel(revealTargetLabel)}
>
{titleBlock}
</button>
) : (
<div className="min-w-0 flex-1">
{titleBlock}
</div>
)}
{toggleChildrenButton}
{progressSummaryText && (
<span className="ml-auto min-w-0 max-w-[50%] truncate whitespace-nowrap text-right text-[10px] text-muted-foreground font-mono">
{progressSummaryText}
</span>
)}
{/* Keep pause/cancel outside the progress summary so long
"N done · M found" labels never crowd the action buttons. */}
<div className="ml-1 shrink-0">
{actionButtons}
</div>
</div>
{showBelowParentProgress && (
<div className="mt-2 ml-7">
<div className="h-1.5 overflow-hidden bg-secondary/80">
<div
className={cn(
"h-full relative overflow-hidden",
task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
? "bg-muted-foreground/50 animate-pulse"
: isIndeterminate
? "bg-primary/60 animate-pulse"
: isPausedLike
? "bg-amber-500/80"
: "bg-gradient-to-r from-primary via-primary/90 to-primary",
)}
style={{
width: progressBarWidth,
// Match ~200ms IPC ticks so the bar eases between samples.
transition: 'width 220ms linear',
}}
/>
</div>
</div>
)}
{hasFooterContent && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]">
{showTransferSizeCalculation && (
<span className="text-muted-foreground">{t('sftp.transfers.calculatingTotal')}</span>
)}
{showFailedError && (
<span className="text-destructive">{task.error}</span>
)}
{showPauseUnavailable && (
<span className="text-amber-600 dark:text-amber-400">{task.pauseUnavailableReason}</span>
)}
</div>
)}
</div>
);
})();
return (
<TooltipProvider delayDuration={300} skipDelayDuration={100}>
{content}
</TooltipProvider>
);
};
const arePropsEqual = (
prevProps: SftpTransferItemProps,
nextProps: SftpTransferItemProps,
): boolean => {
const prev = prevProps.task;
const next = nextProps.task;
if (prev.status !== next.status) return false;
if (prev.error !== next.error) return false;
if (prev.pauseUnavailableReason !== next.pauseUnavailableReason) return false;
if (prev.reconnectRequired !== next.reconnectRequired) return false;
if (prev.resumable !== next.resumable) return false;
if (prev.fileName !== next.fileName) return false;
if (prev.targetPath !== next.targetPath) return false;
if (prev.totalBytes !== next.totalBytes) return false;
if (prev.transferredBytes !== next.transferredBytes) return false;
if (prev.phase !== next.phase) return false;
if (prev.progressMode !== next.progressMode) return false;
if ((prevProps.canRevealTarget ?? false) !== (nextProps.canRevealTarget ?? false)) return false;
if ((prevProps.canCopyTargetPath ?? false) !== (nextProps.canCopyTargetPath ?? false)) return false;
if ((prevProps.isChild ?? false) !== (nextProps.isChild ?? false)) return false;
if ((prevProps.childNameColumnWidth ?? 260) !== (nextProps.childNameColumnWidth ?? 260)) return false;
if ((prevProps.canToggleChildren ?? false) !== (nextProps.canToggleChildren ?? false)) return false;
if ((prevProps.isExpanded ?? false) !== (nextProps.isExpanded ?? false)) return false;
if ((prevProps.visibleChildCount ?? 0) !== (nextProps.visibleChildCount ?? 0)) return false;
if ((prevProps.childNameColumnMinWidth ?? 160) !== (nextProps.childNameColumnMinWidth ?? 160)) return false;
if ((prevProps.childNameColumnMaxWidth ?? 480) !== (nextProps.childNameColumnMaxWidth ?? 480)) return false;
if ((prevProps.childListId ?? '') !== (nextProps.childListId ?? '')) return false;
if ((prevProps.resizeHandleTabIndex ?? 0) !== (nextProps.resizeHandleTabIndex ?? 0)) return false;
if (next.status === 'transferring' || next.status === 'pausing' || next.status === 'pending' || next.status === 'queued') {
if (next.speed !== prev.speed) return false;
}
return true;
};
export const SftpTransferItem = memo(SftpTransferItemInner, arePropsEqual);
SftpTransferItem.displayName = 'SftpTransferItem';

View File

@@ -0,0 +1,476 @@
import { GripHorizontal } from "lucide-react";
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { sftpTransferCenterStore } from "../../application/state/sftpTransferCenterStore";
import { useStoredNumber } from "../../application/state/useStoredNumber";
import type { useSftpState } from "../../application/state/useSftpState";
import {
STORAGE_KEY_SFTP_TRANSFER_CHILD_NAME_WIDTH,
STORAGE_KEY_SFTP_TRANSFER_PANEL_HEIGHT,
} from "../../infrastructure/config/storageKeys";
import type { TransferTask } from "../../types";
import { Button } from "../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { SftpTransferItem } from "./SftpTransferItem";
/** Same control path as the global transfer center — never a second pause implementation. */
const pauseViaCenter = (taskId: string) => {
void sftpTransferCenterStore.pause(taskId);
};
const resumeViaCenter = (taskId: string) => {
void sftpTransferCenterStore.resume(taskId);
};
type SftpState = ReturnType<typeof useSftpState>;
interface SftpTransferQueueProps {
sftp: SftpState;
visibleTransfers: SftpState["transfers"];
allTransfers: SftpState["transfers"];
canRevealTransferTarget?: (task: TransferTask) => boolean;
onRevealTransferTarget?: (task: TransferTask) => void | Promise<void>;
canCopyTransferTargetPath?: (task: TransferTask) => boolean;
onCopyTransferTargetPath?: (task: TransferTask) => void | Promise<void>;
}
const MIN_PANEL_HEIGHT = 112;
const MAX_PANEL_HEIGHT = 480;
const HEADER_HEIGHT = 42;
const MIN_CHILD_NAME_WIDTH = 160;
const MAX_CHILD_NAME_WIDTH = 480;
const CHILD_ROW_HEIGHT = 28;
const CHILD_VIRTUALIZE_THRESHOLD = 80;
const CHILD_OVERSCAN = 8;
const childListIdForTask = (taskId: string) => `sftp-transfer-children-${taskId.replace(/[^A-Za-z0-9_-]/g, "-")}`;
interface TransferChildListProps {
childTasks: TransferTask[];
childListId: string;
childNameWidth: number;
onResizeNameColumn: (event: React.MouseEvent<HTMLDivElement>) => void;
scrollContainerRef: React.RefObject<HTMLDivElement>;
scrollTop: number;
viewportHeight: number;
onCancel: (taskId: string) => void;
onPause: (taskId: string) => void;
onResume: (taskId: string) => void;
onRetry: (taskId: string) => Promise<void>;
onDismiss: (taskId: string) => void;
onSetNameColumnWidth: (width: number) => void;
}
const TransferChildList: React.FC<TransferChildListProps> = ({
childTasks,
childListId,
childNameWidth,
onResizeNameColumn,
scrollContainerRef,
scrollTop,
viewportHeight,
onCancel,
onPause,
onResume,
onRetry,
onDismiss,
onSetNameColumnWidth,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [contentTop, setContentTop] = useState(0);
useLayoutEffect(() => {
const container = containerRef.current;
const scrollContainer = scrollContainerRef.current;
if (!container || !scrollContainer) return;
const nextTop =
container.getBoundingClientRect().top -
scrollContainer.getBoundingClientRect().top +
scrollTop;
if (Math.abs(nextTop - contentTop) > 1) {
setContentTop(nextTop);
}
}, [childTasks.length, contentTop, scrollContainerRef, scrollTop, viewportHeight]);
const needsVirtualization = childTasks.length > CHILD_VIRTUALIZE_THRESHOLD;
// Use a fallback viewport height when not yet measured to avoid rendering
// all children on the first frame. This caps the initial render to ~15 rows
// instead of potentially thousands.
const effectiveViewportHeight = viewportHeight > 0 ? viewportHeight : MAX_PANEL_HEIGHT;
const shouldVirtualize = needsVirtualization;
const { startIndex, visibleTasks } = useMemo(() => {
if (!shouldVirtualize) {
return {
startIndex: 0,
visibleTasks: childTasks,
};
}
const relativeTop = Math.max(0, scrollTop - contentTop);
const relativeBottom = Math.max(0, scrollTop + effectiveViewportHeight - contentTop);
const start = Math.max(0, Math.floor(relativeTop / CHILD_ROW_HEIGHT) - CHILD_OVERSCAN);
const end = Math.min(
childTasks.length - 1,
Math.ceil(relativeBottom / CHILD_ROW_HEIGHT) + CHILD_OVERSCAN,
);
return {
startIndex: start,
visibleTasks: childTasks.slice(start, end + 1),
};
}, [childTasks, contentTop, effectiveViewportHeight, scrollTop, shouldVirtualize]);
return (
<div
id={childListId}
ref={containerRef}
className="border-t border-border/30 bg-background/30"
>
<div
className={shouldVirtualize ? "relative" : undefined}
style={shouldVirtualize ? { height: childTasks.length * CHILD_ROW_HEIGHT } : undefined}
>
{visibleTasks.map((child, visibleIndex) => {
const index = shouldVirtualize ? startIndex + visibleIndex : visibleIndex;
return (
<div
key={child.id}
className={shouldVirtualize ? "absolute left-0 right-0" : undefined}
style={shouldVirtualize ? { top: index * CHILD_ROW_HEIGHT } : undefined}
>
<SftpTransferItem
task={child}
isChild
childNameColumnWidth={childNameWidth}
childNameColumnMinWidth={MIN_CHILD_NAME_WIDTH}
childNameColumnMaxWidth={MAX_CHILD_NAME_WIDTH}
onResizeNameColumn={onResizeNameColumn}
onSetNameColumnWidth={onSetNameColumnWidth}
resizeHandleTabIndex={visibleIndex === 0 ? 0 : -1}
onCancel={() => onCancel(child.id)}
onPause={() => onPause(child.id)}
onResume={() => onResume(child.id)}
onRetry={() => onRetry(child.id)}
onDismiss={() => onDismiss(child.id)}
/>
</div>
);
})}
</div>
</div>
);
};
export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
sftp,
visibleTransfers,
allTransfers,
canRevealTransferTarget,
onRevealTransferTarget,
canCopyTransferTargetPath,
onCopyTransferTargetPath,
}) => {
const { t } = useI18n();
const [expandedParents, setExpandedParents] = useState<Record<string, boolean>>({});
const [panelHeight, setPanelHeight, persistPanelHeight] = useStoredNumber(
STORAGE_KEY_SFTP_TRANSFER_PANEL_HEIGHT,
220,
{ min: MIN_PANEL_HEIGHT, max: MAX_PANEL_HEIGHT },
);
const [childNameWidth, setChildNameWidth, persistChildNameWidth] = useStoredNumber(
STORAGE_KEY_SFTP_TRANSFER_CHILD_NAME_WIDTH,
260,
{ min: MIN_CHILD_NAME_WIDTH, max: MAX_CHILD_NAME_WIDTH },
);
const panelHeightRef = useRef(panelHeight);
const childNameWidthRef = useRef(childNameWidth);
const dragStateRef = useRef<{ startY: number; startHeight: number } | null>(null);
const childColumnDragRef = useRef<{ startX: number; startWidth: number } | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const scrollFrameRef = useRef<number | null>(null);
panelHeightRef.current = panelHeight;
childNameWidthRef.current = childNameWidth;
const childrenByParent = useMemo(() => {
const map = new Map<string, TransferTask[]>();
for (const task of allTransfers) {
if (task.parentTaskId && task.status !== "cancelled") {
const children = map.get(task.parentTaskId) || [];
children.push(task);
map.set(task.parentTaskId, children);
}
}
for (const [parentId, children] of map) {
map.set(
parentId,
[...children].sort((a, b) => b.startTime - a.startTime),
);
}
return map;
}, [allTransfers]);
const topLevelTransfers = useMemo(
() => visibleTransfers.filter((task) => !task.parentTaskId),
[visibleTransfers],
);
const clampPanelHeight = useCallback((height: number) => {
if (typeof window === "undefined") {
return Math.max(MIN_PANEL_HEIGHT, Math.min(MAX_PANEL_HEIGHT, height));
}
const viewportMax = Math.floor(window.innerHeight * 0.6);
return Math.max(MIN_PANEL_HEIGHT, Math.min(Math.min(MAX_PANEL_HEIGHT, viewportMax), height));
}, []);
useEffect(() => {
setExpandedParents((prev) => {
const next: Record<string, boolean> = {};
let changed = false;
for (const task of topLevelTransfers) {
const hasChildren = (childrenByParent.get(task.id)?.length ?? 0) > 0;
if (!hasChildren) continue;
next[task.id] = prev[task.id] ?? true;
if (next[task.id] !== prev[task.id]) {
changed = true;
}
}
if (!changed && Object.keys(prev).length === Object.keys(next).length) {
return prev;
}
return next;
});
}, [childrenByParent, topLevelTransfers]);
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return;
const updateViewport = () => setViewportHeight(scrollContainer.clientHeight);
updateViewport();
const resizeObserver = new ResizeObserver(updateViewport);
resizeObserver.observe(scrollContainer);
return () => {
resizeObserver.disconnect();
};
}, []);
useEffect(() => {
return () => {
if (scrollFrameRef.current !== null) {
window.cancelAnimationFrame(scrollFrameRef.current);
}
};
}, []);
useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
if (dragStateRef.current) {
const deltaY = dragStateRef.current.startY - event.clientY;
setPanelHeight(clampPanelHeight(dragStateRef.current.startHeight + deltaY));
}
if (childColumnDragRef.current) {
const deltaX = event.clientX - childColumnDragRef.current.startX;
const nextWidth = Math.max(
MIN_CHILD_NAME_WIDTH,
Math.min(MAX_CHILD_NAME_WIDTH, childColumnDragRef.current.startWidth + deltaX),
);
setChildNameWidth(nextWidth);
}
};
const handleMouseUp = () => {
const hadPanelDrag = !!dragStateRef.current;
const hadChildColumnDrag = !!childColumnDragRef.current;
dragStateRef.current = null;
childColumnDragRef.current = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
if (hadPanelDrag) {
persistPanelHeight(panelHeightRef.current);
}
if (hadChildColumnDrag) {
persistChildNameWidth(childNameWidthRef.current);
}
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [clampPanelHeight, panelHeight, persistChildNameWidth, persistPanelHeight, setChildNameWidth, setPanelHeight]);
const handleResizeStart = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
dragStateRef.current = {
startY: event.clientY,
startHeight: panelHeight,
};
document.body.style.cursor = "row-resize";
document.body.style.userSelect = "none";
}, [panelHeight]);
const handleChildColumnResizeStart = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
childColumnDragRef.current = {
startX: event.clientX,
startWidth: childNameWidth,
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}, [childNameWidth]);
const handleChildColumnWidthSet = useCallback((width: number) => {
const nextWidth = Math.max(MIN_CHILD_NAME_WIDTH, Math.min(MAX_CHILD_NAME_WIDTH, width));
setChildNameWidth(nextWidth);
persistChildNameWidth(nextWidth);
}, [persistChildNameWidth, setChildNameWidth]);
const toggleExpanded = useCallback((taskId: string) => {
setExpandedParents((prev) => ({
...prev,
[taskId]: !(prev[taskId] ?? true),
}));
}, []);
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
const nextTop = event.currentTarget.scrollTop;
if (scrollFrameRef.current !== null) return;
scrollFrameRef.current = window.requestAnimationFrame(() => {
scrollFrameRef.current = null;
setScrollTop(nextTop);
});
}, []);
if (topLevelTransfers.length === 0) {
return null;
}
return (
<div
className="border-t border-border/70 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm shrink-0"
data-section="terminal-sftp-transfer-queue"
style={{ height: clampPanelHeight(panelHeight) }}
>
<Tooltip>
<TooltipTrigger asChild>
<div
className="group flex h-3 cursor-row-resize items-center justify-center border-b border-border/30 text-muted-foreground/70"
onMouseDown={handleResizeStart}
>
<GripHorizontal size={14} className="transition-colors group-hover:text-foreground/80" />
</div>
</TooltipTrigger>
<TooltipContent>{t("sftp.transfers.dragToResize")}</TooltipContent>
</Tooltip>
<div
className="flex items-center justify-between border-b border-border/40 px-3 py-1.5 text-[11px] text-muted-foreground"
data-section="terminal-sftp-transfer-queue-header"
>
<span className="font-medium">
{t("sftp.transfers")}
{sftp.activeTransfersCount > 0 && (
<span className="ml-2 text-primary">
({t("sftp.transfers.active", { count: sftp.activeTransfersCount })})
</span>
)}
</span>
{sftp.transfers.some(
(transfer) => transfer.status === "completed" || transfer.status === "cancelled",
) && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-[11px]"
onClick={sftp.clearCompletedTransfers}
>
{t("sftp.transfers.clearCompleted")}
</Button>
)}
</div>
<div
ref={scrollContainerRef}
className="overflow-auto"
data-section="terminal-sftp-transfer-list"
style={{ height: `calc(100% - ${HEADER_HEIGHT}px)` }}
onScroll={handleScroll}
>
{topLevelTransfers.map((task) => {
const childTasks = childrenByParent.get(task.id) ?? [];
const isExpanded = expandedParents[task.id] ?? true;
const childListId = childListIdForTask(task.id);
return (
<React.Fragment key={task.id}>
<SftpTransferItem
task={task}
canToggleChildren={childTasks.length > 0}
isExpanded={isExpanded}
visibleChildCount={childTasks.length}
childListId={childListId}
onToggleChildren={() => toggleExpanded(task.id)}
onCancel={() => {
void sftpTransferCenterStore.cancel(task.id);
}}
onPause={() => pauseViaCenter(task.id)}
onResume={() => resumeViaCenter(task.id)}
onRetry={() => { void sftpTransferCenterStore.retry(task.id); }}
onDismiss={() => sftpTransferCenterStore.dismiss(task.id)}
canRevealTarget={canRevealTransferTarget?.(task) ?? false}
onRevealTarget={
onRevealTransferTarget
? () => {
void onRevealTransferTarget(task);
}
: undefined
}
canCopyTargetPath={canCopyTransferTargetPath?.(task) ?? false}
onCopyTargetPath={
onCopyTransferTargetPath
? () => {
void onCopyTransferTargetPath(task);
}
: undefined
}
/>
{isExpanded && childTasks.length > 0 && (
<TransferChildList
childTasks={childTasks}
childListId={childListId}
childNameWidth={childNameWidth}
onResizeNameColumn={handleChildColumnResizeStart}
onSetNameColumnWidth={handleChildColumnWidthSet}
scrollContainerRef={scrollContainerRef}
scrollTop={scrollTop}
viewportHeight={viewportHeight}
onCancel={(taskId) => { void sftpTransferCenterStore.cancel(taskId); }}
onPause={(taskId) => pauseViaCenter(taskId)}
onResume={(taskId) => resumeViaCenter(taskId)}
onRetry={(taskId) => { void sftpTransferCenterStore.retry(taskId); }}
onDismiss={(taskId) => sftpTransferCenterStore.dismiss(taskId)}
/>
)}
</React.Fragment>
);
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,164 @@
import type { SftpFileEntry } from "../../types";
import type { DropEntry } from "../../lib/sftpFileUtils";
import type { KeyBinding } from "../../domain/models";
import { joinPath } from "../../application/state/sftp/utils";
import { isNavigableDirectory } from "./utils";
export interface ClipboardLocalFile {
path: string;
name: string;
isDirectory: boolean;
size?: number;
}
export interface SftpClipboardUploadTreeSelection {
name: string;
path: string;
isDirectory: boolean;
}
export interface ResolveSftpClipboardUploadTargetParams {
currentPath: string;
selectedFileNames: string[];
files: SftpFileEntry[];
treeSelection: SftpClipboardUploadTreeSelection[];
}
export interface GetSftpClipboardSystemTextPathsParams {
currentPath: string;
selectedFileNames: string[];
treeSelection: SftpClipboardUploadTreeSelection[];
}
export function resolveSftpClipboardUploadTarget({
currentPath,
selectedFileNames,
files,
treeSelection,
}: ResolveSftpClipboardUploadTargetParams): string {
const selectedTreeFolders = treeSelection.filter((entry) => entry.isDirectory && entry.name !== "..");
if (selectedTreeFolders.length === 1) {
return selectedTreeFolders[0].path;
}
if (selectedFileNames.length === 1) {
const filesByName = new Map(files.map((entry) => [entry.name, entry]));
const selectedEntry = filesByName.get(selectedFileNames[0]);
if (selectedEntry && isNavigableDirectory(selectedEntry)) {
return joinPath(currentPath, selectedEntry.name);
}
}
return currentPath;
}
export function getSftpClipboardSystemTextPaths({
currentPath,
selectedFileNames,
treeSelection,
}: GetSftpClipboardSystemTextPathsParams): string[] {
if (treeSelection.length > 0) {
return treeSelection.map((entry) => entry.path);
}
return selectedFileNames.map((name) => joinPath(currentPath, name));
}
export function createDropEntriesFromClipboardFiles(files: ClipboardLocalFile[]): DropEntry[] {
return files.map((file) => ({
file: null,
localPath: file.path,
relativePath: file.name,
isDirectory: file.isDirectory,
size: file.size,
}));
}
export function getSupportedClipboardUploadFiles(files: ClipboardLocalFile[]): ClipboardLocalFile[] {
return files;
}
export function shouldLetNativePasteEventHandleSftpPaste(
action: string,
key: string | undefined,
): boolean {
if (action !== "sftpPaste" || !key) return false;
const normalized = key.toLowerCase().replace(/\s+/g, "");
return [
"ctrl+v",
"⌘+v",
"cmd+v",
"command+v",
].includes(normalized);
}
export function isSftpNativeClipboardPasteEnabled(
hotkeyScheme: "disabled" | "mac" | "pc",
keyBindings: KeyBinding[],
): boolean {
if (hotkeyScheme === "disabled") return false;
const pasteBinding = keyBindings.find((binding) => (
binding.category === "sftp" && binding.action === "sftpPaste"
));
if (!pasteBinding) return false;
const key = hotkeyScheme === "mac" ? pasteBinding.mac : pasteBinding.pc;
return shouldLetNativePasteEventHandleSftpPaste("sftpPaste", key);
}
export interface SftpClipboardUploadRequest {
scopeId: string;
side: "left" | "right";
targetPath: string;
files: ClipboardLocalFile[];
onConfirm: () => Promise<void>;
}
type ClipboardUploadListener = () => void;
let clipboardUploadRequest: SftpClipboardUploadRequest | null = null;
const clipboardUploadListeners = new Set<ClipboardUploadListener>();
const notifyClipboardUploadListeners = () => {
clipboardUploadListeners.forEach((listener) => listener());
};
export const sftpClipboardUploadStore = {
trigger: (request: SftpClipboardUploadRequest) => {
clipboardUploadRequest = request;
notifyClipboardUploadListeners();
},
clear: (request?: SftpClipboardUploadRequest | null) => {
if (request && clipboardUploadRequest !== request) return;
clipboardUploadRequest = null;
notifyClipboardUploadListeners();
},
getSnapshot: () => clipboardUploadRequest,
subscribe: (listener: ClipboardUploadListener) => {
clipboardUploadListeners.add(listener);
return () => clipboardUploadListeners.delete(listener);
},
};
/**
* Hand off a confirmed clipboard upload without holding the modal open.
* Clears the active request first, then runs the transfer (issue #2478).
*/
export async function confirmSftpClipboardUpload(params: {
request: SftpClipboardUploadRequest;
clear?: (request: SftpClipboardUploadRequest) => void;
onUploaded?: (targetPath: string) => void;
}): Promise<void> {
const { request, onUploaded } = params;
const clear = params.clear ?? sftpClipboardUploadStore.clear;
clear(request);
await request.onConfirm();
onUploaded?.(request.targetPath);
}
/** True when Upload should start for this request (blocks same-request double-click only). */
export function shouldStartClipboardUploadConfirm(
request: SftpClipboardUploadRequest | null | undefined,
alreadyStartedFor: SftpClipboardUploadRequest | null | undefined,
): boolean {
return !!request && alreadyStartedFor !== request;
}

View File

@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
canCopyToOtherPane,
requireCopyToOtherPaneTarget,
type SftpPaneSide,
} from "./copyToOtherPane";
test("copy to other pane is unavailable when the destination pane is missing", () => {
assert.equal(canCopyToOtherPane({ getActivePane: () => null }, "right"), false);
assert.equal(canCopyToOtherPane({ getActivePane: () => ({}) }, "right"), false);
});
test("copy to other pane is unavailable until the destination connection is ready", () => {
for (const status of ["connecting", "disconnected", "error"] as const) {
assert.equal(
canCopyToOtherPane({ getActivePane: () => ({ connection: { status } }) }, "right"),
false,
);
}
});
test("copy to other pane is unavailable while the destination is reconnecting", () => {
assert.equal(
canCopyToOtherPane({
getActivePane: () => ({
connection: { status: "connected" },
reconnecting: true,
}),
}, "right"),
false,
);
});
test("copy to other pane is available when the requested destination is connected", () => {
const requestedSides: SftpPaneSide[] = [];
const state = {
getActivePane: (side: SftpPaneSide) => {
requestedSides.push(side);
return { connection: { status: "connected" as const } };
},
};
assert.equal(canCopyToOtherPane(state, "left"), true);
assert.deepEqual(requestedSides, ["left"]);
});
test("copy to other pane reports why it cannot start instead of silently returning", () => {
let unavailableCount = 0;
const disconnectedState = { getActivePane: () => ({}) };
const connectedState = { getActivePane: () => ({ connection: { status: "connected" as const } }) };
assert.equal(
requireCopyToOtherPaneTarget(disconnectedState, "right", () => { unavailableCount += 1; }),
false,
);
assert.equal(unavailableCount, 1);
assert.equal(
requireCopyToOtherPaneTarget(connectedState, "right", () => { unavailableCount += 1; }),
true,
);
assert.equal(unavailableCount, 1);
});

View File

@@ -0,0 +1,26 @@
export type SftpPaneSide = "left" | "right";
type CopyTargetState = {
getActivePane: (side: SftpPaneSide) => {
connection?: { status?: "connecting" | "connected" | "disconnected" | "error" } | null;
reconnecting?: boolean;
} | null | undefined;
};
export const canCopyToOtherPane = (
state: CopyTargetState,
targetSide: SftpPaneSide,
): boolean => {
const targetPane = state.getActivePane(targetSide);
return targetPane?.connection?.status === "connected" && targetPane.reconnecting !== true;
};
export const requireCopyToOtherPaneTarget = (
state: CopyTargetState,
targetSide: SftpPaneSide,
onUnavailable: () => void,
): boolean => {
if (canCopyToOtherPane(state, targetSide)) return true;
onUnavailable();
return false;
};

View File

@@ -0,0 +1,37 @@
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { sftpTreeSelectionStore } from "../../../application/state/sftp/sftpTreeSelectionStore";
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

@@ -0,0 +1,53 @@
import { useCallback, useMemo, useSyncExternalStore } from "react";
import {
getGlobalSftpBookmarksSnapshot,
setGlobalSftpBookmarks,
subscribeGlobalSftpBookmarks,
} from "../../../application/state/sftp/globalSftpBookmarks";
import { createSftpBookmark, moveSftpBookmark, renameSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
interface UseGlobalSftpBookmarksParams {
currentPath: string | undefined;
}
export const useGlobalSftpBookmarks = ({
currentPath,
}: UseGlobalSftpBookmarksParams) => {
const bookmarks = useSyncExternalStore(
subscribeGlobalSftpBookmarks,
getGlobalSftpBookmarksSnapshot,
getGlobalSftpBookmarksSnapshot,
);
const isCurrentPathBookmarked = useMemo(
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
[currentPath, bookmarks],
);
const addBookmark = useCallback((path: string) => {
if (!path) return;
if (bookmarks.some((b) => b.path === path)) return;
setGlobalSftpBookmarks((prev) => [...prev, createSftpBookmark(path, { global: true })]);
}, [bookmarks]);
const deleteBookmark = useCallback((id: string) => {
setGlobalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
}, []);
const reorderBookmark = useCallback((fromId: string, toId: string) => {
setGlobalSftpBookmarks((prev) => moveSftpBookmark(prev, fromId, toId));
}, []);
const renameBookmark = useCallback((id: string, label: string) => {
setGlobalSftpBookmarks((prev) => renameSftpBookmark(prev, id, label));
}, []);
return {
bookmarks,
isCurrentPathBookmarked,
addBookmark,
deleteBookmark,
reorderBookmark,
renameBookmark,
};
};

View File

@@ -0,0 +1,8 @@
/** @deprecated Import from `@/application/state/sftp/localSftpBookmarks` instead. */
export {
useLocalSftpBookmarks,
subscribeLocalSftpBookmarks,
getLocalSftpBookmarksSnapshot,
rehydrateLocalSftpBookmarks,
setLocalSftpBookmarks,
} from "../../../application/state/sftp/localSftpBookmarks";

View File

@@ -0,0 +1,79 @@
import { useCallback, useMemo } from "react";
import type { Host, SftpBookmark } from "../../../domain/models";
import { createSftpBookmark, moveSftpBookmark, renameSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
interface UseSftpBookmarksParams {
host: Host | undefined;
currentPath: string | undefined;
onUpdateHost: ((host: Host) => void) | undefined;
}
interface UseSftpBookmarksResult {
bookmarks: SftpBookmark[];
isCurrentPathBookmarked: boolean;
toggleBookmark: () => void;
deleteBookmark: (id: string) => void;
reorderBookmark: (fromId: string, toId: string) => void;
renameBookmark: (id: string, label: string) => void;
}
export const useSftpBookmarks = ({
host,
currentPath,
onUpdateHost,
}: UseSftpBookmarksParams): UseSftpBookmarksResult => {
const bookmarks = useMemo(() => host?.sftpBookmarks ?? [], [host]);
const isCurrentPathBookmarked = useMemo(
() =>
!!currentPath && bookmarks.some((b) => b.path === currentPath),
[currentPath, bookmarks],
);
const updateHostBookmarks = useCallback(
(newBookmarks: SftpBookmark[]) => {
if (!host || !onUpdateHost) return;
onUpdateHost({ ...host, sftpBookmarks: newBookmarks });
},
[host, onUpdateHost],
);
const toggleBookmark = useCallback(() => {
if (!currentPath || !host) return;
if (isCurrentPathBookmarked) {
updateHostBookmarks(bookmarks.filter((b) => b.path !== currentPath));
} else {
updateHostBookmarks([...bookmarks, createSftpBookmark(currentPath)]);
}
}, [currentPath, host, isCurrentPathBookmarked, bookmarks, updateHostBookmarks]);
const deleteBookmark = useCallback(
(id: string) => {
updateHostBookmarks(bookmarks.filter((b) => b.id !== id));
},
[bookmarks, updateHostBookmarks],
);
const reorderBookmark = useCallback(
(fromId: string, toId: string) => {
updateHostBookmarks(moveSftpBookmark(bookmarks, fromId, toId));
},
[bookmarks, updateHostBookmarks],
);
const renameBookmark = useCallback(
(id: string, label: string) => {
updateHostBookmarks(renameSftpBookmark(bookmarks, id, label));
},
[bookmarks, updateHostBookmarks],
);
return {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
reorderBookmark,
renameBookmark,
};
};

View File

@@ -0,0 +1,6 @@
/** @deprecated Import from `@/application/state/sftp/sftpClipboardStore` instead. */
export {
sftpClipboardStore,
useSftpClipboard,
type SftpClipboardFile,
} from "../../../application/state/sftp/sftpClipboardStore";

View File

@@ -0,0 +1,6 @@
/** @deprecated Import from `@/application/state/sftp/sftpDialogActionStore` instead. */
export {
sftpDialogActionStore,
useSftpDialogAction,
useSftpDialogActionHandler,
} from "../../../application/state/sftp/sftpDialogActionStore";

View File

@@ -0,0 +1,6 @@
/** @deprecated Import from `@/application/state/sftp/sftpFocusStore` instead. */
export {
sftpFocusStore,
useSftpFocusedSide,
type SftpFocusedSide,
} from "../../../application/state/sftp/sftpFocusStore";

View File

@@ -0,0 +1,6 @@
/** @deprecated Import from `@/application/state/sftp/sftpHostViewModeStore` instead. */
export {
getHostViewMode,
setHostViewMode,
useSftpHostViewMode,
} from "../../../application/state/sftp/sftpHostViewModeStore";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
/**
* Lightweight store that tracks the sorted display file names per SFTP pane.
* Used by keyboard shortcuts to navigate with ArrowUp/ArrowDown in list view.
*/
const paneItems = new Map<string, string[]>();
export const sftpListOrderStore = {
/** Update the ordered list of file names for a pane (call from SftpPaneFileList). */
setItems: (paneId: string, names: string[]) => {
paneItems.set(paneId, names);
},
/** Get the ordered list of file names (excluding "..") for arrow key navigation. */
getItems: (paneId: string): string[] => paneItems.get(paneId) ?? [],
clearPane: (paneId: string) => {
paneItems.delete(paneId);
},
};

View File

@@ -0,0 +1,373 @@
import { useCallback, useRef, useState } from "react";
import type { SftpPaneCallbacks } from "../SftpContext";
import type { SftpPane } from "../../../application/state/sftp/types";
import { getFileName, getParentPath } from "../../../application/state/sftp/utils";
import { logger } from "../../../lib/logger";
const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/;
const RESERVED_NAMES = new Set([
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
]);
interface UseSftpPaneDialogsParams {
t: (key: string, params?: Record<string, unknown>) => string;
pane: SftpPane;
onCreateDirectory: SftpPaneCallbacks["onCreateDirectory"];
onCreateDirectoryAtPath: SftpPaneCallbacks["onCreateDirectoryAtPath"];
onCreateFile: SftpPaneCallbacks["onCreateFile"];
onCreateFileAtPath: SftpPaneCallbacks["onCreateFileAtPath"];
onRenameFileAtPath: SftpPaneCallbacks["onRenameFileAtPath"];
onDeleteFilesAtPath: SftpPaneCallbacks["onDeleteFilesAtPath"];
onClearSelection: SftpPaneCallbacks["onClearSelection"];
onMutateSuccess?: (paths?: string[]) => void;
}
interface UseSftpPaneDialogsResult {
showHostPicker: boolean;
hostSearch: string;
showNewFolderDialog: boolean;
newFolderName: string;
showNewFileDialog: boolean;
newFileName: string;
fileNameError: string | null;
showOverwriteConfirm: boolean;
overwriteTarget: string | null;
showRenameDialog: boolean;
renameTarget: string | null;
renameName: string;
showDeleteConfirm: boolean;
deleteTargets: string[];
isCreating: boolean;
isCreatingFile: boolean;
isRenaming: boolean;
isDeleting: boolean;
setShowHostPicker: (open: boolean) => void;
setHostSearch: (value: string) => void;
setShowNewFolderDialog: (open: boolean) => void;
setNewFolderName: (value: string) => void;
setShowNewFileDialog: (open: boolean) => void;
setNewFileName: (value: string) => void;
setFileNameError: (value: string | null) => void;
setShowOverwriteConfirm: (open: boolean) => void;
setShowRenameDialog: (open: boolean) => void;
setRenameName: (value: string) => void;
setShowDeleteConfirm: (open: boolean) => void;
handleCreateFolder: () => Promise<void>;
handleCreateFile: (forceOverwrite?: boolean) => Promise<void>;
handleConfirmOverwrite: () => Promise<void>;
handleRename: () => Promise<void>;
handleDelete: () => Promise<void>;
openNewFolderDialogAtPath: (path: string) => void;
openNewFileDialogAtPath: (path: string) => void;
openRenameDialog: (name: string) => void;
openDeleteConfirm: (names: string[]) => void;
getNextUntitledName: (existingFiles: string[]) => string;
}
export const useSftpPaneDialogs = ({
t,
pane,
onCreateDirectory,
onCreateDirectoryAtPath,
onCreateFile,
onCreateFileAtPath,
onRenameFileAtPath,
onDeleteFilesAtPath,
onClearSelection,
onMutateSuccess,
}: UseSftpPaneDialogsParams): UseSftpPaneDialogsResult => {
const [showHostPicker, setShowHostPicker] = useState(false);
const [hostSearch, setHostSearch] = useState("");
const [showNewFolderDialogState, setShowNewFolderDialogState] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [showNewFileDialogState, setShowNewFileDialogState] = useState(false);
const [newFileName, setNewFileName] = useState("");
const [createTargetPath, setCreateTargetPath] = useState<string | null>(null);
const [fileNameError, setFileNameError] = useState<string | null>(null);
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false);
const [overwriteTarget, setOverwriteTarget] = useState<string | null>(null);
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [renameTarget, setRenameTarget] = useState<string | null>(null);
const [renameName, setRenameName] = useState("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteTargets, setDeleteTargets] = useState<string[]>([]);
const [isCreating, setIsCreating] = useState(false);
const [isCreatingFile, setIsCreatingFile] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Refs for values accessed inside useCallback to avoid stale closures
const newFolderNameRef = useRef(newFolderName);
newFolderNameRef.current = newFolderName;
const newFileNameRef = useRef(newFileName);
newFileNameRef.current = newFileName;
const createTargetPathRef = useRef(createTargetPath);
createTargetPathRef.current = createTargetPath;
const renameTargetRef = useRef(renameTarget);
renameTargetRef.current = renameTarget;
const renameNameRef = useRef(renameName);
renameNameRef.current = renameName;
const deleteTargetsRef = useRef(deleteTargets);
deleteTargetsRef.current = deleteTargets;
const paneRef = useRef(pane);
paneRef.current = pane;
const validateFileName = useCallback(
(name: string): string | null => {
const trimmed = name.trim();
if (!trimmed) return null;
const invalidMatch = trimmed.match(INVALID_FILENAME_CHARS);
if (invalidMatch) {
return t("sftp.error.invalidFileName", { chars: invalidMatch[0] });
}
const baseName = trimmed.split(".")[0].toUpperCase();
if (RESERVED_NAMES.has(baseName)) {
return t("sftp.error.reservedName");
}
return null;
},
[t],
);
const getNextUntitledName = useCallback((existingFiles: string[]): string => {
const existingSet = new Set(existingFiles.map((f) => f.toLowerCase()));
if (!existingSet.has("untitled.txt")) {
return "untitled.txt";
}
let counter = 1;
while (counter < 1000) {
const name = `untitled (${counter}).txt`;
if (!existingSet.has(name.toLowerCase())) {
return name;
}
counter++;
}
return `untitled_${Date.now()}.txt`;
}, []);
const handleCreateFolder = useCallback(async () => {
if (!newFolderNameRef.current.trim() || isCreating) return;
setIsCreating(true);
try {
if (createTargetPathRef.current) {
await onCreateDirectoryAtPath(createTargetPathRef.current, newFolderNameRef.current.trim());
} else {
await onCreateDirectory(newFolderNameRef.current.trim());
}
const affectedPath = createTargetPathRef.current ?? paneRef.current.connection?.currentPath;
onMutateSuccess?.(affectedPath ? [affectedPath] : undefined);
setShowNewFolderDialogState(false);
setCreateTargetPath(null);
setNewFolderName("");
} catch (err) {
logger.warn("Failed to create folder", err);
} finally {
setIsCreating(false);
}
}, [isCreating, onCreateDirectory, onCreateDirectoryAtPath, onMutateSuccess]);
const handleCreateFile = useCallback(async (forceOverwrite = false) => {
const trimmedName = newFileNameRef.current.trim();
if (!trimmedName || isCreatingFile) return;
const error = validateFileName(trimmedName);
if (error) {
setFileNameError(error);
return;
}
const currentPane = paneRef.current;
if (!forceOverwrite && (!createTargetPathRef.current || createTargetPathRef.current === currentPane.connection?.currentPath)) {
const existingFile = currentPane.files.find(
(f) =>
f.name.toLowerCase() === trimmedName.toLowerCase() && f.type === "file",
);
if (existingFile) {
setOverwriteTarget(trimmedName);
setShowOverwriteConfirm(true);
return;
}
}
setIsCreatingFile(true);
try {
if (createTargetPathRef.current) {
await onCreateFileAtPath(createTargetPathRef.current, trimmedName);
} else {
await onCreateFile(trimmedName);
}
const affectedPath = createTargetPathRef.current ?? paneRef.current.connection?.currentPath;
onMutateSuccess?.(affectedPath ? [affectedPath] : undefined);
setShowNewFileDialogState(false);
setShowOverwriteConfirm(false);
setOverwriteTarget(null);
setCreateTargetPath(null);
setNewFileName("");
setFileNameError(null);
} catch (err) {
logger.warn("Failed to create file", err);
} finally {
setIsCreatingFile(false);
}
}, [isCreatingFile, validateFileName, onCreateFile, onCreateFileAtPath, onMutateSuccess]);
const handleConfirmOverwrite = useCallback(async () => {
await handleCreateFile(true);
}, [handleCreateFile]);
const handleRename = useCallback(async () => {
if (!renameTargetRef.current || !renameNameRef.current.trim() || isRenaming) return;
setIsRenaming(true);
try {
// renameTarget is always a full path; use the path-aware variant
await onRenameFileAtPath(renameTargetRef.current, renameNameRef.current.trim());
onMutateSuccess?.([getParentPath(renameTargetRef.current)]);
setShowRenameDialog(false);
setRenameTarget(null);
setRenameName("");
} catch (err) {
logger.warn("Failed to rename file", err);
} finally {
setIsRenaming(false);
}
}, [isRenaming, onRenameFileAtPath, onMutateSuccess]);
const handleDelete = useCallback(async () => {
if (deleteTargetsRef.current.length === 0 || isDeleting) return;
setIsDeleting(true);
try {
// deleteTargets are full paths; group by parent dir and use path-aware variant
const byDir = new Map<string, string[]>();
for (const fullPath of deleteTargetsRef.current) {
const dir = getParentPath(fullPath);
const name = getFileName(fullPath);
const list = byDir.get(dir) ?? [];
list.push(name);
byDir.set(dir, list);
}
const connectionId = paneRef.current.connection?.id;
if (!connectionId) {
throw new Error("Pane connection is no longer available");
}
for (const [dir, names] of byDir) {
await onDeleteFilesAtPath(connectionId, dir, names);
}
onMutateSuccess?.(Array.from(byDir.keys()));
setShowDeleteConfirm(false);
setDeleteTargets([]);
onClearSelection();
} catch (err) {
logger.warn("Failed to delete files", err);
} finally {
setIsDeleting(false);
}
}, [isDeleting, onDeleteFilesAtPath, onMutateSuccess, onClearSelection]);
// entryPath is the full path; renameName is initialized to the basename
const openRenameDialog = useCallback((entryPath: string) => {
setRenameTarget(entryPath);
setRenameName(getFileName(entryPath) || entryPath);
setShowRenameDialog(true);
}, []);
const setShowNewFolderDialog = useCallback((open: boolean) => {
if (!open) {
setCreateTargetPath(null);
}
setShowNewFolderDialogState(open);
}, []);
const setShowNewFileDialog = useCallback((open: boolean) => {
if (!open) {
setCreateTargetPath(null);
}
setShowNewFileDialogState(open);
}, []);
const openNewFolderDialogAtPath = useCallback((path: string) => {
setCreateTargetPath(path);
setNewFolderName("");
setShowNewFolderDialogState(true);
}, []);
const openNewFileDialogAtPath = useCallback((path: string) => {
setCreateTargetPath(path);
setNewFileName("");
setFileNameError(null);
setShowNewFileDialogState(true);
}, []);
const openDeleteConfirm = useCallback((names: string[]) => {
setDeleteTargets(names);
setShowDeleteConfirm(true);
}, []);
return {
showHostPicker,
hostSearch,
showNewFolderDialog: showNewFolderDialogState,
newFolderName,
showNewFileDialog: showNewFileDialogState,
newFileName,
fileNameError,
showOverwriteConfirm,
overwriteTarget,
showRenameDialog,
renameTarget,
renameName,
showDeleteConfirm,
deleteTargets,
isCreating,
isCreatingFile,
isRenaming,
isDeleting,
setShowHostPicker,
setHostSearch,
setShowNewFolderDialog,
setNewFolderName,
setShowNewFileDialog,
setNewFileName,
setFileNameError,
setShowOverwriteConfirm,
setShowRenameDialog,
setRenameName,
setShowDeleteConfirm,
handleCreateFolder,
handleCreateFile,
handleConfirmOverwrite,
handleRename,
handleDelete,
openNewFolderDialogAtPath,
openNewFileDialogAtPath,
openRenameDialog,
openDeleteConfirm,
getNextUntitledName,
};
};

View File

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

View File

@@ -0,0 +1,90 @@
import { useMemo } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPane } from "../../../application/state/sftp/types";
import { isWindowsRoot, resolveSftpWindowsPathOptions } from "../../../application/state/sftp/utils";
import type { SortField, SortOrder } from "../utils";
import { filterHiddenFiles, filterSftpEntriesByName, sortSftpEntries } from "../utils";
interface UseSftpPaneFilesParams {
files: SftpFileEntry[];
filter: string;
connection: SftpPane["connection"] | null;
showHiddenFiles: boolean;
enableListView: boolean;
sortField: SortField;
sortOrder: SortOrder;
directoriesFirst: boolean;
}
interface UseSftpPaneFilesResult {
filteredFiles: SftpFileEntry[];
displayFiles: SftpFileEntry[];
sortedDisplayFiles: SftpFileEntry[];
}
export const useSftpPaneFiles = ({
files,
filter,
connection,
showHiddenFiles,
enableListView,
sortField,
sortOrder,
directoriesFirst,
}: UseSftpPaneFilesParams): UseSftpPaneFilesResult => {
// Extract ".." once and process the remaining files through filter -> sort
// in fewer passes, instead of repeatedly filtering/finding ".." entries.
const filteredFiles = useMemo(() => {
if (!enableListView) return [] as SftpFileEntry[];
return filterSftpEntriesByName(
filterHiddenFiles(files, showHiddenFiles),
filter,
);
}, [enableListView, files, filter, showHiddenFiles]);
const { displayFiles, sortedDisplayFiles } = useMemo(() => {
if (!connection || !enableListView) {
return { displayFiles: [] as SftpFileEntry[], sortedDisplayFiles: [] as SftpFileEntry[] };
}
const isRootPath =
connection.currentPath === "/" ||
isWindowsRoot(
connection.currentPath,
resolveSftpWindowsPathOptions(connection.currentPath, connection.homeDir),
);
// Split ".." from other files in a single pass
let parentEntry: SftpFileEntry | undefined;
const otherFiles: SftpFileEntry[] = [];
for (const f of filteredFiles) {
if (f.name === "..") {
parentEntry = f;
} else {
otherFiles.push(f);
}
}
// For non-root paths, always ensure a ".." entry exists
if (!isRootPath && !parentEntry) {
parentEntry = {
name: "..",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: 0,
lastModifiedFormatted: "--",
};
}
const display = parentEntry ? [parentEntry, ...otherFiles] : otherFiles;
const sorted = otherFiles.length
? sortSftpEntries(otherFiles, sortField, sortOrder, directoriesFirst)
: otherFiles;
const sortedDisplay = parentEntry ? [parentEntry, ...sorted] : sorted;
return { displayFiles: display, sortedDisplayFiles: sortedDisplay };
}, [connection, directoriesFirst, enableListView, filteredFiles, sortField, sortOrder]);
return { filteredFiles, displayFiles, sortedDisplayFiles };
};

View File

@@ -0,0 +1,164 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPane } from "../../../application/state/sftp/types";
import {
isWindowsPath,
normalizeSftpNavigationPath,
} from "../../../application/state/sftp/utils";
import { filterHiddenFiles, isNavigableDirectory } from "../utils";
interface UseSftpPanePathParams {
connection: SftpPane["connection"] | null;
files: SftpFileEntry[];
showHiddenFiles: boolean;
onNavigateTo: (path: string) => void;
}
interface UseSftpPanePathResult {
isEditingPath: boolean;
editingPathValue: string;
showPathSuggestions: boolean;
pathSuggestionIndex: number;
pathInputRef: React.RefObject<HTMLInputElement>;
pathDropdownRef: React.RefObject<HTMLDivElement>;
pathSuggestions: { path: string; type: "folder" | "history" }[];
setEditingPathValue: (value: string) => void;
setShowPathSuggestions: (value: boolean) => void;
setPathSuggestionIndex: (value: number) => void;
handlePathBlur: () => void;
handlePathKeyDown: (e: React.KeyboardEvent) => void;
handlePathDoubleClick: () => void;
handlePathSubmit: (pathOverride?: string) => void;
}
export const useSftpPanePath = ({
connection,
files,
showHiddenFiles,
onNavigateTo,
}: UseSftpPanePathParams): UseSftpPanePathResult => {
const [isEditingPath, setIsEditingPath] = useState(false);
const [editingPathValue, setEditingPathValue] = useState("");
const [showPathSuggestions, setShowPathSuggestions] = useState(false);
const [pathSuggestionIndex, setPathSuggestionIndex] = useState(-1);
const pathInputRef = useRef<HTMLInputElement>(null);
const pathDropdownRef = useRef<HTMLDivElement>(null);
const pathSuggestions = useMemo(() => {
if (!isEditingPath || !connection) return [];
const currentValue = editingPathValue.trim().toLowerCase();
const suggestions: { path: string; type: "folder" | "history" }[] = [];
const folders = filterHiddenFiles(files, showHiddenFiles).filter(
(f) => isNavigableDirectory(f) && f.name !== "..",
);
folders.forEach((f) => {
const fullPath =
connection.currentPath === "/"
? `/${f.name}`
: `${connection.currentPath}/${f.name}`;
if (
!currentValue ||
fullPath.toLowerCase().includes(currentValue) ||
f.name.toLowerCase().includes(currentValue)
) {
suggestions.push({ path: fullPath, type: "folder" });
}
});
const quickPaths = ["/home", "/var", "/etc", "/tmp", "/usr", "/opt", "/root"];
quickPaths.forEach((qp) => {
if (!currentValue || qp.toLowerCase().includes(currentValue)) {
if (!suggestions.some((s) => s.path === qp)) {
suggestions.push({ path: qp, type: "history" });
}
}
});
return suggestions.slice(0, 8);
}, [connection, editingPathValue, files, isEditingPath, showHiddenFiles]);
const handlePathDoubleClick = () => {
if (!connection) return;
setEditingPathValue(connection.currentPath);
setIsEditingPath(true);
setShowPathSuggestions(true);
setPathSuggestionIndex(-1);
setTimeout(() => pathInputRef.current?.select(), 0);
};
const handlePathSubmit = useCallback((pathOverride?: string) => {
// Only treat //host/share as UNC when this pane is already on a Windows-style path.
const acceptForwardSlashUnc = !!(
connection && isWindowsPath(connection.currentPath)
);
const newPath = normalizeSftpNavigationPath(
pathOverride ?? editingPathValue,
{ acceptForwardSlashUnc },
);
setIsEditingPath(false);
setShowPathSuggestions(false);
setPathSuggestionIndex(-1);
if (connection && newPath !== connection.currentPath) {
onNavigateTo(newPath);
}
}, [connection, editingPathValue, onNavigateTo]);
const handlePathKeyDown = (e: React.KeyboardEvent) => {
if (showPathSuggestions && pathSuggestions.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setPathSuggestionIndex((prev) =>
prev < pathSuggestions.length - 1 ? prev + 1 : 0,
);
return;
} else if (e.key === "ArrowUp") {
e.preventDefault();
setPathSuggestionIndex((prev) =>
prev > 0 ? prev - 1 : pathSuggestions.length - 1,
);
return;
} else if (e.key === "Tab" && pathSuggestionIndex >= 0) {
e.preventDefault();
setEditingPathValue(pathSuggestions[pathSuggestionIndex].path);
return;
}
}
if (e.key === "Enter") {
if (pathSuggestionIndex >= 0 && pathSuggestions[pathSuggestionIndex]) {
handlePathSubmit(pathSuggestions[pathSuggestionIndex].path);
} else {
handlePathSubmit();
}
} else if (e.key === "Escape") {
setIsEditingPath(false);
setShowPathSuggestions(false);
setPathSuggestionIndex(-1);
}
};
const handlePathBlur = useCallback(() => {
setTimeout(() => {
if (!pathDropdownRef.current?.contains(document.activeElement)) {
handlePathSubmit();
}
}, 150);
}, [handlePathSubmit]);
return {
isEditingPath,
editingPathValue,
showPathSuggestions,
pathSuggestionIndex,
pathInputRef,
pathDropdownRef,
pathSuggestions,
setEditingPathValue,
setShowPathSuggestions,
setPathSuggestionIndex,
handlePathBlur,
handlePathKeyDown,
handlePathDoubleClick,
handlePathSubmit,
};
};

View File

@@ -0,0 +1,5 @@
/** @deprecated Import from `@/application/state/sftp/useSftpPaneSorting` instead. */
export {
useSftpPaneSorting,
type UseSftpPaneSortingResult,
} from "../../../application/state/sftp/useSftpPaneSorting";

View File

@@ -0,0 +1,128 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
interface UseSftpPaneVirtualListParams {
isActive: boolean;
enabled?: boolean;
sortedDisplayFiles: SftpFileEntry[];
layoutKey?: string;
}
interface UseSftpPaneVirtualListResult {
fileListRef: React.RefObject<HTMLDivElement>;
rowHeight: number;
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
shouldVirtualize: boolean;
totalHeight: number;
visibleRows: { entry: SftpFileEntry; index: number; top: number }[];
}
export const useSftpPaneVirtualList = ({
isActive,
enabled = true,
sortedDisplayFiles,
layoutKey,
}: UseSftpPaneVirtualListParams): UseSftpPaneVirtualListResult => {
const fileListRef = useRef<HTMLDivElement>(null);
const [rowHeight, setRowHeight] = useState(0);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const scrollFrameRef = useRef<number | null>(null);
useLayoutEffect(() => {
const container = fileListRef.current;
if (!container || !isActive || !enabled) return;
const update = () => setViewportHeight(container.clientHeight);
update();
const raf = window.requestAnimationFrame(update);
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
window.cancelAnimationFrame(raf);
};
}, [enabled, isActive, layoutKey, sortedDisplayFiles.length]);
useLayoutEffect(() => {
const container = fileListRef.current;
if (!container || !isActive || !enabled || sortedDisplayFiles.length === 0) return;
const raf = window.requestAnimationFrame(() => {
const rowElement = container.querySelector(
'[data-sftp-row="true"]',
) as HTMLElement | null;
if (!rowElement) return;
const nextHeight = Math.round(rowElement.getBoundingClientRect().height);
if (nextHeight && Math.abs(nextHeight - rowHeight) > 1) {
setRowHeight(nextHeight);
}
});
return () => window.cancelAnimationFrame(raf);
}, [enabled, isActive, layoutKey, rowHeight, sortedDisplayFiles.length]);
useEffect(() => {
return () => {
if (scrollFrameRef.current !== null) {
window.cancelAnimationFrame(scrollFrameRef.current);
}
};
}, []);
const handleFileListScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
if (!isActive || !enabled) return;
const nextTop = e.currentTarget.scrollTop;
if (scrollFrameRef.current !== null) return;
scrollFrameRef.current = window.requestAnimationFrame(() => {
scrollFrameRef.current = null;
setScrollTop(nextTop);
});
},
[enabled, isActive],
);
const { shouldVirtualize, totalHeight, visibleRows } = useMemo(() => {
const overscan = 6;
const canVirtualize = enabled && isActive && viewportHeight > 0 && rowHeight > 0;
const shouldVirtualizeLocal = canVirtualize && sortedDisplayFiles.length > 50;
const totalHeightLocal = shouldVirtualizeLocal
? sortedDisplayFiles.length * rowHeight
: 0;
const startIndex = shouldVirtualizeLocal
? Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
: 0;
const endIndex = shouldVirtualizeLocal
? Math.min(
sortedDisplayFiles.length - 1,
Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan,
)
: sortedDisplayFiles.length - 1;
const visibleRowsLocal = shouldVirtualizeLocal
? sortedDisplayFiles
.slice(startIndex, endIndex + 1)
.map((entry, idx) => ({
entry,
index: startIndex + idx,
top: (startIndex + idx) * rowHeight,
}))
: sortedDisplayFiles.map((entry, index) => ({
entry,
index,
top: 0,
}));
return {
shouldVirtualize: shouldVirtualizeLocal,
totalHeight: totalHeightLocal,
visibleRows: visibleRowsLocal,
};
}, [enabled, isActive, rowHeight, scrollTop, sortedDisplayFiles, viewportHeight]);
return {
fileListRef,
rowHeight,
handleFileListScroll,
shouldVirtualize,
totalHeight,
visibleRows,
};
};

View File

@@ -0,0 +1,6 @@
/** @deprecated Import from `@/application/state/sftp/sftpTreeSelectionStore` instead. */
export {
sftpTreeSelectionStore,
useSftpTreeSelectionState,
type SftpTreeSelectionItem,
} from "../../../application/state/sftp/sftpTreeSelectionStore";

View File

@@ -0,0 +1,799 @@
import { useCallback, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
import type { TransferStatus } from "../../../domain/models";
import { getParentPath, joinPath as joinFsPath } from "../../../application/state/sftp/utils";
import {
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
runBoundedConcurrency,
} from "../../../application/state/sftp/transferConcurrency";
import { logger } from "../../../lib/logger";
import { toast } from "../../ui/toast";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { getFileExtension, getLanguageId, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { isNavigableDirectory } from "../utils";
import { reportSftpUploadResults } from "../reportSftpUploadResults";
import { editorTabStore } from "../../../application/state/editorTabStore";
import { toEditorTabId, activeTabStore } from "../../../application/state/activeTabStore";
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
import type { UseSftpViewFileOpsParams, UseSftpViewFileOpsResult } from "./useSftpViewFileOps.types";
import { assertSftpFileFitsBuiltinEditor } from "../sftpEditorFileLimits";
/** Local multi-select blob downloads read whole files into ArrayBuffers. */
const LOCAL_BLOB_DOWNLOAD_CONCURRENCY = 1;
/**
* Multi-select roots each start their own interleaved folder walk / session
* work. Bound them so many selected directories cannot stampede the scheduler.
*/
const MULTI_SELECT_ROOT_DOWNLOAD_CONCURRENCY = DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY;
export const useSftpViewFileOps = ({
sftpRef,
behaviorRef,
autoSyncRef,
getOpenerForFileRef,
setOpenerForExtension,
t,
showSaveDialog,
selectDirectory,
getSftpIdForConnection,
}: UseSftpViewFileOpsParams): UseSftpViewFileOpsResult => {
const [permissionsState, setPermissionsState] = useState<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null>(null);
const [showTextEditor, setShowTextEditor] = useState(false);
const [textEditorTarget, setTextEditorTarget] = useState<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
/** Host ID at the time the file was opened, to prevent saving to wrong host.
* Uses hostId (not connectionId) because auto-reconnect after a transient
* disconnect generates a fresh connectionId for the same endpoint. */
hostId?: string;
} | null>(null);
const [textEditorContent, setTextEditorContent] = useState("");
const [loadingTextContent, setLoadingTextContent] = useState(false);
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
const [fileOpenerTarget, setFileOpenerTarget] = useState<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null>(null);
// Refs for frequently-changing state used inside stable callbacks
const fileOpenerTargetRef = useRef(fileOpenerTarget);
fileOpenerTargetRef.current = fileOpenerTarget;
const textEditorTargetRef = useRef(textEditorTarget);
textEditorTargetRef.current = textEditorTarget;
const onEditPermissionsLeft = useCallback(
(file: SftpFileEntry, fullPath?: string) => {
const pane = sftpRef.current.leftPane;
if (!pane.connection) return;
setPermissionsState({
file,
side: "left",
fullPath: fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name),
});
},
[sftpRef],
);
const onEditPermissionsRight = useCallback(
(file: SftpFileEntry, fullPath?: string) => {
const pane = sftpRef.current.rightPane;
if (!pane.connection) return;
setPermissionsState({
file,
side: "right",
fullPath: fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name),
});
},
[sftpRef],
);
const handleEditFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
try {
assertSftpFileFitsBuiltinEditor(file.size);
setLoadingTextContent(true);
setTextEditorTarget({ file, side, fullPath: resolvedFullPath, hostId: pane.connection.hostId });
const content = await sftpRef.current.readTextFile(side, resolvedFullPath);
setTextEditorContent(content);
setShowTextEditor(true);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to load file", "SFTP");
setTextEditorTarget(null);
} finally {
setLoadingTextContent(false);
}
},
[sftpRef],
);
const handleOpenFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const savedOpener = getOpenerForFileRef.current(file.name);
if (savedOpener && savedOpener.openerType) {
if (savedOpener.openerType === "builtin-editor") {
handleEditFileForSide(side, file, resolvedFullPath);
return;
} else if (savedOpener.openerType === "system-app" && savedOpener.systemApp) {
try {
await sftpRef.current.downloadToTempAndOpen(
side,
resolvedFullPath,
file.name,
savedOpener.systemApp.path,
{ enableWatch: autoSyncRef.current },
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to open file", "SFTP");
}
return;
}
}
setFileOpenerTarget({ file, side, fullPath: resolvedFullPath });
setShowFileOpenerDialog(true);
},
[sftpRef, handleEditFileForSide, getOpenerForFileRef, autoSyncRef],
);
const handleFileOpenerSelect = useCallback(
async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => {
const target = fileOpenerTargetRef.current;
if (!target) return;
if (setAsDefault) {
const ext = getFileExtension(target.file.name);
setOpenerForExtension(ext, openerType, systemApp);
}
setShowFileOpenerDialog(false);
if (openerType === "builtin-editor") {
handleEditFileForSide(target.side, target.file, target.fullPath);
} else if (openerType === "system-app" && systemApp) {
try {
await sftpRef.current.downloadToTempAndOpen(
target.side,
target.fullPath,
target.file.name,
systemApp.path,
{ enableWatch: autoSyncRef.current },
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to open file", "SFTP");
}
}
setFileOpenerTarget(null);
},
[setOpenerForExtension, handleEditFileForSide, autoSyncRef, sftpRef],
);
const handleSelectSystemApp = useCallback(async (): Promise<SystemAppInfo | null> => {
const result = await sftpRef.current.selectApplication();
if (result) {
return { path: result.path, name: result.name };
}
return null;
}, [sftpRef]);
const handleSaveTextFile = useCallback(
async (content: string) => {
const target = textEditorTargetRef.current;
if (!target) return;
// Verify the SFTP connection hasn't switched to a different host.
// We check hostId (not connectionId) because auto-reconnect after a
// transient disconnect generates a fresh connectionId for the same
// endpoint. The auto-connect effect in SftpSidePanel blocks
// host-switching while the editor is open, so a hostId mismatch here
// reliably indicates a genuinely different endpoint.
const currentPane = target.side === "left"
? sftpRef.current.leftPane
: sftpRef.current.rightPane;
if (target.hostId && currentPane.connection?.hostId !== target.hostId) {
throw new Error("SFTP connection changed while editing — file not saved to prevent writing to wrong host");
}
await sftpRef.current.writeTextFile(
target.side,
target.fullPath,
content,
);
},
[sftpRef],
);
const handlePromoteToTab = useCallback((snapshot: TextEditorModalSnapshot) => {
const target = textEditorTargetRef.current;
if (!target) return;
const pane = target.side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
const connection = pane.connection;
if (!connection || !target.hostId) return;
const editorId = editorTabStore.promoteFromModal({
sessionId: connection.id,
sftpTabId: pane.id,
hostId: target.hostId,
remotePath: target.fullPath,
fileName: target.file.name,
languageId: snapshot.languageId || getLanguageId(target.file.name),
content: snapshot.content,
baselineContent: snapshot.baselineContent,
wordWrap: snapshot.wordWrap,
viewState: snapshot.viewState,
});
activeTabStore.setActiveTabId(toEditorTabId(editorId));
// Close the modal
setShowTextEditor(false);
setTextEditorTarget(null);
setTextEditorContent("");
}, [sftpRef]);
const onEditFileLeft = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleEditFileForSide("left", file, fullPath),
[handleEditFileForSide],
);
const onEditFileRight = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleEditFileForSide("right", file, fullPath),
[handleEditFileForSide],
);
const onOpenFileLeft = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleOpenFileForSide("left", file, fullPath),
[handleOpenFileForSide],
);
const onOpenFileRight = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleOpenFileForSide("right", file, fullPath),
[handleOpenFileForSide],
);
const handleOpenFileWithSystemDefaultForSide = useCallback(
(side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
void sftpRef.current.openWithSystemDefault(side, resolvedFullPath, file.name, { enableWatch: autoSyncRef.current });
},
[sftpRef, autoSyncRef],
);
const onOpenFileWithSystemDefaultLeft = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleOpenFileWithSystemDefaultForSide("left", file, fullPath),
[handleOpenFileWithSystemDefaultForSide],
);
const onOpenFileWithSystemDefaultRight = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleOpenFileWithSystemDefaultForSide("right", file, fullPath),
[handleOpenFileWithSystemDefaultForSide],
);
const handleOpenFileWithForSide = useCallback(
(side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
setFileOpenerTarget({ file, side, fullPath: resolvedFullPath });
setShowFileOpenerDialog(true);
},
[sftpRef],
);
const onOpenFileWithLeft = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleOpenFileWithForSide("left", file, fullPath),
[handleOpenFileWithForSide],
);
const onOpenFileWithRight = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleOpenFileWithForSide("right", file, fullPath),
[handleOpenFileWithForSide],
);
const handleUploadExternalFilesForSide = useCallback(
async (side: "left" | "right", dataTransfer: DataTransfer, targetPath?: string) => {
try {
const results = await sftpRef.current.uploadExternalFiles(side, dataTransfer, targetPath);
reportSftpUploadResults({ results, t, toast });
} catch (error) {
logger.error("[SftpView] Failed to upload external files:", error);
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP",
);
}
},
[sftpRef, t],
);
const onUploadExternalFilesLeft = useCallback(
(dataTransfer: DataTransfer, targetPath?: string) => handleUploadExternalFilesForSide("left", dataTransfer, targetPath),
[handleUploadExternalFilesForSide],
);
const onUploadExternalFilesRight = useCallback(
(dataTransfer: DataTransfer, targetPath?: string) => handleUploadExternalFilesForSide("right", dataTransfer, targetPath),
[handleUploadExternalFilesForSide],
);
const handleUploadExternalFileListForSide = useCallback(
async (side: "left" | "right", fileList: FileList, targetPath?: string) => {
try {
const results = await sftpRef.current.uploadExternalFileList(side, fileList, targetPath);
reportSftpUploadResults({ results, t, toast });
} catch (error) {
logger.error("[SftpView] Failed to upload picked files:", error);
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP",
);
}
},
[sftpRef, t],
);
const onUploadExternalFileListLeft = useCallback(
(fileList: FileList, targetPath?: string) => handleUploadExternalFileListForSide("left", fileList, targetPath),
[handleUploadExternalFileListForSide],
);
const onUploadExternalFileListRight = useCallback(
(fileList: FileList, targetPath?: string) => handleUploadExternalFileListForSide("right", fileList, targetPath),
[handleUploadExternalFileListForSide],
);
const handleUploadExternalFolderForSide = useCallback(
async (side: "left" | "right", targetPath?: string) => {
if (!selectDirectory) {
toast.error(t("sftp.error.uploadFailed"), "SFTP");
return;
}
const selectedDirectory = await selectDirectory(t("sftp.context.uploadFolder"));
if (!selectedDirectory) return;
try {
const results = await sftpRef.current.uploadExternalFolderPath(side, selectedDirectory, targetPath);
const folderName = selectedDirectory.split(/[/\\]/).filter(Boolean).pop() || selectedDirectory;
reportSftpUploadResults({
results,
t,
toast,
successMessage: `${t("sftp.uploadFolder")}: ${folderName}`,
});
} catch (error) {
logger.error("[SftpView] Failed to upload picked folder:", error);
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP",
);
}
},
[selectDirectory, sftpRef, t],
);
const onUploadExternalFolderLeft = useCallback(
(targetPath?: string) => handleUploadExternalFolderForSide("left", targetPath),
[handleUploadExternalFolderForSide],
);
const onUploadExternalFolderRight = useCallback(
(targetPath?: string) => handleUploadExternalFolderForSide("right", targetPath),
[handleUploadExternalFolderForSide],
);
const handleDownloadFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const isDirectory = isNavigableDirectory(file);
try {
// For local files, use blob download.
if (pane.connection.isLocal) {
if (isDirectory) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
const content = await sftpRef.current.readBinaryFile(side, resolvedFullPath);
const blob = new Blob([content], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
return;
}
// For remote SFTP files/directories, use transfer-center downloads
// (dedicated pool sessions via downloadToLocal).
if (!showSaveDialog || !getSftpIdForConnection) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
const sftpId = getSftpIdForConnection(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
if (isDirectory) {
if (!selectDirectory) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
const selectedDirectory = await selectDirectory(t("sftp.context.download"));
if (!selectedDirectory) return;
const targetPath = joinFsPath(selectedDirectory, file.name);
try {
const status = await sftpRef.current.downloadToLocal({
fileName: file.name,
sourcePath: resolvedFullPath,
targetPath,
sftpId,
connectionId: pane.connection.id,
sourceHostId: pane.connection.hostId,
sourceHostLabel: pane.connection.hostLabel,
sourceEncoding: pane.filenameEncoding,
isDirectory: true,
});
if (status === "completed") {
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
} else if (status === "failed") {
toast.error(`${t("sftp.error.downloadFailed")}: ${file.name}`, "SFTP");
} else if (status === "attention") {
toast.error(`${file.name}: another transfer for this path is already in progress`, "SFTP");
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t("sftp.error.downloadFailed");
if (!errorMessage.includes("cancelled") && !errorMessage.includes("canceled")) {
toast.error(errorMessage, "SFTP");
}
}
return;
}
// Show save dialog to get target path
const targetPath = await showSaveDialog(file.name);
if (!targetPath) {
// User cancelled
return;
}
const fileSize = typeof file.size === "string" ? parseInt(file.size, 10) || 0 : (file.size || 0);
// Route through downloadToLocal so FileZilla-style transfer pool
// sessions are used (browse session stays free for listing).
const status = await sftpRef.current.downloadToLocal({
fileName: file.name,
sourcePath: resolvedFullPath,
targetPath,
sftpId,
connectionId: pane.connection.id,
sourceHostId: pane.connection.hostId,
sourceHostLabel: pane.connection.hostLabel,
sourceEncoding: pane.filenameEncoding,
isDirectory: false,
totalBytes: fileSize,
});
if (status === "completed") {
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
} else if (status === "failed") {
toast.error(`${t("sftp.error.downloadFailed")}: ${file.name}`, "SFTP");
} else if (status === "attention") {
toast.error(`${file.name}: another transfer for this path is already in progress`, "SFTP");
}
} catch (e) {
logger.error("[SftpView] Failed to download file:", e);
const errorMessage = e instanceof Error ? e.message : String(e);
const isCancelError = errorMessage.includes("cancelled") || errorMessage.includes("canceled");
if (!isCancelError) toast.error(errorMessage || t("sftp.error.downloadFailed"), "SFTP");
}
},
[
sftpRef,
t,
showSaveDialog,
selectDirectory,
getSftpIdForConnection,
],
);
const onDownloadFileLeft = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleDownloadFileForSide("left", file, fullPath),
[handleDownloadFileForSide],
);
const onDownloadFileRight = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleDownloadFileForSide("right", file, fullPath),
[handleDownloadFileForSide],
);
const handleExtractArchiveForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const resolvedPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
toast.info(t("sftp.extract.extracting", { fileName: file.name }), "SFTP");
try {
const bridge = netcattyBridge.get();
if (pane.connection.isLocal) {
if (!bridge?.extractLocalArchive) {
throw new Error("Local extract unavailable");
}
await bridge.extractLocalArchive(resolvedPath);
} else {
const sftpId = getSftpIdForConnection?.(pane.connection.id);
if (!sftpId || !bridge?.extractSftpArchive) {
throw new Error("SFTP session not found");
}
await bridge.extractSftpArchive(sftpId, resolvedPath, pane.filenameEncoding);
}
await sftpRef.current.refresh(side);
toast.success(t("sftp.extract.success", { fileName: file.name }), "SFTP");
} catch (e) {
logger.error("[SftpView] Failed to extract archive:", e);
toast.error(
t("sftp.extract.error", {
fileName: file.name,
error: e instanceof Error ? e.message : String(e),
}),
"SFTP",
);
}
},
[getSftpIdForConnection, sftpRef, t],
);
const onExtractArchiveLeft = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleExtractArchiveForSide("left", file, fullPath),
[handleExtractArchiveForSide],
);
const onExtractArchiveRight = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleExtractArchiveForSide("right", file, fullPath),
[handleExtractArchiveForSide],
);
// Multi-file download. For local panes, each file auto-downloads as a blob
// (no prompt). For remote panes, prompts for a target directory once and
// streams all selected entries into it — avoids the per-file save dialog
// that would otherwise appear N times.
const handleDownloadFilesForSide = useCallback(
async (side: "left" | "right", files: SftpFileEntry[]) => {
if (files.length === 0) return;
if (files.length === 1) {
await handleDownloadFileForSide(side, files[0]);
return;
}
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
if (pane.connection.isLocal) {
// Sequential: each local download materializes a full ArrayBuffer.
await runBoundedConcurrency(
files,
LOCAL_BLOB_DOWNLOAD_CONCURRENCY,
async (file) => {
await handleDownloadFileForSide(side, file);
},
);
return;
}
if (!selectDirectory || !getSftpIdForConnection) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
const sftpId = getSftpIdForConnection(pane.connection.id);
if (!sftpId) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
const selectedDirectory = await selectDirectory(t("sftp.context.download"));
if (!selectedDirectory) return;
// Bound root jobs: each directory root walks and transfers independently,
// so unbounded Promise.allSettled would multiply session / LIST pressure.
const results: Array<PromiseSettledResult<{ file: SftpFileEntry; status: TransferStatus }>> = [];
await runBoundedConcurrency(
files,
MULTI_SELECT_ROOT_DOWNLOAD_CONCURRENCY,
async (file, index) => {
try {
const sourcePath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const targetPath = joinFsPath(selectedDirectory, file.name);
const isDirectory = isNavigableDirectory(file);
const fileSize = typeof file.size === "string" ? parseInt(file.size, 10) || 0 : (file.size || 0);
const status = await sftpRef.current.downloadToLocal({
fileName: file.name,
sourcePath,
targetPath,
sftpId,
connectionId: pane.connection.id,
sourceHostId: pane.connection.hostId,
sourceHostLabel: pane.connection.hostLabel,
sourceEncoding: pane.filenameEncoding,
isDirectory,
totalBytes: isDirectory ? undefined : fileSize,
});
results[index] = { status: "fulfilled", value: { file, status } };
} catch (reason) {
results[index] = { status: "rejected", reason };
}
},
);
for (const result of results) {
if (!result) continue;
if (result.status === "fulfilled") {
const { file, status } = result.value;
if (status === "completed") {
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
} else if (status === "failed") {
toast.error(`${t("sftp.error.downloadFailed")}: ${file.name}`, "SFTP");
} else if (status === "attention") {
toast.error(`${file.name}: another transfer for this path is already in progress`, "SFTP");
}
} else {
logger.error("[SftpView] Failed to download file:", result.reason);
const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason);
const isCancelError = errorMessage.includes("cancelled") || errorMessage.includes("canceled");
if (!isCancelError) toast.error(errorMessage || t("sftp.error.downloadFailed"), "SFTP");
}
}
},
[
sftpRef,
t,
selectDirectory,
getSftpIdForConnection,
handleDownloadFileForSide,
],
);
const onDownloadFilesLeft = useCallback(
(files: SftpFileEntry[]) => handleDownloadFilesForSide("left", files),
[handleDownloadFilesForSide],
);
const onDownloadFilesRight = useCallback(
(files: SftpFileEntry[]) => handleDownloadFilesForSide("right", files),
[handleDownloadFilesForSide],
);
const onOpenEntryLeft = useCallback(
(entry: SftpFileEntry, fullPath?: string) => {
const pane = sftpRef.current.leftPane;
const isDir = isNavigableDirectory(entry);
if (entry.name === ".." || isDir) {
sftpRef.current.openEntry("left", entry);
return;
}
if (behaviorRef.current === "transfer") {
const sourcePath = fullPath ? getParentPath(fullPath) : pane.connection?.currentPath;
const sourceConnectionId = pane.connection?.id;
const fileData = [{
name: entry.name,
isDirectory: isDir,
sourceConnectionId,
sourcePath,
}];
sftpRef.current.startTransfer(fileData, "left", "right", {
sourceConnectionId,
sourcePath,
});
} else {
onOpenFileLeft(entry, fullPath);
}
},
[sftpRef, onOpenFileLeft, behaviorRef],
);
const onOpenEntryRight = useCallback(
(entry: SftpFileEntry, fullPath?: string) => {
const pane = sftpRef.current.rightPane;
const isDir = isNavigableDirectory(entry);
if (entry.name === ".." || isDir) {
sftpRef.current.openEntry("right", entry);
return;
}
if (behaviorRef.current === "transfer") {
const sourcePath = fullPath ? getParentPath(fullPath) : pane.connection?.currentPath;
const sourceConnectionId = pane.connection?.id;
const fileData = [{
name: entry.name,
isDirectory: isDir,
sourceConnectionId,
sourcePath,
}];
sftpRef.current.startTransfer(fileData, "right", "left", {
sourceConnectionId,
sourcePath,
});
} else {
onOpenFileRight(entry, fullPath);
}
},
[sftpRef, onOpenFileRight, behaviorRef],
);
return {
permissionsState,
setPermissionsState,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
loadingTextContent,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
handleSaveTextFile,
onPromoteToTab: handlePromoteToTab,
handleFileOpenerSelect,
handleSelectSystemApp,
onEditPermissionsLeft,
onEditPermissionsRight,
onOpenEntryLeft,
onOpenEntryRight,
onEditFileLeft,
onEditFileRight,
onOpenFileLeft,
onOpenFileRight,
onOpenFileWithSystemDefaultLeft,
onOpenFileWithSystemDefaultRight,
onOpenFileWithLeft,
onOpenFileWithRight,
onDownloadFileLeft,
onDownloadFileRight,
onExtractArchiveLeft,
onExtractArchiveRight,
onDownloadFilesLeft,
onDownloadFilesRight,
onUploadExternalFilesLeft,
onUploadExternalFilesRight,
onUploadExternalFileListLeft,
onUploadExternalFileListRight,
onUploadExternalFolderLeft,
onUploadExternalFolderRight,
};
};

View File

@@ -0,0 +1,94 @@
import type React from "react";
import type { MutableRefObject } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
export interface UseSftpViewFileOpsParams {
sftpRef: MutableRefObject<SftpStateApi>;
behaviorRef: MutableRefObject<string>;
autoSyncRef: MutableRefObject<boolean>;
getOpenerForFileRef: MutableRefObject<
(fileName: string) => { openerType?: FileOpenerType; systemApp?: SystemAppInfo } | null
>;
setOpenerForExtension: (
extension: string,
openerType: FileOpenerType,
systemApp?: SystemAppInfo,
) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
getSftpIdForConnection?: (connectionId: string) => string | undefined;
}
export interface UseSftpViewFileOpsResult {
permissionsState: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
setPermissionsState: React.Dispatch<
React.SetStateAction<{ file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null>
>;
showTextEditor: boolean;
setShowTextEditor: React.Dispatch<React.SetStateAction<boolean>>;
textEditorTarget: {
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null;
setTextEditorTarget: React.Dispatch<
React.SetStateAction<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null>
>;
textEditorContent: string;
setTextEditorContent: React.Dispatch<React.SetStateAction<string>>;
loadingTextContent: boolean;
showFileOpenerDialog: boolean;
setShowFileOpenerDialog: React.Dispatch<React.SetStateAction<boolean>>;
fileOpenerTarget: {
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null;
setFileOpenerTarget: React.Dispatch<
React.SetStateAction<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null>
>;
handleSaveTextFile: (content: string) => Promise<void>;
onPromoteToTab: (snapshot: TextEditorModalSnapshot) => void;
handleFileOpenerSelect: (
openerType: FileOpenerType,
setAsDefault: boolean,
systemApp?: SystemAppInfo,
) => Promise<void>;
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
onEditPermissionsLeft: (file: SftpFileEntry, fullPath?: string) => void;
onEditPermissionsRight: (file: SftpFileEntry, fullPath?: string) => void;
onOpenEntryLeft: (entry: SftpFileEntry, fullPath?: string) => void;
onOpenEntryRight: (entry: SftpFileEntry, fullPath?: string) => void;
onEditFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
onEditFileRight: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileRight: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileWithSystemDefaultLeft: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileWithSystemDefaultRight: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileWithLeft: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileWithRight: (file: SftpFileEntry, fullPath?: string) => void;
onDownloadFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
onDownloadFileRight: (file: SftpFileEntry, fullPath?: string) => void;
onExtractArchiveLeft: (file: SftpFileEntry, fullPath?: string) => void | Promise<void>;
onExtractArchiveRight: (file: SftpFileEntry, fullPath?: string) => void | Promise<void>;
onDownloadFilesLeft: (files: SftpFileEntry[]) => void;
onDownloadFilesRight: (files: SftpFileEntry[]) => void;
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
onUploadExternalFilesRight: (dataTransfer: DataTransfer, targetPath?: string) => void;
onUploadExternalFileListLeft: (fileList: FileList, targetPath?: string) => void;
onUploadExternalFileListRight: (fileList: FileList, targetPath?: string) => void;
onUploadExternalFolderLeft: (targetPath?: string) => Promise<void>;
onUploadExternalFolderRight: (targetPath?: string) => Promise<void>;
}

View File

@@ -0,0 +1,405 @@
import { useCallback, useMemo, useRef, 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";
import { editorTabStore } from "../../../application/state/editorTabStore";
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave";
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
import { toast } from "../../ui/toast";
import { requireCopyToOtherPaneTarget } from "../copyToOtherPane";
interface UseSftpViewPaneActionsParams {
sftpRef: MutableRefObject<SftpStateApi>;
t: (key: string, vars?: Record<string, string | number>) => string;
}
interface UseSftpViewPaneActionsResult {
dragCallbacks: SftpDragCallbacks;
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
onConnectLeft: (
host: Parameters<SftpStateApi["connect"]>[1],
options?: Parameters<SftpStateApi["connect"]>[2],
) => void;
onConnectRight: (
host: Parameters<SftpStateApi["connect"]>[1],
options?: Parameters<SftpStateApi["connect"]>[2],
) => void;
onDisconnectLeft: () => Promise<boolean>;
onDisconnectRight: () => Promise<boolean>;
onPrepareSelectionLeft: () => void;
onPrepareSelectionRight: () => void;
onNavigateToLeft: (path: string) => void;
onNavigateToRight: (path: string) => void;
onNavigateUpLeft: () => void;
onNavigateUpRight: () => void;
onRefreshLeft: () => void;
onRefreshRight: () => void;
onRefreshTabLeft: (tabId: string) => void;
onRefreshTabRight: (tabId: string) => void;
onSetFilenameEncodingLeft: (encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) => void;
onSetFilenameEncodingRight: (encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) => void;
onToggleSelectionLeft: (name: string, multi: boolean) => void;
onToggleSelectionRight: (name: string, multi: boolean) => void;
onRangeSelectLeft: (fileNames: string[]) => void;
onRangeSelectRight: (fileNames: string[]) => void;
onClearSelectionLeft: () => void;
onClearSelectionRight: () => void;
onSetFilterLeft: (filter: string) => void;
onSetFilterRight: (filter: string) => void;
onCreateDirectoryLeft: (name: string) => void;
onCreateDirectoryRight: (name: string) => void;
onCreateDirectoryAtPathLeft: (path: string, name: string) => void;
onCreateDirectoryAtPathRight: (path: string, name: string) => void;
onCreateFileLeft: (name: string) => void;
onCreateFileRight: (name: string) => void;
onCreateFileAtPathLeft: (path: string, name: string) => void;
onCreateFileAtPathRight: (path: string, name: string) => void;
onDeleteFilesLeft: (names: string[]) => void;
onDeleteFilesRight: (names: string[]) => void;
onDeleteFilesAtPathLeft: (connectionId: string, path: string, names: string[]) => void;
onDeleteFilesAtPathRight: (connectionId: string, path: string, names: string[]) => void;
onRenameFileLeft: (old: string, newName: string) => void;
onRenameFileRight: (old: string, newName: string) => void;
onRenameFileAtPathLeft: (oldPath: string, newName: string) => void;
onRenameFileAtPathRight: (oldPath: string, newName: string) => void;
onMoveEntriesToPathLeft: (sourcePaths: string[], targetPath: string) => void;
onMoveEntriesToPathRight: (sourcePaths: string[], targetPath: string) => void;
onCopyToOtherPaneLeft: (files: SftpTransferSource[]) => void;
onCopyToOtherPaneRight: (files: SftpTransferSource[]) => void;
onReceiveFromOtherPaneLeft: (files: SftpTransferSource[]) => void;
onReceiveFromOtherPaneRight: (files: SftpTransferSource[]) => void;
}
export async function disconnectSftpPaneAfterConfirmation(params: {
confirmClose: () => Promise<boolean>;
disconnect: () => Promise<void>;
}): Promise<boolean> {
if (!await params.confirmClose()) return false;
await params.disconnect();
return true;
}
export const useSftpViewPaneActions = ({
sftpRef,
t,
}: UseSftpViewPaneActionsParams): UseSftpViewPaneActionsResult => {
const tRef = useRef(t);
tRef.current = t;
const [draggedFiles, setDraggedFiles] = useState<
(SftpTransferSource & { side: "left" | "right" })[] | null
>(null);
const handleDragStart = useCallback(
(
files: SftpTransferSource[],
side: "left" | "right",
) => {
setDraggedFiles(files.map((f) => ({ ...f, side })));
},
[],
);
const handleDragEnd = useCallback(() => {
setDraggedFiles(null);
}, []);
const startGroupedTransfer = useCallback(
(files: SftpTransferSource[], sourceSide: "left" | "right", targetSide: "left" | "right") => {
if (!requireCopyToOtherPaneTarget(
sftpRef.current,
targetSide,
() => toast.info(tRef.current("sftp.copyToOtherPane.unavailable"), "SFTP"),
)) {
return;
}
const groups = new Map<string, SftpTransferSource[]>();
for (const file of files) {
const key = `${file.sourceConnectionId ?? ""}::${file.sourcePath ?? ""}`;
const group = groups.get(key) ?? [];
group.push(file);
groups.set(key, group);
}
for (const group of groups.values()) {
const [{ sourceConnectionId, sourcePath, targetPath }] = group;
void sftpRef.current.startTransfer(group, sourceSide, targetSide, {
sourceConnectionId,
sourcePath,
targetPath,
});
}
},
[sftpRef],
);
const onCopyToOtherPaneLeft = useCallback(
(files: SftpTransferSource[]) => startGroupedTransfer(files, "left", "right"),
[startGroupedTransfer],
);
const onCopyToOtherPaneRight = useCallback(
(files: SftpTransferSource[]) => startGroupedTransfer(files, "right", "left"),
[startGroupedTransfer],
);
const onReceiveFromOtherPaneLeft = useCallback(
(files: SftpTransferSource[]) => startGroupedTransfer(files, "right", "left"),
[startGroupedTransfer],
);
const onReceiveFromOtherPaneRight = useCallback(
(files: SftpTransferSource[]) => startGroupedTransfer(files, "left", "right"),
[startGroupedTransfer],
);
const onConnectLeft = useCallback(
(
host: Parameters<SftpStateApi["connect"]>[1],
options?: Parameters<SftpStateApi["connect"]>[2],
) => sftpRef.current.connect("left", host, options),
[sftpRef],
);
const onConnectRight = useCallback(
(
host: Parameters<SftpStateApi["connect"]>[1],
options?: Parameters<SftpStateApi["connect"]>[2],
) => sftpRef.current.connect("right", host, options),
[sftpRef],
);
// Returns `true` if the disconnect actually happened, `false` if the user
// canceled the dirty-editor prompt. Callers that kick off a replacement
// connect (e.g. the host picker) MUST gate their follow-up on this result
// so a canceled prompt doesn't silently drop the user onto a new host.
const confirmCloseActivePaneEditors = useCallback(async (side: "left" | "right"): Promise<boolean> => {
const pane = sftpRef.current.getActivePane(side);
if (!pane?.connection?.id && !pane?.id) return true;
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
const saveTab = async (id: EditorTabId) => {
const ok = await saveEditorTab(id);
const tab = editorTabStore.getTab(id);
if (!ok || (tab && tab.content !== tab.baselineContent)) {
throw new Error(tab?.saveError ?? "Save failed");
}
};
return editorTabStore.confirmCloseByOwner(
{ sessionId: pane.connection?.id, sftpTabId: pane.id },
choice,
saveTab,
releaseEditorTabSaveCoordinator,
);
}, [sftpRef]);
const onDisconnectLeft = useCallback(async (): Promise<boolean> => {
return disconnectSftpPaneAfterConfirmation({
confirmClose: () => confirmCloseActivePaneEditors("left"),
disconnect: () => sftpRef.current.disconnect("left"),
});
}, [confirmCloseActivePaneEditors, sftpRef]);
const onDisconnectRight = useCallback(async (): Promise<boolean> => {
return disconnectSftpPaneAfterConfirmation({
confirmClose: () => confirmCloseActivePaneEditors("right"),
disconnect: () => sftpRef.current.disconnect("right"),
});
}, [confirmCloseActivePaneEditors, 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],
);
const onNavigateToRight = useCallback(
(path: string) => sftpRef.current.navigateTo("right", path),
[sftpRef],
);
const onNavigateUpLeft = useCallback(() => sftpRef.current.navigateUp("left"), [sftpRef]);
const onNavigateUpRight = useCallback(() => sftpRef.current.navigateUp("right"), [sftpRef]);
const onRefreshLeft = useCallback(() => sftpRef.current.refresh("left"), [sftpRef]);
const onRefreshRight = useCallback(() => sftpRef.current.refresh("right"), [sftpRef]);
const onRefreshTabLeft = useCallback((tabId: string) => sftpRef.current.refresh("left", { tabId }), [sftpRef]);
const onRefreshTabRight = useCallback((tabId: string) => sftpRef.current.refresh("right", { tabId }), [sftpRef]);
const onSetFilenameEncodingLeft = useCallback(
(encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) =>
sftpRef.current.setFilenameEncoding("left", encoding),
[sftpRef],
);
const onSetFilenameEncodingRight = useCallback(
(encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) =>
sftpRef.current.setFilenameEncoding("right", encoding),
[sftpRef],
);
const onToggleSelectionLeft = useCallback(
(name: string, multi: boolean) => {
onPrepareSelectionLeft();
sftpRef.current.toggleSelection("left", name, multi);
},
[onPrepareSelectionLeft, sftpRef],
);
const onToggleSelectionRight = useCallback(
(name: string, multi: boolean) => {
onPrepareSelectionRight();
sftpRef.current.toggleSelection("right", name, multi);
},
[onPrepareSelectionRight, sftpRef],
);
const onRangeSelectLeft = useCallback(
(fileNames: string[]) => {
onPrepareSelectionLeft();
sftpRef.current.rangeSelect("left", fileNames);
},
[onPrepareSelectionLeft, sftpRef],
);
const onRangeSelectRight = useCallback(
(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]);
const onSetFilterLeft = useCallback(
(filter: string) => sftpRef.current.setFilter("left", filter),
[sftpRef],
);
const onSetFilterRight = useCallback(
(filter: string) => sftpRef.current.setFilter("right", filter),
[sftpRef],
);
const onCreateDirectoryLeft = useCallback(
(name: string) => sftpRef.current.createDirectory("left", name),
[sftpRef],
);
const onCreateDirectoryRight = useCallback(
(name: string) => sftpRef.current.createDirectory("right", name),
[sftpRef],
);
const onCreateDirectoryAtPathLeft = useCallback(
(path: string, name: string) => sftpRef.current.createDirectoryAtPath("left", path, name),
[sftpRef],
);
const onCreateDirectoryAtPathRight = useCallback(
(path: string, name: string) => sftpRef.current.createDirectoryAtPath("right", path, name),
[sftpRef],
);
const onCreateFileLeft = useCallback(
(name: string) => sftpRef.current.createFile("left", name),
[sftpRef],
);
const onCreateFileRight = useCallback(
(name: string) => sftpRef.current.createFile("right", name),
[sftpRef],
);
const onCreateFileAtPathLeft = useCallback(
(path: string, name: string) => sftpRef.current.createFileAtPath("left", path, name),
[sftpRef],
);
const onCreateFileAtPathRight = useCallback(
(path: string, name: string) => sftpRef.current.createFileAtPath("right", path, name),
[sftpRef],
);
const onDeleteFilesLeft = useCallback(
(names: string[]) => sftpRef.current.deleteFiles("left", names),
[sftpRef],
);
const onDeleteFilesRight = useCallback(
(names: string[]) => sftpRef.current.deleteFiles("right", names),
[sftpRef],
);
const onDeleteFilesAtPathLeft = useCallback(
(connectionId: string, path: string, names: string[]) =>
sftpRef.current.deleteFilesAtPath("left", connectionId, path, names),
[sftpRef],
);
const onDeleteFilesAtPathRight = useCallback(
(connectionId: string, path: string, names: string[]) =>
sftpRef.current.deleteFilesAtPath("right", connectionId, path, names),
[sftpRef],
);
const onRenameFileLeft = useCallback(
(old: string, newName: string) => sftpRef.current.renameFile("left", old, newName),
[sftpRef],
);
const onRenameFileRight = useCallback(
(old: string, newName: string) => sftpRef.current.renameFile("right", old, newName),
[sftpRef],
);
const onRenameFileAtPathLeft = useCallback(
(oldPath: string, newName: string) => sftpRef.current.renameFileAtPath("left", oldPath, newName),
[sftpRef],
);
const onRenameFileAtPathRight = useCallback(
(oldPath: string, newName: string) => sftpRef.current.renameFileAtPath("right", oldPath, newName),
[sftpRef],
);
const onMoveEntriesToPathLeft = useCallback(
(sourcePaths: string[], targetPath: string) => sftpRef.current.moveEntriesToPath("left", sourcePaths, targetPath),
[sftpRef],
);
const onMoveEntriesToPathRight = useCallback(
(sourcePaths: string[], targetPath: string) => sftpRef.current.moveEntriesToPath("right", sourcePaths, targetPath),
[sftpRef],
);
const dragCallbacks = useMemo<SftpDragCallbacks>(
() => ({
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
}),
[handleDragStart, handleDragEnd],
);
return {
dragCallbacks,
draggedFiles,
onConnectLeft,
onConnectRight,
onDisconnectLeft,
onDisconnectRight,
onPrepareSelectionLeft,
onPrepareSelectionRight,
onNavigateToLeft,
onNavigateToRight,
onNavigateUpLeft,
onNavigateUpRight,
onRefreshLeft,
onRefreshRight,
onRefreshTabLeft,
onRefreshTabRight,
onSetFilenameEncodingLeft,
onSetFilenameEncodingRight,
onToggleSelectionLeft,
onToggleSelectionRight,
onRangeSelectLeft,
onRangeSelectRight,
onClearSelectionLeft,
onClearSelectionRight,
onSetFilterLeft,
onSetFilterRight,
onCreateDirectoryLeft,
onCreateDirectoryRight,
onCreateDirectoryAtPathLeft,
onCreateDirectoryAtPathRight,
onCreateFileLeft,
onCreateFileRight,
onCreateFileAtPathLeft,
onCreateFileAtPathRight,
onDeleteFilesLeft,
onDeleteFilesRight,
onDeleteFilesAtPathLeft,
onDeleteFilesAtPathRight,
onRenameFileLeft,
onRenameFileRight,
onRenameFileAtPathLeft,
onRenameFileAtPathRight,
onMoveEntriesToPathLeft,
onMoveEntriesToPathRight,
onCopyToOtherPaneLeft,
onCopyToOtherPaneRight,
onReceiveFromOtherPaneLeft,
onReceiveFromOtherPaneRight,
};
};

View File

@@ -0,0 +1,235 @@
import { useEffect, useMemo, useRef } from "react";
import type { MutableRefObject } from "react";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { RemoteFile, SftpFilenameEncoding } from "../../../types";
import type { SftpPaneCallbacks } from "../SftpContext";
import type { SftpPane } from "../../../application/state/sftp/types";
import { useSftpViewPaneActions } from "./useSftpViewPaneActions";
import { useSftpViewFileOps } from "./useSftpViewFileOps";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { formatFileSize, formatDate } from '../../../application/state/sftp/utils';
import { isSessionError } from "../../../application/state/sftp/errors";
import { filterHiddenFiles } from "../utils";
interface UseSftpViewPaneCallbacksParams {
sftpRef: MutableRefObject<SftpStateApi>;
behaviorRef: MutableRefObject<string>;
autoSyncRef: MutableRefObject<boolean>;
getOpenerForFileRef: MutableRefObject<
(fileName: string) => { openerType?: FileOpenerType; systemApp?: SystemAppInfo } | null
>;
setOpenerForExtension: (
extension: string,
openerType: FileOpenerType,
systemApp?: SystemAppInfo,
) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
listSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<RemoteFile[]>;
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
getSftpIdForConnection?: (connectionId: string) => string | undefined;
listLocalFiles: (path: string) => Promise<RemoteFile[]>;
mkdirLocal?: (path: string) => Promise<void>;
deleteLocalFile?: (path: string) => Promise<void>;
listDrives: () => Promise<string[]>;
}
export const useSftpViewPaneCallbacks = ({
sftpRef,
behaviorRef,
autoSyncRef,
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
showSaveDialog,
selectDirectory,
getSftpIdForConnection,
listLocalFiles,
listDrives,
}: UseSftpViewPaneCallbacksParams) => {
const paneActions = useSftpViewPaneActions({ sftpRef, t });
const fileOps = useSftpViewFileOps({
sftpRef,
behaviorRef,
autoSyncRef,
getOpenerForFileRef,
setOpenerForExtension,
t,
showSaveDialog,
selectDirectory,
getSftpIdForConnection,
});
const listLocalFilesRef = useRef(listLocalFiles);
const listSftpRef = useRef(listSftp);
const getSftpIdForConnectionRef = useRef(getSftpIdForConnection);
useEffect(() => {
listLocalFilesRef.current = listLocalFiles;
listSftpRef.current = listSftp;
getSftpIdForConnectionRef.current = getSftpIdForConnection;
}, [listLocalFiles, listSftp, getSftpIdForConnection]);
const makeListDirectory = (side: "left" | "right", getPane: () => SftpPane) =>
async (path: string) => {
const pane = getPane();
if (!pane.connection) return [];
const toSize = (raw: string) => parseInt(raw) || 0;
const toTs = (raw: string) => new Date(raw).getTime();
const normalizeEntries = (rawFiles: RemoteFile[]) =>
filterHiddenFiles(
rawFiles.map(f => {
const s = toSize(f.size);
const ms = toTs(f.lastModified);
return {
name: f.name,
type: f.type as 'file' | 'directory' | 'symlink',
size: s,
sizeFormatted: formatFileSize(s),
lastModified: ms,
lastModifiedFormatted: formatDate(ms),
permissions: f.permissions,
owner: f.owner,
linkTarget: f.linkTarget as 'file' | 'directory' | null | undefined,
hidden: f.hidden,
};
}),
pane.showHiddenFiles,
);
if (pane.connection.isLocal) {
return normalizeEntries(await listLocalFilesRef.current(path));
}
const sftpId = getSftpIdForConnectionRef.current?.(pane.connection.id);
if (!sftpId) {
const error = new Error("SFTP session not found");
sftpRef.current.reportSessionError(side, error);
throw error;
}
let rawFiles: RemoteFile[] | undefined;
try {
rawFiles = await listSftpRef.current?.(sftpId, path, pane.filenameEncoding);
} catch (err) {
if (isSessionError(err)) {
sftpRef.current.reportSessionError(side, err as Error);
}
throw err;
}
if (!rawFiles) return [];
return normalizeEntries(rawFiles);
};
/* eslint-disable react-hooks/exhaustive-deps -- Handlers use refs, so they are stable */
const leftCallbacks = useMemo<SftpPaneCallbacks>(
() => ({
onConnect: paneActions.onConnectLeft,
onDisconnect: paneActions.onDisconnectLeft,
onPrepareSelection: paneActions.onPrepareSelectionLeft,
onNavigateTo: paneActions.onNavigateToLeft,
onNavigateUp: paneActions.onNavigateUpLeft,
onRefresh: paneActions.onRefreshLeft,
onRefreshTab: paneActions.onRefreshTabLeft,
onSetFilenameEncoding: paneActions.onSetFilenameEncodingLeft,
onOpenEntry: fileOps.onOpenEntryLeft,
onToggleSelection: paneActions.onToggleSelectionLeft,
onRangeSelect: paneActions.onRangeSelectLeft,
onClearSelection: paneActions.onClearSelectionLeft,
onSetFilter: paneActions.onSetFilterLeft,
onCreateDirectory: paneActions.onCreateDirectoryLeft,
onCreateDirectoryAtPath: paneActions.onCreateDirectoryAtPathLeft,
onCreateFile: paneActions.onCreateFileLeft,
onCreateFileAtPath: paneActions.onCreateFileAtPathLeft,
onDeleteFiles: paneActions.onDeleteFilesLeft,
onDeleteFilesAtPath: paneActions.onDeleteFilesAtPathLeft,
onRenameFile: paneActions.onRenameFileLeft,
onRenameFileAtPath: paneActions.onRenameFileAtPathLeft,
onMoveEntriesToPath: paneActions.onMoveEntriesToPathLeft,
onCopyToOtherPane: paneActions.onCopyToOtherPaneLeft,
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneLeft,
onEditPermissions: fileOps.onEditPermissionsLeft,
onEditFile: fileOps.onEditFileLeft,
onOpenFile: fileOps.onOpenFileLeft,
onOpenFileWithSystemDefault: fileOps.onOpenFileWithSystemDefaultLeft,
onOpenFileWith: fileOps.onOpenFileWithLeft,
onDownloadFile: fileOps.onDownloadFileLeft,
onDownloadFiles: fileOps.onDownloadFilesLeft,
onExtractArchive: fileOps.onExtractArchiveLeft,
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
onUploadExternalFileList: fileOps.onUploadExternalFileListLeft,
onUploadExternalFolder: fileOps.onUploadExternalFolderLeft,
onListDirectory: makeListDirectory("left", () => sftpRef.current.leftPane),
onListDrives: listDrives,
}),
[],
);
const rightCallbacks = useMemo<SftpPaneCallbacks>(
() => ({
onConnect: paneActions.onConnectRight,
onDisconnect: paneActions.onDisconnectRight,
onPrepareSelection: paneActions.onPrepareSelectionRight,
onNavigateTo: paneActions.onNavigateToRight,
onNavigateUp: paneActions.onNavigateUpRight,
onRefresh: paneActions.onRefreshRight,
onRefreshTab: paneActions.onRefreshTabRight,
onSetFilenameEncoding: paneActions.onSetFilenameEncodingRight,
onOpenEntry: fileOps.onOpenEntryRight,
onToggleSelection: paneActions.onToggleSelectionRight,
onRangeSelect: paneActions.onRangeSelectRight,
onClearSelection: paneActions.onClearSelectionRight,
onSetFilter: paneActions.onSetFilterRight,
onCreateDirectory: paneActions.onCreateDirectoryRight,
onCreateDirectoryAtPath: paneActions.onCreateDirectoryAtPathRight,
onCreateFile: paneActions.onCreateFileRight,
onCreateFileAtPath: paneActions.onCreateFileAtPathRight,
onDeleteFiles: paneActions.onDeleteFilesRight,
onDeleteFilesAtPath: paneActions.onDeleteFilesAtPathRight,
onRenameFile: paneActions.onRenameFileRight,
onRenameFileAtPath: paneActions.onRenameFileAtPathRight,
onMoveEntriesToPath: paneActions.onMoveEntriesToPathRight,
onCopyToOtherPane: paneActions.onCopyToOtherPaneRight,
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneRight,
onEditPermissions: fileOps.onEditPermissionsRight,
onEditFile: fileOps.onEditFileRight,
onOpenFile: fileOps.onOpenFileRight,
onOpenFileWithSystemDefault: fileOps.onOpenFileWithSystemDefaultRight,
onOpenFileWith: fileOps.onOpenFileWithRight,
onDownloadFile: fileOps.onDownloadFileRight,
onDownloadFiles: fileOps.onDownloadFilesRight,
onExtractArchive: fileOps.onExtractArchiveRight,
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
onUploadExternalFileList: fileOps.onUploadExternalFileListRight,
onUploadExternalFolder: fileOps.onUploadExternalFolderRight,
onListDirectory: makeListDirectory("right", () => sftpRef.current.rightPane),
onListDrives: listDrives,
}),
[],
);
/* eslint-enable react-hooks/exhaustive-deps */
return {
leftCallbacks,
rightCallbacks,
dragCallbacks: paneActions.dragCallbacks,
draggedFiles: paneActions.draggedFiles,
permissionsState: fileOps.permissionsState,
setPermissionsState: fileOps.setPermissionsState,
showTextEditor: fileOps.showTextEditor,
setShowTextEditor: fileOps.setShowTextEditor,
textEditorTarget: fileOps.textEditorTarget,
setTextEditorTarget: fileOps.setTextEditorTarget,
textEditorContent: fileOps.textEditorContent,
setTextEditorContent: fileOps.setTextEditorContent,
loadingTextContent: fileOps.loadingTextContent,
showFileOpenerDialog: fileOps.showFileOpenerDialog,
setShowFileOpenerDialog: fileOps.setShowFileOpenerDialog,
fileOpenerTarget: fileOps.fileOpenerTarget,
setFileOpenerTarget: fileOps.setFileOpenerTarget,
handleSaveTextFile: fileOps.handleSaveTextFile,
onPromoteToTab: fileOps.onPromoteToTab,
handleFileOpenerSelect: fileOps.handleFileOpenerSelect,
handleSelectSystemApp: fileOps.handleSelectSystemApp,
};
};

View File

@@ -0,0 +1,260 @@
import React, { useCallback, useMemo, useState } from "react";
import type { MutableRefObject } from "react";
import type { Host } from "../../../types";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { editorTabStore } from "../../../application/state/editorTabStore";
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave";
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
import {
getSftpTabDuplicateRequest,
type SftpTabDuplicateMode,
} from "../sftpTabDuplication";
interface UseSftpViewTabsParams {
sftp: SftpStateApi;
sftpRef: MutableRefObject<SftpStateApi>;
hosts?: Host[];
}
interface UseSftpViewTabsResult {
leftPanes: SftpStateApi["leftPane"][];
rightPanes: SftpStateApi["rightPane"][];
leftTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null; canDuplicate: boolean }[];
rightTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null; canDuplicate: boolean }[];
showHostPickerLeft: boolean;
showHostPickerRight: boolean;
hostSearchLeft: string;
hostSearchRight: string;
setShowHostPickerLeft: React.Dispatch<React.SetStateAction<boolean>>;
setShowHostPickerRight: React.Dispatch<React.SetStateAction<boolean>>;
setHostSearchLeft: React.Dispatch<React.SetStateAction<string>>;
setHostSearchRight: React.Dispatch<React.SetStateAction<string>>;
handleAddTabLeft: () => string;
handleAddTabRight: () => string;
handleCloseTabLeft: (tabId: string) => Promise<void>;
handleCloseTabRight: (tabId: string) => Promise<void>;
handleSelectTabLeft: (tabId: string) => void;
handleSelectTabRight: (tabId: string) => void;
handleReorderTabsLeft: (draggedId: string, targetId: string, position: "before" | "after") => void;
handleReorderTabsRight: (draggedId: string, targetId: string, position: "before" | "after") => void;
handleMoveTabFromLeftToRight: (tabId: string) => void;
handleMoveTabFromRightToLeft: (tabId: string) => void;
handleDuplicateTabLeft: (tabId: string, mode: SftpTabDuplicateMode) => Promise<string | null>;
handleDuplicateTabRight: (tabId: string, mode: SftpTabDuplicateMode) => Promise<string | null>;
handleHostSelectLeft: (
host: Host | "local",
options?: Parameters<SftpStateApi["connect"]>[2],
) => void;
handleHostSelectRight: (
host: Host | "local",
options?: Parameters<SftpStateApi["connect"]>[2],
) => void;
}
export const useSftpViewTabs = ({ sftp, sftpRef, hosts = [] }: UseSftpViewTabsParams): UseSftpViewTabsResult => {
const [showHostPickerLeft, setShowHostPickerLeft] = useState(false);
const [showHostPickerRight, setShowHostPickerRight] = useState(false);
const [hostSearchLeft, setHostSearchLeft] = useState("");
const [hostSearchRight, setHostSearchRight] = useState("");
const hostsRef = React.useRef(hosts);
hostsRef.current = hosts;
const handleAddTabLeft = useCallback(() => {
const tabId = sftpRef.current.addTab("left");
setShowHostPickerLeft(true);
return tabId;
}, [sftpRef]);
const handleAddTabRight = useCallback(() => {
const tabId = sftpRef.current.addTab("right");
setShowHostPickerRight(true);
return tabId;
}, [sftpRef]);
const confirmCloseEditorTabsByOwner = useCallback(async (owner: {
sessionId?: string;
sftpTabId?: string;
}): Promise<boolean> => {
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
const saveTab = async (id: EditorTabId) => {
const ok = await saveEditorTab(id);
const tab = editorTabStore.getTab(id);
if (!ok || (tab && tab.content !== tab.baselineContent)) {
throw new Error(tab?.saveError ?? "Save failed");
}
};
return editorTabStore.confirmCloseByOwner(
owner,
choice,
saveTab,
releaseEditorTabSaveCoordinator,
);
}, []);
const handleCloseSftpTab = useCallback(async (side: "left" | "right", tabId: string) => {
const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs;
const pane = sideTabs.tabs.find((tab) => tab.id === tabId);
if (pane?.connection?.id || pane) {
const ok = await confirmCloseEditorTabsByOwner({
sessionId: pane?.connection?.id,
sftpTabId: tabId,
});
if (!ok) return;
}
await sftpRef.current.closeTab(side, tabId);
}, [confirmCloseEditorTabsByOwner, sftpRef]);
const handleCloseTabLeft = useCallback((tabId: string) => (
handleCloseSftpTab("left", tabId)
), [handleCloseSftpTab]);
const handleCloseTabRight = useCallback((tabId: string) => (
handleCloseSftpTab("right", tabId)
), [handleCloseSftpTab]);
const handleSelectTabLeft = useCallback((tabId: string) => {
sftpRef.current.selectTab("left", tabId);
}, [sftpRef]);
const handleSelectTabRight = useCallback((tabId: string) => {
sftpRef.current.selectTab("right", tabId);
}, [sftpRef]);
const leftPanes = useMemo(
() => (sftp.leftTabs.tabs.length > 0 ? sftp.leftTabs.tabs : [sftp.leftPane]),
[sftp.leftTabs.tabs, sftp.leftPane],
);
const rightPanes = useMemo(
() => (sftp.rightTabs.tabs.length > 0 ? sftp.rightTabs.tabs : [sftp.rightPane]),
[sftp.rightTabs.tabs, sftp.rightPane],
);
const handleReorderTabsLeft = useCallback(
(draggedId: string, targetId: string, position: "before" | "after") => {
sftpRef.current.reorderTabs("left", draggedId, targetId, position);
},
[sftpRef],
);
const handleReorderTabsRight = useCallback(
(draggedId: string, targetId: string, position: "before" | "after") => {
sftpRef.current.reorderTabs("right", draggedId, targetId, position);
},
[sftpRef],
);
const handleMoveTabFromLeftToRight = useCallback((tabId: string) => {
sftpRef.current.moveTabToOtherSide("left", tabId);
}, [sftpRef]);
const handleMoveTabFromRightToLeft = useCallback((tabId: string) => {
sftpRef.current.moveTabToOtherSide("right", tabId);
}, [sftpRef]);
const handleDuplicateTab = useCallback(
async (side: "left" | "right", tabId: string, mode: SftpTabDuplicateMode) => {
const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs;
const pane = sideTabs.tabs.find((tab) => tab.id === tabId);
const request = getSftpTabDuplicateRequest(pane, mode);
if (!request) return null;
const host = request.kind === "local"
? "local"
: hostsRef.current.find((item) => item.id === request.hostId);
if (!host) return null;
let duplicatedTabId: string | null = null;
await sftpRef.current.connect(side, host, {
forceNewTab: true,
ignoreSharedCache: mode === "defaultPath",
initialPath: request.path,
onTabCreated: (createdTabId) => {
duplicatedTabId = createdTabId;
},
});
return duplicatedTabId;
},
[sftpRef],
);
const handleDuplicateTabLeft = useCallback(
(tabId: string, mode: SftpTabDuplicateMode) => handleDuplicateTab("left", tabId, mode),
[handleDuplicateTab],
);
const handleDuplicateTabRight = useCallback(
(tabId: string, mode: SftpTabDuplicateMode) => handleDuplicateTab("right", tabId, mode),
[handleDuplicateTab],
);
const handleHostSelectLeft = useCallback((
host: Host | "local",
options?: Parameters<SftpStateApi["connect"]>[2],
) => {
sftpRef.current.connect("left", host, options);
setShowHostPickerLeft(false);
}, [sftpRef]);
const handleHostSelectRight = useCallback((
host: Host | "local",
options?: Parameters<SftpStateApi["connect"]>[2],
) => {
sftpRef.current.connect("right", host, options);
setShowHostPickerRight(false);
}, [sftpRef]);
const leftTabsInfo = useMemo(
() =>
sftp.leftTabs.tabs.map((pane) => ({
id: pane.id,
label: pane.connection?.hostLabel || "New Tab",
isLocal: pane.connection?.isLocal || false,
hostId: pane.connection?.hostId || null,
canDuplicate: pane.connection?.status === "connected",
})),
[sftp.leftTabs.tabs],
);
const rightTabsInfo = useMemo(
() =>
sftp.rightTabs.tabs.map((pane) => ({
id: pane.id,
label: pane.connection?.hostLabel || "New Tab",
isLocal: pane.connection?.isLocal || false,
hostId: pane.connection?.hostId || null,
canDuplicate: pane.connection?.status === "connected",
})),
[sftp.rightTabs.tabs],
);
return {
leftPanes,
rightPanes,
leftTabsInfo,
rightTabsInfo,
showHostPickerLeft,
showHostPickerRight,
hostSearchLeft,
hostSearchRight,
setShowHostPickerLeft,
setShowHostPickerRight,
setHostSearchLeft,
setHostSearchRight,
handleAddTabLeft,
handleAddTabRight,
handleCloseTabLeft,
handleCloseTabRight,
handleSelectTabLeft,
handleSelectTabRight,
handleReorderTabsLeft,
handleReorderTabsRight,
handleMoveTabFromLeftToRight,
handleMoveTabFromRightToLeft,
handleDuplicateTabLeft,
handleDuplicateTabRight,
handleHostSelectLeft,
handleHostSelectRight,
};
};

13
components/sftp/index.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* SFTP Components - Index
*
* Re-exports the SFTP entries consumed by top-level views.
*/
// Context
export {
SftpContextProvider,
activeTabStore,
} from './SftpContext';
export { SftpTabBar } from './SftpTabBar';

View File

@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import test from "node:test";
import { reportSftpUploadResults } from "./reportSftpUploadResults.ts";
const t = (key: string, params?: Record<string, string | number>) => {
if (key === "sftp.upload.partialSuccess") {
return `partial ${params?.success}/${params?.failed}`;
}
return key;
};
test("does not toast success when zero files uploaded", () => {
const calls: Array<{ type: string; message: string }> = [];
reportSftpUploadResults({
results: [],
t,
toast: {
success: (message) => calls.push({ type: "success", message }),
error: (message) => calls.push({ type: "error", message }),
info: (message) => calls.push({ type: "info", message }),
},
});
assert.deepEqual(calls, [{ type: "info", message: "sftp.upload.noFiles" }]);
});
test("toasts cancelled when any result is cancelled", () => {
const calls: Array<{ type: string; message: string }> = [];
reportSftpUploadResults({
results: [
{ fileName: "a", success: false, cancelled: true },
{ fileName: "b", success: true },
],
t,
toast: {
success: (message) => calls.push({ type: "success", message }),
error: (message) => calls.push({ type: "error", message }),
info: (message) => calls.push({ type: "info", message }),
},
});
assert.deepEqual(calls, [{ type: "info", message: "sftp.upload.cancelled" }]);
});
test("toasts multi-file success count", () => {
const calls: Array<{ type: string; message: string }> = [];
reportSftpUploadResults({
results: [
{ fileName: "a", success: true },
{ fileName: "b", success: true },
],
t,
toast: {
success: (message) => calls.push({ type: "success", message }),
error: (message) => calls.push({ type: "error", message }),
info: (message) => calls.push({ type: "info", message }),
},
});
assert.deepEqual(calls, [{ type: "success", message: "sftp.uploadFiles: 2" }]);
});
test("terminal drop success names the exact remote destination", () => {
const calls: Array<{ type: string; message: string }> = [];
reportSftpUploadResults({
results: [{ fileName: "release.tgz", success: true }],
targetPath: "/srv/releases",
t,
toast: {
success: (message) => calls.push({ type: "success", message }),
error: (message) => calls.push({ type: "error", message }),
info: (message) => calls.push({ type: "info", message }),
},
});
assert.deepEqual(calls, [{
type: "success",
message: "sftp.upload: release.tgz (/srv/releases)",
}]);
});
test("toasts failures and partial success separately", () => {
const calls: Array<{ type: string; message: string }> = [];
reportSftpUploadResults({
results: [
{ fileName: "a", success: true },
{ fileName: "b", success: false, error: "boom" },
],
t,
toast: {
success: (message) => calls.push({ type: "success", message }),
error: (message) => calls.push({ type: "error", message }),
info: (message) => calls.push({ type: "info", message }),
},
});
assert.equal(calls[0]?.type, "error");
assert.equal(calls[1]?.type, "info");
assert.match(calls[1]?.message ?? "", /partial 1\/1/);
});
test("partial terminal drop success names the exact remote destination", () => {
const calls: Array<{ type: string; message: string }> = [];
reportSftpUploadResults({
results: [
{ fileName: "a", success: true },
{ fileName: "b", success: false, error: "boom" },
],
targetPath: "/srv/releases",
t,
toast: {
success: (message) => calls.push({ type: "success", message }),
error: (message) => calls.push({ type: "error", message }),
info: (message) => calls.push({ type: "info", message }),
},
});
assert.deepEqual(calls[1], {
type: "info",
message: "partial 1/1 (/srv/releases)",
});
});

View File

@@ -0,0 +1,74 @@
import type { UploadResult } from "../../lib/uploadService.types";
export type ReportSftpUploadResultsOptions = {
results: readonly UploadResult[];
t: (key: string, params?: Record<string, string | number>) => string;
toast: {
success: (message: string, title?: string) => void;
error: (message: string, title?: string) => void;
info: (message: string, title?: string) => void;
};
/** Optional single-item success message override (e.g. folder name). */
successMessage?: string;
/** Exact remote directory selected for a terminal-drop upload. */
targetPath?: string;
};
/**
* Shared toast reporting for external SFTP uploads.
* Never shows "Uploaded files: 0" as a success toast.
*/
export function reportSftpUploadResults({
results,
t,
toast,
successMessage,
targetPath,
}: ReportSftpUploadResultsOptions): void {
const withTargetPath = (message: string): string => (
targetPath ? `${message} (${targetPath})` : message
);
if (results.some((result) => result.cancelled)) {
toast.info(t("sftp.upload.cancelled"), "SFTP");
return;
}
const failed = results.filter((result) => !result.success && !result.cancelled);
const succeeded = results.filter((result) => result.success);
if (failed.length > 0) {
for (const item of failed) {
const errorMsg = item.error ? ` - ${item.error}` : "";
toast.error(
`${t("sftp.error.uploadFailed")}: ${item.fileName || t("sftp.upload")}${errorMsg}`,
"SFTP",
);
}
// Also mention partial success when some files made it.
if (succeeded.length > 0) {
toast.info(
withTargetPath(
t("sftp.upload.partialSuccess", { success: succeeded.length, failed: failed.length }),
),
"SFTP",
);
}
return;
}
if (succeeded.length === 0) {
// Empty drop / no uploadable files — not a green "success".
toast.info(t("sftp.upload.noFiles"), "SFTP");
return;
}
if (successMessage) {
toast.success(withTargetPath(successMessage), "SFTP");
return;
}
const message = succeeded.length === 1
? `${t("sftp.upload")}: ${succeeded[0]?.fileName ?? ""}`
: `${t("sftp.uploadFiles")}: ${succeeded.length}`;
toast.success(withTargetPath(message), "SFTP");
}

View File

@@ -0,0 +1,81 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildSftpColumnTemplate,
DEFAULT_SFTP_COLUMN_VISIBILITY,
isSftpColumnMenuKey,
normalizeSftpColumnVisibility,
type ColumnWidths,
} from './utils.ts';
const widths: ColumnWidths = {
name: 56,
modified: 28,
size: 7,
type: 9,
owner: 10,
};
test('normalizes missing and invalid SFTP column preferences to all columns', () => {
assert.deepEqual(normalizeSftpColumnVisibility(null), DEFAULT_SFTP_COLUMN_VISIBILITY);
assert.deepEqual(normalizeSftpColumnVisibility('invalid'), DEFAULT_SFTP_COLUMN_VISIBILITY);
});
test('keeps the name column visible while restoring optional column preferences', () => {
assert.deepEqual(
normalizeSftpColumnVisibility({ name: false, modified: false, size: true, type: false, owner: false }),
{ name: true, modified: false, size: true, type: false, owner: false },
);
});
test('treats a missing owner preference as visible', () => {
assert.equal(
normalizeSftpColumnVisibility({ name: true, modified: true, size: true, type: true }).owner,
true,
);
});
test('builds a grid containing only visible SFTP columns', () => {
const template = buildSftpColumnTemplate(widths, {
name: true,
modified: false,
size: true,
type: false,
owner: false,
});
assert.equal(template, 'minmax(140px, 56fr) minmax(52px, 7fr)');
});
test('includes the owner column when it is visible', () => {
const template = buildSftpColumnTemplate(widths, {
name: true,
modified: false,
size: false,
type: false,
owner: true,
});
assert.equal(template, 'minmax(140px, 56fr) minmax(56px, 10fr)');
});
test('can reduce the SFTP file list to only the name column', () => {
assert.equal(
buildSftpColumnTemplate(widths, {
name: true,
modified: false,
size: false,
type: false,
owner: false,
}),
'minmax(140px, 56fr)',
);
});
test('recognizes standard keyboard shortcuts for opening the column menu', () => {
assert.equal(isSftpColumnMenuKey('ContextMenu', false), true);
assert.equal(isSftpColumnMenuKey('F10', true), true);
assert.equal(isSftpColumnMenuKey('F10', false), false);
assert.equal(isSftpColumnMenuKey('Enter', false), false);
});

View File

@@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const readSource = (fileName: string): string =>
readFileSync(new URL(fileName, import.meta.url), 'utf8');
test('list and tree SFTP views share column visibility and keyboard-accessible menus', () => {
const listSource = readSource('./SftpPaneFileList.tsx');
const treeSource = readSource('./SftpPaneTreeView.tsx');
const treeNodeSource = readSource('./SftpPaneTreeNode.tsx');
const columnMenuSource = readSource('./SftpColumnMenuItems.tsx');
for (const source of [listSource, treeSource]) {
assert.match(source, /buildSftpColumnTemplate\(columnWidths, visibleColumns\)/);
assert.match(source, /SftpColumnMenuItems/);
assert.match(source, /isSftpColumnMenuKey/);
assert.match(source, /tabIndex=\{0\}/);
}
assert.match(columnMenuSource, /ContextMenuCheckboxItem/);
assert.match(
listSource,
/import\s*\{[^}]*\bContextMenuSeparator\b[^}]*\}\s*from "\.\.\/ui\/context-menu";/,
);
assert.match(treeNodeSource, /visibleColumns\.modified/);
assert.match(treeNodeSource, /visibleColumns\.size/);
assert.match(treeNodeSource, /visibleColumns\.type/);
assert.match(treeNodeSource, /visibleColumns\.owner/);
assert.match(columnMenuSource, /'modified', 'size', 'type', 'owner'/);
const paneCallbacksSource = readFileSync(new URL('./hooks/useSftpViewPaneCallbacks.ts', import.meta.url), 'utf8');
assert.match(paneCallbacksSource, /owner: f\.owner/);
});

View File

@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import { assertSftpFileFitsBuiltinEditor, MAX_BUILTIN_SFTP_EDITOR_BYTES } from "./sftpEditorFileLimits.ts";
test("built-in editor rejects oversized files before reading them", () => {
assert.doesNotThrow(() => assertSftpFileFitsBuiltinEditor(MAX_BUILTIN_SFTP_EDITOR_BYTES));
assert.throws(
() => assertSftpFileFitsBuiltinEditor(MAX_BUILTIN_SFTP_EDITOR_BYTES + 1),
/too large.*10 MB/i,
);
});

View File

@@ -0,0 +1,6 @@
export const MAX_BUILTIN_SFTP_EDITOR_BYTES = 10 * 1024 * 1024;
export function assertSftpFileFitsBuiltinEditor(size: number | undefined): void {
if (!Number.isFinite(size) || (size ?? 0) <= MAX_BUILTIN_SFTP_EDITOR_BYTES) return;
throw new Error("This file is too large for the built-in editor (maximum 10 MB). Download it or open it with another app.");
}

View File

@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const sidePanelSource = readFileSync(new URL("../SftpSidePanel.tsx", import.meta.url), "utf8");
const slotSource = readFileSync(
new URL("../terminalLayer/terminalLayerSidePanelSlots.tsx", import.meta.url),
"utf8",
);
test("locate-path write skips sessions waiting on sensitive/password prompts", () => {
assert.match(
sidePanelSource,
/isTerminalSensitiveInputActive\(action\.sessionId\)[\s\S]*?writeToSession\(action\.sessionId, action\.data/,
);
assert.match(
sidePanelSource,
/if \(isTerminalSensitiveInputActive\(action\.sessionId\)\) return;/,
);
});
test("locate-path write requires an idle shell prompt before PTY injection", () => {
assert.match(
sidePanelSource,
/isTerminalReadyForCommandInjection\(action\.sessionId\)[\s\S]*?writeToSession\(action\.sessionId, action\.data/,
);
assert.match(
sidePanelSource,
/if \(!isTerminalReadyForCommandInjection\(action\.sessionId\)\) return;/,
);
});
test("locate-path uses the confirmed toolbar path rather than an optimistic navigate target", () => {
assert.match(sidePanelSource, /getNextSftpToolbarDisplayPath\(/);
assert.match(
sidePanelSource,
/path: confirmedLocatePathRef\.current \|\| connection\?\.currentPath/,
);
});
test("locate-path uses focused session fallback when SFTP cannot reuse the terminal", () => {
assert.match(sidePanelSource, /resolveLocateSftpPathSessionId\(\{\s*activeSessionId,\s*focusedSessionId,/);
assert.match(
slotSource,
/focusedSessionId=\{panelFocusedSessionId\}/,
);
});

View File

@@ -0,0 +1,240 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { filterSftpEntriesByName, filterSftpTreeEntriesByName } from './utils.ts';
const entry = (name: string, type: 'file' | 'directory' = 'file') => ({ name, type });
const isDirectory = (e: { type: string }) => e.type === 'directory';
test('SFTP name filter returns all entries when term is empty', () => {
const files = [entry('..'), entry('README.md'), entry('src')];
assert.deepEqual(filterSftpEntriesByName(files, ' '), files);
});
test('SFTP name filter matches case-insensitively and keeps parent entry', () => {
const files = [entry('..'), entry('README.md'), entry('src'), entry('read-notes.txt')];
assert.deepEqual(
filterSftpEntriesByName(files, 'Read').map(({ name }) => name),
['..', 'README.md', 'read-notes.txt'],
);
});
test('SFTP name filter hides non-matching siblings including directories', () => {
const files = [entry('config'), entry('logs'), entry('app.js')];
assert.deepEqual(
filterSftpEntriesByName(files, 'log').map(({ name }) => name),
['logs'],
);
});
test('SFTP tree filter keeps loaded ancestors of matching children', () => {
const childrenByPath = new Map<string, ReturnType<typeof entry>[]>([
['/project/src', [entry('README.md'), entry('utils.ts'), entry('components', 'directory')]],
['/project/src/components', [entry('Button.tsx'), entry('readme-local.txt')]],
]);
const root = [
entry('src', 'directory'),
entry('logs', 'directory'),
entry('app.js'),
];
assert.deepEqual(
filterSftpTreeEntriesByName(root, 'readme', {
parentPath: '/project',
joinPath: (parent, name) => `${parent}/${name}`,
isDirectory,
getChildren: (path) => childrenByPath.get(path),
}).map(({ name }) => name),
['src'],
);
assert.deepEqual(
filterSftpTreeEntriesByName(childrenByPath.get('/project/src')!, 'readme', {
parentPath: '/project/src',
joinPath: (parent, name) => `${parent}/${name}`,
isDirectory,
getChildren: (path) => childrenByPath.get(path),
}).map(({ name }) => name),
['README.md', 'components'],
);
});
test('SFTP tree filter hides unloaded non-matching directories', () => {
const root = [entry('src', 'directory'), entry('README.md')];
assert.deepEqual(
filterSftpTreeEntriesByName(root, 'readme', {
parentPath: '/project',
joinPath: (parent, name) => `${parent}/${name}`,
isDirectory,
getChildren: () => undefined,
}).map(({ name }) => name),
['README.md'],
);
});
test('SFTP tree filter does not keep ancestors for hidden-only descendant matches', () => {
// getChildren must apply the same hidden-file policy as visible rows; otherwise a
// dotfile match can keep a parent folder while the matching child stays invisible.
const childrenByPath = new Map<string, ReturnType<typeof entry>[]>([
['/project/src', [entry('.readme'), entry('utils.ts')]],
]);
const root = [entry('src', 'directory')];
assert.deepEqual(
filterSftpTreeEntriesByName(root, 'readme', {
parentPath: '/project',
joinPath: (parent, name) => `${parent}/${name}`,
isDirectory,
getChildren: (path) => {
const children = childrenByPath.get(path);
if (!children) return undefined;
return children.filter((child) => !child.name.startsWith('.'));
},
}).map(({ name }) => name),
[],
);
});
test('SFTP tree filter does not keep ancestors for collapsed descendant matches', () => {
// Collapsed directories keep children in cache, but buildTree does not render
// them. getChildren must treat collapsed paths as unavailable or the parent
// stays visible with no matching row.
const childrenByPath = new Map<string, ReturnType<typeof entry>[]>([
['/project/src', [entry('README.md'), entry('utils.ts')]],
]);
const expandedPaths = new Set<string>();
const root = [entry('src', 'directory'), entry('app.js')];
assert.deepEqual(
filterSftpTreeEntriesByName(root, 'readme', {
parentPath: '/project',
joinPath: (parent, name) => `${parent}/${name}`,
isDirectory,
getChildren: (path) => {
if (!expandedPaths.has(path)) return undefined;
return childrenByPath.get(path);
},
}).map(({ name }) => name),
[],
);
expandedPaths.add('/project/src');
assert.deepEqual(
filterSftpTreeEntriesByName(root, 'readme', {
parentPath: '/project',
joinPath: (parent, name) => `${parent}/${name}`,
isDirectory,
getChildren: (path) => {
if (!expandedPaths.has(path)) return undefined;
return childrenByPath.get(path);
},
}).map(({ name }) => name),
['src'],
);
});
test('SFTP tree filter does not keep ancestors for loading or error descendant matches', () => {
// Expanded dirs keep children in cache during reload / after LOAD_ERROR, but
// buildTree only shows the loading or error row. getChildren must treat those
// paths as unavailable or a nonmatching parent stays as an empty result.
const childrenByPath = new Map<string, ReturnType<typeof entry>[]>([
['/project/src', [entry('README.md'), entry('utils.ts')]],
]);
const expandedPaths = new Set(['/project/src']);
const loadingPaths = new Set<string>();
const errorPaths = new Set<string>();
const root = [entry('src', 'directory'), entry('app.js')];
const getChildren = (path: string) => {
if (!expandedPaths.has(path)) return undefined;
if (loadingPaths.has(path) || errorPaths.has(path)) return undefined;
return childrenByPath.get(path);
};
assert.deepEqual(
filterSftpTreeEntriesByName(root, 'readme', {
parentPath: '/project',
joinPath: (parent, name) => `${parent}/${name}`,
isDirectory,
getChildren,
}).map(({ name }) => name),
['src'],
);
loadingPaths.add('/project/src');
assert.deepEqual(
filterSftpTreeEntriesByName(root, 'readme', {
parentPath: '/project',
joinPath: (parent, name) => `${parent}/${name}`,
isDirectory,
getChildren,
}).map(({ name }) => name),
[],
);
loadingPaths.clear();
errorPaths.add('/project/src');
assert.deepEqual(
filterSftpTreeEntriesByName(root, 'readme', {
parentPath: '/project',
joinPath: (parent, name) => `${parent}/${name}`,
isDirectory,
getChildren,
}).map(({ name }) => name),
[],
);
});
test('SFTP tree view applies the tree name filter to visible rows', () => {
const treeSource = readFileSync(new URL('./SftpPaneTreeView.tsx', import.meta.url), 'utf8');
assert.match(
treeSource,
/sortSftpEntries\(\s*filterSftpTreeEntriesByName\(\s*filterHiddenFiles\(entries, pane\.showHiddenFiles\),\s*pane\.filter,/s,
);
assert.match(treeSource, /pane\.showHiddenFiles\}:\$\{pane\.filter\}/);
assert.match(
treeSource,
/getChildren:\s*\(entryPath\)\s*=>\s*\{[\s\S]*?expandedPaths\.has\(entryPath\)[\s\S]*?loadingPaths\.has\(entryPath\)[\s\S]*?errorPaths\.has\(entryPath\)[\s\S]*?filterHiddenFiles\([\s\S]*?pane\.showHiddenFiles/,
);
assert.match(
treeSource,
/prevExpandedPathsRef[\s\S]*?sortedChildrenCacheRef\.current\.clear\(\)/,
);
assert.match(
treeSource,
/prevLoadingPathsRef[\s\S]*?sortedChildrenCacheRef\.current\.clear\(\)/,
);
assert.match(
treeSource,
/prevErrorPathsRef[\s\S]*?sortedChildrenCacheRef\.current\.clear\(\)/,
);
});
test('SFTP tree child reload invalidates sorted cache so filter reapplies', () => {
// After expand/reload, childrenCache is replaced. Ancestor visibility also
// depends on those descendants, so the sorted cache must be cleared broadly
// (not only for the loaded path) or parents stay hidden.
const treeSource = readFileSync(new URL('./SftpPaneTreeView.tsx', import.meta.url), 'utf8');
const loadFn = treeSource.match(
/const loadChildrenForPath = useCallback\(async \(entryPath: string\) => \{[\s\S]*?\n {2}\}, \[\]\);/,
);
assert.ok(loadFn, 'expected loadChildrenForPath callback');
const setIdx = loadFn[0].indexOf('childrenCacheRef.current.set(entryPath, children)');
const clearIdx = loadFn[0].indexOf('sortedChildrenCacheRef.current.clear()');
assert.ok(setIdx >= 0, 'expected childrenCache write on successful load');
assert.ok(clearIdx > setIdx, 'sorted cache must clear after childrenCache write');
});
test('SFTP tree move mutation clears all sorted snapshots for ancestor filter', () => {
// With an active search, ancestor keep decisions depend on cached descendants.
// Move To / drag-move update childrenCache for source/target only; deleting just
// those sorted keys leaves grandparent/root snapshots stale (old parent kept,
// new parent hidden). Mirror loadChildrenForPath and clear every sorted snapshot.
const treeSource = readFileSync(new URL('./SftpPaneTreeView.tsx', import.meta.url), 'utf8');
const moveFn = treeSource.match(
/const applyLocalMoveMutation = useCallback\(\(\s*sourceParentPaths: string\[\],\s*targetPath: string,\s*movedEntries: SftpFileEntry\[\],\s*\) => \{[\s\S]*?\n {2}\}, \[pane\.connection\?\.currentPath\]\);/,
);
assert.ok(moveFn, 'expected applyLocalMoveMutation callback');
const body = moveFn[0];
assert.match(body, /childrenCacheRef\.current\.set\(/);
const lastChildrenSet = body.lastIndexOf('childrenCacheRef.current.set(');
const clearIdx = body.indexOf('sortedChildrenCacheRef.current.clear()');
assert.ok(clearIdx > lastChildrenSet, 'sorted cache must clear after childrenCache mutations');
assert.equal(
(body.match(/sortedChildrenCacheRef\.current\.delete\(/g) || []).length,
0,
'move mutation must not rely on per-path sorted deletes alone',
);
});

View File

@@ -0,0 +1,772 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { SftpPane } from "../../application/state/sftp/types";
import {
connectionKeyMatchesHost,
findPendingSftpRebindTargetPane,
findReusableSftpSidePanelTab,
isPendingSameEndpointSshSession,
isRemoteSftpTabHealthy,
rememberSftpSidePanelSourceStatus,
resolvePendingSftpUploadCancellation,
resolveSftpSidePanelTrackedSourceStatusUpdate,
shouldAcceptPendingSftpUpload,
shouldBlockPendingSftpUploadForSourceRebind,
shouldCancelPendingSftpUpload,
shouldCancelSettledPendingSftpRebindWithoutTarget,
shouldDeferPendingSftpUploadForOriginFocus,
shouldDeferSftpSidePanelAutoConnectForSession,
shouldRebindSftpSidePanelSourceSession,
shouldResetSftpSidePanelSourceSession,
shouldSkipSftpSidePanelAutoConnect,
shouldStartPendingSftpUploadRebind,
shouldWaitForPendingSftpRebind,
} from "./sftpSidePanelAutoConnect";
const remoteConnectedTab = (overrides: Partial<SftpPane> = {}): SftpPane => ({
id: "tab-1",
connection: {
id: "conn-1",
hostId: "host-1",
hostLabel: "server",
isLocal: false,
status: "connected",
currentPath: "/var/www",
},
files: [],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles: false,
transferMutationToken: 0,
...overrides,
});
test("isRemoteSftpTabHealthy rejects loading tabs", () => {
const tab = remoteConnectedTab({ loading: true });
assert.equal(isRemoteSftpTabHealthy(tab, true), false);
});
test("isRemoteSftpTabHealthy rejects tabs without a backend SFTP session", () => {
const tab = remoteConnectedTab();
assert.equal(isRemoteSftpTabHealthy(tab, false), false);
});
test("isRemoteSftpTabHealthy rejects connecting tabs", () => {
const tab = remoteConnectedTab({
connection: {
...remoteConnectedTab().connection!,
status: "connecting",
},
});
assert.equal(isRemoteSftpTabHealthy(tab, true), false);
});
test("shouldSkipSftpSidePanelAutoConnect returns false for stale connected keys", () => {
const tab = remoteConnectedTab({ loading: true });
assert.equal(
shouldSkipSftpSidePanelAutoConnect("host-key", "host-key", tab, true, "host-key"),
false,
);
});
test("shouldSkipSftpSidePanelAutoConnect rejects a healthy tab mapped to another endpoint", () => {
const tab = remoteConnectedTab();
assert.equal(
shouldSkipSftpSidePanelAutoConnect("host-a-key", "host-a-key", tab, true, "host-b-key"),
false,
);
});
test("shouldSkipSftpSidePanelAutoConnect rejects when the active tab has no endpoint map", () => {
const tab = remoteConnectedTab();
assert.equal(
shouldSkipSftpSidePanelAutoConnect("host-a-key", "host-a-key", tab, true, null),
false,
);
});
test("isPendingSameEndpointSshSession only waits for actively connecting SSH sessions", () => {
const host = {
id: "host-1",
hostname: "server.example",
port: 22,
username: "root",
};
const baseSession = {
hostId: "host-1",
hostname: "server.example",
port: 22,
username: "root",
protocol: "ssh",
};
assert.equal(
isPendingSameEndpointSshSession({ ...baseSession, status: "connecting" }, host),
true,
);
assert.equal(
isPendingSameEndpointSshSession({ ...baseSession, status: "disconnected" }, host),
false,
);
assert.equal(
isPendingSameEndpointSshSession({ ...baseSession, status: "connected" }, host),
false,
);
});
test("connectionKeyMatchesHost accepts host-id prefix keys", () => {
assert.equal(connectionKeyMatchesHost("host-1:server:22:ssh::root", "host-1"), true);
assert.equal(connectionKeyMatchesHost("host-2:server:22:ssh::root", "host-1"), false);
assert.equal(connectionKeyMatchesHost(null, "host-1"), false);
});
test("shouldAcceptPendingSftpUpload waits until the pane endpoint matches the drop", () => {
const connected = {
hostId: "host-1",
isLocal: false,
status: "connected",
};
assert.equal(
shouldAcceptPendingSftpUpload({
ownerPanelOpen: true,
pendingHostId: "host-1",
pendingConnectionKey: "host-1:b.example:22:ssh::root",
activeHostId: "host-1",
connection: connected,
paneConnectionKey: "host-1:a.example:22:ssh::root",
}),
false,
);
assert.equal(
shouldAcceptPendingSftpUpload({
ownerPanelOpen: true,
pendingHostId: "host-1",
pendingConnectionKey: "host-1:b.example:22:ssh::root",
activeHostId: "host-1",
connection: connected,
paneConnectionKey: null,
}),
false,
);
assert.equal(
shouldAcceptPendingSftpUpload({
ownerPanelOpen: true,
pendingHostId: "host-1",
pendingConnectionKey: "host-1:b.example:22:ssh::root",
activeHostId: "host-1",
connection: connected,
paneConnectionKey: "host-1:b.example:22:ssh::root",
}),
true,
);
});
test("shouldAcceptPendingSftpUpload waits for the terminal session that requested the upload", () => {
const endpointKey = "host-1:prod.internal:22:ssh::deploy:";
const connection = {
hostId: "host-1",
isLocal: false,
status: "connected",
sourceSessionId: "session-through-jump-a",
};
assert.equal(
shouldAcceptPendingSftpUpload({
ownerPanelOpen: true,
pendingHostId: "host-1",
pendingConnectionKey: endpointKey,
pendingSourceSessionId: "session-through-jump-b",
activeHostId: "host-1",
connection,
paneConnectionKey: endpointKey,
}),
false,
);
assert.equal(
shouldAcceptPendingSftpUpload({
ownerPanelOpen: true,
pendingHostId: "host-1",
pendingConnectionKey: endpointKey,
pendingSourceSessionId: "session-through-jump-a",
activeHostId: "host-1",
connection,
paneConnectionKey: endpointKey,
}),
true,
);
});
test("a closed owner panel never starts a pending terminal upload", () => {
assert.equal(shouldAcceptPendingSftpUpload({
ownerPanelOpen: false,
pendingHostId: "host-1",
pendingConnectionKey: "host-1:target.example:22:ssh::alice",
pendingSourceSessionId: "session-a",
activeHostId: "host-1",
connection: {
hostId: "host-1",
isLocal: false,
status: "connected",
sourceSessionId: "session-a",
},
paneConnectionKey: "host-1:target.example:22:ssh::alice",
}), false);
});
test("pending terminal upload is cancelled when its source terminal changes", () => {
assert.equal(resolvePendingSftpUploadCancellation({
pendingHostId: "host-1",
pendingSourceSessionId: "session-a",
activeHostId: "host-1",
activeSessionId: "session-b",
connection: null,
}), "source-changed");
});
test("pending drop does not cancel while waiting for its own origin focus", () => {
assert.equal(resolvePendingSftpUploadCancellation({
pendingHostId: "host-1",
pendingOriginSessionId: "session-b",
pendingSourceSessionId: "session-b",
originSessionStatus: "connected",
activeHostId: "host-1",
activeSessionId: "session-a",
focusedSessionId: "session-a",
panelVisible: true,
waitingForOriginFocus: true,
waitingForSourceSession: true,
connection: null,
}), null);
});
test("Mosh/ET drops wait for origin focus before binding an SSH route", () => {
assert.equal(shouldDeferPendingSftpUploadForOriginFocus({
originSessionId: "mosh-b",
focusedSessionId: "ssh-a",
}), true);
assert.equal(shouldDeferPendingSftpUploadForOriginFocus({
originSessionId: "mosh-b",
focusedSessionId: "mosh-b",
}), false);
assert.equal(shouldDeferPendingSftpUploadForOriginFocus({
originSessionId: undefined,
focusedSessionId: "ssh-a",
}), false);
});
test("pending drop still cancels after origin focus landed and the user leaves", () => {
assert.equal(resolvePendingSftpUploadCancellation({
pendingHostId: "host-1",
pendingOriginSessionId: "session-b",
pendingSourceSessionId: "session-b",
originSessionStatus: "connected",
activeHostId: "host-1",
activeSessionId: "session-a",
focusedSessionId: "session-a",
panelVisible: true,
waitingForOriginFocus: false,
waitingForSourceSession: false,
connection: null,
}), "source-changed");
});
test("visible panel cancels an SSH drop when focus moves to same-host mosh or ET", () => {
const params = {
pendingHostId: "host-1",
pendingOriginSessionId: "ssh-session",
pendingSourceSessionId: undefined,
activeHostId: "host-1",
activeSessionId: null,
focusedSessionId: "mosh-session",
connection: {
hostId: "host-1",
sourceSessionId: "ssh-session",
status: "connected",
},
};
assert.equal(resolvePendingSftpUploadCancellation({
...params,
panelVisible: true,
}), "source-changed");
assert.equal(resolvePendingSftpUploadCancellation({
...params,
panelVisible: false,
}), null);
});
test("pending terminal upload tolerates a transient missing focused session", () => {
assert.equal(resolvePendingSftpUploadCancellation({
pendingHostId: "host-1",
pendingSourceSessionId: "session-a",
activeHostId: "host-1",
activeSessionId: null,
connection: null,
}), null);
});
test("pending Mosh/ET origin upload is cancelled when its origin session disconnects", () => {
const params = {
pendingHostId: "host-1",
pendingOriginSessionId: "mosh-session",
pendingSourceSessionId: undefined,
activeHostId: "host-1",
activeSessionId: null,
focusedSessionId: "mosh-session",
panelVisible: true,
connection: {
hostId: "host-1",
status: "connected",
},
};
assert.equal(resolvePendingSftpUploadCancellation({
...params,
originSessionStatus: "disconnected",
}), "source-changed");
assert.equal(resolvePendingSftpUploadCancellation({
...params,
originSessionStatus: null,
}), "source-changed");
// Still pending origin routes must not cancel.
assert.equal(resolvePendingSftpUploadCancellation({
...params,
originSessionStatus: "connected",
}), null);
assert.equal(resolvePendingSftpUploadCancellation({
...params,
originSessionStatus: "connecting",
}), null);
});
test("pending SSH origin upload keeps waiting through a same-tab reconnect", () => {
assert.equal(resolvePendingSftpUploadCancellation({
pendingHostId: "host-1",
pendingOriginSessionId: "ssh-session",
pendingSourceSessionId: "ssh-session",
activeHostId: "host-1",
activeSessionId: "ssh-session",
focusedSessionId: "ssh-session",
panelVisible: true,
originSessionStatus: "disconnected",
connection: {
hostId: "host-1",
sourceSessionId: "ssh-session",
status: "connected",
},
}), null);
});
test("pending terminal upload is cancelled after its matching connection fails", () => {
assert.equal(resolvePendingSftpUploadCancellation({
pendingHostId: "host-1",
pendingSourceSessionId: "session-a",
activeHostId: "host-1",
activeSessionId: "session-a",
connection: {
hostId: "host-1",
sourceSessionId: "session-a",
status: "error",
},
}), "connection-failed");
});
test("pending terminal upload survives an old connection while strict reconnect is starting", () => {
assert.equal(resolvePendingSftpUploadCancellation({
pendingHostId: "host-1",
pendingSourceSessionId: "session-a",
activeHostId: "host-1",
activeSessionId: "session-a",
connection: {
hostId: "host-1",
sourceSessionId: "session-old",
status: "connected",
},
}), null);
assert.equal(resolvePendingSftpUploadCancellation({
pendingHostId: "host-1",
pendingSourceSessionId: "session-a",
activeHostId: "host-1",
activeSessionId: "session-a",
connection: {
hostId: "host-1",
sourceSessionId: "session-a",
status: "connecting",
},
}), null);
});
test("an old disconnected pane does not cancel a pending strict rebind", () => {
assert.equal(shouldCancelPendingSftpUpload("connection-failed", true), false);
assert.equal(shouldCancelPendingSftpUpload("connection-failed", false), true);
assert.equal(shouldCancelPendingSftpUpload("source-changed", true), true);
});
test("pending terminal upload is blocked while the same terminal tab changes routes", () => {
assert.equal(shouldBlockPendingSftpUploadForSourceRebind({
pendingSourceSessionId: "session-a",
previousSessionId: "session-a",
activeSessionId: "session-a",
previousStatus: "connecting",
activeStatus: "connected",
}), true);
assert.equal(shouldBlockPendingSftpUploadForSourceRebind({
pendingSourceSessionId: "session-a",
previousSessionId: "session-a",
activeSessionId: "session-a",
previousStatus: "connected",
activeStatus: "connected",
}), false);
});
test("terminal drop waits until the exact forced rebind settles", () => {
assert.equal(shouldWaitForPendingSftpRebind({
pendingSourceSessionId: "session-a",
requestId: "drop-1",
startedRequestId: null,
connectionId: "old-connection",
}), true);
assert.equal(shouldWaitForPendingSftpRebind({
pendingSourceSessionId: "session-a",
requestId: "drop-1",
startedRequestId: "drop-1",
barrierRequestId: "drop-1",
previousConnectionId: "old-connection",
connectionId: "old-connection",
}), true);
assert.equal(shouldWaitForPendingSftpRebind({
pendingSourceSessionId: "session-a",
requestId: "drop-1",
startedRequestId: "drop-1",
barrierRequestId: "drop-1",
previousConnectionId: "old-connection",
connectionId: "unrelated-connection",
}), true);
assert.equal(shouldWaitForPendingSftpRebind({
pendingSourceSessionId: "session-a",
requestId: "drop-1",
startedRequestId: "drop-1",
settledRequestId: "drop-1",
barrierRequestId: "drop-1",
targetTabId: "new-tab",
targetConnectionId: "new-connection",
tabId: "old-tab",
connectionId: "unrelated-connection",
}), true);
assert.equal(shouldWaitForPendingSftpRebind({
pendingSourceSessionId: "session-a",
requestId: "drop-1",
startedRequestId: "drop-1",
settledRequestId: "drop-1",
barrierRequestId: "drop-1",
targetTabId: "new-tab",
targetConnectionId: "new-connection",
tabId: "new-tab",
connectionId: "new-connection",
}), false);
});
test("a repeated terminal drop stops waiting when its shared strict connect settles", () => {
assert.equal(shouldWaitForPendingSftpRebind({
pendingSourceSessionId: "session-a",
requestId: "drop-2",
startedRequestId: "drop-2",
settledRequestId: "drop-2",
barrierRequestId: "drop-2",
previousConnectionId: "connecting-connection",
targetTabId: "connecting-tab",
targetConnectionId: "connecting-connection",
tabId: "connecting-tab",
connectionId: "connecting-connection",
}), false);
});
test("a terminal drop is cancelled when its settled forced target was closed", () => {
assert.equal(shouldCancelSettledPendingSftpRebindWithoutTarget({
pendingRequiresRebind: true,
requestId: "drop-1",
startedRequestId: "drop-1",
settledRequestId: "drop-1",
barrierRequestId: "drop-1",
targetTabId: "closed-tab",
targetConnectionId: "closed-connection",
targetExists: false,
}), true);
assert.equal(shouldCancelSettledPendingSftpRebindWithoutTarget({
pendingRequiresRebind: true,
requestId: "drop-1",
startedRequestId: "drop-1",
settledRequestId: null,
barrierRequestId: "drop-1",
targetTabId: "closed-tab",
targetConnectionId: "closed-connection",
targetExists: false,
}), false);
assert.equal(shouldCancelSettledPendingSftpRebindWithoutTarget({
pendingRequiresRebind: true,
requestId: "drop-1",
startedRequestId: "drop-1",
settledRequestId: "drop-1",
barrierRequestId: "drop-1",
targetTabId: "live-tab",
targetConnectionId: "live-connection",
targetExists: true,
}), false);
});
test("a forced upload target remains valid after moving to the other SFTP pane", () => {
const movedTarget = remoteConnectedTab({
id: "moved-tab",
connection: {
...remoteConnectedTab().connection!,
id: "moved-connection",
},
});
assert.equal(findPendingSftpRebindTargetPane(
[],
[movedTarget],
"moved-tab",
"moved-connection",
), movedTarget);
});
test("Mosh and ET drops force a fresh SFTP route even when an old tab is healthy", () => {
assert.equal(shouldStartPendingSftpUploadRebind({
pendingMatchesTarget: true,
requestId: "mosh-drop",
startedRequestId: null,
originSessionId: "mosh-session",
sourceSessionId: undefined,
}), true);
assert.equal(shouldStartPendingSftpUploadRebind({
pendingMatchesTarget: true,
requestId: "mosh-drop",
startedRequestId: "mosh-drop",
originSessionId: "mosh-session",
sourceSessionId: undefined,
}), false);
});
test("findReusableSftpSidePanelTab ignores tabs stuck in loading after SSH disconnect", () => {
const tab = remoteConnectedTab({ loading: true });
const map = new Map([[tab.id, "host-key"]]);
assert.equal(
findReusableSftpSidePanelTab([tab], "host-1", "host-key", map, () => true),
null,
);
});
test("findReusableSftpSidePanelTab returns healthy tabs", () => {
const tab = remoteConnectedTab();
const map = new Map([[tab.id, "host-key"]]);
assert.equal(
findReusableSftpSidePanelTab([tab], "host-1", "host-key", map, () => true),
tab,
);
});
test("shouldResetSftpSidePanelSourceSession detects terminal session changes", () => {
assert.equal(shouldResetSftpSidePanelSourceSession("sess-a", "sess-b"), true);
assert.equal(shouldResetSftpSidePanelSourceSession("sess-a", "sess-a"), false);
assert.equal(shouldResetSftpSidePanelSourceSession(null, "sess-a"), false);
assert.equal(shouldResetSftpSidePanelSourceSession("sess-a", null), false);
});
test("shouldRebindSftpSidePanelSourceSession treats SSH start-over as a transport change", () => {
// Same terminal tab id after Start over - transport was replaced even though
// the session id did not change.
assert.equal(
shouldRebindSftpSidePanelSourceSession({
previousSessionId: "sess-a",
nextSessionId: "sess-a",
previousStatus: "disconnected",
nextStatus: "connected",
}),
true,
);
assert.equal(
shouldRebindSftpSidePanelSourceSession({
previousSessionId: "sess-a",
nextSessionId: "sess-a",
previousStatus: "connecting",
nextStatus: "connected",
}),
true,
);
assert.equal(
shouldRebindSftpSidePanelSourceSession({
previousSessionId: "sess-a",
nextSessionId: "sess-a",
previousStatus: "connected",
nextStatus: "connected",
}),
false,
);
assert.equal(
shouldRebindSftpSidePanelSourceSession({
previousSessionId: "sess-a",
nextSessionId: "sess-b",
previousStatus: "connected",
nextStatus: "connected",
}),
true,
);
assert.equal(
shouldRebindSftpSidePanelSourceSession({
previousSessionId: "sess-a",
nextSessionId: "sess-a",
previousStatus: "disconnected",
nextStatus: "connecting",
}),
false,
);
});
test("shouldDeferSftpSidePanelAutoConnectForSession only waits during an active reconnect", () => {
assert.equal(
shouldDeferSftpSidePanelAutoConnectForSession({
activeSessionId: "sess-a",
sessionStatus: "disconnected",
}),
false,
);
assert.equal(
shouldDeferSftpSidePanelAutoConnectForSession({
activeSessionId: "sess-a",
sessionStatus: "connecting",
}),
true,
);
assert.equal(
shouldDeferSftpSidePanelAutoConnectForSession({
activeSessionId: "sess-a",
sessionStatus: "connected",
}),
false,
);
assert.equal(
shouldDeferSftpSidePanelAutoConnectForSession({
activeSessionId: null,
sessionStatus: "connecting",
}),
false,
);
});
test("resolveSftpSidePanelTrackedSourceStatusUpdate remembers background disconnects", () => {
assert.deepEqual(
resolveSftpSidePanelTrackedSourceStatusUpdate({
trackedSessionId: "sess-a",
sessionStatus: "disconnected",
}),
{ sessionId: "sess-a", status: "disconnected" },
);
assert.deepEqual(
resolveSftpSidePanelTrackedSourceStatusUpdate({
trackedSessionId: "sess-a",
sessionStatus: "connecting",
}),
{ sessionId: "sess-a", status: "connecting" },
);
assert.equal(
resolveSftpSidePanelTrackedSourceStatusUpdate({
trackedSessionId: "sess-a",
sessionStatus: "connected",
}),
null,
);
assert.equal(
resolveSftpSidePanelTrackedSourceStatusUpdate({
trackedSessionId: null,
sessionStatus: "disconnected",
}),
null,
);
});
test("rememberSftpSidePanelSourceStatus keeps the linked SSH status across non-SSH focus", () => {
assert.equal(
rememberSftpSidePanelSourceStatus({
previousStatus: "connecting",
activeSessionId: null,
activeSessionStatus: null,
}),
"connecting",
);
assert.equal(
rememberSftpSidePanelSourceStatus({
previousStatus: "disconnected",
activeSessionId: null,
activeSessionStatus: null,
}),
"disconnected",
);
assert.equal(
rememberSftpSidePanelSourceStatus({
previousStatus: "disconnected",
activeSessionId: "sess-a",
activeSessionStatus: "connected",
}),
"connected",
);
});
test("failed terminal reconnect keeps a healthy standalone SFTP tab", () => {
const tab = remoteConnectedTab();
const sessionChanged = shouldRebindSftpSidePanelSourceSession({
previousSessionId: "sess-a",
nextSessionId: "sess-a",
previousStatus: "connecting",
nextStatus: "disconnected",
});
assert.equal(sessionChanged, false);
assert.equal(
!sessionChanged && shouldSkipSftpSidePanelAutoConnect(
"host-1:key",
"host-1:key",
tab,
true,
"host-1:key",
),
true,
);
assert.equal(
shouldRebindSftpSidePanelSourceSession({
previousSessionId: "sess-a",
nextSessionId: "sess-a",
previousStatus: "connecting",
nextStatus: "connected",
}),
true,
);
});
test("session change still requires rebind even when the endpoint key matches", () => {
const tab = remoteConnectedTab();
// Callers must not skip auto-connect solely because the tab is healthy —
// a new focused terminal may share host/port/user while proxy/jump differs.
// Path stickiness is handled by remembered initialPath on reconnect.
assert.equal(shouldResetSftpSidePanelSourceSession("sess-a", "sess-b"), true);
assert.equal(
shouldSkipSftpSidePanelAutoConnect("host-key", "host-key", tab, true, "host-key"),
true,
);
// Reuse lookup still finds the tab, but callers pass sessionChanged and skip
// it so connect rebinds with the new sourceSessionId.
assert.equal(
findReusableSftpSidePanelTab(
[tab],
"host-1",
"host-key",
new Map([[tab.id, "host-key"]]),
() => true,
),
tab,
);
});

View File

@@ -0,0 +1,388 @@
import type { SftpPane } from "../../application/state/sftp/types";
export type SftpSidePanelTabHealth = Pick<SftpPane, "connection" | "loading" | "reconnecting">;
/** Whether a remote SFTP tab is safe to reuse without reconnecting. */
export function isRemoteSftpTabHealthy(
tab: SftpSidePanelTabHealth,
hasBackendSession: boolean,
): boolean {
const conn = tab.connection;
if (!conn || conn.isLocal) return true;
if (conn.status !== "connected") return false;
if (tab.loading || tab.reconnecting) return false;
if (!hasBackendSession) return false;
return true;
}
/**
* Skip auto-connect only when the active tab is already bound to this endpoint
* and healthy. `activeTabConnectionKey` must match so a manually selected tab
* for a different host cannot be kept just because `connectedKey` is stale.
*/
export function shouldSkipSftpSidePanelAutoConnect(
connectionKey: string,
connectedKey: string | null,
activeTab: SftpSidePanelTabHealth | null | undefined,
hasBackendSession: boolean,
activeTabConnectionKey?: string | null,
): boolean {
if (connectedKey !== connectionKey) return false;
if (!activeTab) return false;
if (activeTabConnectionKey !== connectionKey) return false;
return isRemoteSftpTabHealthy(activeTab, hasBackendSession);
}
export function isPendingSameEndpointSshSession(
session: {
hostId?: string | null;
status?: string;
hostname?: string | null;
port?: number | null;
username?: string | null;
protocol?: string | null;
moshEnabled?: boolean;
etEnabled?: boolean;
},
host: {
id: string;
hostname: string;
port?: number | null;
username: string;
},
): boolean {
return Boolean(
session.hostId === host.id
&& session.status === "connecting"
&& session.hostname === host.hostname
&& (session.port ?? 22) === (host.port ?? 22)
&& session.username === host.username
&& (session.protocol === "ssh" || session.protocol === undefined)
&& !session.moshEnabled
&& !session.etEnabled,
);
}
/** Whether a stored endpoint key still belongs to the live connection's host. */
export function connectionKeyMatchesHost(
connectionKey: string | null | undefined,
hostId: string,
): boolean {
if (!connectionKey) return false;
return connectionKey === hostId || connectionKey.startsWith(`${hostId}:`);
}
/**
* Accept a terminal-drop pending upload only when the active pane is already
* connected to the exact endpoint the drop requested. Matching hostId alone is
* unsafe: session overrides can share hostId while hostname/port/user differ,
* and auto-connect may still be deferred via rAF while the previous endpoint
* looks connected.
*/
export function shouldAcceptPendingSftpUpload(params: {
ownerPanelOpen: boolean;
pendingHostId: string;
pendingConnectionKey: string;
pendingSourceSessionId?: string;
activeHostId: string | null | undefined;
connection: {
hostId?: string | null;
isLocal?: boolean;
status?: string;
sourceSessionId?: string;
} | null | undefined;
paneConnectionKey: string | null | undefined;
}): boolean {
const {
ownerPanelOpen,
pendingHostId,
pendingConnectionKey,
pendingSourceSessionId,
activeHostId,
connection,
paneConnectionKey,
} = params;
if (!ownerPanelOpen) return false;
if (!activeHostId || pendingHostId !== activeHostId) return false;
if (!connection || connection.isLocal || connection.hostId !== activeHostId) return false;
if (connection.status !== "connected") return false;
if (!paneConnectionKey || paneConnectionKey !== pendingConnectionKey) return false;
if (pendingSourceSessionId && connection.sourceSessionId !== pendingSourceSessionId) return false;
return true;
}
/** Wait until the drop's origin pane is focused before binding SFTP. */
export function shouldDeferPendingSftpUploadForOriginFocus(params: {
originSessionId?: string;
focusedSessionId?: string | null;
}): boolean {
if (!params.originSessionId || !params.focusedSessionId) return false;
return params.focusedSessionId !== params.originSessionId;
}
export type PendingSftpUploadCancellationReason = "source-changed" | "connection-failed";
/** Old-pane failures are ignored until a strict replacement has finished binding. */
export function shouldCancelPendingSftpUpload(
reason: PendingSftpUploadCancellationReason | null,
waitingForStrictRebind: boolean,
): boolean {
if (reason === "source-changed") return true;
return reason === "connection-failed" && !waitingForStrictRebind;
}
/** Return a terminal-drop cancellation only for terminal or connection changes that cannot recover. */
export function resolvePendingSftpUploadCancellation(params: {
pendingHostId: string;
pendingOriginSessionId?: string;
pendingSourceSessionId?: string;
/**
* Status of the origin terminal session while the drop is pending: the
* session's live status, `null` once it no longer exists, or `undefined`
* when the caller does not track it.
*/
originSessionStatus?: string | null;
activeHostId: string | null | undefined;
activeSessionId: string | null | undefined;
focusedSessionId?: string | null;
panelVisible?: boolean;
/**
* True while this drop's own focus switch has not landed yet. A live focus
* still sitting on another same-host session is then lag, not a user leave.
*/
waitingForOriginFocus?: boolean;
/**
* True while the drop's originating SSH session has not yet become the
* panel's active session. Distinguishes scheduled rebind from a real switch.
*/
waitingForSourceSession?: boolean;
connection: {
hostId?: string | null;
sourceSessionId?: string;
status?: string;
} | null | undefined;
}): PendingSftpUploadCancellationReason | null {
if (params.activeHostId !== params.pendingHostId) return "source-changed";
if (
params.panelVisible
&& params.pendingOriginSessionId
&& params.focusedSessionId
&& params.focusedSessionId !== params.pendingOriginSessionId
&& !params.waitingForOriginFocus
) return "source-changed";
if (
params.pendingSourceSessionId
&& params.activeSessionId
&& params.activeSessionId !== params.pendingSourceSessionId
&& !params.waitingForSourceSession
) return "source-changed";
// A Mosh/ET (or local) origin has no reusable SSH source session, so the
// origin terminal itself is the only evidence of the drop's destination
// route. If it disconnects or disappears while the standalone SFTP rebind
// is pending, cancel instead of uploading into a route that is gone. SSH
// origins are excluded: their same-tab reconnect rebind is expected to
// pass through non-connected statuses.
if (params.pendingOriginSessionId && !params.pendingSourceSessionId) {
if (
params.originSessionStatus === null
|| params.originSessionStatus === "disconnected"
) return "source-changed";
}
if (
params.connection?.hostId === params.pendingHostId
&& (!params.pendingSourceSessionId
|| params.connection.sourceSessionId === params.pendingSourceSessionId)
&& (params.connection.status === "error" || params.connection.status === "disconnected")
) return "connection-failed";
return null;
}
/** Block an old SFTP connection while the same terminal tab is replacing its SSH route. */
export function shouldBlockPendingSftpUploadForSourceRebind(params: {
pendingSourceSessionId?: string;
previousSessionId?: string | null;
activeSessionId?: string | null;
previousStatus?: string | null;
activeStatus?: string | null;
}): boolean {
if (!params.pendingSourceSessionId) return false;
if (params.activeSessionId !== params.pendingSourceSessionId) return false;
return shouldRebindSftpSidePanelSourceSession({
previousSessionId: params.previousSessionId,
nextSessionId: params.activeSessionId,
previousStatus: params.previousStatus,
nextStatus: params.activeStatus,
});
}
export function shouldStartPendingSftpUploadRebind(params: {
pendingMatchesTarget: boolean;
requestId: string;
startedRequestId?: string | null;
originSessionId?: string;
sourceSessionId?: string;
}): boolean {
return Boolean(
params.pendingMatchesTarget
&& (params.originSessionId || params.sourceSessionId)
&& params.startedRequestId !== params.requestId
);
}
/** A terminal drop must wait until its forced route rebind has finished. */
export function shouldWaitForPendingSftpRebind(params: {
pendingRequiresRebind?: boolean;
pendingSourceSessionId?: string;
requestId: string;
startedRequestId?: string | null;
settledRequestId?: string | null;
connectionId?: string | null;
tabId?: string | null;
barrierRequestId?: string | null;
previousConnectionId?: string | null;
targetTabId?: string | null;
targetConnectionId?: string | null;
}): boolean {
const pendingRequiresRebind = params.pendingRequiresRebind
?? Boolean(params.pendingSourceSessionId);
if (!pendingRequiresRebind) return false;
if (params.startedRequestId !== params.requestId) return true;
if (params.barrierRequestId !== params.requestId) return true;
if (params.settledRequestId !== params.requestId) return true;
if (!params.targetTabId || !params.targetConnectionId) return true;
return params.tabId !== params.targetTabId
|| params.connectionId !== params.targetConnectionId;
}
/** A completed forced connect cannot recover after its exact target was closed. */
export function shouldCancelSettledPendingSftpRebindWithoutTarget(params: {
pendingRequiresRebind: boolean;
requestId: string;
startedRequestId?: string | null;
settledRequestId?: string | null;
barrierRequestId?: string | null;
targetTabId?: string | null;
targetConnectionId?: string | null;
targetExists: boolean;
}): boolean {
return Boolean(
params.pendingRequiresRebind
&& params.startedRequestId === params.requestId
&& params.settledRequestId === params.requestId
&& params.barrierRequestId === params.requestId
&& (
!params.targetTabId
|| !params.targetConnectionId
|| !params.targetExists
)
);
}
/** Locate an exact forced-connect target even if its tab moved between panes. */
export function findPendingSftpRebindTargetPane(
leftTabs: ReadonlyArray<SftpPane>,
rightTabs: ReadonlyArray<SftpPane>,
targetTabId?: string | null,
targetConnectionId?: string | null,
): SftpPane | null {
if (!targetTabId || !targetConnectionId) return null;
return [...leftTabs, ...rightTabs].find((pane) => (
pane.id === targetTabId
&& pane.connection?.id === targetConnectionId
)) ?? null;
}
export function findReusableSftpSidePanelTab(
tabs: SftpPane[],
hostId: string,
connectionKey: string,
tabConnectionKeyMap: ReadonlyMap<string, string>,
hasBackendSession: (connectionId: string) => boolean,
getConnectionKey?: (connectionId: string) => string | null,
): SftpPane | null {
const candidate = tabs.find((tab) => {
if (!tab.connection || tab.connection.hostId !== hostId) return false;
if (tab.connection.status === "error" || tab.connection.status === "disconnected") return false;
const liveKey = getConnectionKey?.(tab.connection.id) ?? null;
const tabKey = liveKey ?? tabConnectionKeyMap.get(tab.id) ?? null;
return tabKey === connectionKey;
});
if (!candidate?.connection) return null;
if (!isRemoteSftpTabHealthy(candidate, hasBackendSession(candidate.connection.id))) {
return null;
}
return candidate;
}
/** True when the linked terminal SSH session id changed. */
export function shouldResetSftpSidePanelSourceSession(
previousSessionId: string | null | undefined,
nextSessionId: string | null | undefined,
): boolean {
if (!nextSessionId) return false;
if (!previousSessionId) return false;
return nextSessionId !== previousSessionId;
}
/**
* True when the SFTP side panel must rebind onto a fresh SSH transport.
* Covers focus switches (session id change) and same-tab reconnect /
* Start over, where the id is stable but the underlying channel was replaced.
*/
export function shouldRebindSftpSidePanelSourceSession(params: {
previousSessionId: string | null | undefined;
nextSessionId: string | null | undefined;
previousStatus?: string | null;
nextStatus?: string | null;
}): boolean {
if (shouldResetSftpSidePanelSourceSession(params.previousSessionId, params.nextSessionId)) {
return true;
}
if (!params.nextSessionId) return false;
if (params.previousSessionId !== params.nextSessionId) return false;
if (params.nextStatus !== "connected") return false;
if (params.previousStatus == null) return false;
return params.previousStatus !== "connected";
}
/**
* While the linked terminal is actively connecting, wait for its transport so
* SFTP can reuse it. A terminal left disconnected must not block standalone
* SFTP fallback.
*/
export function shouldDeferSftpSidePanelAutoConnectForSession(params: {
activeSessionId?: string | null;
sessionStatus?: string | null;
}): boolean {
if (!params.activeSessionId) return false;
return params.sessionStatus === "connecting";
}
/**
* While the last linked SSH session is not the active reusable source,
* remember its non-connected status so a background reconnect still rebinds
* when that session becomes active again.
*/
export function resolveSftpSidePanelTrackedSourceStatusUpdate(params: {
trackedSessionId?: string | null;
sessionStatus?: string | null;
}): { sessionId: string; status: string } | null {
if (!params.trackedSessionId) return null;
if (!params.sessionStatus || params.sessionStatus === "connected") return null;
return {
sessionId: params.trackedSessionId,
status: params.sessionStatus,
};
}
/** Keep the tracked source status when focus temporarily has no reusable SSH. */
export function rememberSftpSidePanelSourceStatus(params: {
previousStatus?: string | null;
activeSessionId?: string | null;
activeSessionStatus?: string | null;
}): string | null {
if (params.activeSessionId && params.activeSessionStatus) {
return params.activeSessionStatus;
}
return params.previousStatus ?? null;
}

View File

@@ -0,0 +1,99 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
MAX_SFTP_SIDE_PANEL_REMEMBERED_PATHS,
canApplySftpSidePanelInitialLocation,
pruneSftpSidePanelState,
recallSftpSidePanelPath,
rememberSftpSidePanelPath,
} from "./sftpSidePanelConnectionMemory";
const initialLocationParams = {
activeHostId: "host-1",
initialLocation: { hostId: "host-1", path: "/srv/b" },
expectedConnectionKey: "host-1:endpoint",
actualConnectionKey: "host-1:endpoint",
pendingRequiresExactTarget: true,
pendingTargetConnectionId: "connection-b",
connection: {
id: "connection-b",
hostId: "host-1",
isLocal: false,
status: "connected",
},
};
test("initial location waits for the exact endpoint and pending target", () => {
assert.equal(canApplySftpSidePanelInitialLocation(initialLocationParams), true);
assert.equal(canApplySftpSidePanelInitialLocation({
...initialLocationParams,
connection: {
...initialLocationParams.connection,
id: "connection-a",
},
}), false);
assert.equal(canApplySftpSidePanelInitialLocation({
...initialLocationParams,
pendingTargetConnectionId: null,
}), false);
assert.equal(canApplySftpSidePanelInitialLocation({
...initialLocationParams,
actualConnectionKey: "host-1:other-endpoint",
}), false);
});
test("ordinary initial locations apply once the matching pane is connected", () => {
assert.equal(canApplySftpSidePanelInitialLocation({
...initialLocationParams,
pendingRequiresExactTarget: false,
pendingTargetConnectionId: null,
}), true);
assert.equal(canApplySftpSidePanelInitialLocation({
...initialLocationParams,
pendingRequiresExactTarget: false,
pendingTargetConnectionId: null,
connection: {
...initialLocationParams.connection,
status: "connecting",
},
}), false);
});
test("closed SFTP side-panel tabs release their remembered connection keys", () => {
const connectionKeys = new Map<string, string>();
for (let index = 0; index < 100; index += 1) {
connectionKeys.set(`tab-${index}`, `connection-${index}`);
}
pruneSftpSidePanelState(connectionKeys, ["tab-97", "tab-98", "tab-99"]);
assert.deepEqual([...connectionKeys], [
["tab-97", "connection-97"],
["tab-98", "connection-98"],
["tab-99", "connection-99"],
]);
});
test("SFTP side-panel path memory stays bounded during endpoint churn", () => {
const paths = new Map<string, string>();
for (let index = 0; index < 100; index += 1) {
rememberSftpSidePanelPath(paths, `endpoint-${index}`, `/path/${index}`);
}
assert.equal(paths.size, MAX_SFTP_SIDE_PANEL_REMEMBERED_PATHS);
assert.equal(paths.has("endpoint-0"), false);
assert.equal(paths.get("endpoint-99"), "/path/99");
});
test("reading a remembered SFTP path keeps that endpoint in the LRU", () => {
const paths = new Map<string, string>();
rememberSftpSidePanelPath(paths, "endpoint-a", "/a", 3);
rememberSftpSidePanelPath(paths, "endpoint-b", "/b", 3);
rememberSftpSidePanelPath(paths, "endpoint-c", "/c", 3);
assert.equal(recallSftpSidePanelPath(paths, "endpoint-a"), "/a");
rememberSftpSidePanelPath(paths, "endpoint-d", "/d", 3);
assert.equal(paths.has("endpoint-a"), true);
assert.equal(paths.has("endpoint-b"), false);
});

View File

@@ -0,0 +1,69 @@
export const MAX_SFTP_SIDE_PANEL_REMEMBERED_PATHS = 32;
export function canApplySftpSidePanelInitialLocation(params: {
activeHostId: string;
initialLocation: { hostId: string; path: string };
expectedConnectionKey: string;
actualConnectionKey: string | null;
pendingRequiresExactTarget: boolean;
pendingTargetConnectionId: string | null;
connection: {
id: string;
hostId: string;
isLocal: boolean;
status: string;
} | null | undefined;
}): boolean {
const { connection } = params;
if (!params.initialLocation.path) return false;
if (params.initialLocation.hostId !== params.activeHostId) return false;
if (!connection || connection.isLocal || connection.status !== "connected") return false;
if (connection.hostId !== params.activeHostId) return false;
if (params.actualConnectionKey !== params.expectedConnectionKey) return false;
if (
params.pendingRequiresExactTarget
&& (
!params.pendingTargetConnectionId
|| connection.id !== params.pendingTargetConnectionId
)
) return false;
return true;
}
export function pruneSftpSidePanelState<Value>(
valuesById: Map<string, Value>,
activeIdsInput: Iterable<string>,
): void {
const activeIds = new Set(activeIdsInput);
for (const id of valuesById.keys()) {
if (!activeIds.has(id)) {
valuesById.delete(id);
}
}
}
export function rememberSftpSidePanelPath(
paths: Map<string, string>,
connectionKey: string,
remotePath: string,
limit = MAX_SFTP_SIDE_PANEL_REMEMBERED_PATHS,
): void {
paths.delete(connectionKey);
paths.set(connectionKey, remotePath);
while (paths.size > limit) {
const oldestKey = paths.keys().next().value;
if (oldestKey === undefined) break;
paths.delete(oldestKey);
}
}
export function recallSftpSidePanelPath(
paths: Map<string, string>,
connectionKey: string,
): string | undefined {
const remotePath = paths.get(connectionKey);
if (remotePath === undefined) return undefined;
paths.delete(connectionKey);
paths.set(connectionKey, remotePath);
return remotePath;
}

View File

@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const sidePanelSource = readFileSync(new URL("../SftpSidePanel.tsx", import.meta.url), "utf8");
const tabBridgeSource = readFileSync(
new URL("../terminalLayer/TerminalLayerTabBridge.tsx", import.meta.url),
"utf8",
);
test("SFTP side panel rebinds after same-tab SSH start-over", () => {
assert.match(sidePanelSource, /shouldRebindSftpSidePanelSourceSession\(/);
assert.match(sidePanelSource, /shouldDeferSftpSidePanelAutoConnectForSession\(/);
assert.match(sidePanelSource, /lastSourceSessionStatusRef/);
assert.match(
sidePanelSource,
/previousStatus:\s*lastSourceSessionStatusRef\.current/,
);
// Reuse only after SSH is connected; linked id may arrive while reconnecting.
assert.match(
sidePanelSource,
/activeSessionStatus === "connected" \? \(activeSessionId \?\? undefined\) : undefined/,
);
assert.match(sidePanelSource, /requireSourceSessionReuse:\s*Boolean\(pendingStrictSourceSessionId\)/);
assert.match(sidePanelSource, /resolveSftpSidePanelTrackedSourceStatusUpdate\(/);
assert.match(
sidePanelSource,
/trackedSessionId = lastSourceSessionIdRef\.current/,
);
assert.match(sidePanelSource, /if \(activeSessionId\) return;/);
});
test("terminal layer keeps the linked SFTP session id while SSH reconnects", () => {
assert.match(tabBridgeSource, /isTerminalSessionEligibleForSftpReuse\(session\)/);
assert.doesNotMatch(
tabBridgeSource,
/activeTerminalSessionIdForSftp[\s\S]*canReuseTerminalConnection\(session\)/,
);
});

View File

@@ -0,0 +1,84 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { SftpFileEntry } from '../../types.ts';
import { sortSftpEntries } from './utils.ts';
const entry = (
name: string,
type: SftpFileEntry['type'],
lastModified: number,
): SftpFileEntry => ({
name,
type,
size: 0,
sizeFormatted: '0 B',
lastModified,
lastModifiedFormatted: String(lastModified),
});
const entries = [
entry('dir-a', 'directory', 100),
entry('newest.log', 'file', 300),
entry('dir-b', 'directory', 200),
];
test('SFTP sorting keeps directories first by default', () => {
const sorted = sortSftpEntries(entries, 'modified', 'desc');
assert.deepEqual(sorted.map(({ name }) => name), ['dir-b', 'dir-a', 'newest.log']);
});
test('SFTP sorting can mix files and directories in the selected order', () => {
const sorted = sortSftpEntries(entries, 'modified', 'desc', false);
assert.deepEqual(sorted.map(({ name }) => name), ['newest.log', 'dir-b', 'dir-a']);
});
test('SFTP kind sorting keeps directories first when enabled', () => {
const kindEntries = [
entry('BUILD', 'file', 100),
entry('src', 'directory', 100),
entry('archive.zip', 'file', 100),
];
const sorted = sortSftpEntries(kindEntries, 'type', 'asc', true);
assert.deepEqual(sorted.map(({ name }) => name), ['src', 'BUILD', 'archive.zip']);
});
test('SFTP descending kind sorting keeps directories first when enabled', () => {
const kindEntries = [
entry('BUILD', 'file', 100),
entry('src', 'directory', 100),
entry('archive.zip', 'file', 100),
];
const sorted = sortSftpEntries(kindEntries, 'type', 'desc', true);
assert.deepEqual(sorted.map(({ name }) => name), ['src', 'archive.zip', 'BUILD']);
});
test('SFTP owner sorting compares usernames and keeps directories first', () => {
const ownerEntries = [
{ ...entry('www.log', 'file', 100), owner: 'www-data' },
{ ...entry('root.txt', 'file', 100), owner: 'root' },
{ ...entry('home', 'directory', 100), owner: 'alice' },
];
const sorted = sortSftpEntries(ownerEntries, 'owner', 'asc', true);
assert.deepEqual(sorted.map(({ name }) => name), ['home', 'root.txt', 'www.log']);
});
test('SFTP kind sorting can mix files and directories when folders first is disabled', () => {
const kindEntries = [
entry('BUILD', 'file', 100),
entry('src', 'directory', 100),
entry('archive.zip', 'file', 100),
];
const sorted = sortSftpEntries(kindEntries, 'type', 'asc', false);
assert.deepEqual(sorted.map(({ name }) => name), ['BUILD', 'src', 'archive.zip']);
});

View File

@@ -0,0 +1,114 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { SftpPane } from "../../application/state/sftp/types.ts";
import {
canDuplicateSftpTab,
getSftpTabDuplicateRequest,
isSftpTabKeyboardContextMenuShortcut,
isSftpTabKeyboardSelectShortcut,
shouldHandleSftpTabKeyboardEvent,
SFTP_TAB_DUPLICATE_MENU_ITEMS,
} from "./sftpTabDuplication.ts";
const connectedPane = (overrides: Partial<NonNullable<SftpPane["connection"]>> = {}): SftpPane => ({
id: "tab-1",
connection: {
id: "conn-1",
hostId: "host-1",
hostLabel: "Prod",
isLocal: false,
status: "connected",
currentPath: "/var/www/app",
homeDir: "/home/deploy",
...overrides,
},
files: [],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles: false,
transferMutationToken: 0,
});
test("default-path SFTP tab duplication keeps only the remote host identity", () => {
assert.deepEqual(getSftpTabDuplicateRequest(connectedPane(), "defaultPath"), {
kind: "remote",
hostId: "host-1",
});
});
test("current-path SFTP tab duplication carries the active directory", () => {
assert.deepEqual(getSftpTabDuplicateRequest(connectedPane(), "currentPath"), {
kind: "remote",
hostId: "host-1",
path: "/var/www/app",
});
});
test("local SFTP tab duplication targets the local filesystem", () => {
assert.deepEqual(
getSftpTabDuplicateRequest(
connectedPane({
hostId: "local",
hostLabel: "Local",
isLocal: true,
currentPath: "/Users/damao/projects",
homeDir: "/Users/damao",
}),
"currentPath",
),
{
kind: "local",
path: "/Users/damao/projects",
},
);
});
test("SFTP tab duplication is unavailable before a tab is connected", () => {
assert.equal(getSftpTabDuplicateRequest({ ...connectedPane(), connection: null }, "defaultPath"), null);
assert.equal(
getSftpTabDuplicateRequest(connectedPane({ status: "connecting" }), "currentPath"),
null,
);
});
test("SFTP tab duplicate menu exposes separate default and current path actions", () => {
assert.deepEqual(
SFTP_TAB_DUPLICATE_MENU_ITEMS.map((item) => item.mode),
["defaultPath", "currentPath"],
);
assert.deepEqual(
SFTP_TAB_DUPLICATE_MENU_ITEMS.map((item) => item.labelKey),
["sftp.tabs.copyDefaultPath", "sftp.tabs.copyCurrentPath"],
);
});
test("SFTP tab duplicate menu is disabled without a connected tab and handler", () => {
assert.equal(canDuplicateSftpTab({ canDuplicate: true }, true), true);
assert.equal(canDuplicateSftpTab({ canDuplicate: true }, false), false);
assert.equal(canDuplicateSftpTab({ canDuplicate: false }, true), false);
assert.equal(canDuplicateSftpTab(connectedPane(), true), true);
assert.equal(canDuplicateSftpTab(connectedPane({ status: "connecting" }), true), false);
});
test("SFTP tab duplicate menu has keyboard shortcuts for selection and menu access", () => {
assert.equal(isSftpTabKeyboardSelectShortcut("Enter"), true);
assert.equal(isSftpTabKeyboardSelectShortcut(" "), true);
assert.equal(isSftpTabKeyboardSelectShortcut("Escape"), false);
assert.equal(isSftpTabKeyboardContextMenuShortcut("ContextMenu"), true);
assert.equal(isSftpTabKeyboardContextMenuShortcut("F10", true), true);
assert.equal(isSftpTabKeyboardContextMenuShortcut("F10", false), false);
});
test("SFTP tab keyboard shortcuts do not intercept nested close button events", () => {
const tab = new EventTarget();
const closeButton = new EventTarget();
assert.equal(shouldHandleSftpTabKeyboardEvent(tab, tab), true);
assert.equal(shouldHandleSftpTabKeyboardEvent(closeButton, tab), false);
});

View File

@@ -0,0 +1,73 @@
import type { SftpPane } from "../../application/state/sftp/types";
export type SftpTabDuplicateMode = "defaultPath" | "currentPath";
export type SftpTabDuplicateRequest =
| { kind: "local"; path?: string }
| { kind: "remote"; hostId: string; path?: string };
export const SFTP_TAB_DUPLICATE_MENU_ITEMS: ReadonlyArray<{
mode: SftpTabDuplicateMode;
labelKey: "sftp.tabs.copyDefaultPath" | "sftp.tabs.copyCurrentPath";
}> = Object.freeze([
{ mode: "defaultPath", labelKey: "sftp.tabs.copyDefaultPath" },
{ mode: "currentPath", labelKey: "sftp.tabs.copyCurrentPath" },
]);
export function canDuplicateSftpTab(
tab: Pick<SftpPane, "connection"> | { canDuplicate?: boolean } | null | undefined,
hasDuplicateHandler: boolean,
): boolean {
if (!hasDuplicateHandler || !tab) return false;
if ("connection" in tab) return tab.connection?.status === "connected";
return !!tab.canDuplicate;
}
export function isSftpTabKeyboardContextMenuShortcut(
key: string,
shiftKey = false,
): boolean {
return key === "ContextMenu" || (shiftKey && key === "F10");
}
export function isSftpTabKeyboardSelectShortcut(key: string): boolean {
return key === "Enter" || key === " ";
}
export function shouldHandleSftpTabKeyboardEvent(
target: EventTarget | null,
currentTarget: EventTarget | null,
): boolean {
return target === currentTarget;
}
export function getSftpTabDuplicateRequest(
pane: Pick<SftpPane, "connection"> | null | undefined,
mode: SftpTabDuplicateMode,
): SftpTabDuplicateRequest | null {
const connection = pane?.connection;
if (!connection || connection.status !== "connected") {
return null;
}
const path = mode === "currentPath" && connection.currentPath
? { path: connection.currentPath }
: {};
if (connection.isLocal) {
return {
kind: "local",
...path,
};
}
if (!connection.hostId) {
return null;
}
return {
kind: "remote",
hostId: connection.hostId,
...path,
};
}

View File

@@ -0,0 +1,57 @@
export type TreePathsState = {
expandedPaths: Set<string>;
loadingPaths: Set<string>;
errorPaths: Set<string>;
};
export type TreePathsAction =
| { type: 'START_LOADING'; path: string }
| { type: 'FINISH_LOADING'; path: string }
| { type: 'LOAD_ERROR'; path: string }
| { type: 'EXPAND'; path: string }
| { type: 'COLLAPSE'; path: string }
| { type: 'RESET' };
export const INITIAL_TREE_PATHS_STATE: TreePathsState = {
expandedPaths: new Set(),
loadingPaths: new Set(),
errorPaths: new Set(),
};
export function treePathsReducer(state: TreePathsState, action: TreePathsAction): TreePathsState {
switch (action.type) {
case 'START_LOADING': {
const loadingPaths = new Set(state.loadingPaths);
loadingPaths.add(action.path);
const errorPaths = new Set(state.errorPaths);
errorPaths.delete(action.path);
return { ...state, loadingPaths, errorPaths };
}
case 'FINISH_LOADING': {
const loadingPaths = new Set(state.loadingPaths);
loadingPaths.delete(action.path);
return { ...state, loadingPaths };
}
case 'LOAD_ERROR': {
const loadingPaths = new Set(state.loadingPaths);
loadingPaths.delete(action.path);
const errorPaths = new Set(state.errorPaths);
errorPaths.add(action.path);
return { ...state, loadingPaths, errorPaths };
}
case 'EXPAND': {
const expandedPaths = new Set(state.expandedPaths);
expandedPaths.add(action.path);
return { ...state, expandedPaths };
}
case 'COLLAPSE': {
const expandedPaths = new Set(state.expandedPaths);
expandedPaths.delete(action.path);
return { ...state, expandedPaths };
}
case 'RESET':
return INITIAL_TREE_PATHS_STATE;
default:
return state;
}
}

View File

@@ -0,0 +1,49 @@
import type { SftpFileEntry } from "../../types";
import { getParentPath, joinPath } from "../../application/state/sftp/utils";
import { isNavigableDirectory } from "./utils";
export const shouldShowSftpUploadFilesMenu = ({
isLocal,
hasFileListUpload,
}: {
isLocal: boolean;
hasFileListUpload: boolean;
}) => !isLocal && hasFileListUpload;
export const shouldShowSftpUploadFolderMenu = ({
isLocal,
hasFolderUpload,
}: {
isLocal: boolean;
hasFolderUpload: boolean;
}) => !isLocal && hasFolderUpload;
export const getSftpListUploadFilesTargetPath = (
entry: SftpFileEntry,
currentPath: string,
): string | undefined => {
if (!isNavigableDirectory(entry) || entry.name === "..") {
return undefined;
}
return joinPath(currentPath, entry.name);
};
export const getSftpTreeUploadFilesTargetPath = (
entry: SftpFileEntry,
entryPath: string,
): string | undefined => {
if (entry.name === "..") {
return undefined;
}
return isNavigableDirectory(entry) ? entryPath : getParentPath(entryPath);
};
export const getSftpUploadFilesLabelKey = (entry: SftpFileEntry): string =>
isNavigableDirectory(entry) && entry.name !== ".."
? "sftp.context.uploadFilesHere"
: "sftp.context.uploadFiles";
export const getSftpUploadFolderLabelKey = (entry: SftpFileEntry): string =>
isNavigableDirectory(entry) && entry.name !== ".."
? "sftp.context.uploadFolderHere"
: "sftp.context.uploadFolder";

View File

@@ -0,0 +1,203 @@
import { useMemo } from 'react';
import { AppWindow, Archive, ArrowRight, ArrowUp, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderInput, FolderPlus, Pencil, RefreshCw, Shield, Trash2, Upload } from 'lucide-react';
import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from '../ui/context-menu';
import { getParentPath } from '../../application/state/sftp/utils';
import { isKnownBinaryFile } from '../../lib/sftpFileUtils';
import { isExtractableArchive } from '../../domain/sftpArchive';
import { isNavigableDirectory } from './utils';
import { getSftpTreeUploadFilesTargetPath, getSftpUploadFilesLabelKey, getSftpUploadFolderLabelKey } from './sftpUploadMenu';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type SftpPaneTreeContextMenuProps = Record<string, any>;
export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps) {
const {
contextTarget, pane, toggleExpand, stableOnOpenEntry, stableOnRefresh, getActionPaths, toTransferSources,
executeMoveAction, triggerUploadPicker, onUploadExternalFolder, uploadEnabled, folderUploadEnabled,
setMoveTargetPaths, setMoveToPath, setMoveToError, setMoveToSuggestions, setMoveToSuggestionIndex,
setIsMoving, setShowMoveToDialog, tRef, onCopyToOtherPaneRef, onNavigateToRef, onOpenFileWithSystemDefaultRef, onOpenFileWithRef,
onEditFileRef, onDownloadFileRef, onExtractArchiveRef, onEditPermissionsRef, openDeleteConfirmRef, openRenameDialogRef,
openNewFolderDialogRef, openNewFileDialogRef,
} = props;
return useMemo(() => {
const target = contextTarget;
if (!target) return null;
const { entry, entryPath } = target;
const isDir = isNavigableDirectory(entry);
const isLocal = pane.connection?.isLocal;
const handleOpen = () => {
if (isDir) void toggleExpand(entry, entryPath);
else stableOnOpenEntry(entry, entryPath);
};
const handleCopyToOtherPane = () => {
const paths = getActionPaths(entryPath);
const files = toTransferSources(paths);
if (files.length === 0) {
files.push({
name: entry.name,
isDirectory: isDir,
sourceConnectionId: pane.connection?.id,
sourcePath: getParentPath(entryPath),
});
}
onCopyToOtherPaneRef.current(files);
};
const handleDelete = () => {
openDeleteConfirmRef.current(getActionPaths(entryPath));
};
return (
<ContextMenuContent>
<ContextMenuItem onClick={handleOpen}>
{isDir
? <><Folder size={14} className="mr-2" />{tRef.current('sftp.context.open')}</>
: <><ExternalLink size={14} className="mr-2" />{tRef.current('sftp.context.open')}</>}
</ContextMenuItem>
{isDir && (
<ContextMenuItem onClick={() => onNavigateToRef.current(entryPath)}>
<ArrowRight size={14} className="mr-2" />{tRef.current('sftp.context.navigateTo')}
</ContextMenuItem>
)}
{!isDir && onOpenFileWithSystemDefaultRef.current && (
<ContextMenuItem onClick={() => onOpenFileWithSystemDefaultRef.current?.(entry, entryPath)}>
<AppWindow size={14} className="mr-2" />{tRef.current('sftp.context.openWithDefault')}
</ContextMenuItem>
)}
{!isDir && onOpenFileWithRef.current && (
<ContextMenuItem onClick={() => onOpenFileWithRef.current?.(entry, entryPath)}>
<ExternalLink size={14} className="mr-2" />{tRef.current('sftp.context.openWith')}
</ContextMenuItem>
)}
{!isDir && !isKnownBinaryFile(entry.name) && onEditFileRef.current && (
<ContextMenuItem onClick={() => onEditFileRef.current?.(entry, entryPath)}>
<Edit2 size={14} className="mr-2" />{tRef.current('sftp.context.edit')}
</ContextMenuItem>
)}
{onDownloadFileRef.current && (!isDir || !isLocal) && (
<ContextMenuItem onClick={() => onDownloadFileRef.current?.(entry, entryPath)}>
<Download size={14} className="mr-2" />{tRef.current('sftp.context.download')}
</ContextMenuItem>
)}
{!isDir && onExtractArchiveRef.current && isExtractableArchive(entry.name) && (
<ContextMenuItem onClick={() => onExtractArchiveRef.current?.(entry, entryPath)}>
<Archive size={14} className="mr-2" />{tRef.current('sftp.context.extract')}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={handleCopyToOtherPane}>
<Copy size={14} className="mr-2" />{tRef.current('sftp.context.copyToOtherPane')}
</ContextMenuItem>
<ContextMenuItem onClick={() => navigator.clipboard.writeText(entryPath)}>
<ClipboardCopy size={14} className="mr-2" />{tRef.current('sftp.context.copyPath')}
</ContextMenuItem>
<ContextMenuSeparator />
{(() => {
const sourceParent = getParentPath(entryPath);
const targetParent = getParentPath(sourceParent);
if (sourceParent === targetParent) return null;
return (
<ContextMenuItem onClick={() => {
const paths = getActionPaths(entryPath);
void executeMoveAction(paths, targetParent);
}}>
<ArrowUp size={14} className="mr-2" />{tRef.current('sftp.context.moveToParent')}
</ContextMenuItem>
);
})()}
<ContextMenuItem onClick={() => {
setMoveTargetPaths(getActionPaths(entryPath));
setMoveToPath('');
setMoveToError(null);
setMoveToSuggestions([]);
setMoveToSuggestionIndex(-1);
setIsMoving(false);
setShowMoveToDialog(true);
}}>
<FolderInput size={14} className="mr-2" />{tRef.current('sftp.context.moveTo')}
</ContextMenuItem>
<ContextMenuItem onClick={() => openRenameDialogRef.current(entryPath)}>
<Pencil size={14} className="mr-2" />{tRef.current('common.rename')}
</ContextMenuItem>
{onEditPermissionsRef.current && !isLocal && (
<ContextMenuItem onClick={() => onEditPermissionsRef.current?.(entry, entryPath)}>
<Shield size={14} className="mr-2" />{tRef.current('sftp.context.permissions')}
</ContextMenuItem>
)}
<ContextMenuItem
className="text-destructive"
onClick={handleDelete}
>
<Trash2 size={14} className="mr-2" />{tRef.current('action.delete')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={stableOnRefresh}>
<RefreshCw size={14} className="mr-2" />{tRef.current('common.refresh')}
</ContextMenuItem>
<ContextMenuItem onClick={() => openNewFolderDialogRef.current(isDir ? entryPath : getParentPath(entryPath))}>
<FolderPlus size={14} className="mr-2" />{tRef.current('sftp.newFolder')}
</ContextMenuItem>
<ContextMenuItem onClick={() => openNewFileDialogRef.current(isDir ? entryPath : getParentPath(entryPath))}>
<FilePlus size={14} className="mr-2" />{tRef.current('sftp.newFile')}
</ContextMenuItem>
{uploadEnabled && (
<ContextMenuItem
onClick={() => {
triggerUploadPicker(getSftpTreeUploadFilesTargetPath(entry, entryPath));
}}
>
<Upload size={14} className="mr-2" />{tRef.current(getSftpUploadFilesLabelKey(entry))}
</ContextMenuItem>
)}
{folderUploadEnabled && (
<ContextMenuItem
onClick={() => {
void onUploadExternalFolder?.(getSftpTreeUploadFilesTargetPath(entry, entryPath));
}}
>
<Upload size={14} className="mr-2" />{tRef.current(getSftpUploadFolderLabelKey(entry))}
</ContextMenuItem>
)}
</ContextMenuContent>
);
}, [
contextTarget,
pane.connection?.isLocal,
pane.connection?.id,
toggleExpand,
stableOnOpenEntry,
stableOnRefresh,
getActionPaths,
toTransferSources,
executeMoveAction,
triggerUploadPicker,
uploadEnabled,
folderUploadEnabled,
onUploadExternalFolder,
onCopyToOtherPaneRef,
onDownloadFileRef,
onExtractArchiveRef,
onEditFileRef,
onEditPermissionsRef,
onNavigateToRef,
onOpenFileWithSystemDefaultRef,
onOpenFileWithRef,
openDeleteConfirmRef,
openNewFileDialogRef,
openNewFolderDialogRef,
openRenameDialogRef,
setIsMoving,
setMoveTargetPaths,
setMoveToError,
setMoveToPath,
setMoveToSuggestionIndex,
setMoveToSuggestions,
setShowMoveToDialog,
tRef,
]);
}

View File

@@ -0,0 +1,120 @@
import React, { useMemo } from 'react';
import { AlertCircle, Loader2 } from 'lucide-react';
import { TreeNode, TREE_ROW_HEIGHT } from './SftpPaneTreeNode';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type SftpPaneTreeRowsProps = Record<string, any>;
export function useSftpPaneTreeRows(props: SftpPaneTreeRowsProps) {
const {
nodeDescriptors, scrollTop, viewportHeight, tRef, columnTemplate, visibleColumns, selectedPaths, dragOverNodePath,
toggleExpand, handleNodeClick, stableOnOpenEntry, stableOnDragStart, stableOnDragEnd,
handleNodeDragOver, handleNodeDrop, handleNodeDragLeave, handleNodeContextMenu,
} = props;
const { totalHeight, visibleRange } = useMemo(() => {
const totalCount = nodeDescriptors.length;
const total = totalCount * TREE_ROW_HEIGHT;
const shouldVirtualize = viewportHeight > 0 && totalCount > 50;
if (!shouldVirtualize) {
return { totalHeight: 0, visibleRange: { start: 0, end: totalCount - 1, virtualized: false } };
}
const overscan = 6;
const start = Math.max(0, Math.floor(scrollTop / TREE_ROW_HEIGHT) - overscan);
const end = Math.min(totalCount - 1, Math.ceil((scrollTop + viewportHeight) / TREE_ROW_HEIGHT) + overscan);
return { totalHeight: total, visibleRange: { start, end, virtualized: true } };
}, [nodeDescriptors.length, scrollTop, viewportHeight]);
// ── Render visible rows ──────────────────────────────────────────
const treeRows = useMemo(() => {
const { start, end, virtualized } = visibleRange;
const rows: React.ReactNode[] = [];
for (let i = start; i <= end; i++) {
const descriptor = nodeDescriptors[i];
if (!descriptor) continue;
let content: React.ReactNode;
if (descriptor.type === 'loading') {
content = (
<div
style={{ paddingLeft: (descriptor.depth + 1) * 16 + 8, height: TREE_ROW_HEIGHT }}
className="text-xs text-muted-foreground flex items-center gap-1"
>
<Loader2 size={12} className="animate-spin" /> {tRef.current('sftp.tree.loading')}
</div>
);
} else if (descriptor.type === 'error') {
content = (
<div
style={{ paddingLeft: (descriptor.depth + 1) * 16 + 8, height: TREE_ROW_HEIGHT }}
className="text-xs text-destructive flex items-center gap-1"
>
<AlertCircle size={12} /> {tRef.current('sftp.tree.loadError')}
</div>
);
} else {
content = (
<TreeNode
entry={descriptor.entry}
entryPath={descriptor.entryPath}
depth={descriptor.depth}
columnTemplate={columnTemplate}
visibleColumns={visibleColumns}
isSelected={selectedPaths.has(descriptor.entryPath)}
isExpanded={descriptor.isExpanded}
isLoading={descriptor.isLoading}
isDragOver={dragOverNodePath === descriptor.entryPath}
onToggleExpand={toggleExpand}
onNodeClick={handleNodeClick}
onOpenEntry={stableOnOpenEntry}
onDragStart={stableOnDragStart}
onDragEnd={stableOnDragEnd}
onDragOverEntry={handleNodeDragOver}
onDropEntry={handleNodeDrop}
onDragLeaveEntry={handleNodeDragLeave}
onContextMenu={handleNodeContextMenu}
/>
);
}
const key = descriptor.type === 'node' ? descriptor.entryPath : descriptor.key;
if (virtualized) {
rows.push(
<div
key={key}
className="absolute left-0 right-0"
style={{ top: i * TREE_ROW_HEIGHT, height: TREE_ROW_HEIGHT }}
>
{content}
</div>,
);
} else {
rows.push(<React.Fragment key={key}>{content}</React.Fragment>);
}
}
return rows;
}, [
visibleRange,
nodeDescriptors,
columnTemplate,
visibleColumns,
selectedPaths,
dragOverNodePath,
toggleExpand,
handleNodeClick,
stableOnOpenEntry,
stableOnDragStart,
stableOnDragEnd,
handleNodeDragOver,
handleNodeDrop,
handleNodeDragLeave,
handleNodeContextMenu,
tRef,
]);
return { totalHeight, treeRows, visibleRange };
}

View File

@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import test from "node:test";
import { disconnectSftpPaneAfterConfirmation } from "./hooks/useSftpViewPaneActions.ts";
test("host switching waits for the previous SFTP disconnect to finish", async () => {
let releaseDisconnect: (() => void) | undefined;
let settled = false;
const result = disconnectSftpPaneAfterConfirmation({
confirmClose: async () => true,
disconnect: async () => new Promise<void>((resolve) => {
releaseDisconnect = resolve;
}),
}).then((value) => {
settled = true;
return value;
});
await Promise.resolve();
assert.equal(settled, false);
releaseDisconnect?.();
assert.equal(await result, true);
});
test("a cancelled editor prompt never disconnects the current SFTP pane", async () => {
let disconnects = 0;
const result = await disconnectSftpPaneAfterConfirmation({
confirmClose: async () => false,
disconnect: async () => {
disconnects += 1;
},
});
assert.equal(result, false);
assert.equal(disconnects, 0);
});

413
components/sftp/utils.ts Normal file
View File

@@ -0,0 +1,413 @@
/**
* SFTP utility functions for formatting and file type detection
*/
import {
Database,
ExternalLink,
File,
FileArchive,
FileAudio,
FileCode,
FileImage,
FileSpreadsheet,
FileText,
FileType,
FileVideo,
Folder,
Globe,
Key,
Lock,
Settings,
Terminal,
} from 'lucide-react';
import React from 'react';
import type { LucideIcon } from 'lucide-react';
import { SftpFileEntry } from '../../types';
import {
formatDate as formatDateFromState,
isNavigableDirectory,
} from '../../application/state/sftp/utils';
export { isNavigableDirectory };
/** Accept undefined timestamps from file-list rows. */
export const formatDate = (timestamp: number | undefined): string =>
formatDateFromState(timestamp ?? 0);
// Pre-built icon maps for O(1) lookup in getFileIcon
type IconDef = [LucideIcon, string?];
const EXTENSION_ICON_MAP = new Map<string, IconDef>([
// Documents
['doc', [FileText, "text-blue-500"]],
['docx', [FileText, "text-blue-500"]],
['rtf', [FileText, "text-blue-500"]],
['odt', [FileText, "text-blue-500"]],
['xls', [FileSpreadsheet, "text-green-500"]],
['xlsx', [FileSpreadsheet, "text-green-500"]],
['csv', [FileSpreadsheet, "text-green-500"]],
['ods', [FileSpreadsheet, "text-green-500"]],
['ppt', [FileType, "text-orange-500"]],
['pptx', [FileType, "text-orange-500"]],
['odp', [FileType, "text-orange-500"]],
['pdf', [FileText, "text-red-500"]],
// Code/Scripts
['js', [FileCode, "text-yellow-500"]],
['jsx', [FileCode, "text-yellow-500"]],
['ts', [FileCode, "text-yellow-500"]],
['tsx', [FileCode, "text-yellow-500"]],
['mjs', [FileCode, "text-yellow-500"]],
['cjs', [FileCode, "text-yellow-500"]],
['py', [FileCode, "text-blue-400"]],
['pyc', [FileCode, "text-blue-400"]],
['pyw', [FileCode, "text-blue-400"]],
['sh', [Terminal, "text-green-400"]],
['bash', [Terminal, "text-green-400"]],
['zsh', [Terminal, "text-green-400"]],
['fish', [Terminal, "text-green-400"]],
['bat', [Terminal, "text-green-400"]],
['cmd', [Terminal, "text-green-400"]],
['ps1', [Terminal, "text-green-400"]],
['c', [FileCode, "text-blue-600"]],
['cpp', [FileCode, "text-blue-600"]],
['h', [FileCode, "text-blue-600"]],
['hpp', [FileCode, "text-blue-600"]],
['cc', [FileCode, "text-blue-600"]],
['cxx', [FileCode, "text-blue-600"]],
['java', [FileCode, "text-orange-600"]],
['class', [FileCode, "text-orange-600"]],
['jar', [FileCode, "text-orange-600"]],
['go', [FileCode, "text-cyan-500"]],
['rs', [FileCode, "text-orange-400"]],
['rb', [FileCode, "text-red-400"]],
['php', [FileCode, "text-purple-500"]],
['html', [Globe, "text-orange-500"]],
['htm', [Globe, "text-orange-500"]],
['xhtml', [Globe, "text-orange-500"]],
['css', [FileCode, "text-blue-500"]],
['scss', [FileCode, "text-blue-500"]],
['sass', [FileCode, "text-blue-500"]],
['less', [FileCode, "text-blue-500"]],
['vue', [FileCode, "text-green-500"]],
['svelte', [FileCode, "text-green-500"]],
// Config/Data
['json', [FileCode, "text-yellow-600"]],
['json5', [FileCode, "text-yellow-600"]],
['xml', [FileCode, "text-orange-400"]],
['xsl', [FileCode, "text-orange-400"]],
['xslt', [FileCode, "text-orange-400"]],
['yml', [Settings, "text-pink-400"]],
['yaml', [Settings, "text-pink-400"]],
['toml', [Settings, "text-gray-400"]],
['ini', [Settings, "text-gray-400"]],
['conf', [Settings, "text-gray-400"]],
['cfg', [Settings, "text-gray-400"]],
['config', [Settings, "text-gray-400"]],
['env', [Lock, "text-yellow-500"]],
['sql', [Database, "text-blue-400"]],
['sqlite', [Database, "text-blue-400"]],
['db', [Database, "text-blue-400"]],
// Images
['jpg', [FileImage, "text-purple-400"]],
['jpeg', [FileImage, "text-purple-400"]],
['png', [FileImage, "text-purple-400"]],
['gif', [FileImage, "text-purple-400"]],
['bmp', [FileImage, "text-purple-400"]],
['webp', [FileImage, "text-purple-400"]],
['svg', [FileImage, "text-purple-400"]],
['ico', [FileImage, "text-purple-400"]],
['tiff', [FileImage, "text-purple-400"]],
['tif', [FileImage, "text-purple-400"]],
['heic', [FileImage, "text-purple-400"]],
['heif', [FileImage, "text-purple-400"]],
['avif', [FileImage, "text-purple-400"]],
// Videos
['mp4', [FileVideo, "text-pink-500"]],
['mkv', [FileVideo, "text-pink-500"]],
['avi', [FileVideo, "text-pink-500"]],
['mov', [FileVideo, "text-pink-500"]],
['wmv', [FileVideo, "text-pink-500"]],
['flv', [FileVideo, "text-pink-500"]],
['webm', [FileVideo, "text-pink-500"]],
['m4v', [FileVideo, "text-pink-500"]],
['3gp', [FileVideo, "text-pink-500"]],
['mpeg', [FileVideo, "text-pink-500"]],
['mpg', [FileVideo, "text-pink-500"]],
// Audio
['mp3', [FileAudio, "text-green-400"]],
['wav', [FileAudio, "text-green-400"]],
['flac', [FileAudio, "text-green-400"]],
['aac', [FileAudio, "text-green-400"]],
['ogg', [FileAudio, "text-green-400"]],
['m4a', [FileAudio, "text-green-400"]],
['wma', [FileAudio, "text-green-400"]],
['opus', [FileAudio, "text-green-400"]],
['aiff', [FileAudio, "text-green-400"]],
// Archives
['zip', [FileArchive, "text-amber-500"]],
['rar', [FileArchive, "text-amber-500"]],
['7z', [FileArchive, "text-amber-500"]],
['tar', [FileArchive, "text-amber-500"]],
['gz', [FileArchive, "text-amber-500"]],
['bz2', [FileArchive, "text-amber-500"]],
['xz', [FileArchive, "text-amber-500"]],
['tgz', [FileArchive, "text-amber-500"]],
['tbz2', [FileArchive, "text-amber-500"]],
['lz', [FileArchive, "text-amber-500"]],
['lzma', [FileArchive, "text-amber-500"]],
['cab', [FileArchive, "text-amber-500"]],
['iso', [FileArchive, "text-amber-500"]],
['dmg', [FileArchive, "text-amber-500"]],
// Executables
['exe', [File, "text-red-400"]],
['msi', [File, "text-red-400"]],
['app', [File, "text-red-400"]],
['deb', [File, "text-red-400"]],
['rpm', [File, "text-red-400"]],
['apk', [File, "text-red-400"]],
['ipa', [File, "text-red-400"]],
['dll', [File, "text-gray-500"]],
['so', [File, "text-gray-500"]],
['dylib', [File, "text-gray-500"]],
// Keys/Certs
['pem', [Key, "text-yellow-400"]],
['crt', [Key, "text-yellow-400"]],
['cer', [Key, "text-yellow-400"]],
['key', [Key, "text-yellow-400"]],
['pub', [Key, "text-yellow-400"]],
['ppk', [Key, "text-yellow-400"]],
// Text/Markdown
['md', [FileText, "text-gray-400"]],
['markdown', [FileText, "text-gray-400"]],
['mdx', [FileText, "text-gray-400"]],
['txt', [FileText, "text-muted-foreground"]],
['log', [FileText, "text-muted-foreground"]],
['text', [FileText, "text-muted-foreground"]],
]);
/**
* Format bytes with appropriate unit (B, KB, MB, GB)
*/
export const formatBytes = (bytes: number | string): string => {
const numBytes = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes;
if (isNaN(numBytes) || numBytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(numBytes) / Math.log(1024));
const size = numBytes / Math.pow(1024, i);
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
/**
* Format bytes for transfer display
*/
export const formatTransferBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = bytes / Math.pow(1024, i);
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
/**
* Format speed with appropriate unit
*/
export const formatSpeed = (bytesPerSecond: number): string => {
if (bytesPerSecond <= 0) return '';
if (bytesPerSecond >= 1024 * 1024) {
return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`;
}
return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`;
};
/**
* Comprehensive file icon helper - returns JSX element based on file type.
* Uses pre-built Map for O(1) extension lookup.
*/
export const getFileIcon = (entry: SftpFileEntry): React.ReactElement => {
if (entry.type === 'directory') return React.createElement(Folder, { size: 14 });
// For symlink files (not directories), show a special symlink icon
if (entry.type === 'symlink' && entry.linkTarget !== 'directory') {
return React.createElement(ExternalLink, { size: 14, className: "text-cyan-500" });
}
const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() ?? '' : '';
const iconDef = EXTENSION_ICON_MAP.get(ext);
if (iconDef) {
const [Icon, className] = iconDef;
return React.createElement(Icon, { size: 14, ...(className ? { className } : {}) });
}
// Default
return React.createElement(FileCode, { size: 14 });
};
// Sort / column layout — owned by application/state; re-exported for UI callers.
export {
DEFAULT_SFTP_COLUMN_VISIBILITY,
normalizeSftpColumnVisibility,
type ColumnWidths,
type SftpColumnVisibility,
type SortField,
type SortOrder,
} from '../../application/state/sftp/columnLayout';
export const isSftpColumnMenuKey = (key: string, shiftKey: boolean): boolean =>
key === 'ContextMenu' || (key === 'F10' && shiftKey);
export const buildSftpColumnTemplate = (
columnWidths: ColumnWidths,
visibleColumns: SftpColumnVisibility = DEFAULT_SFTP_COLUMN_VISIBILITY,
): string => {
const columns = [`minmax(140px, ${columnWidths.name}fr)`];
if (visibleColumns.modified) columns.push(`minmax(0, ${columnWidths.modified}fr)`);
if (visibleColumns.size) columns.push(`minmax(52px, ${columnWidths.size}fr)`);
if (visibleColumns.type) columns.push(`minmax(64px, ${columnWidths.type}fr)`);
if (visibleColumns.owner) columns.push(`minmax(56px, ${columnWidths.owner}fr)`);
return columns.join(' ');
};
export const sortSftpEntries = (
entries: SftpFileEntry[],
sortField: SortField,
sortOrder: SortOrder,
directoriesFirst = true,
): SftpFileEntry[] => {
if (!entries.length) return entries;
const sorted = [...entries].sort((a, b) => {
const aIsDir = isNavigableDirectory(a);
const bIsDir = isNavigableDirectory(b);
if (directoriesFirst) {
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
}
let cmp = 0;
switch (sortField) {
case 'name':
cmp = a.name.localeCompare(b.name);
break;
case 'size':
cmp = (a.size || 0) - (b.size || 0);
break;
case 'modified':
cmp = (a.lastModified || 0) - (b.lastModified || 0);
break;
case 'type': {
const extA = aIsDir
? 'folder'
: a.name.split('.').pop()?.toLowerCase() || '';
const extB = bIsDir
? 'folder'
: b.name.split('.').pop()?.toLowerCase() || '';
cmp = extA.localeCompare(extB);
break;
}
case 'owner':
cmp = (a.owner || '').localeCompare(b.owner || '');
break;
}
return sortOrder === 'asc' ? cmp : -cmp;
});
return sorted;
};
/**
* Check if a file is hidden
* - Windows: checks the `hidden` attribute (set by localFsBridge)
* - Unix/Linux (remote): also treats dotfiles (names starting with '.') as hidden
/**
* A file is considered hidden if:
* - It has the Windows hidden attribute (`hidden === true`), OR
* - Its name starts with a dot (Unix/Linux dotfile convention)
*
* The ".." parent directory entry is never considered hidden.
*/
const isHiddenFile = <T extends { name: string; hidden?: boolean }>(
file: T,
): boolean => {
if (file.name === "..") return false;
// Windows hidden attribute
if (file.hidden === true) return true;
// Unix/Linux dotfile convention
if (file.name.startsWith(".")) return true;
return false;
};
/**
* Filter files based on hidden file visibility setting.
* Filters Windows hidden files and Unix/Linux dotfiles on all connections.
* Always preserves ".." parent directory entry.
*/
export const filterHiddenFiles = <T extends { name: string; hidden?: boolean }>(
files: T[],
showHiddenFiles: boolean,
): T[] => {
if (showHiddenFiles) return files;
return files.filter((f) => !isHiddenFile(f));
};
/**
* Filter files by search term (case-insensitive substring match on name).
* Always preserves ".." parent directory entry. Empty/whitespace terms are no-ops.
*/
export const filterSftpEntriesByName = <T extends { name: string }>(
files: T[],
filter: string,
): T[] => {
const term = filter.trim().toLowerCase();
if (!term) return files;
return files.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
);
};
export type SftpTreeNameFilterOptions<T extends { name: string }> = {
parentPath: string;
joinPath: (parentPath: string, name: string) => string;
isDirectory: (entry: T) => boolean;
/** Loaded children for a directory path; undefined means not loaded yet. */
getChildren: (entryPath: string) => T[] | undefined;
};
/**
* Tree-aware name filter: keeps list-view match rules, and also keeps directory
* ancestors when an expanded loaded descendant matches. Collapsed, loading,
* error, and unloaded directories only appear when their own name matches
* (callers should treat those paths as unavailable in getChildren; no
* server-side recursive search).
*/
export const filterSftpTreeEntriesByName = <T extends { name: string }>(
files: T[],
filter: string,
options: SftpTreeNameFilterOptions<T>,
): T[] => {
const term = filter.trim().toLowerCase();
if (!term) return files;
const subtreeHasMatch = (entries: T[], parentPath: string): boolean => {
for (const entry of entries) {
if (entry.name === "..") continue;
if (entry.name.toLowerCase().includes(term)) return true;
if (!options.isDirectory(entry)) continue;
const entryPath = options.joinPath(parentPath, entry.name);
const children = options.getChildren(entryPath);
if (children && subtreeHasMatch(children, entryPath)) return true;
}
return false;
};
return files.filter((entry) => {
if (entry.name === "..") return true;
if (entry.name.toLowerCase().includes(term)) return true;
if (!options.isDirectory(entry)) return false;
const entryPath = options.joinPath(options.parentPath, entry.name);
const children = options.getChildren(entryPath);
return Boolean(children && subtreeHasMatch(children, entryPath));
});
};