mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-01 03:12:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3bf7c25fc | ||
|
|
d350b02f78 | ||
|
|
d8f43a7138 | ||
|
|
34c3431483 |
No files matched your search
@@ -1180,6 +1180,7 @@ Highlight the focused split pane:
|
||||
'vault.hosts.moveSelected': 'Move to group ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Move the selected hosts to an existing group, or leave them ungrouped. Selected groups are not moved.',
|
||||
'vault.hosts.moveMultiple.success': 'Moved {count} hosts to {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Host name is required.',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
|
||||
@@ -1139,6 +1139,7 @@ Resalta el panel dividido enfocado:
|
||||
'vault.hosts.moveSelected': 'Mover al grupo ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Mueve los hosts seleccionados a un grupo existente o los deja sin grupo. Los grupos seleccionados no se mueven.',
|
||||
'vault.hosts.moveMultiple.success': 'Se movieron {count} hosts a {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Se movió {host} a {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Se requiere el nombre del host.',
|
||||
'vault.hosts.empty.title': 'Configura tus hosts',
|
||||
|
||||
@@ -131,6 +131,7 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.hosts.moveSelected': 'Переместить в группу ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Переместить выбранные хосты в существующую группу или оставить без группы. Выбранные группы не перемещаются.',
|
||||
'vault.hosts.moveMultiple.success': 'Перемещено {count} хостов в {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} хостов',
|
||||
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
|
||||
'vault.hosts.empty.title': 'Настройте свои хосты',
|
||||
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
|
||||
|
||||
@@ -785,6 +785,7 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'vault.hosts.moveSelected': '移动到分组 ({count})',
|
||||
'vault.hosts.moveSelected.description': '将选中的主机移动到现有分组,也可移出分组。选中的分组本身不会移动。',
|
||||
'vault.hosts.moveMultiple.success': '已将 {count} 个主机移动到 {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} 个主机',
|
||||
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
|
||||
@@ -787,6 +787,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'vault.hosts.moveSelected': '移動到群組 ({count})',
|
||||
'vault.hosts.moveSelected.description': '將選取的主機移動到現有群組,也可移出群組。選取的群組本身不會移動。',
|
||||
'vault.hosts.moveMultiple.success': '已將 {count} 個主機移動到 {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} 個主機',
|
||||
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
|
||||
'vault.hosts.empty.title': '設定你的主機',
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
isMissingDirectoryReplacePathError,
|
||||
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
|
||||
} from "./directoryReplacePromotion";
|
||||
import { unwrapSftpIpcError } from "./errors";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
export interface DedicatedResumeDeps {
|
||||
@@ -179,9 +180,11 @@ export async function openTransferSftpSession(
|
||||
// Restart/resume owns an independent SSH dial. Normal pooled transfer opens
|
||||
// leave reuseTransport enabled so the main-process registry can reuse an
|
||||
// authenticated endpoint even when no terminal tab exists.
|
||||
// No SFTP-page host-key dialog owns this session id. An untrusted key must
|
||||
// fail the transfer immediately instead of sitting on the verifier TTL.
|
||||
const openOpts = wantDedicated
|
||||
? { ...credentials, reuseTransport: false as const }
|
||||
: credentials;
|
||||
? { ...credentials, reuseTransport: false as const, hostKeyUnattended: true as const }
|
||||
: { ...credentials, hostKeyUnattended: true as const };
|
||||
|
||||
// The main-process auth driver owns method ordering, fallback, MFA and
|
||||
// retry. A renderer-side key-then-password retry performs a second full SSH
|
||||
@@ -740,7 +743,7 @@ async function resumeSingleFileWithDedicatedSession(
|
||||
const err = error as Error & { dedicatedAttention?: boolean; resetCheckpoint?: boolean };
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(error),
|
||||
error: unwrapSftpIpcError(error),
|
||||
needsAttention: !!err.dedicatedAttention,
|
||||
resetCheckpoint: !!err.resetCheckpoint,
|
||||
};
|
||||
|
||||
@@ -38,6 +38,20 @@ test("runWithTransferRetry does not retry permanent errors", async () => {
|
||||
assert.equal(attempts, 1);
|
||||
});
|
||||
|
||||
test("a dead SFTP channel is not retried into another dial (#3310)", async () => {
|
||||
assert.equal(isTransientTransferError(new Error("SFTP channel closed")), false);
|
||||
assert.equal(isTransientTransferError(new Error("SFTP session not found")), true);
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
() => runWithTransferRetry(async () => {
|
||||
attempts += 1;
|
||||
throw new Error("SFTP channel closed");
|
||||
}, { retries: 1, delayMs: 0 }),
|
||||
/SFTP channel closed/,
|
||||
);
|
||||
assert.equal(attempts, 1, "channel death must not schedule a reconnect");
|
||||
});
|
||||
|
||||
test("runWithTransferRetry does not retry cancel", async () => {
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
|
||||
@@ -6,6 +6,9 @@ import { isSessionError } from "./errors";
|
||||
* path) should not be retried.
|
||||
*/
|
||||
export function isTransientTransferError(err: unknown): boolean {
|
||||
// The channel is gone. Retrying opens a new dial, and an OTP host then
|
||||
// sits on keyboard-interactive while the transfer stays active.
|
||||
if (err instanceof Error && /sftp channel closed/i.test(err.message)) return false;
|
||||
if (isSessionError(err)) return true;
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
|
||||
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
|
||||
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
|
||||
import { unwrapSftpIpcError } from "./errors";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
@@ -957,7 +958,7 @@ export const useSftpConnections = ({
|
||||
if (!isTargetConnectionAtPath(startPath)) return;
|
||||
updateTargetTab((prev) => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : "Failed to list directory",
|
||||
error: unwrapSftpIpcError(err) || "Failed to list directory",
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
@@ -1263,14 +1264,14 @@ export const useSftpConnections = ({
|
||||
? {
|
||||
...prev.connection,
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
error: unwrapSftpIpcError(err) || "Connection failed",
|
||||
}
|
||||
: null,
|
||||
files: isReconnectAttempt ? [] : prev.files,
|
||||
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
|
||||
error: isReconnectAttempt
|
||||
? "sftp.error.reconnectFailed"
|
||||
: (err instanceof Error ? err.message : "Connection failed"),
|
||||
: (unwrapSftpIpcError(err) || "Connection failed"),
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
|
||||
@@ -56,6 +56,166 @@ test(`failed ${direction} connection replaces Waiting with the actual error (#36
|
||||
});
|
||||
}
|
||||
|
||||
test("upload open uses known hosts trusted after the transfer callback was created (#3310)", async () => {
|
||||
const seen: string[] = [];
|
||||
let fingerprint = "";
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let firstStart: ReturnType<typeof useSftpTransfers>["startTransfer"] | undefined;
|
||||
const restore = installGlobals({});
|
||||
function Probe() {
|
||||
const captured = fingerprint;
|
||||
const acquireTransferSession = React.useCallback(async () => {
|
||||
seen.push(captured);
|
||||
throw new Error(`known-hosts:${captured || "empty"}`);
|
||||
}, [captured]);
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "known-hosts-after-trust-3310",
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession,
|
||||
});
|
||||
if (!firstStart) firstStart = ops.startTransfer;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
fingerprint = "trusted-this-session";
|
||||
await act(async () => { renderer?.update(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await firstStart!(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.deepEqual(seen, ["trusted-this-session"]);
|
||||
const task = sftpTransferCenterStore.getOwnerTasks("known-hosts-after-trust-3310")[0];
|
||||
assert.equal(task?.error, "known-hosts:trusted-this-session");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("channel death fails the upload without a second session open (#3310)", async () => {
|
||||
let acquires = 0;
|
||||
let streams = 0;
|
||||
const restore = installGlobals({
|
||||
statLocal: async () => ({ size: 48, lastModified: 1 }),
|
||||
statSftp: async () => null,
|
||||
startStreamTransfer: async () => {
|
||||
streams += 1;
|
||||
return { error: "SFTP channel closed" };
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const ownerId = "channel-death-no-reconnect-3310";
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession: async () => {
|
||||
acquires += 1;
|
||||
return {
|
||||
sftpId: "sftp-borrowed",
|
||||
release: () => undefined,
|
||||
discard: () => undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.equal(streams, 1, "channel death must not start another transfer dial");
|
||||
assert.equal(acquires, 2, "work lease plus the file lease, and no retry open");
|
||||
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
|
||||
assert.equal(task?.status, "failed");
|
||||
assert.equal(task?.error, "SFTP channel closed");
|
||||
assert.equal(
|
||||
sftpTransferCenterStore.getOwnerTasks(ownerId)
|
||||
.filter((row) => ["pending", "queued", "transferring", "pausing"].includes(row.status))
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("transfer failure strips the Electron IPC wrapper (#3310)", async () => {
|
||||
const restore = installGlobals({});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const ownerId = "ipc-prefix-3310";
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession: async () => {
|
||||
throw new Error("Error invoking remote method 'netcatty:sftp:open': Error: Host key verification required for 127.0.0.1");
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
|
||||
assert.equal(task?.error, "Host key verification required for 127.0.0.1");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
type StartOptions = {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
|
||||
@@ -24,7 +24,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { SftpPane } from "./types";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import { isMissingStatError, unwrapSftpIpcError } from "./errors";
|
||||
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
|
||||
import { useSftpTransferConflictOps } from "./transferConflictOps";
|
||||
import { useSftpTransferTaskOps } from "./transferTaskOps";
|
||||
@@ -191,6 +191,21 @@ export const useSftpTransfers = ({
|
||||
const { t } = useI18n();
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
// startTransfer keeps the processTransfer closure from its first render.
|
||||
// Read the latest acquire fn so a host key trusted after that render is
|
||||
// included in the transfer open, instead of the empty list from mount.
|
||||
const acquireTransferSessionRef = useRef(acquireTransferSession);
|
||||
acquireTransferSessionRef.current = acquireTransferSession;
|
||||
const acquireTransferSessionLive = useCallback<NonNullable<UseSftpTransfersParams["acquireTransferSession"]>>(
|
||||
(hostId, transferId, connectHost) => {
|
||||
const acquire = acquireTransferSessionRef.current;
|
||||
if (!acquire) {
|
||||
return Promise.reject(new Error("SFTP transfer session acquisition is unavailable"));
|
||||
}
|
||||
return acquire(hostId, transferId, connectHost);
|
||||
},
|
||||
[],
|
||||
);
|
||||
// Ref so progress flushes always see the latest visibility without rebinding.
|
||||
const surfaceVisibleRef = useRef(surfaceVisible);
|
||||
surfaceVisibleRef.current = surfaceVisible;
|
||||
@@ -468,7 +483,7 @@ export const useSftpTransfers = ({
|
||||
setTransfers,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
acquireTransferSession,
|
||||
acquireTransferSession: acquireTransferSession ? acquireTransferSessionLive : undefined,
|
||||
});
|
||||
|
||||
const processTransfer = async (
|
||||
@@ -602,8 +617,9 @@ export const useSftpTransfers = ({
|
||||
// survive browse park and tab hide (pool holders, not panel map ids).
|
||||
if (!sourcePane.connection?.isLocal) {
|
||||
const sourceHostId = task.sourceHostId || sourcePane.connection?.hostId;
|
||||
if (acquireTransferSession && sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(sourceHostId, `${task.id}:work-source`);
|
||||
const acquireSourceSession = acquireTransferSessionRef.current;
|
||||
if (acquireSourceSession && sourceHostId) {
|
||||
sourceWorkLease = await acquireSourceSession(sourceHostId, `${task.id}:work-source`);
|
||||
sourceSftpId = sourceWorkLease.sftpId;
|
||||
}
|
||||
if (!sourceSftpId) {
|
||||
@@ -615,8 +631,9 @@ export const useSftpTransfers = ({
|
||||
|
||||
if (!targetPane.connection?.isLocal) {
|
||||
const targetHostId = task.targetHostId || targetPane.connection?.hostId;
|
||||
if (acquireTransferSession && targetHostId) {
|
||||
targetWorkLease = await acquireTransferSession(targetHostId, `${task.id}:work-target`);
|
||||
const acquireTargetSession = acquireTransferSessionRef.current;
|
||||
if (acquireTargetSession && targetHostId) {
|
||||
targetWorkLease = await acquireTargetSession(targetHostId, `${task.id}:work-target`);
|
||||
targetSftpId = targetWorkLease.sftpId;
|
||||
}
|
||||
if (!targetSftpId) {
|
||||
@@ -1122,7 +1139,7 @@ export const useSftpTransfers = ({
|
||||
|
||||
updateTask({
|
||||
status: "failed",
|
||||
error: err instanceof Error ? err.message : "Transfer failed",
|
||||
error: unwrapSftpIpcError(err) || "Transfer failed",
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
@@ -1507,8 +1524,8 @@ export const useSftpTransfers = ({
|
||||
try {
|
||||
const result = await retryExternalDragDropFileUpload(task, {
|
||||
getBrowseSftpId: (connectionId) => sftpSessionsRef.current.get(connectionId),
|
||||
acquireTransferSession: acquireTransferSession
|
||||
? (hostId, id) => acquireTransferSession(hostId, id)
|
||||
acquireTransferSession: acquireTransferSessionRef.current
|
||||
? (hostId, id) => acquireTransferSessionLive(hostId, id)
|
||||
: undefined,
|
||||
startStreamTransfer: async (options) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -2000,8 +2017,9 @@ export const useSftpTransfers = ({
|
||||
let sourceWorkLease: TransferConnectionLease | null = null;
|
||||
let workingSourceSftpId = params.sftpId;
|
||||
try {
|
||||
if (acquireTransferSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(
|
||||
const acquireDownloadSession = acquireTransferSessionRef.current;
|
||||
if (acquireDownloadSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireDownloadSession(
|
||||
params.sourceHostId,
|
||||
`${task.id}:work-source`,
|
||||
);
|
||||
@@ -2067,7 +2085,7 @@ export const useSftpTransfers = ({
|
||||
const isCancelled = cancelledTasksRef.current.has(task.id);
|
||||
// Clean up cancelled task tracking to prevent memory leak
|
||||
if (isCancelled) cancelledTasksRef.current.delete(task.id);
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const errMsg = unwrapSftpIpcError(err);
|
||||
patchDownload({
|
||||
status: isCancelled ? "cancelled" : "failed",
|
||||
error: isCancelled ? undefined : errMsg,
|
||||
|
||||
@@ -235,6 +235,7 @@ test("HostTreeView exposes selected hosts to keyboard and assistive technology",
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*data-tree-depth="0"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*aria-level="1"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*tabindex="0"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*draggable="true"/);
|
||||
});
|
||||
|
||||
test("HostTreeView exposes only one tree item in the tab order", () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '../domain/hostClickBehavior';
|
||||
import { sortByVaultOrder } from '../domain/vaultOrder';
|
||||
import { getVaultHostRowSubtitle } from '../domain/localShellHost';
|
||||
import { isVaultHostDraggable } from '../domain/vaultHostDrag';
|
||||
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
|
||||
import { GroupConfig, GroupNode, Host } from '../types';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
@@ -25,6 +26,8 @@ import { ContextMenu, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { HostNotesIndicator } from './host/HostNotesIndicator';
|
||||
import { Button } from './ui/button';
|
||||
import { handleVaultHostDropToGroup } from './vault/vaultReorderDrag';
|
||||
import { beginVaultHostCardDrag } from './vault/vaultHostDragImage';
|
||||
import { VaultTreeGroupRow, VaultTreeItemRow } from './vault/VaultTreeRow';
|
||||
|
||||
const getTreeGroupDropIntent = (
|
||||
@@ -293,6 +296,7 @@ interface HostTreeViewProps {
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
@@ -334,6 +338,7 @@ interface TreeNodeProps {
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
@@ -381,6 +386,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
@@ -526,9 +532,14 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverDropTarget?.(null);
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
if (handleVaultHostDropToGroup({
|
||||
dataTransfer: e.dataTransfer,
|
||||
groupPath: node.path,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState: () => {},
|
||||
})) return;
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
if (groupPath && getTreeGroupDropIntent(e.currentTarget, e.clientY) === "inside") {
|
||||
moveGroup(groupPath, node.path);
|
||||
}
|
||||
@@ -598,6 +609,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveHostsToGroup={moveHostsToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
@@ -774,11 +786,16 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
? 0
|
||||
: -1}
|
||||
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => {
|
||||
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData("host-id", host.id);
|
||||
const ids = beginVaultHostCardDrag({
|
||||
dataTransfer: e.dataTransfer,
|
||||
hostId: host.id,
|
||||
isMultiSelectMode: Boolean(isMultiSelectMode),
|
||||
selectedHostIds,
|
||||
countLabel: (count) => t("vault.hosts.dragSelectionCount", { count }),
|
||||
});
|
||||
if (!ids) e.preventDefault();
|
||||
}}
|
||||
onClick={() => {
|
||||
const action = resolveHostActivateAction({
|
||||
@@ -909,6 +926,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
@@ -1219,6 +1237,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveHostsToGroup={moveHostsToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
@@ -1311,6 +1330,7 @@ function hostTreeViewAreEqual(prev: HostTreeViewProps, next: HostTreeViewProps):
|
||||
&& prev.onEditGroup === next.onEditGroup
|
||||
&& prev.onDeleteGroup === next.onDeleteGroup
|
||||
&& prev.moveHostToGroup === next.moveHostToGroup
|
||||
&& prev.moveHostsToGroup === next.moveHostsToGroup
|
||||
&& prev.moveGroup === next.moveGroup
|
||||
&& prev.commitInlineGroupRename === next.commitInlineGroupRename
|
||||
&& prev.cancelInlineGroupEdit === next.cancelInlineGroupEdit
|
||||
|
||||
@@ -47,6 +47,15 @@ test("plugin importer merge and commit workflow is owned by application state",
|
||||
assert.match(pluginImporterCommitHookSource, /buildPluginImporterSafePreview/);
|
||||
});
|
||||
|
||||
test("host list section receives marquee selection and batched group move", () => {
|
||||
const sectionStart = vaultViewLayoutSource.indexOf("<VaultHostListSection");
|
||||
const sectionEnd = vaultViewLayoutSource.indexOf("/>", sectionStart);
|
||||
const section = vaultViewLayoutSource.slice(sectionStart, sectionEnd);
|
||||
assert.match(section, /\bmoveHostsToGroup,/);
|
||||
assert.match(section, /\bsetIsMultiSelectMode,/);
|
||||
assert.match(section, /\bsetSelectedHostIds,/);
|
||||
});
|
||||
|
||||
test("Vault import entry is disabled while an import is running", () => {
|
||||
assert.match(vaultViewLayoutSource, /disabled=\{importProgress\?\.status === "running"\}/);
|
||||
assert.match(vaultViewLayoutSource, /if \(importProgress\?\.status === "running"\) return;/);
|
||||
|
||||
@@ -360,6 +360,36 @@ test("VaultHostListSection exposes normal group cards to keyboard", () => {
|
||||
assert.match(markup, /data-group-path="production"[^>]*tabindex="0"/);
|
||||
});
|
||||
|
||||
test("VaultHostListSection keeps selected host cards draggable and leaves unselected cards in place", () => {
|
||||
const otherHost = makeHost("other-host", "Other Router");
|
||||
const markup = renderHostList({
|
||||
viewMode: "list",
|
||||
displayedGroups: [],
|
||||
displayedHosts: [mainHost, otherHost],
|
||||
visibleDisplayedHosts: [mainHost, otherHost],
|
||||
isMultiSelectMode: true,
|
||||
selectedHostIds: new Set([mainHost.id]),
|
||||
});
|
||||
|
||||
assert.match(markup, /data-host-id="main-host"[^>]*draggable="true"/);
|
||||
assert.match(markup, /data-host-id="other-host"[^>]*draggable="false"/);
|
||||
const source = readFileSync(new URL("./VaultHostListSection.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /useVaultHostMarquee/);
|
||||
assert.match(source, /data-vault-marquee/);
|
||||
assert.match(source, /moveHostsToGroup/);
|
||||
});
|
||||
|
||||
test("VaultHostListSection keeps a normal host card draggable outside multi-select", () => {
|
||||
const markup = renderHostList({
|
||||
viewMode: "grid",
|
||||
displayedGroups: [],
|
||||
displayedHosts: [mainHost],
|
||||
visibleDisplayedHosts: [mainHost],
|
||||
});
|
||||
|
||||
assert.match(markup, /data-host-id="main-host"[^>]*draggable="true"/);
|
||||
});
|
||||
|
||||
test("VaultHostListSection exposes ungrouped hosts to keyboard and assistive technology", () => {
|
||||
const markup = renderHostList({
|
||||
viewMode: "list",
|
||||
|
||||
@@ -27,7 +27,10 @@ import {
|
||||
import type { GroupNode, Host } from "../../domain/models";
|
||||
import { isPluginHostProtocol } from "../../domain/pluginConnection";
|
||||
import { OpenDualPaneSftpMenuItem, OpenHostBrowserMenuItem } from "../host/HostTreeContextMenus";
|
||||
import { VAULT_HOST_DRAG_IDS, isVaultHostDraggable } from "../../domain/vaultHostDrag";
|
||||
import { getVaultHostRowSubtitle } from "../../domain/localShellHost";
|
||||
import { beginVaultHostCardDrag } from "./vaultHostDragImage";
|
||||
import { useVaultHostMarquee } from "./useVaultHostMarquee";
|
||||
|
||||
type VaultHostListSectionContext = Record<string, any>;
|
||||
|
||||
@@ -54,12 +57,15 @@ const isRelatedTargetInside = (
|
||||
};
|
||||
|
||||
const EMPTY_GROUP_PATH_SET = new Set<string>();
|
||||
const EMPTY_HOST_ID_SET: ReadonlySet<string> = new Set();
|
||||
const noopSetMultiSelectMode = (_enabled: boolean) => {};
|
||||
const noopSetSelectedHostIds = (_ids: Set<string>) => {};
|
||||
|
||||
export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext }) {
|
||||
const { Badge, Boolean, Button, cancelInlineGroupEdit, CheckSquare, ClipboardCopy, Clock, cn, commitInlineGroupRename, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, displayedGroups, displayedHosts, DistroAvatar, Edit2, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, groupConfigs, groupedDisplayHosts, handleCopyCredentials, handleCopyHostname, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleHostConnect, handleOpenHostBrowser, hostClickBehavior: hostClickBehaviorProp, handleUnmanageGroup, hasHostsSidePanel, hostListScrollRef, HostTreeView, isHostsSectionActive, isMultiSelectMode, lastPinnedId, LayoutGrid, managedGroupPaths, moveGroup, moveHostToGroup, onDeleteHost, Pin, pinnedHosts, Plug, recentHosts, reorderGroup, reorderHost, sanitizeHost, search, selectedGroupPath, selectedGroupPaths, selectedHostIds, selectedTags, sessionCount, setDeleteTargetPath, setDragOverDropTarget, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsNewFolderOpen, setLastPinnedId, setNewFolderName, setSelectedGroupPath, setTargetParentPath, shouldHideEmptyRootHostsSection, showRecentHosts, sortMode, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, t, toggleGroupSelection, toggleHostPinned, toggleHostSelection, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, viewMode, visibleDisplayedHosts } = ctx;
|
||||
const { Badge, Boolean, Button, cancelInlineGroupEdit, CheckSquare, ClipboardCopy, Clock, cn, commitInlineGroupRename, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, displayedGroups, displayedHosts, DistroAvatar, Edit2, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, groupConfigs, groupedDisplayHosts, handleCopyCredentials, handleCopyHostname, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleHostConnect, handleOpenHostBrowser, hostClickBehavior: hostClickBehaviorProp, handleUnmanageGroup, hasHostsSidePanel, hostListScrollRef, HostTreeView, isHostsSectionActive, isMultiSelectMode, lastPinnedId, LayoutGrid, managedGroupPaths, moveGroup, moveHostToGroup, moveHostsToGroup, onDeleteHost, Pin, pinnedHosts, Plug, recentHosts, reorderGroup, reorderHost, sanitizeHost, search, selectedGroupPath, selectedGroupPaths, selectedHostIds, selectedTags, sessionCount, setDeleteTargetPath, setDragOverDropTarget, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsMultiSelectMode = noopSetMultiSelectMode, setIsNewFolderOpen, setLastPinnedId, setNewFolderName, setSelectedGroupPath, setSelectedHostIds = noopSetSelectedHostIds, setTargetParentPath, shouldHideEmptyRootHostsSection, showRecentHosts, sortMode, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, t, toggleGroupSelection, toggleHostPinned, toggleHostSelection, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, viewMode, visibleDisplayedHosts } = ctx;
|
||||
const hostClickBehavior: HostClickBehavior = hostClickBehaviorProp === 'select' ? 'select' : 'connect';
|
||||
const multiSelectedGroupPaths: Set<string> = selectedGroupPaths ?? EMPTY_GROUP_PATH_SET;
|
||||
const [draggingHostId, setDraggingHostId] = React.useState<string | null>(null);
|
||||
const [draggingHostIds, setDraggingHostIds] = React.useState<ReadonlySet<string> | null>(null);
|
||||
const draggingHostIdRef = React.useRef<string | null>(null);
|
||||
const lastPreviewReorderRef = React.useRef<string | null>(null);
|
||||
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(hostListScrollRef);
|
||||
@@ -120,6 +126,19 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
setFocusedGroupPath(null);
|
||||
}, [selectedGroupPath, viewMode, hostClickBehavior]);
|
||||
|
||||
const {
|
||||
marqueeRect,
|
||||
onPointerDown: onMarqueePointerDown,
|
||||
onClickCapture: onMarqueeClickCapture,
|
||||
} = useVaultHostMarquee({
|
||||
containerRef: hostListScrollRef,
|
||||
viewMode,
|
||||
isMultiSelectMode: Boolean(isMultiSelectMode),
|
||||
selectedHostIds: selectedHostIds ?? EMPTY_HOST_ID_SET,
|
||||
setIsMultiSelectMode,
|
||||
setSelectedHostIds,
|
||||
});
|
||||
|
||||
const activateHost = React.useCallback((host: Host) => {
|
||||
const action = resolveHostActivateAction({
|
||||
behavior: hostClickBehavior,
|
||||
@@ -284,17 +303,25 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
|
||||
const resetHostDragState = React.useCallback(() => {
|
||||
draggingHostIdRef.current = null;
|
||||
setDraggingHostId(null);
|
||||
setDraggingHostIds(null);
|
||||
lastPreviewReorderRef.current = null;
|
||||
setDragOverDropTarget(null);
|
||||
}, [setDragOverDropTarget]);
|
||||
|
||||
const handleHostDragStart = React.useCallback((e: React.DragEvent, hostId: string) => {
|
||||
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData("host-id", hostId);
|
||||
const ids = beginVaultHostCardDrag({
|
||||
dataTransfer: e.dataTransfer,
|
||||
hostId,
|
||||
isMultiSelectMode: !!isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
countLabel: (count) => t("vault.hosts.dragSelectionCount", { count }),
|
||||
});
|
||||
if (!ids) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
draggingHostIdRef.current = hostId;
|
||||
setDraggingHostId(hostId);
|
||||
setDraggingHostIds(new Set(ids));
|
||||
lastPreviewReorderRef.current = null;
|
||||
|
||||
// Grid preview reorder can move the card into another virtual row, and rows
|
||||
@@ -308,7 +335,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
resetHostDragState();
|
||||
};
|
||||
sourceNode.addEventListener("dragend", handleNativeDragEnd);
|
||||
}, [resetHostDragState]);
|
||||
}, [isMultiSelectMode, resetHostDragState, selectedHostIds, t]);
|
||||
|
||||
const renderHostEditButton = (host: any, compact = false) => (
|
||||
<Button
|
||||
@@ -351,14 +378,21 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
return <div
|
||||
ref={hostListScrollRef}
|
||||
className={cn(
|
||||
"flex-1 overflow-auto px-4 pb-4 space-y-3",
|
||||
"relative flex-1 overflow-auto px-4 pb-4 [&>:not([data-vault-marquee])~:not([data-vault-marquee])]:mt-3",
|
||||
viewMode === "tree" ? "pt-1.5" : "pt-0",
|
||||
!isHostsSectionActive && "hidden",
|
||||
marqueeRect && "select-none",
|
||||
)}
|
||||
data-section="vault-host-list"
|
||||
onClick={handleHostListClick}
|
||||
onClickCapture={onMarqueeClickCapture}
|
||||
onPointerDown={onMarqueePointerDown}
|
||||
onDragOverCapture={(e) => {
|
||||
const target = (e.target as Element | null)?.closest("[data-host-id], [data-group-path]");
|
||||
if (hasVaultDragType(e.dataTransfer, VAULT_HOST_DRAG_IDS)) {
|
||||
const groupTarget = target instanceof HTMLElement && target.hasAttribute("data-group-path");
|
||||
if (!groupTarget) return;
|
||||
}
|
||||
if (target) e.preventDefault();
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const draggedGroupPath = e.dataTransfer.getData("group-path");
|
||||
@@ -392,6 +426,10 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
const target = (e.target as Element | null)?.closest("[data-host-id], [data-group-path]");
|
||||
if (hasVaultDragType(e.dataTransfer, VAULT_HOST_DRAG_IDS)) {
|
||||
const groupTarget = target instanceof HTMLElement && target.hasAttribute("data-group-path");
|
||||
if (!groupTarget) return;
|
||||
}
|
||||
if (!(target instanceof HTMLElement) || viewMode === "grid") return;
|
||||
const draggedGroupPath = e.dataTransfer.getData("group-path");
|
||||
const isDraggingGroup = hasVaultDragType(e.dataTransfer, "group-path");
|
||||
@@ -419,7 +457,12 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const targetHostId = target.getAttribute("data-host-id");
|
||||
const targetGroupPath = target.getAttribute("data-group-path");
|
||||
if (draggedHostId && targetHostId && draggedHostId !== targetHostId) {
|
||||
if (
|
||||
draggedHostId
|
||||
&& targetHostId
|
||||
&& draggedHostId !== targetHostId
|
||||
&& !hasVaultDragType(e.dataTransfer, VAULT_HOST_DRAG_IDS)
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const position = getVaultDropPosition(target, e.clientX, e.clientY, viewMode === "grid");
|
||||
@@ -447,6 +490,19 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
resetHostDragState();
|
||||
}}
|
||||
>
|
||||
{marqueeRect && (
|
||||
<div
|
||||
data-vault-marquee="true"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute z-20 border border-primary/80 bg-primary/15"
|
||||
style={{
|
||||
left: marqueeRect.left,
|
||||
top: marqueeRect.top,
|
||||
width: marqueeRect.right - marqueeRect.left,
|
||||
height: marqueeRect.bottom - marqueeRect.top,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{viewMode !== "tree" && (
|
||||
<section className="space-y-2 pt-2">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
@@ -476,6 +532,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
setDragOverDropTarget,
|
||||
moveGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
});
|
||||
}}
|
||||
@@ -546,7 +603,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -565,7 +622,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
style={lastPinnedId === host.id ? { animation: "pop-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both" } : undefined}
|
||||
onAnimationEnd={() => { if (lastPinnedId === host.id) setLastPinnedId(null); }}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
@@ -672,7 +729,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -689,7 +746,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
? selectedHostIds.has(host.id)
|
||||
: undefined}
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
@@ -794,6 +851,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
dataTransfer: e.dataTransfer,
|
||||
groupPath: selectedGroupPath,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
})) return;
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
@@ -874,6 +932,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
dataTransfer: e.dataTransfer,
|
||||
groupPath: node.path,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
})) return;
|
||||
const groupPath =
|
||||
@@ -983,6 +1042,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
cancelInlineGroupEdit={cancelInlineGroupEdit}
|
||||
onDeleteGroup={startInlineDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveHostsToGroup={moveHostsToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={handleUnmanageGroup}
|
||||
@@ -1041,7 +1101,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -1058,7 +1118,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
? selectedHostIds.has(host.id)
|
||||
: undefined}
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
@@ -1192,7 +1252,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -1209,7 +1269,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
? selectedHostIds.has(host.id)
|
||||
: undefined}
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
|
||||
@@ -1237,6 +1237,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
managedGroupPaths,
|
||||
moveGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
onDeleteHost,
|
||||
Pin,
|
||||
pinnedHosts,
|
||||
@@ -1256,10 +1257,12 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
setDragOverDropTarget,
|
||||
setGroupDragOverDropTarget,
|
||||
setIsDeleteGroupOpen,
|
||||
setIsMultiSelectMode,
|
||||
setIsNewFolderOpen,
|
||||
setLastPinnedId,
|
||||
setNewFolderName,
|
||||
setSelectedGroupPath,
|
||||
setSelectedHostIds,
|
||||
setTargetParentPath,
|
||||
shouldHideEmptyRootHostsSection,
|
||||
showRecentHosts,
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import type { Host, ManagedSource } from '../../types';
|
||||
import { handleVaultHostDropToGroup } from './vaultReorderDrag';
|
||||
import { useVaultGroupDragHandlers } from './useVaultGroupDragHandlers';
|
||||
|
||||
const host = (id: string, extra: Partial<Host> = {}): Host => ({
|
||||
@@ -44,6 +45,37 @@ test('mixed protocols entering and leaving managed groups retain single-host mov
|
||||
}, writes);
|
||||
});
|
||||
|
||||
test('a multi-id group drop publishes the whole selection through the batched move', async () => {
|
||||
const original = [host('ssh'), host('telnet', { protocol: 'telnet' }), host('local', { protocol: 'local' })];
|
||||
const sources = [{ id: 'parent', groupName: 'Managed' }, { id: 'child', groupName: 'Managed/Nested' }] as ManagedSource[];
|
||||
const writes: Host[][] = [];
|
||||
await withActions(original, sources, actions => {
|
||||
let handled = false;
|
||||
act(() => {
|
||||
handled = handleVaultHostDropToGroup({
|
||||
dataTransfer: {
|
||||
getData: (type: string) => type === 'host-ids' ? JSON.stringify(['ssh', 'telnet', 'local']) : '',
|
||||
},
|
||||
groupPath: 'Managed/Nested',
|
||||
moveHostToGroup: () => {
|
||||
throw new Error('multi-id drop must not move hosts one at a time');
|
||||
},
|
||||
moveHostsToGroup: actions.moveHostsToGroup,
|
||||
resetHostDragState: () => {},
|
||||
});
|
||||
});
|
||||
assert.equal(handled, true);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.deepEqual(writes[0].map(item => item.group), ['Managed/Nested', 'Managed/Nested', 'Managed/Nested']);
|
||||
assert.equal(writes[0][0].managedSourceId, 'child');
|
||||
assert.equal(writes[0][0].label, 'Hostssh');
|
||||
assert.equal(writes[0][1].managedSourceId, undefined);
|
||||
assert.equal(writes[0][1].label, 'Host telnet');
|
||||
assert.equal(writes[0][2].managedSourceId, undefined);
|
||||
assert.equal(writes[0][2].label, 'Host local');
|
||||
}, writes);
|
||||
});
|
||||
|
||||
test('same-group, missing and empty selections do not publish changes', async () => {
|
||||
const writes: Host[][] = [];
|
||||
await withActions([host('a')], [], actions => {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
clientPointToContent,
|
||||
contentRectFromClientBox,
|
||||
exceedsMarqueeThreshold,
|
||||
hostIdsIntersectingMarquee,
|
||||
isPointerOnNativeScrollbar,
|
||||
marqueeAutoScrollDelta,
|
||||
marqueeCancelSelection,
|
||||
marqueeCommitSelection,
|
||||
marqueeRectFromPoints,
|
||||
marqueeSelectionMode,
|
||||
mergeMarqueeSelection,
|
||||
rememberHostPlacements,
|
||||
shouldStartVaultMarquee,
|
||||
type HostPlacement,
|
||||
type MarqueeContentFrame,
|
||||
type MarqueeRect,
|
||||
} from "../../domain/vaultMarqueeSelection";
|
||||
|
||||
const MARQUEE_BLOCKED_SELECTOR = [
|
||||
"[data-host-id]",
|
||||
"[data-group-path]",
|
||||
"button",
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
"a",
|
||||
"label",
|
||||
"[role='scrollbar']",
|
||||
].join(",");
|
||||
|
||||
const selectionSignature = (ids: ReadonlySet<string>) => [...ids].sort().join("\0");
|
||||
|
||||
const elementFromEventTarget = (target: EventTarget | null): Element | null => {
|
||||
if (target instanceof Element) return target;
|
||||
if (typeof Node !== "undefined" && target instanceof Node) return target.parentElement;
|
||||
return null;
|
||||
};
|
||||
|
||||
const isBlockedMarqueeTarget = (target: EventTarget | null): boolean => {
|
||||
const element = elementFromEventTarget(target);
|
||||
if (!element) return true;
|
||||
return Boolean(element.closest(MARQUEE_BLOCKED_SELECTOR));
|
||||
};
|
||||
|
||||
const readFrame = (container: HTMLElement): MarqueeContentFrame => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
scrollLeft: container.scrollLeft,
|
||||
scrollTop: container.scrollTop,
|
||||
clientLeft: container.clientLeft,
|
||||
clientTop: container.clientTop,
|
||||
};
|
||||
};
|
||||
|
||||
const readVisibleHostPlacements = (container: HTMLElement): HostPlacement[] => {
|
||||
const frame = readFrame(container);
|
||||
const placements: HostPlacement[] = [];
|
||||
container.querySelectorAll<HTMLElement>("[data-host-id]").forEach((node, index) => {
|
||||
const id = node.dataset.hostId;
|
||||
if (!id) return;
|
||||
const box = node.getBoundingClientRect();
|
||||
placements.push({
|
||||
key: node.dataset.vaultGridItem || `${id}:${index}`,
|
||||
id,
|
||||
rect: contentRectFromClientBox(box, frame),
|
||||
});
|
||||
});
|
||||
return placements;
|
||||
};
|
||||
|
||||
interface UseVaultHostMarqueeOptions {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
viewMode: string;
|
||||
isMultiSelectMode: boolean;
|
||||
selectedHostIds: ReadonlySet<string>;
|
||||
setIsMultiSelectMode?: (value: boolean) => void;
|
||||
setSelectedHostIds?: (ids: Set<string>) => void;
|
||||
}
|
||||
|
||||
export function useVaultHostMarquee({
|
||||
containerRef,
|
||||
viewMode,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
setIsMultiSelectMode,
|
||||
setSelectedHostIds,
|
||||
}: UseVaultHostMarqueeOptions) {
|
||||
const [marqueeRect, setMarqueeRect] = useState<MarqueeRect | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const sessionRef = useRef(0);
|
||||
const stopGestureRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => () => stopGestureRef.current?.(), []);
|
||||
|
||||
const onPointerDown = useCallback((event: React.PointerEvent<HTMLElement>) => {
|
||||
if (suppressClickRef.current) suppressClickRef.current = false;
|
||||
const container = containerRef.current;
|
||||
if (!container || !setSelectedHostIds || !setIsMultiSelectMode) return;
|
||||
const bounds = container.getBoundingClientRect();
|
||||
if (!shouldStartVaultMarquee({
|
||||
viewMode,
|
||||
button: event.button,
|
||||
pointerType: event.pointerType,
|
||||
onScrollbar: isPointerOnNativeScrollbar({
|
||||
rectLeft: bounds.left,
|
||||
rectTop: bounds.top,
|
||||
clientLeft: container.clientLeft,
|
||||
clientTop: container.clientTop,
|
||||
clientWidth: container.clientWidth,
|
||||
clientHeight: container.clientHeight,
|
||||
}, event.clientX, event.clientY),
|
||||
blockedTarget: isBlockedMarqueeTarget(event.target),
|
||||
})) return;
|
||||
|
||||
stopGestureRef.current?.();
|
||||
const startContent = clientPointToContent(
|
||||
{ x: event.clientX, y: event.clientY },
|
||||
readFrame(container),
|
||||
);
|
||||
const baseline = new Set(selectedHostIds);
|
||||
const wasMultiSelectMode = Boolean(isMultiSelectMode);
|
||||
const mode = marqueeSelectionMode(event);
|
||||
const cache = new Map<string, HostPlacement>();
|
||||
const session = sessionRef.current + 1;
|
||||
sessionRef.current = session;
|
||||
let active = false;
|
||||
let scrollDelta = 0;
|
||||
let frameId = 0;
|
||||
let lastClientX = event.clientX;
|
||||
let lastClientY = event.clientY;
|
||||
let lastSignature = selectionSignature(baseline);
|
||||
let lastMode = wasMultiSelectMode;
|
||||
|
||||
const publish = (nextIds: Set<string>, modeEnabled: boolean) => {
|
||||
const signature = selectionSignature(nextIds);
|
||||
if (signature !== lastSignature) {
|
||||
lastSignature = signature;
|
||||
setSelectedHostIds(nextIds);
|
||||
}
|
||||
if (modeEnabled !== lastMode) {
|
||||
lastMode = modeEnabled;
|
||||
setIsMultiSelectMode(modeEnabled);
|
||||
}
|
||||
};
|
||||
|
||||
const applyAt = (clientX: number, clientY: number) => {
|
||||
if (sessionRef.current !== session) return;
|
||||
const current = clientPointToContent({ x: clientX, y: clientY }, readFrame(container));
|
||||
if (!active && !exceedsMarqueeThreshold(startContent, current)) return;
|
||||
active = true;
|
||||
const rect = marqueeRectFromPoints(startContent, current);
|
||||
rememberHostPlacements(cache, readVisibleHostPlacements(container));
|
||||
const hits = hostIdsIntersectingMarquee(rect, cache.values());
|
||||
const next = marqueeCommitSelection({
|
||||
wasMultiSelectMode,
|
||||
nextIds: mergeMarqueeSelection(baseline, hits, mode),
|
||||
});
|
||||
setMarqueeRect(rect);
|
||||
publish(next.selectedHostIds, next.isMultiSelectMode);
|
||||
};
|
||||
|
||||
const listeners: {
|
||||
move?: (event: PointerEvent) => void;
|
||||
up?: () => void;
|
||||
cancel?: () => void;
|
||||
key?: (event: KeyboardEvent) => void;
|
||||
scroll?: () => void;
|
||||
} = {};
|
||||
let stopGesture = () => {};
|
||||
|
||||
const finishListeners = () => {
|
||||
if (listeners.move) window.removeEventListener("pointermove", listeners.move);
|
||||
if (listeners.up) window.removeEventListener("pointerup", listeners.up);
|
||||
if (listeners.cancel) window.removeEventListener("pointercancel", listeners.cancel);
|
||||
if (listeners.key) window.removeEventListener("keydown", listeners.key);
|
||||
if (listeners.scroll) container.removeEventListener("scroll", listeners.scroll);
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
frameId = 0;
|
||||
scrollDelta = 0;
|
||||
if (stopGestureRef.current === stopGesture) stopGestureRef.current = null;
|
||||
};
|
||||
|
||||
stopGesture = () => {
|
||||
if (sessionRef.current !== session) return;
|
||||
sessionRef.current += 1;
|
||||
finishListeners();
|
||||
setMarqueeRect(null);
|
||||
};
|
||||
|
||||
const end = (cancel: boolean) => {
|
||||
if (sessionRef.current !== session) return;
|
||||
const wasActive = active;
|
||||
stopGesture();
|
||||
if (!wasActive) return;
|
||||
suppressClickRef.current = true;
|
||||
if (!cancel) return;
|
||||
const restored = marqueeCancelSelection({
|
||||
wasMultiSelectMode,
|
||||
baselineIds: baseline,
|
||||
});
|
||||
lastSignature = "";
|
||||
lastMode = !restored.isMultiSelectMode;
|
||||
publish(restored.selectedHostIds, restored.isMultiSelectMode);
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
frameId = 0;
|
||||
if (!scrollDelta || sessionRef.current !== session) return;
|
||||
container.scrollTop += scrollDelta;
|
||||
frameId = window.requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
listeners.move = (pointerEvent: PointerEvent) => {
|
||||
lastClientX = pointerEvent.clientX;
|
||||
lastClientY = pointerEvent.clientY;
|
||||
applyAt(pointerEvent.clientX, pointerEvent.clientY);
|
||||
if (active) {
|
||||
pointerEvent.preventDefault();
|
||||
window.getSelection?.()?.removeAllRanges();
|
||||
}
|
||||
const nextBounds = container.getBoundingClientRect();
|
||||
scrollDelta = active
|
||||
? marqueeAutoScrollDelta(pointerEvent.clientY, nextBounds.top, nextBounds.bottom)
|
||||
: 0;
|
||||
if (scrollDelta && !frameId) frameId = window.requestAnimationFrame(tick);
|
||||
if (!scrollDelta && frameId) {
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = 0;
|
||||
}
|
||||
};
|
||||
|
||||
listeners.scroll = () => {
|
||||
if (!active) return;
|
||||
applyAt(lastClientX, lastClientY);
|
||||
};
|
||||
listeners.up = () => end(false);
|
||||
listeners.cancel = () => end(true);
|
||||
listeners.key = (keyEvent: KeyboardEvent) => {
|
||||
if (keyEvent.key !== "Escape") return;
|
||||
if (!active) {
|
||||
end(false);
|
||||
return;
|
||||
}
|
||||
keyEvent.preventDefault();
|
||||
keyEvent.stopPropagation();
|
||||
end(true);
|
||||
};
|
||||
|
||||
stopGestureRef.current = stopGesture;
|
||||
window.addEventListener("pointermove", listeners.move);
|
||||
window.addEventListener("pointerup", listeners.up);
|
||||
window.addEventListener("pointercancel", listeners.cancel);
|
||||
window.addEventListener("keydown", listeners.key);
|
||||
container.addEventListener("scroll", listeners.scroll, { passive: true });
|
||||
}, [
|
||||
containerRef,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
setIsMultiSelectMode,
|
||||
setSelectedHostIds,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const onClickCapture = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
if (!suppressClickRef.current) return;
|
||||
suppressClickRef.current = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return { marqueeRect, onPointerDown, onClickCapture };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
applyVaultHostDragData,
|
||||
resolveVaultHostDragIds,
|
||||
} from "../../domain/vaultHostDrag";
|
||||
|
||||
export function attachVaultHostDragBadge(dataTransfer: DataTransfer, label: string): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const badge = document.createElement("div");
|
||||
badge.textContent = label;
|
||||
badge.setAttribute("data-vault-host-drag-badge", "true");
|
||||
badge.style.position = "fixed";
|
||||
badge.style.top = "-1000px";
|
||||
badge.style.left = "0";
|
||||
badge.style.zIndex = "2147483647";
|
||||
badge.style.padding = "2px 8px";
|
||||
badge.style.borderRadius = "999px";
|
||||
badge.style.background = "#111827";
|
||||
badge.style.color = "#ffffff";
|
||||
badge.style.font = "12px/16px sans-serif";
|
||||
badge.style.pointerEvents = "none";
|
||||
document.body.appendChild(badge);
|
||||
dataTransfer.setDragImage(badge, badge.offsetWidth / 2, badge.offsetHeight / 2);
|
||||
window.setTimeout(() => badge.remove(), 0);
|
||||
}
|
||||
|
||||
export function beginVaultHostCardDrag(input: {
|
||||
dataTransfer: DataTransfer;
|
||||
hostId: string;
|
||||
isMultiSelectMode: boolean;
|
||||
selectedHostIds: ReadonlySet<string> | undefined;
|
||||
countLabel: (count: number) => string;
|
||||
}): string[] | null {
|
||||
const ids = resolveVaultHostDragIds({
|
||||
hostId: input.hostId,
|
||||
isMultiSelectMode: input.isMultiSelectMode,
|
||||
selectedHostIds: input.selectedHostIds ?? new Set<string>(),
|
||||
});
|
||||
if (!ids) return null;
|
||||
applyVaultHostDragData(input.dataTransfer, ids);
|
||||
if (ids.length > 1) attachVaultHostDragBadge(input.dataTransfer, input.countLabel(ids.length));
|
||||
return ids;
|
||||
}
|
||||
@@ -115,6 +115,58 @@ test("group host drop resets host drag state after moving the host to the group"
|
||||
]);
|
||||
});
|
||||
|
||||
test("group drop of a multi-id payload batches every selected host once", () => {
|
||||
const singleCalls: string[] = [];
|
||||
const batchCalls: Array<{ ids: string[]; group: string | null }> = [];
|
||||
|
||||
const handled = handleVaultHostDropToGroup({
|
||||
dataTransfer: {
|
||||
getData: (type: string) => (
|
||||
type === "host-ids" ? JSON.stringify(["a", "b", "c"]) : type === "host-id" ? "a" : ""
|
||||
),
|
||||
},
|
||||
groupPath: "team/nested",
|
||||
moveHostToGroup: (hostId, targetPath) => {
|
||||
singleCalls.push(`${hostId}:${String(targetPath)}`);
|
||||
},
|
||||
moveHostsToGroup: (hostIds, targetPath) => {
|
||||
batchCalls.push({ ids: [...hostIds], group: targetPath });
|
||||
},
|
||||
resetHostDragState: () => {
|
||||
singleCalls.push("reset");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(singleCalls, ["reset"]);
|
||||
assert.deepEqual(batchCalls, [{ ids: ["a", "b", "c"], group: "team/nested" }]);
|
||||
});
|
||||
|
||||
test("root drop of a multi-id payload batches every selected host into all hosts", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
handleVaultRootDrop({
|
||||
dataTransfer: {
|
||||
getData: (type: string) => (type === "host-ids" ? JSON.stringify(["a", "b"]) : ""),
|
||||
},
|
||||
preventDefault: () => calls.push("preventDefault"),
|
||||
setDragOverDropTarget: () => calls.push("drop:null"),
|
||||
moveGroup: () => calls.push("group"),
|
||||
moveHostToGroup: () => calls.push("single"),
|
||||
moveHostsToGroup: (hostIds, targetPath) => {
|
||||
calls.push(`batch:${[...hostIds].join(",")}:${String(targetPath)}`);
|
||||
},
|
||||
resetHostDragState: () => calls.push("resetHostDragState"),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"preventDefault",
|
||||
"drop:null",
|
||||
"batch:a,b:null",
|
||||
"resetHostDragState",
|
||||
]);
|
||||
});
|
||||
|
||||
test("group drop without a host leaves host drag state alone", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
dispatchVaultHostGroupMove,
|
||||
readVaultDraggedHostIds,
|
||||
} from "../../domain/vaultHostDrag";
|
||||
|
||||
export type VaultDropPosition = "before" | "after";
|
||||
export type VaultDropIntent = VaultDropPosition | "inside";
|
||||
export type VaultDropAxis = "x" | "y";
|
||||
@@ -47,6 +52,7 @@ export const handleVaultRootDrop = ({
|
||||
setDragOverDropTarget,
|
||||
moveGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
}: {
|
||||
dataTransfer: Pick<DataTransfer, "getData">;
|
||||
@@ -54,15 +60,20 @@ export const handleVaultRootDrop = ({
|
||||
setDragOverDropTarget: (target: null) => void;
|
||||
moveGroup: (groupPath: string, targetParentPath: string | null) => void;
|
||||
moveHostToGroup: (hostId: string, targetPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, targetPath: string | null) => void;
|
||||
resetHostDragState: () => void;
|
||||
}) => {
|
||||
preventDefault();
|
||||
setDragOverDropTarget(null);
|
||||
const groupPath = dataTransfer.getData("group-path");
|
||||
const hostId = dataTransfer.getData("host-id");
|
||||
const hostIds = readVaultDraggedHostIds((type) => dataTransfer.getData(type));
|
||||
if (groupPath) moveGroup(groupPath, null);
|
||||
if (hostId) {
|
||||
moveHostToGroup(hostId, null);
|
||||
if (dispatchVaultHostGroupMove({
|
||||
hostIds,
|
||||
groupPath: null,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
})) {
|
||||
resetHostDragState();
|
||||
}
|
||||
};
|
||||
@@ -71,17 +82,23 @@ export const handleVaultHostDropToGroup = ({
|
||||
dataTransfer,
|
||||
groupPath,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
}: {
|
||||
dataTransfer: Pick<DataTransfer, "getData">;
|
||||
groupPath: string | null;
|
||||
moveHostToGroup: (hostId: string, targetPath: string | null) => void;
|
||||
moveHostToGroup?: (hostId: string, targetPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, targetPath: string | null) => void;
|
||||
resetHostDragState: () => void;
|
||||
}) => {
|
||||
const hostId = dataTransfer.getData("host-id");
|
||||
if (!hostId) return false;
|
||||
const moved = dispatchVaultHostGroupMove({
|
||||
hostIds: readVaultDraggedHostIds((type) => dataTransfer.getData(type)),
|
||||
groupPath,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
});
|
||||
if (!moved) return false;
|
||||
|
||||
moveHostToGroup(hostId, groupPath);
|
||||
resetHostDragState();
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { runtimeFailureMessage } from "./containerRuntime.shared.cjs";
|
||||
import { runtimeFailureMessage } from "@/domain/systemManager/containerRuntime.shared.cjs";
|
||||
|
||||
export type {
|
||||
ContainerRuntimeId,
|
||||
@@ -6,7 +6,7 @@ export type {
|
||||
KubeContextInfo,
|
||||
KubectlPodInfo,
|
||||
RuntimeProbeFlags,
|
||||
} from "./containerRuntime.shared.cjs";
|
||||
} from "@/domain/systemManager/containerRuntime.shared.cjs";
|
||||
|
||||
export {
|
||||
NETCATTY_POPUP_HISTORY_MARKER,
|
||||
@@ -24,7 +24,7 @@ export {
|
||||
reconcileContainerSource,
|
||||
rememberedContainerSource,
|
||||
sourcesFromCapabilities,
|
||||
} from "./containerRuntime.shared.cjs";
|
||||
} from "@/domain/systemManager/containerRuntime.shared.cjs";
|
||||
|
||||
export function describeRuntimeFailure(
|
||||
result: { error?: string; errorCode?: string } | null | undefined,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
VAULT_HOST_DRAG_ID,
|
||||
VAULT_HOST_DRAG_IDS,
|
||||
applyVaultHostDragData,
|
||||
dispatchVaultHostGroupMove,
|
||||
isVaultHostDraggable,
|
||||
readVaultDraggedHostIds,
|
||||
resolveVaultHostDragIds,
|
||||
} from './vaultHostDrag.ts';
|
||||
|
||||
test('outside multi-select a host drag carries only that host', () => {
|
||||
const ids = resolveVaultHostDragIds({
|
||||
hostId: 'a',
|
||||
isMultiSelectMode: false,
|
||||
selectedHostIds: new Set(['a', 'b']),
|
||||
});
|
||||
assert.deepEqual(ids, ['a']);
|
||||
assert.equal(isVaultHostDraggable(false, new Set(), 'a'), true);
|
||||
|
||||
const written: Record<string, string> = {};
|
||||
applyVaultHostDragData({
|
||||
effectAllowed: '',
|
||||
setData: (type, value) => {
|
||||
written[type] = value;
|
||||
},
|
||||
}, ids!);
|
||||
assert.deepEqual(written, { [VAULT_HOST_DRAG_ID]: 'a' });
|
||||
});
|
||||
|
||||
test('a multi-select drag starts only on an already selected host and carries every selected id', () => {
|
||||
assert.equal(resolveVaultHostDragIds({
|
||||
hostId: 'c',
|
||||
isMultiSelectMode: true,
|
||||
selectedHostIds: new Set(['a', 'b']),
|
||||
}), null);
|
||||
assert.equal(isVaultHostDraggable(true, new Set(['a', 'b']), 'c'), false);
|
||||
|
||||
const ids = resolveVaultHostDragIds({
|
||||
hostId: 'b',
|
||||
isMultiSelectMode: true,
|
||||
selectedHostIds: new Set(['a', 'b', 'c']),
|
||||
});
|
||||
assert.deepEqual(ids, ['b', 'a', 'c']);
|
||||
|
||||
const written: Record<string, string> = {};
|
||||
applyVaultHostDragData({
|
||||
effectAllowed: 'none',
|
||||
setData: (type, value) => {
|
||||
written[type] = value;
|
||||
},
|
||||
}, ids!);
|
||||
assert.equal(written[VAULT_HOST_DRAG_ID], 'b');
|
||||
assert.deepEqual(JSON.parse(written[VAULT_HOST_DRAG_IDS]), ['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
test('reading a drag payload prefers the multi-id list and falls back to one host id', () => {
|
||||
assert.deepEqual(readVaultDraggedHostIds((type) => (
|
||||
type === VAULT_HOST_DRAG_IDS ? JSON.stringify(['a', 'b', 'a']) : 'a'
|
||||
)), ['a', 'b', 'a']);
|
||||
assert.deepEqual(readVaultDraggedHostIds((type) => (
|
||||
type === VAULT_HOST_DRAG_ID ? 'solo' : ''
|
||||
)), ['solo']);
|
||||
assert.deepEqual(readVaultDraggedHostIds((type) => (
|
||||
type === VAULT_HOST_DRAG_IDS ? '{nope' : 'solo'
|
||||
)), ['solo']);
|
||||
assert.deepEqual(readVaultDraggedHostIds(() => ''), []);
|
||||
});
|
||||
|
||||
test('multi-id drag payload plans one batched group move', () => {
|
||||
const singleCalls: string[] = [];
|
||||
const batchCalls: Array<{ ids: string[]; group: string | null }> = [];
|
||||
const moved = dispatchVaultHostGroupMove({
|
||||
hostIds: readVaultDraggedHostIds((type) => (
|
||||
type === VAULT_HOST_DRAG_IDS ? JSON.stringify(['a', 'b', 'c']) : 'a'
|
||||
)),
|
||||
groupPath: 'Ops/Nested',
|
||||
moveHostToGroup: (hostId, groupPath) => {
|
||||
singleCalls.push(`${hostId}:${String(groupPath)}`);
|
||||
},
|
||||
moveHostsToGroup: (hostIds, groupPath) => {
|
||||
batchCalls.push({ ids: [...hostIds], group: groupPath });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(moved, true);
|
||||
assert.deepEqual(singleCalls, []);
|
||||
assert.deepEqual(batchCalls, [{ ids: ['a', 'b', 'c'], group: 'Ops/Nested' }]);
|
||||
});
|
||||
|
||||
test('a single host id still moves through the single-host path when no batch handler is present', () => {
|
||||
const calls: string[] = [];
|
||||
const moved = dispatchVaultHostGroupMove({
|
||||
hostIds: ['host-1'],
|
||||
groupPath: null,
|
||||
moveHostToGroup: (hostId, groupPath) => {
|
||||
calls.push(`${hostId}:${String(groupPath)}`);
|
||||
},
|
||||
});
|
||||
assert.equal(moved, true);
|
||||
assert.deepEqual(calls, ['host-1:null']);
|
||||
assert.equal(dispatchVaultHostGroupMove({
|
||||
hostIds: [],
|
||||
groupPath: 'Ops',
|
||||
moveHostsToGroup: () => {
|
||||
throw new Error('empty selection must not move');
|
||||
},
|
||||
}), false);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
export const VAULT_HOST_DRAG_ID = 'host-id';
|
||||
export const VAULT_HOST_DRAG_IDS = 'host-ids';
|
||||
|
||||
export interface VaultHostDragWriter {
|
||||
effectAllowed: string;
|
||||
setData: (type: string, value: string) => void;
|
||||
}
|
||||
|
||||
export function resolveVaultHostDragIds(input: {
|
||||
hostId: string;
|
||||
isMultiSelectMode: boolean;
|
||||
selectedHostIds: ReadonlySet<string>;
|
||||
}): string[] | null {
|
||||
if (!input.isMultiSelectMode) return [input.hostId];
|
||||
if (!input.selectedHostIds.has(input.hostId)) return null;
|
||||
return [
|
||||
input.hostId,
|
||||
...input.selectedHostIds,
|
||||
].filter((id, index, ids) => id.length > 0 && ids.indexOf(id) === index);
|
||||
}
|
||||
|
||||
export function isVaultHostDraggable(
|
||||
isMultiSelectMode: boolean,
|
||||
selectedHostIds: ReadonlySet<string> | undefined,
|
||||
hostId: string,
|
||||
): boolean {
|
||||
return resolveVaultHostDragIds({
|
||||
hostId,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds: selectedHostIds ?? new Set(),
|
||||
}) !== null;
|
||||
}
|
||||
|
||||
export function encodeVaultHostDragIds(ids: readonly string[]): string {
|
||||
return JSON.stringify(ids);
|
||||
}
|
||||
|
||||
export function decodeVaultHostDragIds(raw: string | null | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function applyVaultHostDragData(dataTransfer: VaultHostDragWriter, ids: readonly string[]): void {
|
||||
if (ids.length === 0) return;
|
||||
dataTransfer.effectAllowed = 'copyMove';
|
||||
dataTransfer.setData(VAULT_HOST_DRAG_ID, ids[0]);
|
||||
if (ids.length > 1) dataTransfer.setData(VAULT_HOST_DRAG_IDS, encodeVaultHostDragIds(ids));
|
||||
}
|
||||
|
||||
export function readVaultDraggedHostIds(getData: (type: string) => string): string[] {
|
||||
const many = decodeVaultHostDragIds(getData(VAULT_HOST_DRAG_IDS));
|
||||
if (many.length > 0) return many;
|
||||
const single = getData(VAULT_HOST_DRAG_ID)?.trim() ?? '';
|
||||
return single ? [single] : [];
|
||||
}
|
||||
|
||||
export function dispatchVaultHostGroupMove(input: {
|
||||
hostIds: readonly string[];
|
||||
groupPath: string | null;
|
||||
moveHostToGroup?: (hostId: string, groupPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
|
||||
}): boolean {
|
||||
const ids: string[] = [];
|
||||
for (const id of input.hostIds) {
|
||||
if (id && !ids.includes(id)) ids.push(id);
|
||||
}
|
||||
if (ids.length === 0) return false;
|
||||
if (input.moveHostsToGroup) {
|
||||
input.moveHostsToGroup(new Set(ids), input.groupPath);
|
||||
return true;
|
||||
}
|
||||
if (ids.length === 1 && input.moveHostToGroup) {
|
||||
input.moveHostToGroup(ids[0], input.groupPath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
MARQUEE_MOVEMENT_THRESHOLD_PX,
|
||||
clientPointToContent,
|
||||
contentRectFromClientBox,
|
||||
exceedsMarqueeThreshold,
|
||||
hostIdsIntersectingMarquee,
|
||||
isPointerOnNativeScrollbar,
|
||||
marqueeAutoScrollDelta,
|
||||
marqueeCancelSelection,
|
||||
marqueeCommitSelection,
|
||||
marqueeRectFromPoints,
|
||||
marqueeSelectionMode,
|
||||
mergeMarqueeSelection,
|
||||
rememberHostPlacements,
|
||||
shouldStartVaultMarquee,
|
||||
type HostPlacement,
|
||||
} from './vaultMarqueeSelection.ts';
|
||||
|
||||
const box = (left: number, top: number, right: number, bottom: number) => ({
|
||||
left, top, right, bottom,
|
||||
});
|
||||
|
||||
test('marquee rect normalizes a drag that moves up and left', () => {
|
||||
assert.deepEqual(
|
||||
marqueeRectFromPoints({ x: 40, y: 50 }, { x: 10, y: 20 }),
|
||||
box(10, 20, 40, 50),
|
||||
);
|
||||
});
|
||||
|
||||
test('marquee movement threshold keeps a plain click below 4px', () => {
|
||||
const start = { x: 10, y: 10 };
|
||||
assert.equal(exceedsMarqueeThreshold(start, { x: 13, y: 12 }), false);
|
||||
assert.equal(
|
||||
exceedsMarqueeThreshold(start, { x: 10 + MARQUEE_MOVEMENT_THRESHOLD_PX, y: 10 }),
|
||||
true,
|
||||
);
|
||||
assert.equal(exceedsMarqueeThreshold(start, { x: 10, y: 14 }), true);
|
||||
});
|
||||
|
||||
test('marquee modifiers replace, add with shift, and toggle with ctrl or meta', () => {
|
||||
assert.equal(marqueeSelectionMode({ shiftKey: false, ctrlKey: false, metaKey: false }), 'replace');
|
||||
assert.equal(marqueeSelectionMode({ shiftKey: true, ctrlKey: false, metaKey: false }), 'add');
|
||||
assert.equal(marqueeSelectionMode({ shiftKey: false, ctrlKey: true, metaKey: false }), 'toggle');
|
||||
assert.equal(marqueeSelectionMode({ shiftKey: false, ctrlKey: false, metaKey: true }), 'toggle');
|
||||
assert.equal(marqueeSelectionMode({ shiftKey: true, ctrlKey: true, metaKey: false }), 'toggle');
|
||||
});
|
||||
|
||||
test('marquee selection merge uses the selection from drag start', () => {
|
||||
const baseline = new Set(['a', 'b']);
|
||||
const hits = new Set(['b', 'c']);
|
||||
|
||||
assert.deepEqual([...mergeMarqueeSelection(baseline, hits, 'replace')].sort(), ['b', 'c']);
|
||||
assert.deepEqual([...mergeMarqueeSelection(baseline, hits, 'add')].sort(), ['a', 'b', 'c']);
|
||||
assert.deepEqual([...mergeMarqueeSelection(baseline, hits, 'toggle')].sort(), ['a', 'c']);
|
||||
assert.deepEqual([...baseline].sort(), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('host cards that intersect the marquee are selected, including partial overlap', () => {
|
||||
const placements = [
|
||||
{ id: 'inside', rect: box(10, 10, 30, 30) },
|
||||
{ id: 'partial', rect: box(28, 28, 50, 50) },
|
||||
{ id: 'touch', rect: box(40, 10, 55, 20) },
|
||||
{ id: 'miss', rect: box(41, 10, 60, 20) },
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
[...hostIdsIntersectingMarquee(box(0, 0, 40, 40), placements)].sort(),
|
||||
['inside', 'partial'],
|
||||
);
|
||||
});
|
||||
|
||||
test('placement cache keeps hosts that scroll out of the mounted set', () => {
|
||||
const cache = new Map<string, HostPlacement>();
|
||||
rememberHostPlacements(cache, [
|
||||
{ key: 'main:a', id: 'a', rect: box(0, 0, 10, 10) },
|
||||
{ key: 'pinned:a', id: 'a', rect: box(0, 100, 10, 110) },
|
||||
{ key: 'main:b', id: 'b', rect: box(0, 20, 10, 30) },
|
||||
]);
|
||||
rememberHostPlacements(cache, [
|
||||
{ key: 'main:b', id: 'b', rect: box(0, 20, 12, 30) },
|
||||
{ key: 'main:c', id: 'c', rect: box(0, 40, 10, 50) },
|
||||
]);
|
||||
|
||||
const hits = hostIdsIntersectingMarquee(box(0, 0, 10, 50), cache.values());
|
||||
assert.deepEqual([...hits].sort(), ['a', 'b', 'c']);
|
||||
assert.equal(cache.get('main:b')?.rect.right, 12);
|
||||
});
|
||||
|
||||
test('content coordinates stay stable when the scroll container moves', () => {
|
||||
const frame = {
|
||||
left: 100,
|
||||
top: 40,
|
||||
scrollLeft: 0,
|
||||
scrollTop: 25,
|
||||
clientLeft: 0,
|
||||
clientTop: 0,
|
||||
};
|
||||
assert.deepEqual(clientPointToContent({ x: 130, y: 80 }, frame), { x: 30, y: 65 });
|
||||
|
||||
const beforeScroll = contentRectFromClientBox(
|
||||
{ left: 120, top: 70, width: 40, height: 20 },
|
||||
frame,
|
||||
);
|
||||
const afterScroll = contentRectFromClientBox(
|
||||
{ left: 120, top: 50, width: 40, height: 20 },
|
||||
{ ...frame, scrollTop: 45 },
|
||||
);
|
||||
assert.deepEqual(beforeScroll, afterScroll);
|
||||
assert.deepEqual(beforeScroll, box(20, 55, 60, 75));
|
||||
});
|
||||
|
||||
test('auto-scroll only steps when the pointer is near a vertical edge', () => {
|
||||
assert.equal(marqueeAutoScrollDelta(10, 0, 200), -16);
|
||||
assert.equal(marqueeAutoScrollDelta(190, 0, 200), 16);
|
||||
assert.equal(marqueeAutoScrollDelta(100, 0, 200), 0);
|
||||
});
|
||||
|
||||
test('native scrollbar gutter does not start a marquee', () => {
|
||||
const metrics = {
|
||||
rectLeft: 0,
|
||||
rectTop: 0,
|
||||
clientLeft: 0,
|
||||
clientTop: 0,
|
||||
clientWidth: 200,
|
||||
clientHeight: 300,
|
||||
};
|
||||
assert.equal(isPointerOnNativeScrollbar(metrics, 190, 40), false);
|
||||
assert.equal(isPointerOnNativeScrollbar(metrics, 210, 40), true);
|
||||
assert.equal(isPointerOnNativeScrollbar(metrics, 20, 310), true);
|
||||
});
|
||||
|
||||
test('marquee starts on primary grid or list presses in empty space only', () => {
|
||||
const ready = {
|
||||
viewMode: 'grid',
|
||||
button: 0,
|
||||
pointerType: 'mouse',
|
||||
onScrollbar: false,
|
||||
blockedTarget: false,
|
||||
};
|
||||
assert.equal(shouldStartVaultMarquee(ready), true);
|
||||
assert.equal(shouldStartVaultMarquee({ ...ready, viewMode: 'list' }), true);
|
||||
assert.equal(shouldStartVaultMarquee({ ...ready, viewMode: 'tree' }), false);
|
||||
assert.equal(shouldStartVaultMarquee({ ...ready, button: 2 }), false);
|
||||
assert.equal(shouldStartVaultMarquee({ ...ready, pointerType: 'touch' }), false);
|
||||
assert.equal(shouldStartVaultMarquee({ ...ready, onScrollbar: true }), false);
|
||||
assert.equal(shouldStartVaultMarquee({ ...ready, blockedTarget: true }), false);
|
||||
});
|
||||
|
||||
test('committing a marquee enters multi-select only when hosts are selected', () => {
|
||||
assert.deepEqual(
|
||||
marqueeCommitSelection({ wasMultiSelectMode: false, nextIds: new Set(['a']) }),
|
||||
{ isMultiSelectMode: true, selectedHostIds: new Set(['a']) },
|
||||
);
|
||||
assert.deepEqual(
|
||||
marqueeCommitSelection({ wasMultiSelectMode: false, nextIds: new Set() }),
|
||||
{ isMultiSelectMode: false, selectedHostIds: new Set() },
|
||||
);
|
||||
assert.deepEqual(
|
||||
marqueeCommitSelection({ wasMultiSelectMode: true, nextIds: new Set() }),
|
||||
{ isMultiSelectMode: true, selectedHostIds: new Set() },
|
||||
);
|
||||
});
|
||||
|
||||
test('escape restores the selection captured at drag start', () => {
|
||||
assert.deepEqual(
|
||||
marqueeCancelSelection({
|
||||
wasMultiSelectMode: false,
|
||||
baselineIds: new Set(['kept']),
|
||||
}),
|
||||
{ isMultiSelectMode: false, selectedHostIds: new Set(['kept']) },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
export const MARQUEE_MOVEMENT_THRESHOLD_PX = 4;
|
||||
export const MARQUEE_AUTOSCROLL_EDGE_PX = 28;
|
||||
export const MARQUEE_AUTOSCROLL_STEP_PX = 16;
|
||||
|
||||
export interface MarqueePoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface MarqueeRect {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export interface MarqueeContentFrame {
|
||||
left: number;
|
||||
top: number;
|
||||
scrollLeft: number;
|
||||
scrollTop: number;
|
||||
clientLeft?: number;
|
||||
clientTop?: number;
|
||||
}
|
||||
|
||||
export interface ScrollbarMetrics {
|
||||
rectLeft: number;
|
||||
rectTop: number;
|
||||
clientLeft: number;
|
||||
clientTop: number;
|
||||
clientWidth: number;
|
||||
clientHeight: number;
|
||||
}
|
||||
|
||||
export type MarqueeSelectionMode = 'replace' | 'add' | 'toggle';
|
||||
|
||||
export interface HostPlacement {
|
||||
key: string;
|
||||
id: string;
|
||||
rect: MarqueeRect;
|
||||
}
|
||||
|
||||
export function marqueeRectFromPoints(start: MarqueePoint, current: MarqueePoint): MarqueeRect {
|
||||
return {
|
||||
left: Math.min(start.x, current.x),
|
||||
top: Math.min(start.y, current.y),
|
||||
right: Math.max(start.x, current.x),
|
||||
bottom: Math.max(start.y, current.y),
|
||||
};
|
||||
}
|
||||
|
||||
export function exceedsMarqueeThreshold(
|
||||
start: MarqueePoint,
|
||||
current: MarqueePoint,
|
||||
threshold = MARQUEE_MOVEMENT_THRESHOLD_PX,
|
||||
): boolean {
|
||||
return Math.abs(current.x - start.x) >= threshold || Math.abs(current.y - start.y) >= threshold;
|
||||
}
|
||||
|
||||
export function marqueeSelectionMode(modifiers: {
|
||||
shiftKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
}): MarqueeSelectionMode {
|
||||
if (modifiers.ctrlKey || modifiers.metaKey) return 'toggle';
|
||||
if (modifiers.shiftKey) return 'add';
|
||||
return 'replace';
|
||||
}
|
||||
|
||||
export function rectsIntersect(a: MarqueeRect, b: MarqueeRect): boolean {
|
||||
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
||||
}
|
||||
|
||||
export function mergeMarqueeSelection(
|
||||
baseline: ReadonlySet<string>,
|
||||
hitIds: ReadonlySet<string>,
|
||||
mode: MarqueeSelectionMode,
|
||||
): Set<string> {
|
||||
if (mode === 'replace') return new Set(hitIds);
|
||||
const next = new Set(baseline);
|
||||
for (const id of hitIds) {
|
||||
if (mode === 'toggle' && next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function hostIdsIntersectingMarquee(
|
||||
marquee: MarqueeRect,
|
||||
placements: Iterable<{ id: string; rect: MarqueeRect }>,
|
||||
): Set<string> {
|
||||
const hits = new Set<string>();
|
||||
for (const placement of placements) {
|
||||
if (rectsIntersect(marquee, placement.rect)) hits.add(placement.id);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
export function rememberHostPlacements(
|
||||
cache: Map<string, HostPlacement>,
|
||||
visible: readonly HostPlacement[],
|
||||
): Map<string, HostPlacement> {
|
||||
for (const placement of visible) cache.set(placement.key, placement);
|
||||
return cache;
|
||||
}
|
||||
|
||||
export function clientPointToContent(client: MarqueePoint, frame: MarqueeContentFrame): MarqueePoint {
|
||||
return {
|
||||
x: client.x - frame.left - (frame.clientLeft ?? 0) + frame.scrollLeft,
|
||||
y: client.y - frame.top - (frame.clientTop ?? 0) + frame.scrollTop,
|
||||
};
|
||||
}
|
||||
|
||||
export function contentRectFromClientBox(
|
||||
box: { left: number; top: number; width: number; height: number },
|
||||
frame: MarqueeContentFrame,
|
||||
): MarqueeRect {
|
||||
const origin = clientPointToContent({ x: box.left, y: box.top }, frame);
|
||||
return {
|
||||
left: origin.x,
|
||||
top: origin.y,
|
||||
right: origin.x + box.width,
|
||||
bottom: origin.y + box.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function marqueeAutoScrollDelta(
|
||||
client: number,
|
||||
start: number,
|
||||
end: number,
|
||||
edge = MARQUEE_AUTOSCROLL_EDGE_PX,
|
||||
step = MARQUEE_AUTOSCROLL_STEP_PX,
|
||||
): number {
|
||||
if (client < start + edge) return -step;
|
||||
if (client > end - edge) return step;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isPointerOnNativeScrollbar(
|
||||
metrics: ScrollbarMetrics,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): boolean {
|
||||
const x = clientX - metrics.rectLeft - metrics.clientLeft;
|
||||
const y = clientY - metrics.rectTop - metrics.clientTop;
|
||||
return x > metrics.clientWidth || y > metrics.clientHeight;
|
||||
}
|
||||
|
||||
export function shouldStartVaultMarquee(input: {
|
||||
viewMode: string;
|
||||
button: number;
|
||||
pointerType?: string;
|
||||
onScrollbar: boolean;
|
||||
blockedTarget: boolean;
|
||||
}): boolean {
|
||||
if (input.viewMode !== 'grid' && input.viewMode !== 'list') return false;
|
||||
if (input.button !== 0) return false;
|
||||
if (input.pointerType === 'touch') return false;
|
||||
if (input.onScrollbar || input.blockedTarget) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function marqueeCommitSelection(input: {
|
||||
wasMultiSelectMode: boolean;
|
||||
nextIds: ReadonlySet<string>;
|
||||
}): { isMultiSelectMode: boolean; selectedHostIds: Set<string> } {
|
||||
const selectedHostIds = new Set(input.nextIds);
|
||||
return {
|
||||
isMultiSelectMode: selectedHostIds.size > 0 || input.wasMultiSelectMode,
|
||||
selectedHostIds,
|
||||
};
|
||||
}
|
||||
|
||||
export function marqueeCancelSelection(input: {
|
||||
wasMultiSelectMode: boolean;
|
||||
baselineIds: ReadonlySet<string>;
|
||||
}): { isMultiSelectMode: boolean; selectedHostIds: Set<string> } {
|
||||
return {
|
||||
isMultiSelectMode: input.wasMultiSelectMode,
|
||||
selectedHostIds: new Set(input.baselineIds),
|
||||
};
|
||||
}
|
||||
@@ -459,7 +459,7 @@ async function* iterateSse(response, signal) {
|
||||
if (!reader) throw openCodeV2UserError("ai.opencode.v2.unavailable");
|
||||
const decoder = new TextDecoder();
|
||||
const parser = createSseJsonDecoder();
|
||||
const cancelReader = () => { try { void reader.cancel(); } catch {} };
|
||||
const cancelReader = () => { try { void reader.cancel().catch(() => {}); } catch {} };
|
||||
if (signal?.aborted) cancelReader();
|
||||
else signal?.addEventListener("abort", cancelReader, { once: true });
|
||||
try {
|
||||
@@ -991,7 +991,7 @@ async function listOpenCodeV2Models(args = {}) {
|
||||
|
||||
async function runOpenCodeV2Turn({
|
||||
prompt, systemPrompt, attachments, cwd, model, injectedMcpServers, toolIntegrationMode,
|
||||
skillsPathAllowlist, resumeSessionId, env, binPath, emitter, abortController, openCodeV2Factory,
|
||||
skillsPathAllowlist, resumeSessionId, env, binPath, emitter, abortController, openCodeV2Factory, assertSessionValid,
|
||||
} = {}) {
|
||||
const signal = abortController?.signal;
|
||||
const config = buildOpenCodeConfig({ model, injectedMcpServers, toolIntegrationMode, skillsPathAllowlist });
|
||||
@@ -1033,6 +1033,7 @@ async function runOpenCodeV2Turn({
|
||||
emitter.sessionId(sessionId);
|
||||
|
||||
if (systemPrompt) {
|
||||
assertSessionValid?.();
|
||||
const wrote = await client.putInstruction(sessionId, "netcatty", String(systemPrompt), signal);
|
||||
if (!wrote.ok && emitter.warning) emitter.warning("opencode-v2-context", "ai.opencode.v2.contextSkipped");
|
||||
}
|
||||
@@ -1112,6 +1113,7 @@ async function runOpenCodeV2Turn({
|
||||
})();
|
||||
|
||||
try {
|
||||
assertSessionValid?.();
|
||||
await client.prompt(sessionId, promptBody, signal);
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
|
||||
@@ -3,7 +3,8 @@ const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { EventEmitter, getEventListeners } = require("node:events");
|
||||
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
|
||||
const {
|
||||
buildOpenCodeBasicAuthorization,
|
||||
buildOpenCodeV2ConfigContent,
|
||||
@@ -348,6 +349,30 @@ test("OpenCode 2 client hides undici transport failures", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("OpenCode 2 event stream handles rejected reader cancellation on abort", async () => {
|
||||
const abortController = new AbortController();
|
||||
let cancelled = false;
|
||||
const client = createOpenCodeV2Client({
|
||||
baseUrl: "http://127.0.0.1:4096",
|
||||
password: "synthetic-test-password",
|
||||
fetchImpl: async () => new Response(new ReadableStream({
|
||||
start() {},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
return Promise.reject(new Error("synthetic reader cancellation failure"));
|
||||
},
|
||||
}), { status: 200, headers: { "content-type": "text/event-stream" } }),
|
||||
});
|
||||
const stream = await client.openEventStream(abortController.signal);
|
||||
const next = stream.next();
|
||||
abortController.abort();
|
||||
assert.deepEqual(await next, { value: undefined, done: true });
|
||||
assert.equal(cancelled, true);
|
||||
assert.equal(getEventListeners(abortController.signal, "abort").length, 0);
|
||||
// Let node:test observe any unhandled cancellation rejection.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
test("listCatalog omits the location query when no directory is set", async () => {
|
||||
const urls = [];
|
||||
const client = createOpenCodeV2Client({
|
||||
@@ -609,6 +634,88 @@ test("runOpenCodeV2Turn posts the prompt only after the event stream is open", a
|
||||
assert.deepEqual(events, ["Hi", "done"]);
|
||||
});
|
||||
|
||||
for (const pausedAt of ["server startup", "session creation", "event stream opening"]) {
|
||||
test(`runOpenCodeTurn v2 rechecks privacy after paused ${pausedAt}`, { timeout: 2000 }, async () => {
|
||||
const privacy = createAiPrivacyService();
|
||||
privacy.checkSession("chat");
|
||||
const abortController = new AbortController();
|
||||
const sends = [];
|
||||
const errors = [];
|
||||
let closed = false;
|
||||
let streamCancelled = false;
|
||||
let entered;
|
||||
let release;
|
||||
const ready = new Promise((resolve) => { entered = resolve; });
|
||||
const waiting = new Promise((resolve) => { release = resolve; });
|
||||
async function pause(stage) {
|
||||
if (stage !== pausedAt) return;
|
||||
entered();
|
||||
await waiting;
|
||||
}
|
||||
const fetchImpl = async (url, init) => {
|
||||
const pathname = new URL(url).pathname;
|
||||
if (pathname === "/api/session") {
|
||||
await pause("session creation");
|
||||
return jsonResponse({ data: { id: "ses_1" } });
|
||||
}
|
||||
if (pathname.includes("/instructions/")) {
|
||||
sends.push(["instruction", JSON.parse(init.body).value]);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (pathname === "/api/event") {
|
||||
await pause("event stream opening");
|
||||
return new Response(new ReadableStream({
|
||||
start() {},
|
||||
cancel() { streamCancelled = true; },
|
||||
}), { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||
}
|
||||
if (pathname.endsWith("/prompt")) {
|
||||
sends.push(["prompt", JSON.parse(init.body).text]);
|
||||
// Keep the unguarded regression finite without an execution event.
|
||||
return jsonResponse({ message: "unexpected prompt" }, 400);
|
||||
}
|
||||
throw new Error(`Unexpected request: ${pathname}`);
|
||||
};
|
||||
const running = runOpenCodeTurn({
|
||||
prompt: "synthetic raw prompt",
|
||||
systemPrompt: "synthetic raw context",
|
||||
abortController,
|
||||
assertSessionValid: () => privacy.checkSession("chat"),
|
||||
versionProbe: async () => ({ major: 2, minor: 1, patch: 0 }),
|
||||
emitter: {
|
||||
sessionId() {},
|
||||
emitDone() { assert.fail("a retired chat must not complete"); },
|
||||
emitError: (message) => errors.push(message),
|
||||
},
|
||||
openCodeV2Factory: async () => {
|
||||
await pause("server startup");
|
||||
return {
|
||||
client: createOpenCodeV2Client({
|
||||
baseUrl: "http://127.0.0.1:4096",
|
||||
password: "synthetic-test-password",
|
||||
fetchImpl,
|
||||
}),
|
||||
server: { close() { closed = true; } },
|
||||
};
|
||||
},
|
||||
});
|
||||
await ready;
|
||||
privacy.configure({ enabled: true });
|
||||
release();
|
||||
await running;
|
||||
|
||||
assert.deepEqual(sends, pausedAt === "event stream opening"
|
||||
? [["instruction", "synthetic raw context"]]
|
||||
: []);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0], /start a new chat/);
|
||||
assert.equal(closed, true);
|
||||
assert.equal(streamCancelled, pausedAt === "event stream opening");
|
||||
assert.equal(abortController.signal.aborted, false);
|
||||
assert.equal(getEventListeners(abortController.signal, "abort").length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("runOpenCodeV2Turn reports a rejected prompt without waiting on the event stream", { timeout: 2000 }, async () => {
|
||||
let releaseEvent;
|
||||
const eventOpen = new Promise((resolve) => { releaseEvent = resolve; });
|
||||
|
||||
@@ -209,13 +209,32 @@ const createHostVerifier = ({
|
||||
knownHosts = [],
|
||||
verifyHostKeys = true,
|
||||
bootEpoch,
|
||||
// Transfer dials have no SFTP-page prompt owner. Waiting out the request
|
||||
// TTL leaves the upload on "Waiting..." with no dialog and no error.
|
||||
unattended = false,
|
||||
onAccepted,
|
||||
}) => (rawKey, callback) => {
|
||||
const keyInfo = describeHostKey(rawKey);
|
||||
const noteAccepted = () => {
|
||||
try {
|
||||
onAccepted?.({
|
||||
hostname,
|
||||
port,
|
||||
keyType: keyInfo.keyType,
|
||||
fingerprint: keyInfo.fingerprint,
|
||||
publicKey: keyInfo.publicKey,
|
||||
});
|
||||
} catch {
|
||||
// Recording the accepted key must not change the verifier result.
|
||||
}
|
||||
};
|
||||
|
||||
if (verifyHostKeys === false) {
|
||||
noteAccepted();
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const keyInfo = describeHostKey(rawKey);
|
||||
const decision = classifyHostKey({
|
||||
knownHosts,
|
||||
hostname,
|
||||
@@ -225,10 +244,16 @@ const createHostVerifier = ({
|
||||
});
|
||||
|
||||
if (decision.status === "trusted") {
|
||||
noteAccepted();
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (unattended) {
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
|
||||
void requestHostKeyVerification(sender, {
|
||||
sessionId,
|
||||
hostname,
|
||||
@@ -241,6 +266,7 @@ const createHostVerifier = ({
|
||||
knownFingerprint: decision.expectedFingerprint,
|
||||
...(Number.isFinite(bootEpoch) ? { bootEpoch: Number(bootEpoch) } : {}),
|
||||
}).then((response) => {
|
||||
if (response?.accept) noteAccepted();
|
||||
callback(Boolean(response?.accept));
|
||||
}).catch(() => {
|
||||
callback(false);
|
||||
|
||||
@@ -1576,17 +1576,32 @@ async function runRemoteUploadTransaction(client, localPath, remotePath, options
|
||||
}
|
||||
|
||||
const cleanupStage = async () => {
|
||||
// The owner channel is already gone. unlink/delete would wait forever for
|
||||
// a callback ssh2 never sends, and the transfer would stay on screen.
|
||||
const channel = client?.sftp;
|
||||
if (channel && (channel.readable === false || channel.destroyed === true)) return;
|
||||
const removeStage = scpMode
|
||||
? getScpBackendForClient(client).remove(stagedLogical, {
|
||||
recursive: false,
|
||||
encoding,
|
||||
})
|
||||
: typeof client.delete === "function"
|
||||
? client.delete(encodedStagedPath)
|
||||
: null;
|
||||
if (!removeStage) return;
|
||||
let timer = null;
|
||||
try {
|
||||
if (scpMode) {
|
||||
await getScpBackendForClient(client).remove(stagedLogical, {
|
||||
recursive: false,
|
||||
encoding,
|
||||
});
|
||||
} else if (typeof client.delete === "function") {
|
||||
await client.delete(encodedStagedPath);
|
||||
}
|
||||
await Promise.race([
|
||||
Promise.resolve(removeStage),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("SFTP channel closed")), 2000);
|
||||
if (typeof timer.unref === "function") timer.unref();
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
// Best-effort cleanup of a partial stage.
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2385,6 +2400,7 @@ const {
|
||||
completeTransportDial,
|
||||
failTransportDial,
|
||||
buildEndpointKey,
|
||||
buildChannelAdoptionKey,
|
||||
buildConnectionReuseEndpoint,
|
||||
resolveConnectionKeepalivePolicy,
|
||||
endpointAllowsReuse,
|
||||
@@ -2407,6 +2423,7 @@ const openConnectionApi = createOpenConnectionApi({
|
||||
createTransport, borrowTransport, findTransportByEndpoint,
|
||||
beginTransportDial, waitForTransportDial, completeTransportDial, failTransportDial,
|
||||
buildEndpointKey,
|
||||
buildChannelAdoptionKey,
|
||||
buildConnectionReuseEndpoint,
|
||||
resolveConnectionKeepalivePolicy,
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -555,6 +555,41 @@ function createFileOpsApi(ctx) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the alias's lease on the login client. If the SFTP page already
|
||||
* asked to close that client, end it only after the last borrower leaves.
|
||||
*/
|
||||
function releaseSharedChannelOwner(client) {
|
||||
const ownerId = client?.__netcattySharedChannelOwnerId;
|
||||
const aliasId = client?.__netcattySharedChannelAliasId;
|
||||
if (!ownerId || !aliasId || client.__netcattyOwnerLeaseReleased) return;
|
||||
client.__netcattyOwnerLeaseReleased = true;
|
||||
let store;
|
||||
let result;
|
||||
try {
|
||||
const lease = require("../sftpTransferSessionLease.cjs");
|
||||
store = lease.sftpTransferSessionLeaseStore;
|
||||
result = store.release(ownerId, lease.channelAliasLeaseId(aliasId));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!result?.shouldHardClose) return;
|
||||
const closeToken = store.getPendingHardCloseToken(ownerId);
|
||||
setImmediate(() => {
|
||||
if (store.isHeld(ownerId)) {
|
||||
store.markSoftClosed(ownerId);
|
||||
return;
|
||||
}
|
||||
if (!store.commitHardClose(ownerId, closeToken)) return;
|
||||
void closeSftp(null, { sftpId: ownerId, force: true }).catch((err) => {
|
||||
console.warn(
|
||||
`[SFTP] Deferred close of borrowed owner ${ownerId} failed:`,
|
||||
err?.message || err,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close an SFTP connection.
|
||||
* Also cleans up any jump host connections and file watchers if present.
|
||||
@@ -666,8 +701,16 @@ function createFileOpsApi(ctx) {
|
||||
};
|
||||
}
|
||||
} catch { /* optional */ }
|
||||
// Transport-managed / session-backed: close channel + return lease only.
|
||||
if (client.__netcattyRefHolder || client.__netcattyTransportManaged || client.__netcattySessionBacked) {
|
||||
// Alias handles share the login channel. Closing the transfer id must
|
||||
// not end that channel or the SFTP page listing dies with the upload.
|
||||
// Releasing the owner lease lets a deferred closeSftp(owner) finish
|
||||
// only after every borrower is gone.
|
||||
if (client.__netcattySharedChannelAlias) {
|
||||
client.__netcattyDisposed = true;
|
||||
client.sftp = null;
|
||||
try { await client.end?.(); } catch { /* alias end only detaches */ }
|
||||
releaseSharedChannelOwner(client);
|
||||
} else if (client.__netcattyRefHolder || client.__netcattyTransportManaged || client.__netcattySessionBacked) {
|
||||
try {
|
||||
if (client.sftp && typeof client.sftp.end === "function") client.sftp.end();
|
||||
else if (client.sftp && typeof client.sftp.close === "function") client.sftp.close();
|
||||
|
||||
@@ -77,6 +77,17 @@ function shouldRegisterFreshSftpTransport(options) {
|
||||
return allowsSharedSftpTransport(options);
|
||||
}
|
||||
|
||||
function sftpChannelIsLive(sftp) {
|
||||
// A closed ssh2 SFTP stream still has readdir/stat/open. readable/destroyed
|
||||
// are what change when the subsystem or the socket has already ended.
|
||||
return !!sftp
|
||||
&& sftp.readable !== false
|
||||
&& sftp.destroyed !== true
|
||||
&& typeof sftp.readdir === "function"
|
||||
&& typeof sftp.stat === "function"
|
||||
&& typeof sftp.open === "function";
|
||||
}
|
||||
|
||||
/** Sudo is part of the requested connection contract; never downgrade it. */
|
||||
async function openRequiredSudoSftp({
|
||||
connectSudoSftp,
|
||||
@@ -239,6 +250,12 @@ function createOpenConnectionApi(ctx) {
|
||||
port: jump.port || 22,
|
||||
knownHosts: options.knownHosts,
|
||||
verifyHostKeys: jump.verifyHostKeys ?? options.verifyHostKeys,
|
||||
unattended: options.hostKeyUnattended === true,
|
||||
onAccepted: (info) => {
|
||||
if (!info) return;
|
||||
if (!Array.isArray(options._acceptedHostKeys)) options._acceptedHostKeys = [];
|
||||
options._acceptedHostKeys.push(info);
|
||||
},
|
||||
});
|
||||
|
||||
// Auth - support agent (certificate), key, and password fallback
|
||||
@@ -768,6 +785,142 @@ function createOpenConnectionApi(ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A live SFTP channel that was not parked in the SSH transport registry
|
||||
* (single-channel bastion, or sudo elevation). Another open must borrow
|
||||
* that channel. A second subsystem drops TERM-SSHD / similar logins, and
|
||||
* a fresh SSH dial would ask for OTP again.
|
||||
*/
|
||||
function hopsRequiringHostKeyCheck(request) {
|
||||
const hops = [];
|
||||
const add = (hostname, port) => {
|
||||
const normalized = String(hostname || "").trim().toLowerCase();
|
||||
if (!normalized) return;
|
||||
hops.push({ hostname: normalized, port: Number(port) || 22 });
|
||||
};
|
||||
if (request?.verifyHostKeys !== false) add(request?.hostname, request?.port);
|
||||
for (const hop of Array.isArray(request?.jumpHosts) ? request.jumpHosts : []) {
|
||||
if ((hop?.verifyHostKeys ?? request?.verifyHostKeys) === false) continue;
|
||||
add(hop?.hostname, hop?.port);
|
||||
}
|
||||
return hops;
|
||||
}
|
||||
|
||||
function acceptedHostKeysStillTrusted(source, request) {
|
||||
// Every hop whose effective policy still verifies must have an accepted
|
||||
// key that classifies as trusted. Disabling the target does not skip a
|
||||
// jump host that explicitly verifies.
|
||||
const required = hopsRequiringHostKeyCheck(request);
|
||||
if (required.length === 0) return true;
|
||||
const keys = source?.__netcattyAcceptedHostKeys;
|
||||
if (!Array.isArray(keys) || keys.length === 0) return false;
|
||||
const knownHosts = Array.isArray(request?.knownHosts) ? request.knownHosts : [];
|
||||
return required.every((hop) => keys.some((key) => {
|
||||
const hostname = String(key?.hostname || "").trim().toLowerCase();
|
||||
const port = Number(key?.port) || 22;
|
||||
if (hostname !== hop.hostname || port !== hop.port) return false;
|
||||
return hostKeyVerifier.classifyHostKey({
|
||||
knownHosts,
|
||||
hostname: key.hostname,
|
||||
port: key.port || 22,
|
||||
keyType: key.keyType,
|
||||
fingerprint: key.fingerprint,
|
||||
}).status === "trusted";
|
||||
}));
|
||||
}
|
||||
|
||||
function authenticatedChannelMatches(client, endpoint, request) {
|
||||
const exact = buildEndpointKey(endpoint);
|
||||
const exactMatch = !!(exact && client.__netcattyEndpointKey === exact);
|
||||
if (!exactMatch) {
|
||||
// The known-host digest can change when a still-valid key is saved, or
|
||||
// when an unrelated record is added. That may reuse the live channel.
|
||||
if (typeof buildChannelAdoptionKey !== "function") return false;
|
||||
const adoption = buildChannelAdoptionKey(endpoint);
|
||||
if (!adoption || client.__netcattyChannelAdoptionKey !== adoption) return false;
|
||||
}
|
||||
// Exact endpoint keys are not enough. A session trusted from an empty
|
||||
// list and a later open that is also empty (the key was removed) share
|
||||
// a digest, and must still be checked against the caller's records.
|
||||
return acceptedHostKeysStillTrusted(client, request);
|
||||
}
|
||||
|
||||
function channelContractMatches(options, source) {
|
||||
if (Boolean(options?.sudo) !== Boolean(source?.__netcattySudoMode)) return false;
|
||||
const requested = normalizeFileProtocol(options?.fileProtocol);
|
||||
const owner = source?.__netcattyFileProtocol === "scp" ? "scp" : "sftp";
|
||||
// auto follows the live channel. A forced protocol must be the same one
|
||||
// already open, or SCP would silently come back as SFTP.
|
||||
if (requested === "auto") return true;
|
||||
return requested === owner;
|
||||
}
|
||||
|
||||
function findAuthenticatedChannelOwner(endpoint, request) {
|
||||
if (!endpoint || !sftpClients || typeof sftpClients.entries !== "function") return null;
|
||||
for (const [sftpId, client] of sftpClients.entries()) {
|
||||
if (!sftpId || !client || client.__netcattyDisposed || client.__netcattySharedChannelAlias) continue;
|
||||
if (client.__netcattyTransportManaged) continue;
|
||||
if (!authenticatedChannelMatches(client, endpoint, request)) continue;
|
||||
if (!client.__netcattySingleChannelSsh && !client.__netcattySudoMode) continue;
|
||||
if (!sftpChannelIsLive(client.sftp)) continue;
|
||||
return { sftpId, client };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function adoptAuthenticatedSftpChannel(endpoint, connId, options) {
|
||||
const found = findAuthenticatedChannelOwner(endpoint, options);
|
||||
if (!found || typeof createSessionBackedSftpClient !== "function") return null;
|
||||
const { sftpId: ownerId, client: source } = found;
|
||||
if (!channelContractMatches(options, source)) return null;
|
||||
// A pane reconnect can ask for the owner's own id while a transfer still
|
||||
// holds that soft-closed session. Storing the alias under that id would
|
||||
// drop the SSH client and lease the id to itself, so closeSftp could
|
||||
// never hard-close it.
|
||||
const aliasId = connId === ownerId ? `${ownerId}:reconnect:${randomUUID()}` : connId;
|
||||
let leaseStore = null;
|
||||
let leaseId = null;
|
||||
try {
|
||||
const lease = require("../sftpTransferSessionLease.cjs");
|
||||
leaseStore = lease.sftpTransferSessionLeaseStore;
|
||||
leaseId = lease.channelAliasLeaseId(aliasId);
|
||||
// Hold the owner until this alias is torn down. closeSftp(owner) then
|
||||
// soft-closes instead of ending the only SFTP channel mid-transfer.
|
||||
if (!leaseStore.acquire(ownerId, leaseId)) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const alias = createSessionBackedSftpClient(aliasId, source.client, {
|
||||
singleChannelSsh: true,
|
||||
});
|
||||
alias.sftp = source.sftp;
|
||||
alias.__netcattyFileProtocol = source.__netcattyFileProtocol || "sftp";
|
||||
alias.__netcattySudoMode = !!source.__netcattySudoMode;
|
||||
// Keep uploads on this channel. A disposable subsystem would either drop
|
||||
// the single-channel login or bypass sudo elevation.
|
||||
alias.__netcattySingleChannelSsh = true;
|
||||
alias.__netcattySharedChannelAlias = true;
|
||||
alias.__netcattySharedChannelOwnerId = ownerId;
|
||||
alias.__netcattySharedChannelAliasId = aliasId;
|
||||
alias.__netcattyEndpointKey = source.__netcattyEndpointKey;
|
||||
alias.__netcattyChannelAdoptionKey = source.__netcattyChannelAdoptionKey;
|
||||
alias.__netcattyTransportManaged = false;
|
||||
alias.end = async () => {
|
||||
alias.__netcattyDisposed = true;
|
||||
alias.sftp = null;
|
||||
};
|
||||
sftpClients.set(aliasId, alias);
|
||||
return {
|
||||
sftpId: aliasId,
|
||||
fileProtocol: alias.__netcattyFileProtocol === "scp" ? "scp" : "sftp",
|
||||
};
|
||||
} catch (err) {
|
||||
try { leaseStore?.release(ownerId, leaseId); } catch { /* ignore */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a new SFTP connection
|
||||
* Supports jump host connections when options.jumpHosts is provided
|
||||
@@ -779,6 +932,32 @@ function createOpenConnectionApi(ctx) {
|
||||
});
|
||||
let pendingDialCoordination = options._pendingDialCoordination || null;
|
||||
|
||||
// Same rejection as openSftpForSession / the post-ready fresh path.
|
||||
// Doing it before adoption stops {sudo, fileProtocol:'scp'} from
|
||||
// borrowing an elevated SFTP channel and reporting success.
|
||||
if (Boolean(options.sudo) && normalizeFileProtocol(options.fileProtocol) === "scp") {
|
||||
throw new Error(
|
||||
"Sudo Mode is not supported with File Protocol set to SCP. Disable Sudo Mode or use Auto/SFTP.",
|
||||
);
|
||||
}
|
||||
|
||||
// Do this before dial coordination. An in-flight single-channel login is
|
||||
// the dial leader and never registers a transport; joining it waits
|
||||
// forever and never shows keyboard-interactive.
|
||||
if (options.reuseTransport !== false && !pendingDialCoordination) {
|
||||
const adopted = adoptAuthenticatedSftpChannel(reuseEndpoint, connId, options);
|
||||
if (adopted) {
|
||||
sendSftpProgress(
|
||||
event.sender,
|
||||
connId,
|
||||
options.hostname,
|
||||
"connected",
|
||||
"reused authenticated SFTP session",
|
||||
);
|
||||
return adopted;
|
||||
}
|
||||
}
|
||||
|
||||
const openOnSharedTransport = async (transport, detail = "reusing shared SSH transport") => {
|
||||
if (
|
||||
options.singleChannelSsh
|
||||
@@ -871,6 +1050,20 @@ function createOpenConnectionApi(ctx) {
|
||||
: await waitForTransportDial(coordination);
|
||||
return await openOnSharedTransport(transport, "reused coordinated transport");
|
||||
} catch (coordinationErr) {
|
||||
// The leader may have published a live channel and then rejected
|
||||
// this dial (single-channel bastion, or sudo which is not parked).
|
||||
// Adopt that channel before failing the transfer or dialing again.
|
||||
const adopted = adoptAuthenticatedSftpChannel(reuseEndpoint, connId, options);
|
||||
if (adopted) {
|
||||
sendSftpProgress(
|
||||
event.sender,
|
||||
connId,
|
||||
options.hostname,
|
||||
"connected",
|
||||
"reused authenticated SFTP session",
|
||||
);
|
||||
return adopted;
|
||||
}
|
||||
if (
|
||||
coordination.role === "join"
|
||||
&& coordinationErr?.code !== "ERR_SFTP_SINGLE_CHANNEL_BASTION"
|
||||
@@ -898,6 +1091,9 @@ function createOpenConnectionApi(ctx) {
|
||||
const client = new SftpClient();
|
||||
let peerSingleChannelSsh = options.singleChannelSsh === true;
|
||||
client.__netcattyEndpointKey = buildEndpointKey(reuseEndpoint);
|
||||
if (typeof buildChannelAdoptionKey === "function") {
|
||||
client.__netcattyChannelAdoptionKey = buildChannelAdoptionKey(reuseEndpoint);
|
||||
}
|
||||
let freshClientClosed = false;
|
||||
const closeFreshClient = () => {
|
||||
if (freshClientClosed) return;
|
||||
@@ -994,6 +1190,12 @@ function createOpenConnectionApi(ctx) {
|
||||
port: options.port || 22,
|
||||
knownHosts: options.knownHosts,
|
||||
verifyHostKeys: options.verifyHostKeys,
|
||||
unattended: options.hostKeyUnattended === true,
|
||||
onAccepted: (info) => {
|
||||
if (!info) return;
|
||||
if (!Array.isArray(options._acceptedHostKeys)) options._acceptedHostKeys = [];
|
||||
options._acceptedHostKeys.push(info);
|
||||
},
|
||||
});
|
||||
|
||||
// Use the tunneled socket if we have one
|
||||
@@ -1400,7 +1602,25 @@ function createOpenConnectionApi(ctx) {
|
||||
if (client.client) {
|
||||
client.client.__netcattySingleChannelSsh = peerSingleChannelSsh;
|
||||
}
|
||||
if (Array.isArray(options._acceptedHostKeys) && options._acceptedHostKeys.length > 0) {
|
||||
client.__netcattyAcceptedHostKeys = options._acceptedHostKeys.slice();
|
||||
}
|
||||
sftpClients.set(connId, client);
|
||||
|
||||
// Publish the live channel before rejecting waiters. A single-channel
|
||||
// login cannot join the transport registry; leaving the dial pending
|
||||
// makes the next SFTP-page transfer wait forever with no OTP prompt.
|
||||
if (pendingDialCoordination && !client.__netcattyTransportManaged) {
|
||||
const unshared = new Error(
|
||||
peerSingleChannelSsh || options.singleChannelSsh === true
|
||||
? "This host allows one SSH channel. Reuse the authenticated SFTP session instead of opening another."
|
||||
: "SFTP connection was not added to the shared SSH transport registry",
|
||||
);
|
||||
if (peerSingleChannelSsh || options.singleChannelSsh === true) {
|
||||
unshared.code = "ERR_SFTP_SINGLE_CHANNEL_BASTION";
|
||||
}
|
||||
failTransportDial(pendingDialCoordination, unshared);
|
||||
}
|
||||
|
||||
// Store jump connections for cleanup when SFTP is closed (legacy path
|
||||
// only — transport-managed connections park chain on the registry).
|
||||
@@ -1417,6 +1637,16 @@ function createOpenConnectionApi(ctx) {
|
||||
// Cleanup jump connections on error
|
||||
cleanupPendingConnection();
|
||||
closeFreshClient();
|
||||
if (
|
||||
options.hostKeyUnattended === true
|
||||
&& /Host denied \(verification failed\)|Host key rejected/i.test(String(err?.message || ""))
|
||||
) {
|
||||
const denied = new Error(`Host key verification required for ${options.hostname || "host"}`);
|
||||
denied.code = "ERR_HOST_KEY_UNVERIFIED";
|
||||
denied.cause = err;
|
||||
if (pendingDialCoordination) failTransportDial(pendingDialCoordination, denied);
|
||||
throw denied;
|
||||
}
|
||||
if (shouldRetrySftpKeyboardInteractiveFirst(options, authConfig, err)) {
|
||||
console.log("[SFTP] Password auth removed keyboard-interactive; retrying with keyboard-interactive first...");
|
||||
return openSftp(event, {
|
||||
|
||||
@@ -16,8 +16,18 @@
|
||||
*
|
||||
* This is intentionally independent of UI panels. Browse can go away; transfers
|
||||
* keep their leased sessions until they complete.
|
||||
*
|
||||
* A borrowed SFTP handle (single-channel / sudo alias) also acquires a lease
|
||||
* on the owner sftpId for the alias lifetime. closeSftp(owner) then soft-closes
|
||||
* until the last borrower releases, instead of ending the shared channel.
|
||||
*/
|
||||
|
||||
const CHANNEL_ALIAS_LEASE_PREFIX = "sftp-channel-alias:";
|
||||
|
||||
function channelAliasLeaseId(aliasId) {
|
||||
return `${CHANNEL_ALIAS_LEASE_PREFIX}${aliasId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} SftpTransferSessionLeaseStore
|
||||
* @property {(sftpId: string, transferId: string) => boolean} acquire
|
||||
@@ -196,4 +206,5 @@ const sftpTransferSessionLeaseStore = createSftpTransferSessionLeaseStore();
|
||||
module.exports = {
|
||||
createSftpTransferSessionLeaseStore,
|
||||
sftpTransferSessionLeaseStore,
|
||||
channelAliasLeaseId,
|
||||
};
|
||||
@@ -369,7 +369,13 @@ function parseKnownHostTarget(value) {
|
||||
* text or the full known-hosts vault.
|
||||
*/
|
||||
function fingerprintKnownHostTrust(endpoint) {
|
||||
if (!endpoint || endpoint.verifyHostKeys === false) return "disabled";
|
||||
if (!endpoint) return "disabled";
|
||||
const jumps = Array.isArray(endpoint.jumpHosts) ? endpoint.jumpHosts : [];
|
||||
// A jump host can force verification while the target turns it off. The
|
||||
// target flag alone must not drop that hop's pin from the digest.
|
||||
const anyVerification = endpoint.verifyHostKeys !== false
|
||||
|| jumps.some((hop) => (hop?.verifyHostKeys ?? endpoint.verifyHostKeys) !== false);
|
||||
if (!anyVerification) return "disabled";
|
||||
const targets = [];
|
||||
const addTarget = (hostname, port, verifyHostKeys = true) => {
|
||||
if (verifyHostKeys === false) return;
|
||||
@@ -378,7 +384,7 @@ function fingerprintKnownHostTrust(endpoint) {
|
||||
targets.push(`${normalizedHostname}|${Number(port) || 22}`);
|
||||
};
|
||||
addTarget(endpoint.hostname, endpoint.port, endpoint.verifyHostKeys);
|
||||
for (const hop of Array.isArray(endpoint.jumpHosts) ? endpoint.jumpHosts : []) {
|
||||
for (const hop of jumps) {
|
||||
addTarget(
|
||||
hop?.hostname,
|
||||
hop?.port,
|
||||
@@ -481,7 +487,12 @@ function fingerprintAuth(endpoint) {
|
||||
certificate: endpoint.certificate ? secureDigest(endpoint.certificate) : "none",
|
||||
requiresMfa: Boolean(endpoint.requiresMfa),
|
||||
verifyHostKeys: endpoint.verifyHostKeys !== false,
|
||||
knownHostTrust: fingerprintKnownHostTrust(endpoint),
|
||||
// Channel adoption may compare a sibling key that ignores this digest, then
|
||||
// checks the connection's accepted host key against the caller's records.
|
||||
// Parked transport reuse still uses the full key.
|
||||
knownHostTrust: endpoint.ignoreKnownHostTrustForReuse
|
||||
? "ignored"
|
||||
: fingerprintKnownHostTrust(endpoint),
|
||||
authPolicyVersion: endpoint.authPolicyVersion ?? null,
|
||||
useSshAgent: endpoint.useSshAgent ?? null,
|
||||
identityAgent: endpoint.identityAgent ?? null,
|
||||
@@ -628,6 +639,16 @@ function buildEndpointKey(endpoint) {
|
||||
].join("|");
|
||||
}
|
||||
|
||||
/**
|
||||
* Same route as buildEndpointKey, but the known-host trust digest is ignored.
|
||||
* Used only to find an already-authenticated SFTP channel after the renderer
|
||||
* known-host list changes (trust during this app session).
|
||||
*/
|
||||
function buildChannelAdoptionKey(endpoint) {
|
||||
if (!endpoint || typeof endpoint !== "object") return null;
|
||||
return buildEndpointKey({ ...endpoint, ignoreKnownHostTrustForReuse: true });
|
||||
}
|
||||
|
||||
function sameEndpoint(a, b) {
|
||||
const left = normalizeEndpoint(a);
|
||||
const right = normalizeEndpoint(b);
|
||||
@@ -1544,6 +1565,7 @@ module.exports = {
|
||||
setDefaultTransportIdleTtlMs,
|
||||
getDefaultTransportIdleTtlMs,
|
||||
buildEndpointKey,
|
||||
buildChannelAdoptionKey,
|
||||
buildConnectionReuseEndpoint,
|
||||
resolveConnectionKeepalivePolicy,
|
||||
normalizeEndpoint,
|
||||
|
||||
@@ -27,6 +27,7 @@ const {
|
||||
setDefaultTransportIdleTtlMs,
|
||||
getDefaultTransportIdleTtlMs,
|
||||
buildEndpointKey,
|
||||
buildChannelAdoptionKey,
|
||||
buildConnectionReuseEndpoint,
|
||||
normalizeEndpoint,
|
||||
endpointAllowsReuse,
|
||||
@@ -905,6 +906,46 @@ test("endpoint key changes when known-host trust content changes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("a jump host pin changes the endpoint key when the target disables verification", () => {
|
||||
const base = {
|
||||
hostId: "jump-pin-target-disabled",
|
||||
hostname: "target.example",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
verifyHostKeys: false,
|
||||
jumpHosts: [{ hostname: "bastion.example", port: 2222, verifyHostKeys: true }],
|
||||
};
|
||||
const pinned = (publicKey) => buildConnectionReuseEndpoint({
|
||||
...base,
|
||||
knownHosts: [{
|
||||
hostname: "[bastion.example]:2222",
|
||||
port: 2222,
|
||||
keyType: "ssh-ed25519",
|
||||
publicKey,
|
||||
}],
|
||||
});
|
||||
const first = pinned("ssh-ed25519 AAAA_JUMP_FIRST");
|
||||
const rotated = pinned("ssh-ed25519 AAAA_JUMP_ROTATED");
|
||||
assert.notEqual(buildEndpointKey(rotated), buildEndpointKey(first));
|
||||
assert.equal(buildChannelAdoptionKey(rotated), buildChannelAdoptionKey(first));
|
||||
|
||||
const bothOff = {
|
||||
...base,
|
||||
jumpHosts: [{ hostname: "bastion.example", port: 2222, verifyHostKeys: false }],
|
||||
};
|
||||
const offFirst = buildEndpointKey(buildConnectionReuseEndpoint({
|
||||
...bothOff,
|
||||
knownHosts: [{ hostname: "[bastion.example]:2222", port: 2222, publicKey: "ssh-ed25519 AAAA_JUMP_FIRST" }],
|
||||
}));
|
||||
const offRotated = buildEndpointKey(buildConnectionReuseEndpoint({
|
||||
...bothOff,
|
||||
knownHosts: [{ hostname: "[bastion.example]:2222", port: 2222, publicKey: "ssh-ed25519 AAAA_JUMP_ROTATED" }],
|
||||
}));
|
||||
assert.equal(offRotated, offFirst);
|
||||
});
|
||||
|
||||
test("endpoint key changes when an identity file changes in place", (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-pool-key-"));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
@@ -1830,6 +1830,16 @@ function execSshCommandCancellable(sshClient, command, transfer) {
|
||||
});
|
||||
}
|
||||
|
||||
function markDisposableUploadChannel(channel) {
|
||||
if (!channel || typeof channel !== "object") return channel;
|
||||
// This subsystem can die without taking the shared browse connection with it.
|
||||
try { channel.__netcattyDisposableChannel = true; } catch { /* ignore */ }
|
||||
if (channel.sftp && typeof channel.sftp === "object" && channel.sftp !== channel) {
|
||||
try { channel.sftp.__netcattyDisposableChannel = true; } catch { /* ignore */ }
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
async function openIsolatedSftpChannel(client, signal = null) {
|
||||
// The dedicated one-channel login already holds its only session channel.
|
||||
// Opening another SFTP subsystem drops that login, so stay on the browse channel.
|
||||
@@ -2468,7 +2478,9 @@ async function uploadFile(
|
||||
let isolated = null;
|
||||
if (!client.__netcattySudoMode) {
|
||||
try {
|
||||
isolated = await openIsolatedSftpChannel(client, transfer?.signal);
|
||||
isolated = markDisposableUploadChannel(
|
||||
await openIsolatedSftpChannel(client, transfer?.signal),
|
||||
);
|
||||
} catch (err) {
|
||||
rememberPipelineError(err);
|
||||
console.warn(
|
||||
@@ -2534,7 +2546,9 @@ async function uploadFile(
|
||||
|
||||
if (!isolated && !client.__netcattySudoMode) {
|
||||
try {
|
||||
isolated = await openIsolatedSftpChannel(client, transfer?.signal);
|
||||
isolated = markDisposableUploadChannel(
|
||||
await openIsolatedSftpChannel(client, transfer?.signal),
|
||||
);
|
||||
} catch (err) {
|
||||
rememberPipelineError(err);
|
||||
console.warn(
|
||||
@@ -3058,10 +3072,8 @@ function openSftpHandleForTransfer(sftp, filePath, flags, transfer, options = {}
|
||||
transfer.abort = previousAbort;
|
||||
}
|
||||
try { sftp.removeListener?.("error", onOpenChannelError); } catch { /* ignore */ }
|
||||
if (!isWriteOpen) {
|
||||
sftp.removeListener?.("end", onOpenChannelClosed);
|
||||
sftp.removeListener?.("close", onOpenChannelClosed);
|
||||
}
|
||||
sftp.removeListener?.("end", onOpenChannelClosed);
|
||||
sftp.removeListener?.("close", onOpenChannelClosed);
|
||||
fn(value);
|
||||
};
|
||||
|
||||
@@ -3119,7 +3131,9 @@ function openSftpHandleForTransfer(sftp, filePath, flags, transfer, options = {}
|
||||
};
|
||||
|
||||
const onOpenChannelClosed = () => {
|
||||
onOpenChannelError(new Error("SFTP channel closed before the source file was opened"));
|
||||
onOpenChannelError(isWriteOpen
|
||||
? sftpChannelClosedError(null, sftp)
|
||||
: new Error("SFTP channel closed before the source file was opened"));
|
||||
};
|
||||
|
||||
if (transfer.cancelled) {
|
||||
@@ -3131,15 +3145,15 @@ function openSftpHandleForTransfer(sftp, filePath, flags, transfer, options = {}
|
||||
|
||||
transfer.abort = abortDuringOpen;
|
||||
sftp.on?.("error", onOpenChannelError);
|
||||
if (!isWriteOpen) {
|
||||
sftp.on?.("end", onOpenChannelClosed);
|
||||
sftp.on?.("close", onOpenChannelClosed);
|
||||
// Write OPEN used to skip close/end. Killing the server channel then left
|
||||
// the OPEN callback unanswered, so the upload stayed active with no error.
|
||||
sftp.on?.("end", onOpenChannelClosed);
|
||||
sftp.on?.("close", onOpenChannelClosed);
|
||||
if (!isWriteOpen && sftp.readable === false) {
|
||||
// A failed isolated download may fall back after the browse channel's
|
||||
// end event already fired. ssh2 never answers a new OPEN on that channel.
|
||||
if (sftp.readable === false) {
|
||||
onOpenChannelClosed();
|
||||
return;
|
||||
}
|
||||
onOpenChannelClosed();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -3398,10 +3412,13 @@ async function readSftpRange(sftp, handle, buffer, position, length, options = {
|
||||
let settled = false;
|
||||
let timer = null;
|
||||
let unwatchAbort = null;
|
||||
let unwatchDeath = () => {};
|
||||
const cleanup = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
unwatchAbort?.();
|
||||
unwatchAbort = null;
|
||||
try { unwatchDeath(); } catch { /* ignore */ }
|
||||
unwatchDeath = () => {};
|
||||
signal?.removeEventListener?.("abort", onAbort);
|
||||
};
|
||||
const finish = (error, count) => {
|
||||
@@ -3411,6 +3428,7 @@ async function readSftpRange(sftp, handle, buffer, position, length, options = {
|
||||
if (error) reject(error);
|
||||
else resolve(Number(count) || 0);
|
||||
};
|
||||
unwatchDeath = watchSftpChannelDeath(sftp, (error) => finish(error));
|
||||
const onAbort = () => {
|
||||
const error = new Error("Transfer cancelled");
|
||||
error.code = "ABORT_ERR";
|
||||
@@ -3440,7 +3458,7 @@ async function readSftpRange(sftp, handle, buffer, position, length, options = {
|
||||
received,
|
||||
length - received,
|
||||
position + received,
|
||||
(error, count) => finish(error, count),
|
||||
(error, count) => finish(error ? normalizeSftpChannelLoss(sftp, error) : error, count),
|
||||
);
|
||||
} catch (error) {
|
||||
finish(error);
|
||||
@@ -3680,12 +3698,181 @@ async function readLocalRange(fileHandle, buffer, position, length) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One close/error listener per channel. ssh2 does not invoke pending READ/WRITE
|
||||
* callbacks when the owner connection is destroyed, so each in-flight request
|
||||
* must fail itself or the transfer stays at a partial percent forever.
|
||||
* EOF (`end`) and the parent SSH client/socket are included: killing the
|
||||
* server SFTP process often ends the channel without a client-side `error`,
|
||||
* and a TCP reset is delivered on the SSH client rather than the SFTP stream.
|
||||
*/
|
||||
const sftpChannelDeathWaiters = new WeakMap();
|
||||
|
||||
function disposableUploadChannel(sftp) {
|
||||
return sftp?.__netcattyDisposableChannel === true
|
||||
|| sftp?.sftp?.__netcattyDisposableChannel === true;
|
||||
}
|
||||
|
||||
function sftpChannelClosedError(cause, sftp) {
|
||||
const error = new Error("SFTP channel closed");
|
||||
error.code = "ERR_SFTP_CHANNEL_CLOSED";
|
||||
error.sftpChannelClosed = true;
|
||||
// The owner/borrowed/sudo channel is gone: do not fall through into another
|
||||
// dial. A disposable upload subsystem can still fail over to the live browse
|
||||
// channel.
|
||||
if (!disposableUploadChannel(sftp)) error.noTransferFallback = true;
|
||||
if (cause) error.cause = cause;
|
||||
return error;
|
||||
}
|
||||
|
||||
function isSftpChannelLossError(error) {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
if (error.sftpChannelClosed) return true;
|
||||
const code = String(error.code || "");
|
||||
if (
|
||||
code === "ECONNRESET"
|
||||
|| code === "EPIPE"
|
||||
|| code === "ECONNABORTED"
|
||||
|| code === "ERR_SFTP_CHANNEL_CLOSED"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const message = String(error.message || "");
|
||||
return /no response from server/i.test(message)
|
||||
|| /socket hang up/i.test(message)
|
||||
|| /connection reset/i.test(message)
|
||||
|| error.level === "client-socket";
|
||||
}
|
||||
|
||||
function normalizeSftpChannelLoss(sftp, error) {
|
||||
if (!error) return error;
|
||||
if (error.sftpChannelClosed && (error.noTransferFallback || disposableUploadChannel(sftp))) {
|
||||
return error;
|
||||
}
|
||||
const dead = !sftp || sftp.readable === false || sftp.destroyed === true;
|
||||
if (dead || isSftpChannelLossError(error)) return sftpChannelClosedError(error, sftp);
|
||||
return error;
|
||||
}
|
||||
|
||||
function sftpChannelDeathTargets(sftp) {
|
||||
const targets = [];
|
||||
const add = (value) => {
|
||||
if (!value || typeof value.on !== "function" || targets.includes(value)) return;
|
||||
targets.push(value);
|
||||
};
|
||||
// ssh2-sftp-client `.on` listens on the SSH connection. Channel close/end
|
||||
// is emitted on the inner stream the borrowed owner actually writes.
|
||||
if (sftp?.sftp && sftp.sftp !== sftp) add(sftp.sftp);
|
||||
add(sftp);
|
||||
return targets;
|
||||
}
|
||||
|
||||
function rejectPendingSftpRequests(sftp, error) {
|
||||
const requests = sftp?._requests;
|
||||
if (!requests || typeof requests !== "object") return;
|
||||
const pending = requests;
|
||||
sftp._requests = {};
|
||||
for (const key of Object.keys(pending)) {
|
||||
const callback = pending[key]?.cb;
|
||||
if (typeof callback !== "function") continue;
|
||||
try { callback(error); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function watchSftpChannelDeath(sftp, onDeath) {
|
||||
const channels = sftpChannelDeathTargets(sftp);
|
||||
const primary = channels[0];
|
||||
if (!primary) return () => {};
|
||||
let entry = sftpChannelDeathWaiters.get(primary);
|
||||
if (!entry) {
|
||||
const waiters = new Set();
|
||||
const fail = (error, { flushRequests = false } = {}) => {
|
||||
const wrapped = normalizeSftpChannelLoss(
|
||||
primary,
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error?.message || error || "SFTP channel error")),
|
||||
);
|
||||
// CHANNEL_CLOSE emits close/end and leaves READ/WRITE without a reply.
|
||||
// A normal SFTP status error must not cancel the other in-flight requests.
|
||||
if (flushRequests) {
|
||||
for (const channel of channels) rejectPendingSftpRequests(channel, wrapped);
|
||||
}
|
||||
for (const waiter of [...waiters]) waiter(wrapped);
|
||||
};
|
||||
const client = primary._client || sftp?._client;
|
||||
entry = {
|
||||
waiters,
|
||||
channels,
|
||||
client,
|
||||
onError: (error) => fail(error || new Error("SFTP channel error")),
|
||||
onClose: () => fail(sftpChannelClosedError(null, sftp), { flushRequests: true }),
|
||||
onClientError: (error) => {
|
||||
if (isSftpChannelLossError(error)) fail(error || sftpChannelClosedError());
|
||||
},
|
||||
};
|
||||
for (const channel of channels) {
|
||||
channel.on("error", entry.onError);
|
||||
channel.on("close", entry.onClose);
|
||||
channel.on("end", entry.onClose);
|
||||
}
|
||||
if (client && typeof client.on === "function" && !channels.includes(client)) {
|
||||
client.on("close", entry.onClose);
|
||||
client.on("end", entry.onClose);
|
||||
client.on("error", entry.onClientError);
|
||||
}
|
||||
sftpChannelDeathWaiters.set(primary, entry);
|
||||
}
|
||||
entry.waiters.add(onDeath);
|
||||
// end/close is not replayed. OPEN can finish, the channel can die, and only
|
||||
// then does WRITE/READ subscribe — readable is already false and the callback
|
||||
// never comes. Fail this waiter after returning so the caller can unwatch.
|
||||
const alreadyDead = channels.some((channel) => channel.readable === false || channel.destroyed === true);
|
||||
if (alreadyDead) {
|
||||
queueMicrotask(() => {
|
||||
if (!entry.waiters.has(onDeath)) return;
|
||||
onDeath(sftpChannelClosedError(null, sftp));
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
const current = sftpChannelDeathWaiters.get(primary);
|
||||
if (!current) return;
|
||||
current.waiters.delete(onDeath);
|
||||
if (current.waiters.size > 0) return;
|
||||
for (const channel of current.channels) {
|
||||
try { channel.removeListener("error", current.onError); } catch { /* ignore */ }
|
||||
try { channel.removeListener("close", current.onClose); } catch { /* ignore */ }
|
||||
try { channel.removeListener("end", current.onClose); } catch { /* ignore */ }
|
||||
}
|
||||
if (current.client && typeof current.client.removeListener === "function") {
|
||||
try { current.client.removeListener("close", current.onClose); } catch { /* ignore */ }
|
||||
try { current.client.removeListener("end", current.onClose); } catch { /* ignore */ }
|
||||
try { current.client.removeListener("error", current.onClientError); } catch { /* ignore */ }
|
||||
}
|
||||
if (sftpChannelDeathWaiters.get(primary) === current) sftpChannelDeathWaiters.delete(primary);
|
||||
};
|
||||
}
|
||||
|
||||
function writeSftpRange(sftp, handle, buffer, position, length) {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.write(handle, buffer, 0, length, position, (error) => {
|
||||
let settled = false;
|
||||
let unwatch = () => {};
|
||||
const finish = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { unwatch(); } catch { /* ignore */ }
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
};
|
||||
unwatch = watchSftpChannelDeath(sftp, finish);
|
||||
try {
|
||||
sftp.write(handle, buffer, 0, length, position, (error) => {
|
||||
if (error) finish(normalizeSftpChannelLoss(sftp, error));
|
||||
else finish();
|
||||
});
|
||||
} catch (error) {
|
||||
finish(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4729,7 +4916,10 @@ async function uploadFileConcurrent(
|
||||
// CLOSE request would hang forever with no callback from ssh2.
|
||||
if (remoteHandle) {
|
||||
const skipClose = disposeChannel && (failed || transfer.cancelled);
|
||||
if (!skipClose) {
|
||||
// A closed channel never answers CLOSE. Skip it; the 2s race is only for
|
||||
// a live shared session. Falling through to another strategy on a dead
|
||||
// channel is handled by noTransferFallback on the close error.
|
||||
if (!skipClose && sftp?.readable !== false) {
|
||||
let closeTimeout = null;
|
||||
try {
|
||||
await Promise.race([
|
||||
|
||||
@@ -13092,6 +13092,158 @@ test("single-channel SFTP upload does not open a second subsystem", async (t) =>
|
||||
assert.deepEqual(remoteFiles.get(targetPath), payload);
|
||||
});
|
||||
|
||||
test("a closed disposable upload channel falls back once to the healthy shared channel", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-disposable-upload-fallback-"));
|
||||
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
const localPath = path.join(tempDir, "fresh.bin");
|
||||
const payload = Buffer.from("disposable-fallback");
|
||||
await fs.promises.writeFile(localPath, payload);
|
||||
const targetPath = "/tmp/fresh.bin";
|
||||
const remoteFiles = new Map();
|
||||
let isolatedOpens = 0;
|
||||
let sharedWrites = 0;
|
||||
|
||||
const rememberFile = (sftp) => {
|
||||
sftp.lstat = (remotePath, callback) => {
|
||||
const key = String(remotePath);
|
||||
if (!remoteFiles.has(key)) {
|
||||
const error = new Error("ENOENT");
|
||||
error.code = 2;
|
||||
callback(error);
|
||||
return;
|
||||
}
|
||||
callback(null, {
|
||||
size: remoteFiles.get(key).length,
|
||||
mode: 0o100644,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
});
|
||||
};
|
||||
sftp.open = (remotePath, _flags, callback) => {
|
||||
const key = String(remotePath);
|
||||
if (!remoteFiles.has(key)) remoteFiles.set(key, Buffer.alloc(0));
|
||||
callback(null, Buffer.from(key));
|
||||
};
|
||||
sftp.close = (_handle, callback) => callback(null);
|
||||
};
|
||||
|
||||
const shared = createFastSftp({});
|
||||
rememberFile(shared);
|
||||
shared.write = (handle, buffer, offset, length, position, callback) => {
|
||||
sharedWrites += 1;
|
||||
const key = handle.toString();
|
||||
const next = Buffer.alloc(Math.max(remoteFiles.get(key)?.length || 0, position + length));
|
||||
if (remoteFiles.has(key)) remoteFiles.get(key).copy(next);
|
||||
buffer.copy(next, position, offset, offset + length);
|
||||
remoteFiles.set(key, next);
|
||||
setImmediate(() => callback(null));
|
||||
};
|
||||
|
||||
const client = {
|
||||
sftp: shared,
|
||||
stat: async (remotePath) => {
|
||||
const key = String(remotePath);
|
||||
if (!remoteFiles.has(key)) {
|
||||
const error = new Error("ENOENT");
|
||||
error.code = 2;
|
||||
throw error;
|
||||
}
|
||||
return { size: remoteFiles.get(key).length, isDirectory: false };
|
||||
},
|
||||
delete: async (remotePath) => { remoteFiles.delete(String(remotePath)); },
|
||||
async rename(fromPath, toPath) {
|
||||
remoteFiles.set(String(toPath), remoteFiles.get(String(fromPath)));
|
||||
remoteFiles.delete(String(fromPath));
|
||||
},
|
||||
client: {
|
||||
sftp(callback) {
|
||||
isolatedOpens += 1;
|
||||
const isolated = createFastSftp({});
|
||||
rememberFile(isolated);
|
||||
isolated.write = () => {
|
||||
isolated.readable = false;
|
||||
isolated.emit("close");
|
||||
};
|
||||
callback(null, isolated);
|
||||
},
|
||||
},
|
||||
};
|
||||
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
||||
|
||||
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId: "disposable-upload-fallback",
|
||||
sourcePath: localPath,
|
||||
targetPath,
|
||||
sourceType: "local",
|
||||
targetType: "sftp",
|
||||
targetSftpId: "target",
|
||||
totalBytes: payload.length,
|
||||
resumable: false,
|
||||
});
|
||||
|
||||
assert.equal(result.error, undefined, result.error);
|
||||
assert.ok(isolatedOpens >= 1);
|
||||
assert.ok(sharedWrites >= 1);
|
||||
assert.deepEqual(remoteFiles.get(targetPath), payload);
|
||||
});
|
||||
|
||||
for (const mode of ["single-channel", "sudo"]) {
|
||||
test(`a closed ${mode} owner channel fails the upload without another dial`, async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `netcatty-${mode}-owner-close-`));
|
||||
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
const localPath = path.join(tempDir, "fresh.bin");
|
||||
const payload = Buffer.from("owner-channel-closed");
|
||||
await fs.promises.writeFile(localPath, payload);
|
||||
let secondChannelOpens = 0;
|
||||
const shared = createFastSftp({
|
||||
open(_remotePath, _flags, callback) {
|
||||
callback(null, Buffer.from("remote-handle"));
|
||||
},
|
||||
write() {
|
||||
shared.readable = false;
|
||||
shared.emit("close");
|
||||
},
|
||||
close(_handle, callback) {
|
||||
callback(null);
|
||||
},
|
||||
});
|
||||
const client = {
|
||||
__netcattySingleChannelSsh: mode === "single-channel",
|
||||
__netcattySudoMode: mode === "sudo",
|
||||
sftp: shared,
|
||||
stat: async () => ({ size: 0, isDirectory: false }),
|
||||
delete: async () => {},
|
||||
rename: async () => {},
|
||||
client: {
|
||||
sftp(callback) {
|
||||
secondChannelOpens += 1;
|
||||
callback(new Error("second SFTP channel must not open"));
|
||||
},
|
||||
},
|
||||
};
|
||||
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
||||
|
||||
const result = await Promise.race([
|
||||
transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId: `${mode}-owner-channel-closed`,
|
||||
sourcePath: localPath,
|
||||
targetPath: "/tmp/fresh.bin",
|
||||
sourceType: "local",
|
||||
targetType: "sftp",
|
||||
targetSftpId: "target",
|
||||
totalBytes: payload.length,
|
||||
resumable: false,
|
||||
}),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error("upload hung after the owner channel closed")), 3000);
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.match(result.error || "", /SFTP channel closed/);
|
||||
assert.equal(secondChannelOpens, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("single-channel SFTP download does not open a second subsystem", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-single-channel-download-"));
|
||||
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
|
||||
Vendored
+7
@@ -128,6 +128,13 @@ declare global {
|
||||
passphrase?: string;
|
||||
knownHosts?: import("./domain/models").KnownHost[];
|
||||
verifyHostKeys?: boolean;
|
||||
/**
|
||||
* When true, an untrusted host key fails the dial instead of waiting for
|
||||
* a renderer prompt. Transfer opens set this: their session id is not the
|
||||
* SFTP page's host-key dialog, so an unanswered prompt leaves the upload
|
||||
* on "Waiting...".
|
||||
*/
|
||||
hostKeyUnattended?: boolean;
|
||||
// Environment variables to set in the remote shell
|
||||
env?: Record<string, string>;
|
||||
// Proxy configuration
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
|
||||
// Use native ESM linking, rather than Node's CommonJS interop or a bundler,
|
||||
// so a missing browser export fails exactly as it does in the renderer.
|
||||
async function checkDevImport() {
|
||||
const { createServer } = await import("vite");
|
||||
const { SourceTextModule, createContext } = await import("node:vm");
|
||||
const { mkdtempSync, rmSync } = await import("node:fs");
|
||||
const { createRequire } = await import("node:module");
|
||||
const assert = (await import("node:assert/strict")).default;
|
||||
const require = createRequire(`${process.cwd()}/package.json`);
|
||||
const temp = require("./electron/bridges/tempDirBridge.cjs");
|
||||
const cacheDir = mkdtempSync(`${temp.getTempFilePath("container-runtime-vite")}-`);
|
||||
let server;
|
||||
try {
|
||||
server = await createServer({
|
||||
cacheDir,
|
||||
logLevel: "error",
|
||||
server: { port: 0, strictPort: false, open: false },
|
||||
optimizeDeps: { entries: [], noDiscovery: true },
|
||||
});
|
||||
await server.listen();
|
||||
const origin = server.resolvedUrls.local[0];
|
||||
const context = createContext({});
|
||||
const modules = new Map();
|
||||
async function load(url) {
|
||||
if (!modules.has(url)) {
|
||||
modules.set(url, (async () => {
|
||||
const response = await fetch(url);
|
||||
assert.equal(response.status, 200, url);
|
||||
return new SourceTextModule(await response.text(), { context, identifier: url });
|
||||
})());
|
||||
}
|
||||
return modules.get(url);
|
||||
}
|
||||
const entry = await load(new URL("/domain/systemManager/containerRuntime.ts", origin).href);
|
||||
await entry.link((specifier, importer) => load(new URL(specifier, importer.identifier).href));
|
||||
await entry.evaluate();
|
||||
const runtime = entry.namespace;
|
||||
assert.equal(runtime.firstContainerRuntime(["podman"]), "podman");
|
||||
assert.equal(runtime.buildContainerExecShellCommand("podman", "abc").includes("podman exec"), true);
|
||||
assert.equal(runtime.describeRuntimeFailure({ errorCode: "nerdctlUnreachable" }, key => key, "fallback"), "systemManager.errors.nerdctlUnreachable");
|
||||
assert.equal(runtime.describeRuntimeFailure({ error: "original error" }, key => key, "fallback"), "original error");
|
||||
assert.equal(runtime.describeRuntimeFailure(null, key => key, "fallback"), "fallback");
|
||||
} finally {
|
||||
await server?.close();
|
||||
rmSync(cacheDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("Vite serves the renderer container runtime with browser-compatible exports", { timeout: 60_000 }, () => {
|
||||
const result = spawnSync(process.execPath, [
|
||||
"--experimental-vm-modules", "--input-type=module", "-e",
|
||||
`(${checkDevImport.toString()})().catch(error => { console.error(error); process.exitCode = 1; });`,
|
||||
], { cwd: path.resolve(__dirname, ".."), encoding: "utf8", timeout: 55_000 });
|
||||
assert.equal(result.status, 0, result.error?.message || `${result.stdout}\n${result.stderr}`);
|
||||
});
|
||||
@@ -83,6 +83,10 @@ export default defineConfig(() => {
|
||||
},
|
||||
},
|
||||
build: {
|
||||
// The shared runtime is also required by Electron; convert it for browser builds.
|
||||
commonjsOptions: {
|
||||
include: [/node_modules/, /domain\/systemManager\/containerRuntime\.shared\.cjs$/],
|
||||
},
|
||||
chunkSizeWarningLimit: 3000,
|
||||
target: 'esnext', // Required for top-level await in WASM modules
|
||||
sourcemap: false, // Disable source maps to avoid missing map file warnings
|
||||
@@ -124,6 +128,8 @@ export default defineConfig(() => {
|
||||
plugins: [suppressMonacoSourcemapWarning(), devContentSecurityPolicyPlugin(), warnOnOutdatedOptimizeDep(), tailwindcss(), react()],
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
// Keep the renderer alias pre-bundled so dev serves ESM instead of raw CommonJS.
|
||||
'@/domain/systemManager/containerRuntime.shared.cjs',
|
||||
'ai',
|
||||
'@ai-sdk/openai',
|
||||
'@ai-sdk/anthropic',
|
||||
|
||||
Reference in new issue
Block a user