Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 ad5893033b fix(vault): forward marquee and bulk-move callbacks to the host list
The layout already owned these handlers, but the host-list context
omitted them, so the rectangle never changed selection and a multi-host
drop could not publish one batched move.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 07:09:37 +00:00
Cursor Agentand陈大猫 be2fdc3ca1 feat(vault): marquee-select hosts and drag the selection onto groups
Rubber-band selection in the grid and list enters multi-select after a
small drag on empty space. Dragging an already selected host carries
every selected id and drops them through the existing batched group move.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 07:04:10 +00:00
20 changed files with 1130 additions and 31 deletions

No files matched your search

+1
View File
@@ -1176,6 +1176,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
@@ -1135,6 +1135,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': '設定你的主機',
+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
@@ -1236,6 +1236,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
managedGroupPaths,
moveGroup,
moveHostToGroup,
moveHostsToGroup,
onDeleteHost,
Pin,
pinnedHosts,
@@ -1255,10 +1256,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;
};
+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),
};
}