[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
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:
274
components/sftp/SftpBreadcrumb.test.ts
Normal file
274
components/sftp/SftpBreadcrumb.test.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
});
|
||||
338
components/sftp/SftpBreadcrumb.tsx
Normal file
338
components/sftp/SftpBreadcrumb.tsx
Normal 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';
|
||||
114
components/sftp/SftpClipboardUploadDialog.tsx
Normal file
114
components/sftp/SftpClipboardUploadDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
47
components/sftp/SftpColumnMenuItems.tsx
Normal file
47
components/sftp/SftpColumnMenuItems.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
237
components/sftp/SftpConflictDialog.accessibility.test.ts
Normal file
237
components/sftp/SftpConflictDialog.accessibility.test.ts
Normal 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);
|
||||
});
|
||||
109
components/sftp/SftpConflictDialog.test.ts
Normal file
109
components/sftp/SftpConflictDialog.test.ts
Normal 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");
|
||||
});
|
||||
293
components/sftp/SftpConflictDialog.tsx
Normal file
293
components/sftp/SftpConflictDialog.tsx
Normal 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';
|
||||
277
components/sftp/SftpContext.tsx
Normal file
277
components/sftp/SftpContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
197
components/sftp/SftpFileRow.tsx
Normal file
197
components/sftp/SftpFileRow.tsx
Normal 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';
|
||||
329
components/sftp/SftpFilterShortcut.interaction.test.ts
Normal file
329
components/sftp/SftpFilterShortcut.interaction.test.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
});
|
||||
80
components/sftp/SftpHostPicker.test.ts
Normal file
80
components/sftp/SftpHostPicker.test.ts
Normal 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\]"/);
|
||||
});
|
||||
361
components/sftp/SftpHostPicker.tsx
Normal file
361
components/sftp/SftpHostPicker.tsx
Normal 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';
|
||||
114
components/sftp/SftpMoveToDialog.tsx
Normal file
114
components/sftp/SftpMoveToDialog.tsx
Normal 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>
|
||||
);
|
||||
290
components/sftp/SftpOverlays.tsx
Normal file
290
components/sftp/SftpOverlays.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
399
components/sftp/SftpPaneDialogs.tsx
Normal file
399
components/sftp/SftpPaneDialogs.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
85
components/sftp/SftpPaneEmptyState.tsx
Normal file
85
components/sftp/SftpPaneEmptyState.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
798
components/sftp/SftpPaneFileList.tsx
Normal file
798
components/sftp/SftpPaneFileList.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
316
components/sftp/SftpPaneToolbar.interaction.test.ts
Normal file
316
components/sftp/SftpPaneToolbar.interaction.test.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
});
|
||||
602
components/sftp/SftpPaneToolbar.test.ts
Normal file
602
components/sftp/SftpPaneToolbar.test.ts
Normal 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/);
|
||||
});
|
||||
1735
components/sftp/SftpPaneToolbar.tsx
Normal file
1735
components/sftp/SftpPaneToolbar.tsx
Normal file
File diff suppressed because it is too large
Load Diff
24
components/sftp/SftpPaneTreeActivation.test.ts
Normal file
24
components/sftp/SftpPaneTreeActivation.test.ts
Normal 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\)\}/);
|
||||
});
|
||||
130
components/sftp/SftpPaneTreeNode.tsx
Normal file
130
components/sftp/SftpPaneTreeNode.tsx
Normal 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) ──────────────────────────────────
|
||||
1142
components/sftp/SftpPaneTreeView.tsx
Normal file
1142
components/sftp/SftpPaneTreeView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
36
components/sftp/SftpPaneTreeView.types.ts
Normal file
36
components/sftp/SftpPaneTreeView.types.ts
Normal 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 };
|
||||
}
|
||||
788
components/sftp/SftpPaneView.tsx
Normal file
788
components/sftp/SftpPaneView.tsx
Normal 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 };
|
||||
172
components/sftp/SftpPermissionsDialog.tsx
Normal file
172
components/sftp/SftpPermissionsDialog.tsx
Normal 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';
|
||||
518
components/sftp/SftpTabBar.tsx
Normal file
518
components/sftp/SftpTabBar.tsx
Normal 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";
|
||||
663
components/sftp/SftpTransferItem.tsx
Normal file
663
components/sftp/SftpTransferItem.tsx
Normal 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';
|
||||
476
components/sftp/SftpTransferQueue.tsx
Normal file
476
components/sftp/SftpTransferQueue.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
164
components/sftp/clipboardUpload.ts
Normal file
164
components/sftp/clipboardUpload.ts
Normal 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;
|
||||
}
|
||||
64
components/sftp/copyToOtherPane.test.ts
Normal file
64
components/sftp/copyToOtherPane.test.ts
Normal 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);
|
||||
});
|
||||
26
components/sftp/copyToOtherPane.ts
Normal file
26
components/sftp/copyToOtherPane.ts
Normal 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;
|
||||
};
|
||||
37
components/sftp/hooks/selectionScope.ts
Normal file
37
components/sftp/hooks/selectionScope.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { sftpTreeSelectionStore } from "../../../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;
|
||||
};
|
||||
53
components/sftp/hooks/useGlobalSftpBookmarks.ts
Normal file
53
components/sftp/hooks/useGlobalSftpBookmarks.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
8
components/sftp/hooks/useLocalSftpBookmarks.ts
Normal file
8
components/sftp/hooks/useLocalSftpBookmarks.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @deprecated Import from `@/application/state/sftp/localSftpBookmarks` instead. */
|
||||
export {
|
||||
useLocalSftpBookmarks,
|
||||
subscribeLocalSftpBookmarks,
|
||||
getLocalSftpBookmarksSnapshot,
|
||||
rehydrateLocalSftpBookmarks,
|
||||
setLocalSftpBookmarks,
|
||||
} from "../../../application/state/sftp/localSftpBookmarks";
|
||||
79
components/sftp/hooks/useSftpBookmarks.ts
Normal file
79
components/sftp/hooks/useSftpBookmarks.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
6
components/sftp/hooks/useSftpClipboard.ts
Normal file
6
components/sftp/hooks/useSftpClipboard.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @deprecated Import from `@/application/state/sftp/sftpClipboardStore` instead. */
|
||||
export {
|
||||
sftpClipboardStore,
|
||||
useSftpClipboard,
|
||||
type SftpClipboardFile,
|
||||
} from "../../../application/state/sftp/sftpClipboardStore";
|
||||
6
components/sftp/hooks/useSftpDialogAction.ts
Normal file
6
components/sftp/hooks/useSftpDialogAction.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @deprecated Import from `@/application/state/sftp/sftpDialogActionStore` instead. */
|
||||
export {
|
||||
sftpDialogActionStore,
|
||||
useSftpDialogAction,
|
||||
useSftpDialogActionHandler,
|
||||
} from "../../../application/state/sftp/sftpDialogActionStore";
|
||||
6
components/sftp/hooks/useSftpFocusedPane.ts
Normal file
6
components/sftp/hooks/useSftpFocusedPane.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @deprecated Import from `@/application/state/sftp/sftpFocusStore` instead. */
|
||||
export {
|
||||
sftpFocusStore,
|
||||
useSftpFocusedSide,
|
||||
type SftpFocusedSide,
|
||||
} from "../../../application/state/sftp/sftpFocusStore";
|
||||
6
components/sftp/hooks/useSftpHostViewMode.ts
Normal file
6
components/sftp/hooks/useSftpHostViewMode.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @deprecated Import from `@/application/state/sftp/sftpHostViewModeStore` instead. */
|
||||
export {
|
||||
getHostViewMode,
|
||||
setHostViewMode,
|
||||
useSftpHostViewMode,
|
||||
} from "../../../application/state/sftp/sftpHostViewModeStore";
|
||||
1034
components/sftp/hooks/useSftpKeyboardShortcuts.ts
Normal file
1034
components/sftp/hooks/useSftpKeyboardShortcuts.ts
Normal file
File diff suppressed because it is too large
Load Diff
20
components/sftp/hooks/useSftpListOrderStore.ts
Normal file
20
components/sftp/hooks/useSftpListOrderStore.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
373
components/sftp/hooks/useSftpPaneDialogs.ts
Normal file
373
components/sftp/hooks/useSftpPaneDialogs.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
288
components/sftp/hooks/useSftpPaneDragAndSelect.ts
Normal file
288
components/sftp/hooks/useSftpPaneDragAndSelect.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
90
components/sftp/hooks/useSftpPaneFiles.ts
Normal file
90
components/sftp/hooks/useSftpPaneFiles.ts
Normal 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 };
|
||||
};
|
||||
164
components/sftp/hooks/useSftpPanePath.ts
Normal file
164
components/sftp/hooks/useSftpPanePath.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
5
components/sftp/hooks/useSftpPaneSorting.ts
Normal file
5
components/sftp/hooks/useSftpPaneSorting.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/** @deprecated Import from `@/application/state/sftp/useSftpPaneSorting` instead. */
|
||||
export {
|
||||
useSftpPaneSorting,
|
||||
type UseSftpPaneSortingResult,
|
||||
} from "../../../application/state/sftp/useSftpPaneSorting";
|
||||
128
components/sftp/hooks/useSftpPaneVirtualList.ts
Normal file
128
components/sftp/hooks/useSftpPaneVirtualList.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
6
components/sftp/hooks/useSftpTreeSelectionStore.ts
Normal file
6
components/sftp/hooks/useSftpTreeSelectionStore.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @deprecated Import from `@/application/state/sftp/sftpTreeSelectionStore` instead. */
|
||||
export {
|
||||
sftpTreeSelectionStore,
|
||||
useSftpTreeSelectionState,
|
||||
type SftpTreeSelectionItem,
|
||||
} from "../../../application/state/sftp/sftpTreeSelectionStore";
|
||||
799
components/sftp/hooks/useSftpViewFileOps.ts
Normal file
799
components/sftp/hooks/useSftpViewFileOps.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
94
components/sftp/hooks/useSftpViewFileOps.types.ts
Normal file
94
components/sftp/hooks/useSftpViewFileOps.types.ts
Normal 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>;
|
||||
}
|
||||
405
components/sftp/hooks/useSftpViewPaneActions.ts
Normal file
405
components/sftp/hooks/useSftpViewPaneActions.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
235
components/sftp/hooks/useSftpViewPaneCallbacks.ts
Normal file
235
components/sftp/hooks/useSftpViewPaneCallbacks.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
260
components/sftp/hooks/useSftpViewTabs.ts
Normal file
260
components/sftp/hooks/useSftpViewTabs.ts
Normal 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
13
components/sftp/index.ts
Normal 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';
|
||||
119
components/sftp/reportSftpUploadResults.test.ts
Normal file
119
components/sftp/reportSftpUploadResults.test.ts
Normal 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)",
|
||||
});
|
||||
});
|
||||
74
components/sftp/reportSftpUploadResults.ts
Normal file
74
components/sftp/reportSftpUploadResults.ts
Normal 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");
|
||||
}
|
||||
81
components/sftp/sftpColumnVisibility.test.ts
Normal file
81
components/sftp/sftpColumnVisibility.test.ts
Normal 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);
|
||||
});
|
||||
35
components/sftp/sftpColumnVisibilityIntegration.test.ts
Normal file
35
components/sftp/sftpColumnVisibilityIntegration.test.ts
Normal 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/);
|
||||
});
|
||||
12
components/sftp/sftpEditorFileLimits.test.ts
Normal file
12
components/sftp/sftpEditorFileLimits.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
6
components/sftp/sftpEditorFileLimits.ts
Normal file
6
components/sftp/sftpEditorFileLimits.ts
Normal 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.");
|
||||
}
|
||||
47
components/sftp/sftpLocatePathInTerminalSafety.test.ts
Normal file
47
components/sftp/sftpLocatePathInTerminalSafety.test.ts
Normal 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\}/,
|
||||
);
|
||||
});
|
||||
240
components/sftp/sftpNameFilter.test.ts
Normal file
240
components/sftp/sftpNameFilter.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
772
components/sftp/sftpSidePanelAutoConnect.test.ts
Normal file
772
components/sftp/sftpSidePanelAutoConnect.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
388
components/sftp/sftpSidePanelAutoConnect.ts
Normal file
388
components/sftp/sftpSidePanelAutoConnect.ts
Normal 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;
|
||||
}
|
||||
99
components/sftp/sftpSidePanelConnectionMemory.test.ts
Normal file
99
components/sftp/sftpSidePanelConnectionMemory.test.ts
Normal 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);
|
||||
});
|
||||
69
components/sftp/sftpSidePanelConnectionMemory.ts
Normal file
69
components/sftp/sftpSidePanelConnectionMemory.ts
Normal 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;
|
||||
}
|
||||
39
components/sftp/sftpSidePanelReconnectSafety.test.ts
Normal file
39
components/sftp/sftpSidePanelReconnectSafety.test.ts
Normal 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\)/,
|
||||
);
|
||||
});
|
||||
84
components/sftp/sftpSorting.test.ts
Normal file
84
components/sftp/sftpSorting.test.ts
Normal 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']);
|
||||
});
|
||||
114
components/sftp/sftpTabDuplication.test.ts
Normal file
114
components/sftp/sftpTabDuplication.test.ts
Normal 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);
|
||||
});
|
||||
73
components/sftp/sftpTabDuplication.ts
Normal file
73
components/sftp/sftpTabDuplication.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
57
components/sftp/sftpTreePathsReducer.ts
Normal file
57
components/sftp/sftpTreePathsReducer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
49
components/sftp/sftpUploadMenu.ts
Normal file
49
components/sftp/sftpUploadMenu.ts
Normal 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";
|
||||
203
components/sftp/useSftpPaneTreeContextMenu.tsx
Normal file
203
components/sftp/useSftpPaneTreeContextMenu.tsx
Normal 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,
|
||||
]);
|
||||
}
|
||||
120
components/sftp/useSftpPaneTreeRows.tsx
Normal file
120
components/sftp/useSftpPaneTreeRows.tsx
Normal 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 };
|
||||
}
|
||||
36
components/sftp/useSftpViewPaneActions.test.ts
Normal file
36
components/sftp/useSftpViewPaneActions.test.ts
Normal 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
413
components/sftp/utils.ts
Normal 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));
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user