Compare commits

...
Author SHA1 Message Date
bincxz b3bf7c25fc fix: load shared container runtime in Vite renderer 2026-10-21 11:03:51 +08:00
陈大猫 d350b02f78 feat(vault): marquee-select hosts and drag the selection onto groups (#3789)
Closes #3194
2026-10-21 09:52:30 +08:00
陈大猫 d8f43a7138 fix(sftp): reuse authenticated SFTP-page session for OTP transfers (#3801)
Fixes #3310

Borrow the live SFTP channel on single-channel OTP banners so transfers do not hang waiting for a second keyboard-interactive prompt. Channel death fails with SFTP channel closed without OTP redial; disposable upload channels may fall back once onto a healthy shared browse channel.
2026-10-21 08:14:51 +08:00
陈大猫 34c3431483 fix(ai): guard OpenCode v2 privacy handoffs (#3808)
Carry the existing shared session-validity guard to OpenCode v2 instruction and prompt sends after startup waits. Consume rejected SSE reader cancellation during fail-closed cleanup.

Add deterministic regressions for the provider handoffs and cancellation, with real loopback HTTP/SSE smoke coverage.

Refs #3620, #3805, #3802
2026-10-21 01:58:08 +08:00
42 changed files with 3409 additions and 90 deletions

No files matched your search

+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
+1
View File
@@ -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': '设置你的主机',
+1
View File
@@ -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(
+3
View File
@@ -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 -3
View File
@@ -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;
+30 -12
View File
@@ -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,
+1
View File
@@ -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", () => {
+26 -6
View File
@@ -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
+9
View File
@@ -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",
+78 -18
View File
@@ -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);
+3
View File
@@ -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 => {
+277
View File
@@ -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 };
}
+42
View File
@@ -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;
}
+52
View File
@@ -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[] = [];
+24 -7
View File
@@ -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;
};
+3 -3
View File
@@ -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,
+111
View File
@@ -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);
});
+83
View File
@@ -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;
}
+175
View File
@@ -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']) },
);
});
+182
View File
@@ -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),
};
}
+4 -2
View File
@@ -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; });
+27 -1
View File
@@ -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);
+25 -8
View File
@@ -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
+45 -2
View File
@@ -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,
};
+25 -3
View File
@@ -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 }));
+208 -18
View File
@@ -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([
+152
View File
@@ -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
View File
@@ -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
+62
View File
@@ -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}`);
});
+6
View File
@@ -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',