Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 fa8412d70c fix: rebase queued snippet saves onto latest vault snapshot
Locking updateSnippets alone still wrote a pre-delete full array, so a
popup bulk-delete could be resurrected (or a storage-event version bump
could cancel the local edit). Rebase under the vault lock and supersede
only newer local writers via snippetsWriteOwnerRef.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-09 14:48:17 +00:00
Cursor Agentand陈大猫 7bb4e96c55 Merge remote-tracking branch 'origin/main' into cursor/fix-snippets-bulk-delete-lock-e2cf
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-09 14:44:16 +00:00
Cursor Agentand陈大猫 7a66161355 fix: serialize snippet saves with bulk-delete vault lock
updateSnippets wrote STORAGE_KEY_SNIPPETS without withVaultImportLock, so a
concurrent edit could land between bulk-delete's snapshot read and journal
commit and be discarded. Queue ordinary snippet writes on the shared lock and
include them in waitForPendingVaultWrites.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-09 14:36:20 +00:00
netcatty-bot edfe5ba517 fix: address Codex review on PR #2828 2026-08-09 05:07:53 +00:00
netcatty-bot 7bce9e55e3 fix: address Codex review on PR #2828 2026-08-09 04:41:03 +00:00
netcatty-bot 0cb692b17e fix: address Codex review on PR #2828 2026-08-09 04:07:09 +00:00
netcatty-bot 337191382a fix: address Codex review on PR #2828 2026-08-09 03:26:42 +00:00
netcatty-bot 18b19b1ccc fix: address Codex review on PR #2828 2026-08-09 02:45:25 +00:00
Cursor Agentand陈大猫 ce2611d868 fix(scripts): hoist bulk-delete confirm outside toolbar popover
Portalled Dialog focus dismissed the compact Scripts popover and cleared
pendingDeleteIds before confirm. Own the confirm in TerminalToolbar and
defer via onBulkDeleteRequest when nested in that popover.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-09 02:19:33 +00:00
netcatty-bot a0dce456fa fix: address Codex review on PR #2828 2026-08-08 21:17:25 +00:00
Cursor Agentand陈大猫 2c7c0f9fc4 fix(scripts): keep delete refs current within the same tick
Update snippetsRef/hostsRef immediately after applying a delete so a
same-tick second event cannot rebuild vault state from the pre-delete
snapshot and resurrect removed snippets or host bindings.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 07:26:20 +00:00
Cursor Agentand陈大猫 db2152dcfa fix(scripts): harden delete handler against stale vault state
Read snippets/hosts from refs in the shared delete listener so rapid
confirms cannot resurrect already-removed items, and clear pending bulk
delete when the scripts panel hides so the confirm dialog does not
resurface after a mid-prompt hide.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 07:25:27 +00:00
Cursor Agentand陈大猫 877df24f59 fix(scripts): confirm bulk delete and clear host bindings
Scripts side-panel mass-delete skipped confirmation and, when
onSnippetsChange was wired, filtered snippets without clearing host
login/connect script bindings. Confirm first, always dispatch the shared
delete event, and apply deletes via deleteSelectedSnippetsFromVault.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 07:24:27 +00:00
netcatty-bot 40d114eab6 fix(#2826): automated Cursor CLI fix 2026-08-08 02:29:37 +00:00
15 changed files with 779 additions and 48 deletions

No files matched your search

@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
test("snippets delete handler cleans host bindings via deleteSelectedSnippets", () => {
assert.match(source, /collectSnippetDeleteIds/);
assert.match(source, /deleteSelectedSnippets/);
assert.match(
source,
/netcatty:snippets:delete[\s\S]*void deleteSelectedSnippets\(ids\)/,
);
assert.doesNotMatch(
source,
/updateSnippets\(snippets\.filter\(\(s\) => !ids\.has\(s\.id\)\)\)/,
);
});
test("snippets delete handler uses vault live snapshot instead of component refs", () => {
// Component-level snippetsRef/hostsRef lag concurrent vault mutations that
// already advanced useVaultState refs before React re-renders AppSideEffects.
// Deletion must go through the vault hook's atomic live-snapshot path.
assert.match(source, /deleteSelectedSnippets,/);
assert.doesNotMatch(source, /snippetsRef\.current\s*=\s*snippets/);
assert.doesNotMatch(
source,
/deleteSelectedSnippetsFromVault\(\s*snippetsRef\.current/,
);
});
+13 -7
View File
@@ -82,6 +82,7 @@ import { useDiscoveredShells, resolveShellSetting } from '../../lib/useDiscovere
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from '../../types';
import { resolveSnippetCommand } from '../../components/SnippetExecutionProvider';
import { isScriptSnippet } from '../../domain/snippetScript.ts';
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
import { useAppStartupEffects } from './useAppStartupEffects';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl } from './AppHandlers';
@@ -184,6 +185,7 @@ export function AppSideEffects() {
updateHosts,
updateKeys,
updateSnippets,
deleteSelectedSnippets,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
@@ -1600,18 +1602,22 @@ export function AppSideEffects() {
};
}, [handleOpenSettings, t]);
// Delete-from-sidepanel plumbing: ScriptsSidePanel's right-click menu
// dispatches `netcatty:snippets:delete` with the snippet id. Handled here
// (rather than in QuickAddSnippetDialog) because delete needs no UI.
// Delete-from-sidepanel plumbing: ScriptsSidePanel dispatches
// `netcatty:snippets:delete` with `id` (single) or `ids` (bulk). Handled
// here (rather than in QuickAddSnippetDialog) because delete needs no UI.
// Goes through useVaultState.deleteSelectedSnippets so login/connect script
// bindings clear with the snippets (SnippetsManager parity) against the
// latest persisted vault snapshot under the shared cross-window lock.
useEffect(() => {
const handler = (e: Event) => {
const id = (e as CustomEvent<{ id?: string }>).detail?.id;
if (!id) return;
updateSnippets(snippets.filter((s) => s.id !== id));
const detail = (e as CustomEvent<{ id?: string; ids?: string[] }>).detail;
const ids = collectSnippetDeleteIds(detail);
if (ids.size === 0) return;
void deleteSelectedSnippets(ids);
};
window.addEventListener('netcatty:snippets:delete', handler);
return () => window.removeEventListener('netcatty:snippets:delete', handler);
}, [snippets, updateSnippets]);
}, [deleteSelectedSnippets]);
const handleEndSessionDrag = useCallback(() => {
setDraggingSessionId(null);
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("./useVaultState.ts", import.meta.url), "utf8");
test("deleteSelectedSnippets merges into persisted vault under the shared lock", () => {
assert.match(source, /const deleteSelectedSnippets = useCallback\(async/);
assert.match(
source,
/deleteSelectedSnippets[\s\S]*withVaultImportLock\("vault"/,
);
assert.match(
source,
/deleteSelectedSnippetsFromVault\(\s*latestSnippets\s*,\s*latestHosts\s*,\s*selectedSnippetIds,?\s*\)/,
);
assert.match(
source,
/deleteSelectedSnippets[\s\S]*commitPluginImporterTransaction\(localStorageAdapter, \[[\s\S]*STORAGE_KEY_HOSTS[\s\S]*STORAGE_KEY_SNIPPETS/,
);
// Persistence rejection must be caught: callers void the promise after the
// confirm dialog closes, so an uncaught throw would leave no retry feedback.
assert.match(
source,
/deleteSelectedSnippets[\s\S]*try \{\s*commitPluginImporterTransaction[\s\S]*catch \{[\s\S]*notify\.error\([\s\S]*Snippets could not be deleted/,
);
// Must not rebuild hosts from a per-window in-memory snapshot (popup race).
assert.doesNotMatch(
source,
/deleteSelectedSnippetsFromVault\(\s*snippetsRef\.current\s*,\s*hostsRef\.current/,
);
// Paired writes must not publish in-memory state if only one key lands.
assert.doesNotMatch(
source,
/deleteSelectedSnippets[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_HOSTS, encryptedHosts\)[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS/,
);
});
test("updateSnippets disk writes take the shared vault lock", () => {
// Unlocked snippet saves can interleave with bulk-delete's journaled commit
// and discard concurrent edits. Ordinary writers must queue on the same lock
// and be visible to waitForPendingVaultWrites.
assert.match(
source,
/const updateSnippets = useCallback\(\(data: Snippet\[\]\) => \{[\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
);
assert.match(
source,
/snippetsWritePendingRef\.current = writePromise/,
);
assert.match(
source,
/waitForPendingVaultWrites[\s\S]*snippetsWritePendingRef\.current/,
);
assert.doesNotMatch(
source,
/const updateSnippets = useCallback\(\(data: Snippet\[\]\) => \{[\s\S]*setSnippets\(cleaned\);\s*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, cleaned\);\s*\}, \[\]\);/,
);
});
test("updateSnippets rebases onto the latest persisted snapshot under the lock", () => {
// Web Locks serialize writers but do not merge snapshots. A popup bulk-delete
// that lands while a main-window save is queued must not be resurrected.
assert.match(
source,
/rebaseSnippetVaultWrite\(\{\s*base,\s*ours: cleaned,\s*theirs: latest\s*\}\)/,
);
assert.match(
source,
/snippetsWriteOwnerRef\.current !== ver/,
);
});
+133 -5
View File
@@ -64,6 +64,10 @@ import {
type ConnectionLogTerminalDataMap,
} from "../../domain/connectionLogTerminalData";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
import {
deleteSelectedSnippetsFromVault,
rebaseSnippetVaultWrite,
} from "../../domain/snippetSelection";
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
import {
publishConnectionLogsSnapshot,
@@ -290,12 +294,14 @@ export const useVaultState = () => {
const customGroupsRef = useRef<string[]>([]);
const managedSourcesRef = useRef<ManagedSource[]>([]);
const hostsRef = useRef<Host[]>([]);
const snippetsRef = useRef<Snippet[]>([]);
const notesRef = useRef<VaultNote[]>([]);
const noteGroupsRef = useRef<string[]>([]);
const notesPersistFailureNotifiedAtRef = useRef(0);
customGroupsRef.current = customGroups;
managedSourcesRef.current = managedSources;
hostsRef.current = hosts;
snippetsRef.current = snippets;
notesRef.current = notes;
noteGroupsRef.current = noteGroups;
@@ -307,6 +313,10 @@ export const useVaultState = () => {
const identitiesWriteVersion = useRef(0);
const proxyProfilesWriteVersion = useRef(0);
const snippetsWriteVersion = useRef(0);
// Tracks the latest local updateSnippets schedule. Storage events also bump
// snippetsWriteVersion (to invalidate naive writers), so queued saves must
// key supersede checks off this owner instead of that shared counter.
const snippetsWriteOwnerRef = useRef(0);
const customGroupsWriteVersion = useRef(0);
const groupConfigsWriteVersion = useRef(0);
// Encrypt-phase promises can always be awaited, even under the vault lock.
@@ -320,6 +330,7 @@ export const useVaultState = () => {
const keysWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const identitiesWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const groupConfigsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const snippetsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const waitForPendingVaultWrites = useCallback(async () => {
while (true) {
@@ -344,6 +355,7 @@ export const useVaultState = () => {
keysWritePendingRef.current,
identitiesWritePendingRef.current,
groupConfigsWritePendingRef.current,
snippetsWritePendingRef.current,
];
await Promise.all(writePending);
if (
@@ -351,6 +363,7 @@ export const useVaultState = () => {
&& writePending[1] === keysWritePendingRef.current
&& writePending[2] === identitiesWritePendingRef.current
&& writePending[3] === groupConfigsWritePendingRef.current
&& writePending[4] === snippetsWritePendingRef.current
&& encryptPending[0] === hostsEncryptPendingRef.current
&& encryptPending[1] === keysEncryptPendingRef.current
&& encryptPending[2] === identitiesEncryptPendingRef.current
@@ -593,12 +606,120 @@ export const useVaultState = () => {
}, []);
const updateSnippets = useCallback((data: Snippet[]) => {
// Capture the pre-update snapshot for a 3-way rebase once we hold the lock.
// Callers pass a full array derived from this window's view; a popup delete
// (or another window's import) may land on disk before our write runs.
const base = snippetsRef.current;
const cleaned = normalizeVaultOrder(data);
++snippetsWriteVersion.current;
const ver = ++snippetsWriteVersion.current;
snippetsWriteOwnerRef.current = ver;
// Keep live snapshot ahead of React commit so same-tick readers (delete,
// agent bridge) do not observe a stale pre-write array.
snippetsRef.current = cleaned;
setSnippets(cleaned);
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, cleaned);
// Serialize with deleteSelectedSnippets / plugin importer under the shared
// vault lock, then rebase onto the latest persisted snapshot so a queued
// full-array write cannot resurrect concurrently deleted snippets (or drop
// concurrent additions).
const writePromise = withVaultImportLock("vault", async () => {
if (snippetsWriteOwnerRef.current !== ver) return "superseded" as const;
const rawSnippets = localStorageAdapter.readString(STORAGE_KEY_SNIPPETS);
const latest = normalizeVaultOrder(
readStoredArray<Snippet>(STORAGE_KEY_SNIPPETS, rawSnippets),
);
const rebased = normalizeVaultOrder(
rebaseSnippetVaultWrite({ base, ours: cleaned, theirs: latest }),
);
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, rebased);
if (snippetsWriteOwnerRef.current === ver) {
snippetsRef.current = rebased;
setSnippets(rebased);
}
return "written" as const;
});
snippetsWritePendingRef.current = writePromise;
return writePromise;
}, []);
// Cross-window safe: merge binding cleanup into the latest persisted
// hosts/snippets under the shared vault lock. Popup terminals own a separate
// useVaultState instance — writing hostsRef from that window can discard a
// main-window host edit, or a storage-event version bump can cancel the host
// write after snippets were already removed.
const deleteSelectedSnippets = useCallback(async (selectedSnippetIds: ReadonlySet<string>) => {
if (selectedSnippetIds.size === 0) {
return {
snippets: snippetsRef.current,
hosts: hostsRef.current,
deletedCount: 0,
};
}
while (true) {
await waitForPendingVaultWrites();
const attempt = await withVaultImportLock("vault", async () => {
const writeVersions = {
hosts: hostsWriteVersion.current,
snippets: snippetsWriteVersion.current,
};
const rawHosts = localStorageAdapter.readString(STORAGE_KEY_HOSTS);
const rawSnippets = localStorageAdapter.readString(STORAGE_KEY_SNIPPETS);
const storedHosts = readStoredArray<Host>(STORAGE_KEY_HOSTS, rawHosts);
const storedSnippets = readStoredArray<Snippet>(STORAGE_KEY_SNIPPETS, rawSnippets);
const latestHosts = normalizeVaultOrder(
(await decryptHosts(storedHosts)).map((host) => sanitizeHost(host)),
);
const latestSnippets = normalizeVaultOrder(storedSnippets);
const result = deleteSelectedSnippetsFromVault(
latestSnippets,
latestHosts,
selectedSnippetIds,
);
if (result.deletedCount === 0) return result;
const encryptedHosts = await encryptHosts(result.hosts);
const changedWhilePreparing = (
writeVersions.hosts !== hostsWriteVersion.current
|| writeVersions.snippets !== snippetsWriteVersion.current
|| localStorageAdapter.readString(STORAGE_KEY_HOSTS) !== rawHosts
|| localStorageAdapter.readString(STORAGE_KEY_SNIPPETS) !== rawSnippets
);
if (changedWhilePreparing) return null;
++hostsWriteVersion.current;
++snippetsWriteVersion.current;
// Journaled pair write: a partial hosts/snippets persist would drop
// login/connect bindings on restart while leaving the snippets behind.
// Callers fire-and-forget this promise after closing the confirm dialog,
// so storage rejection must not become an unhandled rejection.
try {
commitPluginImporterTransaction(localStorageAdapter, [
[STORAGE_KEY_HOSTS, encryptedHosts],
[STORAGE_KEY_SNIPPETS, result.snippets],
]);
} catch {
notify.error(
"Snippets could not be deleted. Free some local storage space and try again.",
"Scripts",
);
return {
snippets: latestSnippets,
hosts: latestHosts,
deletedCount: 0,
};
}
hostsRef.current = result.hosts;
snippetsRef.current = result.snippets;
setHosts(result.hosts);
setSnippets(result.snippets);
hostsWritePendingRef.current = Promise.resolve("unchanged" as const);
snippetsWritePendingRef.current = Promise.resolve("unchanged" as const);
return result;
});
if (attempt !== null) return attempt;
}
}, [waitForPendingVaultWrites]);
const updateSnippetPackages = useCallback((data: string[]) => {
setSnippetPackages(data);
localStorageAdapter.write(STORAGE_KEY_SNIPPET_PACKAGES, data);
@@ -1356,9 +1477,13 @@ export const useVaultState = () => {
}
if (key === STORAGE_KEY_SNIPPETS) {
const next = safeParse<Snippet[]>(event.newValue) ?? [];
const next = normalizeVaultOrder(safeParse<Snippet[]>(event.newValue) ?? []);
// Invalidate write-version readers, but do not clear snippetsWriteOwnerRef:
// an in-flight updateSnippets rebases onto this disk snapshot under the
// vault lock instead of being cancelled (which would drop local edits).
++snippetsWriteVersion.current;
setSnippets(normalizeVaultOrder(next));
snippetsRef.current = next;
setSnippets(next);
return;
}
@@ -1528,7 +1653,9 @@ export const useVaultState = () => {
if (payload.keys) encryptedWrites.push(updateKeys(payload.keys));
if (payload.identities) encryptedWrites.push(updateIdentities(payload.identities));
if (Array.isArray(payload.proxyProfiles)) encryptedWrites.push(updateProxyProfiles(payload.proxyProfiles));
if (payload.snippets) updateSnippets(payload.snippets);
if (payload.snippets) {
encryptedWrites.push(updateSnippets(payload.snippets).then(() => undefined));
}
if (payload.customGroups) updateCustomGroups(payload.customGroups);
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
if (payload.notes) updateNotes(payload.notes);
@@ -1720,6 +1847,7 @@ export const useVaultState = () => {
updateIdentities,
updateProxyProfiles,
updateSnippets,
deleteSelectedSnippets,
updateSnippetPackages,
updateNotes,
updateNoteGroups,
+55 -1
View File
@@ -2,7 +2,10 @@ import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { buildScriptsSidePanelRows } from "./ScriptsSidePanel.tsx";
import {
buildScriptsSidePanelRows,
collectScriptsSidePanelPackagePaths,
} from "./ScriptsSidePanel.tsx";
import type { Snippet } from "../types";
const snippet = (overrides: Partial<Snippet>): Snippet => ({
@@ -56,6 +59,57 @@ test("scripts side panel add control is a split button with secondary create act
assert.match(source, /snippets\.action\.newSnippet/);
});
test("collectScriptsSidePanelPackagePaths includes implied ancestors", () => {
assert.deepEqual(
collectScriptsSidePanelPackagePaths(["ops/linux"], [
snippet({ id: "a", package: "ops/linux/disk" }),
snippet({ id: "b", package: "" }),
]).sort(),
["ops", "ops/linux", "ops/linux/disk"],
);
});
test("scripts side panel toolbar exposes expand, collapse, and delete-selected actions", () => {
assert.match(source, /vault\.tree\.expandAll/);
assert.match(source, /vault\.tree\.collapseAll/);
assert.match(source, /snippets\.selection\.deleteSelected/);
assert.match(source, /expandAllGroups/);
assert.match(source, /collapseAllGroups/);
assert.match(source, /deleteSelectedSnippets/);
assert.match(source, /isMultiSelectMode/);
});
test("scripts side panel confirms bulk delete and routes it through the shared delete event", () => {
// Mass-delete must confirm first (SnippetsManager parity) and must not bypass
// AppSideEffects via onSnippetsChange filtering — that path skips host binding cleanup.
assert.match(source, /VaultDeleteConfirmDialog/);
assert.match(source, /snippets\.selection\.deleteConfirmTitle/);
assert.match(source, /snippets\.selection\.deleteConfirmDesc/);
assert.match(source, /detail:\s*\{\s*ids\s*\}/);
assert.doesNotMatch(
source,
/onSnippetsChange\(snippets\.filter\(\(snippet\) => !selectedSnippetIds\.has/,
);
});
test("scripts side panel defers bulk-delete confirm when a parent owns the dialog", () => {
// Compact TerminalToolbar nests this panel in a Popover; the portalled confirm
// must be owned outside that tree so focus cannot unmount the prompt.
assert.match(source, /onBulkDeleteRequest\?:/);
assert.match(source, /if\s*\(\s*onBulkDeleteRequest\s*\)\s*\{/);
assert.match(source, /onBulkDeleteRequest\(ids\)/);
assert.match(source, /!onBulkDeleteRequest/);
});
test("scripts side panel clears pending bulk delete when the panel hides", () => {
// Returning null while isVisible is false unmounts the confirm dialog; drop
// pending deletes so a later re-show does not resurrect a half-dismissed prompt.
assert.match(
source,
/if\s*\(\s*!isVisible\s*\)\s*setPendingDeleteIds\(\s*null\s*\)/,
);
});
test("scripts side panel package dialog traps focus and exposes dialog close contract", () => {
assert.match(source, /packageDialogRef/);
assert.match(source, /data-dialog-close="true"/);
+252 -34
View File
@@ -7,10 +7,26 @@
* list of matching snippets regardless of package nesting.
*/
import { ChevronDown, ChevronRight, Edit2, FolderPlus, Layers, Package, Play, Plus, Search, Trash2, Zap } from 'lucide-react';
import {
CheckSquare,
ChevronDown,
ChevronRight,
Edit2,
Expand,
FolderPlus,
Layers,
Minimize2,
Package,
Play,
Plus,
Search,
Trash2,
Zap,
} from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { getScriptRecordingSnapshot, subscribeScriptRecording } from '../application/state/scriptRecordingStore.ts';
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
import { reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from '../domain/vaultOrder';
import { isScriptSnippet } from '../domain/snippetScript.ts';
import { cn } from '../lib/utils';
@@ -33,6 +49,9 @@ import { SnippetCommandTooltipContent } from './snippets/SnippetCommandTooltipCo
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
const toolbarIconButtonClass =
'h-7 w-7 shrink-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:pointer-events-none';
const SCRIPT_ROW_HEIGHT = 34;
const isRootPackagePath = (path: string): boolean => {
@@ -55,6 +74,12 @@ interface ScriptsSidePanelProps {
onResumeRun?: (runId: string) => void;
onStartRecording?: () => void;
focusedSessionId?: string;
/**
* When set, bulk-delete confirmation is owned by the parent. Required when
* this panel is nested in a Popover — the portalled confirm would otherwise
* steal focus, dismiss the popover, and unmount the prompt.
*/
onBulkDeleteRequest?: (ids: string[]) => void;
}
type TreeRow =
@@ -126,15 +151,11 @@ const getVerticalDropIntent = (
const hasDragType = (dataTransfer: DataTransfer, type: string) =>
Array.from(dataTransfer.types).includes(type);
export function buildScriptsSidePanelRows({
snippets,
packages,
expandedPaths,
}: {
snippets: Snippet[];
packages: string[];
expandedPaths: Set<string>;
}): TreeRow[] {
/** Collect every package path (including implied ancestors) shown in the tree. */
export function collectScriptsSidePanelPackagePaths(
packages: string[],
snippets: Snippet[],
): string[] {
const normalizedPackages = new Set<string>();
const addWithAncestors = (raw: string) => {
const path = raw.trim();
@@ -152,6 +173,19 @@ export function buildScriptsSidePanelRows({
snippets.forEach((snippet) => {
if (snippet.package) addWithAncestors(snippet.package);
});
return Array.from(normalizedPackages);
}
export function buildScriptsSidePanelRows({
snippets,
packages,
expandedPaths,
}: {
snippets: Snippet[];
packages: string[];
expandedPaths: Set<string>;
}): TreeRow[] {
const normalizedPackages = new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
const snippetsByPackage = new Map<string, Snippet[]>();
const descendantCountByPackage = new Map<string, number>();
@@ -250,11 +284,15 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
onResumeRun,
onStartRecording,
focusedSessionId,
onBulkDeleteRequest,
}) => {
const { t } = useI18n();
const [search, setSearch] = useState('');
const [subView, setSubView] = useState<'library' | 'running'>('library');
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
const [pendingDeleteIds, setPendingDeleteIds] = useState<string[] | null>(null);
const [isPackageDialogOpen, setIsPackageDialogOpen] = useState(false);
const [newPackageName, setNewPackageName] = useState('');
const [packageError, setPackageError] = useState('');
@@ -290,24 +328,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
// (e.g. package "a/b/c" implies roots "a" and "a/b" even when not listed).
const normalizedPackages = useMemo(() => {
if (!isVisible) return new Set<string>();
const set = new Set<string>();
const addWithAncestors = (raw: string) => {
const path = raw.trim();
if (!path) return;
const isAbs = path.startsWith('/');
const body = isAbs ? path.slice(1) : path;
const parts = body.split('/').filter(Boolean);
for (let i = 1; i <= parts.length; i++) {
const sub = parts.slice(0, i).join('/');
set.add(isAbs ? `/${sub}` : sub);
}
};
packages.forEach(addWithAncestors);
// A snippet may reference a package path that's not in `packages` yet.
snippets.forEach((s) => {
if (s.package) addWithAncestors(s.package);
});
return set;
return new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
}, [packages, snippets, isVisible]);
// Track every package we've ever observed so we can tell "new" from
@@ -348,6 +369,92 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
});
}, []);
const expandAllGroups = useCallback(() => {
setExpandedPaths(new Set(normalizedPackages));
}, [normalizedPackages]);
const collapseAllGroups = useCallback(() => {
setExpandedPaths(new Set());
}, []);
const clearSnippetSelection = useCallback(() => {
setSelectedSnippetIds(new Set());
setIsMultiSelectMode(false);
}, []);
useEffect(() => {
setSelectedSnippetIds((prev) => {
if (prev.size === 0) return prev;
const alive = new Set(snippets.map((snippet) => snippet.id));
let changed = false;
const next = new Set<string>();
prev.forEach((id) => {
if (alive.has(id)) next.add(id);
else changed = true;
});
return changed ? next : prev;
});
}, [snippets]);
useEffect(() => {
if (!isVisible) setPendingDeleteIds(null);
}, [isVisible]);
// Parent-owned confirm (compact toolbar popover) dispatches the shared delete
// event; clear local multi-select when that bulk delete lands.
useEffect(() => {
if (!onBulkDeleteRequest) return;
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ ids?: string[] }>).detail;
if (!detail?.ids?.length) return;
clearSnippetSelection();
};
window.addEventListener('netcatty:snippets:delete', handler);
return () => window.removeEventListener('netcatty:snippets:delete', handler);
}, [clearSnippetSelection, onBulkDeleteRequest]);
const toggleSnippetSelection = useCallback((id: string) => {
setSelectedSnippetIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const deleteSelectedSnippets = useCallback(() => {
const ids = snippets
.filter((snippet) => selectedSnippetIds.has(snippet.id))
.map((snippet) => snippet.id);
if (ids.length === 0) return;
if (onBulkDeleteRequest) {
onBulkDeleteRequest(ids);
return;
}
setPendingDeleteIds(ids);
}, [onBulkDeleteRequest, selectedSnippetIds, snippets]);
const existingPendingDeleteIds = pendingDeleteIds
? pendingDeleteIds.filter((id) => snippets.some((snippet) => snippet.id === id))
: [];
const confirmDeleteSelectedSnippets = useCallback(() => {
const ids = (pendingDeleteIds ?? []).filter((id) =>
snippets.some((snippet) => snippet.id === id),
);
setPendingDeleteIds(null);
if (ids.length === 0) {
clearSnippetSelection();
return;
}
// Always route through the shared event so AppSideEffects can clear host
// login/connect bindings (onSnippetsChange alone would leave them stale).
window.dispatchEvent(
new CustomEvent('netcatty:snippets:delete', { detail: { ids } }),
);
clearSnippetSelection();
}, [clearSnippetSelection, pendingDeleteIds, snippets]);
// When search is active, flatten everything (no tree, no packages).
const searchMatches = useMemo(() => {
if (!isVisible) return null;
@@ -400,6 +507,10 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
const handleSnippetClick = useCallback(
(snippet: Snippet) => {
if (isMultiSelectMode) {
toggleSnippetSelection(snippet.id);
return;
}
if (isScriptSnippet(snippet)) {
onRunScript?.(snippet);
setSubView('running');
@@ -407,9 +518,12 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
}
onSnippetClick(snippet);
},
[onRunScript, onSnippetClick],
[isMultiSelectMode, onRunScript, onSnippetClick, toggleSnippetSelection],
);
const hasSearch = search.trim().length > 0;
const canExpandCollapse = normalizedPackages.size > 0 && !hasSearch;
const sessionRuns = useMemo(() => {
if (!focusedSessionId) return runs;
return runs.filter((run) => run.sessionId === focusedSessionId);
@@ -757,8 +871,8 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
</div>
) : (
<>
{/* Search + Add */}
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1.5">
{/* Search + tree actions + Add */}
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1">
<div className="relative flex-1 min-w-0">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -768,6 +882,76 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
className="h-7 pl-7 text-xs bg-muted/30 border-none"
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={toolbarIconButtonClass}
disabled={!canExpandCollapse}
aria-label={t('vault.tree.expandAll')}
onClick={expandAllGroups}
>
<Expand size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('vault.tree.expandAll')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={toolbarIconButtonClass}
disabled={!canExpandCollapse}
aria-label={t('vault.tree.collapseAll')}
onClick={collapseAllGroups}
>
<Minimize2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('vault.tree.collapseAll')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
toolbarIconButtonClass,
isMultiSelectMode && 'bg-muted/70 text-foreground',
)}
aria-label={t('snippets.action.selectSnippets')}
aria-pressed={isMultiSelectMode}
onClick={() => {
if (isMultiSelectMode) {
clearSnippetSelection();
} else {
setIsMultiSelectMode(true);
}
}}
>
<CheckSquare size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('snippets.action.selectSnippets')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
toolbarIconButtonClass,
selectedSnippetIds.size > 0 && 'text-destructive hover:text-destructive',
)}
disabled={selectedSnippetIds.size === 0}
aria-label={t('snippets.selection.deleteSelected', { count: selectedSnippetIds.size })}
onClick={deleteSelectedSnippets}
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t('snippets.selection.deleteSelected', { count: selectedSnippetIds.size })}
</TooltipContent>
</Tooltip>
{/* Split add control: primary = new snippet; menu = package / automation script */}
<div className="shrink-0 flex items-center">
<Tooltip>
@@ -862,6 +1046,8 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
snippet={item.snippet}
depth={0}
subtitle={item.snippet.package || t('terminal.toolbar.library')}
selected={selectedSnippetIds.has(item.snippet.id)}
multiSelect={isMultiSelectMode}
draggable={false}
sortableTarget={false}
onDragOver={handleRowDragOver}
@@ -904,7 +1090,9 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
<SnippetRow
snippet={item.row.snippet}
depth={item.row.depth}
draggable={Boolean(onSnippetsChange)}
selected={selectedSnippetIds.has(item.row.snippet.id)}
multiSelect={isMultiSelectMode}
draggable={Boolean(onSnippetsChange) && !isMultiSelectMode}
sortableTarget={true}
onDragOver={handleRowDragOver}
onDrop={handleRowDrop}
@@ -1051,6 +1239,20 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
</div>
</div>
) : null}
{!onBulkDeleteRequest ? (
<VaultDeleteConfirmDialog
open={Boolean(pendingDeleteIds)}
title={t('snippets.selection.deleteConfirmTitle', {
count: existingPendingDeleteIds.length,
})}
description={t('snippets.selection.deleteConfirmDesc')}
onOpenChange={(open) => {
if (!open) setPendingDeleteIds(null);
}}
onConfirm={confirmDeleteSelectedSnippets}
/>
) : null}
</div>
</TooltipProvider>
);
@@ -1102,6 +1304,8 @@ interface SnippetRowProps {
snippet: Snippet;
depth: number;
subtitle?: string;
selected?: boolean;
multiSelect?: boolean;
draggable: boolean;
sortableTarget: boolean;
onDragOver: (event: React.DragEvent<HTMLElement>) => void;
@@ -1122,6 +1326,8 @@ const SnippetRow = memo<SnippetRowProps>(({
snippet,
depth,
subtitle,
selected = false,
multiSelect = false,
draggable,
sortableTarget,
onDragOver,
@@ -1157,13 +1363,25 @@ const SnippetRow = memo<SnippetRowProps>(({
<button
type="button"
onClick={onClick}
className="w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors overflow-hidden"
aria-pressed={multiSelect ? selected : undefined}
className={cn(
'w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors overflow-hidden',
selected && 'bg-primary/10 hover:bg-primary/15',
)}
style={{ paddingLeft: 8 + depth * 14 }}
>
{/* Hidden chevron column mirrors PackageRow's layout so the
snippet icon lines up exactly with the package icon above. */}
<ChevronRight size={12} className="shrink-0 opacity-0" aria-hidden />
{isScriptSnippet(snippet) ? (
{multiSelect ? (
<CheckSquare
size={12}
className={cn(
'shrink-0',
selected ? 'text-primary' : 'text-muted-foreground/70',
)}
/>
) : isScriptSnippet(snippet) ? (
<Play size={12} className="shrink-0 text-primary" />
) : (
<Zap size={12} className="shrink-0 text-muted-foreground" />
+3
View File
@@ -250,6 +250,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snippets,
snippetPackages = [],
compactToolbar = false,
onDeleteSnippets,
lineTimestampsAvailable = true,
chainHosts = EMPTY_CHAIN_HOSTS,
appearanceTheme,
@@ -3631,6 +3632,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snippets={snippets}
snippetPackages={snippetPackages}
onSnippetClick={(snippet) => { void executeSnippet(snippet); }}
onDeleteSnippets={onDeleteSnippets}
onOpenSFTP={handleOpenSFTP}
onSendYmodem={isSerialConnection ? handleSendYmodem : undefined}
onReceiveYmodem={isSerialConnection ? handleReceiveYmodem : undefined}
@@ -3694,6 +3696,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isSessionLogging,
isWorkspaceComposeBarOpen,
onCloseSession,
onDeleteSnippets,
onOpenScripts,
onOpenHistory,
onOpenTheme,
+3
View File
@@ -133,9 +133,12 @@ test('resolveTerminalPopupReuseId uses the explicit reuse id from the prepared s
test('popup terminals resolve complete host config and pass jump hosts into Terminal', () => {
assert.match(source, /proxyProfiles,\s+knownHosts,\s+snippets,\s+snippetPackages,\s+groupConfigs,/);
assert.match(source, /deleteSelectedSnippets,/);
assert.match(source, /resolveTerminalPopupHost\(config,\s*hosts,\s*\{\s+groupConfigs,\s+proxyProfiles,/);
assert.match(source, /resolveTerminalChainHosts\(\{\s+host,\s+hosts,\s+groupConfigs,\s+proxyProfiles,/);
assert.match(source, /chainHosts=\{chainHosts\}/);
// Popup has no AppSideEffects listener; bulk delete must hit this vault instance.
assert.match(source, /onDeleteSnippets=\{deleteSelectedSnippets\}/);
});
test('popup provider tree mounts the plugin authentication host', () => {
+2
View File
@@ -272,6 +272,7 @@ function TerminalPopupPageInner() {
snippetPackages,
groupConfigs,
updateKnownHosts,
deleteSelectedSnippets,
} = useVaultState();
const [config, setConfig] = useState<TerminalPopupPayload | null>(null);
const [terminalReady, setTerminalReady] = useState(false);
@@ -453,6 +454,7 @@ function TerminalPopupPageInner() {
snippetPackages={snippetPackages}
chainHosts={chainHosts}
compactToolbar
onDeleteSnippets={deleteSelectedSnippets}
lineTimestampsAvailable={false}
knownHosts={effectiveKnownHosts}
onAddKnownHost={handleAddKnownHost}
@@ -1,5 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
@@ -7,6 +8,8 @@ import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import type { Host } from "../../types.ts";
import { TerminalToolbar } from "./TerminalToolbar.tsx";
const toolbarSource = readFileSync(new URL("./TerminalToolbar.tsx", import.meta.url), "utf8");
const sshHost: Host = {
id: "host-1",
label: "Host",
@@ -169,3 +172,23 @@ test("uses the terminal active button color for pressed toolbar actions", () =>
/aria-label="Search terminal"[^>]*style="background-color:var\(--terminal-toolbar-btn-active\)"/,
);
});
test("compact scripts popover hosts bulk-delete confirm outside the popover", () => {
// Dialog focus leaves PopoverContent; if confirm stays inside ScriptsSidePanel,
// the popover closes, isVisible clears pendingDeleteIds, and the prompt dies.
assert.match(toolbarSource, /onBulkDeleteRequest=\{setPendingScriptDeleteIds\}/);
assert.match(toolbarSource, /<VaultDeleteConfirmDialog/);
// Popup vault mutation goes through the prop; the event only clears popover selection.
assert.match(toolbarSource, /onDeleteSnippets\?\.\(new Set\(ids\)\)/);
assert.match(toolbarSource, /netcatty:snippets:delete/);
const scriptsPopoverIdx = toolbarSource.indexOf("open={scriptsPopoverOpen}");
const popoverEndIdx = toolbarSource.indexOf("</Popover>", scriptsPopoverIdx);
const dialogIdx = toolbarSource.indexOf("<VaultDeleteConfirmDialog", popoverEndIdx);
assert.ok(scriptsPopoverIdx >= 0, "scripts popover open binding");
assert.ok(popoverEndIdx > scriptsPopoverIdx, "scripts popover closes");
assert.ok(dialogIdx > popoverEndIdx, "confirm dialog is a sibling after the popover");
assert.equal(
toolbarSource.includes("document.querySelector('[data-vault-delete-confirm=\"true\"]')"),
false,
);
});
+34
View File
@@ -29,6 +29,7 @@ import type { ToolbarItemLayoutDefaults } from '../../domain/toolbarItemLayout';
import { STORAGE_KEY_TERMINAL_TOOLBAR_LAYOUT } from '../../infrastructure/config/storageKeys';
import { Host, Snippet } from '../../types';
import { ScriptsSidePanel } from '../ScriptsSidePanel';
import { VaultDeleteConfirmDialog } from '../vault/VaultDeleteConfirmDialog';
import { Button } from '../ui/button';
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
import { ToolbarCustomizeContextMenu } from '../ui/toolbar-item-layout';
@@ -90,6 +91,8 @@ export interface TerminalToolbarProps {
snippets?: Snippet[];
snippetPackages?: string[];
onSnippetClick?: (snippet: Snippet) => void;
/** Popup vault mutation path — compact toolbar has no AppSideEffects listener. */
onDeleteSnippets?: (ids: ReadonlySet<string>) => void;
onOpenSFTP: () => void;
onSendYmodem?: () => void;
onReceiveYmodem?: () => void;
@@ -127,6 +130,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
snippets = [],
snippetPackages = [],
onSnippetClick,
onDeleteSnippets,
onOpenSFTP,
onSendYmodem,
onReceiveYmodem,
@@ -179,6 +183,12 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
.sort(comparePluginMenus);
const [highlightPopoverOpen, setHighlightPopoverOpen] = useState(false);
const [scriptsPopoverOpen, setScriptsPopoverOpen] = useState(false);
// Owned outside the scripts Popover so portalled Dialog focus cannot dismiss
// the menu and unmount ScriptsSidePanel before the user confirms.
const [pendingScriptDeleteIds, setPendingScriptDeleteIds] = useState<string[] | null>(null);
const pendingScriptDeleteCount = (pendingScriptDeleteIds ?? []).filter((id) =>
snippets.some((snippet) => snippet.id === id),
).length;
// Overflow popover + encoding submenu are both controlled so that
// picking an encoding closes the whole chain, and so the parent popover
// can ignore clicks that land in the submenu portal (otherwise the
@@ -379,6 +389,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
snippets={snippets}
packages={snippetPackages}
isVisible={scriptsPopoverOpen}
onBulkDeleteRequest={setPendingScriptDeleteIds}
onSnippetClick={(snippet) => {
onSnippetClick?.(snippet);
setScriptsPopoverOpen(false);
@@ -386,6 +397,29 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
/>
</PopoverContent>
</Popover>
<VaultDeleteConfirmDialog
open={Boolean(pendingScriptDeleteIds)}
title={t('snippets.selection.deleteConfirmTitle', {
count: pendingScriptDeleteCount,
})}
description={t('snippets.selection.deleteConfirmDesc')}
onOpenChange={(open) => {
if (!open) setPendingScriptDeleteIds(null);
}}
onConfirm={() => {
const ids = (pendingScriptDeleteIds ?? []).filter((id) =>
snippets.some((snippet) => snippet.id === id),
);
setPendingScriptDeleteIds(null);
if (ids.length === 0) return;
// Popup terminals own vault state locally (no AppSideEffects).
onDeleteSnippets?.(new Set(ids));
// Still emit so ScriptsSidePanel can clear multi-select in the popover.
window.dispatchEvent(
new CustomEvent('netcatty:snippets:delete', { detail: { ids } }),
);
}}
/>
</TooltipProvider>
</div>
);
+2
View File
@@ -108,6 +108,8 @@ export interface TerminalProps {
compactToolbar?: boolean;
/** Line timestamps are unavailable in popup terminals that stream shell output without timestamp metadata. */
lineTimestampsAvailable?: boolean;
/** Compact/popup path: delete snippets against the caller's vault hook. */
onDeleteSnippets?: (ids: ReadonlySet<string>) => void;
chainHosts?: Host[];
appearanceTheme?: TerminalTheme;
knownHosts?: KnownHost[];
+1
View File
@@ -15,6 +15,7 @@ export const terminalPropsAreEqual = (
&& prev.snippetPackages === next.snippetPackages
&& prev.compactToolbar === next.compactToolbar
&& prev.lineTimestampsAvailable === next.lineTimestampsAvailable
&& prev.onDeleteSnippets === next.onDeleteSnippets
&& prev.chainHosts === next.chainHosts
&& themeFingerprint(prev.appearanceTheme ?? prev.terminalTheme) === themeFingerprint(next.appearanceTheme ?? next.terminalTheme)
&& prev.knownHosts === next.knownHosts
+67 -1
View File
@@ -1,7 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Host, Snippet } from './models';
import { deleteSelectedSnippetsFromVault } from './snippetSelection.ts';
import {
collectSnippetDeleteIds,
deleteSelectedSnippetsFromVault,
rebaseSnippetVaultWrite,
} from './snippetSelection.ts';
test('collectSnippetDeleteIds merges id and ids payloads', () => {
assert.deepEqual(
[...collectSnippetDeleteIds({ id: 'a', ids: ['b', 'a', ''] })].sort(),
['a', 'b'],
);
assert.equal(collectSnippetDeleteIds(undefined).size, 0);
assert.equal(collectSnippetDeleteIds({ ids: [] }).size, 0);
});
test('deleteSelectedSnippetsFromVault removes host bindings for every selected snippet', () => {
const snippets: Snippet[] = [
@@ -57,3 +70,56 @@ test('deleteSelectedSnippetsFromVault removes host bindings for every selected s
assert.equal(hosts[0].loginScriptId, 'login');
assert.deepEqual(hosts[0].connectScriptIds, ['connect', 'keep']);
});
test('rebaseSnippetVaultWrite does not resurrect snippets deleted on disk', () => {
const base: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
{ id: 'b', label: 'B', command: 'echo b' },
];
const ours: Snippet[] = [
{ id: 'a', label: 'A edited', command: 'echo a2' },
{ id: 'b', label: 'B', command: 'echo b' },
];
// Concurrent bulk-delete removed B before our queued save ran.
const theirs: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
];
const merged = rebaseSnippetVaultWrite({ base, ours, theirs });
assert.deepEqual(merged.map((snippet) => snippet.id), ['a']);
assert.equal(merged[0]?.label, 'A edited');
});
test('rebaseSnippetVaultWrite keeps concurrent disk additions and local additions', () => {
const base: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
];
const ours: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
{ id: 'local', label: 'Local', command: 'echo local' },
];
const theirs: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
{ id: 'remote', label: 'Remote', command: 'echo remote' },
];
const merged = rebaseSnippetVaultWrite({ base, ours, theirs });
assert.deepEqual(merged.map((snippet) => snippet.id).sort(), ['a', 'local', 'remote']);
});
test('rebaseSnippetVaultWrite keeps local deletes even when disk still has the row', () => {
const base: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
{ id: 'b', label: 'B', command: 'echo b' },
];
const ours: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
];
const theirs: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
{ id: 'b', label: 'B edited elsewhere', command: 'echo b2' },
];
const merged = rebaseSnippetVaultWrite({ base, ours, theirs });
assert.deepEqual(merged.map((snippet) => snippet.id), ['a']);
});
+89
View File
@@ -1,6 +1,20 @@
import type { Host, Snippet } from './models';
import { deleteSnippetFromVault } from './snippetAgentOps.ts';
/** Normalize `netcatty:snippets:delete` detail into a set of snippet ids. */
export function collectSnippetDeleteIds(
detail?: { id?: string; ids?: readonly string[] } | null,
): Set<string> {
const ids = new Set<string>();
for (const id of detail?.ids ?? []) {
if (typeof id === 'string' && id.length > 0) ids.add(id);
}
if (typeof detail?.id === 'string' && detail.id.length > 0) {
ids.add(detail.id);
}
return ids;
}
export function deleteSelectedSnippetsFromVault(
snippets: Snippet[],
hosts: Host[],
@@ -21,3 +35,78 @@ export function deleteSelectedSnippetsFromVault(
return { snippets: nextSnippets, hosts: nextHosts, deletedCount };
}
/**
* Three-way rebase for a queued full-array snippet save against the latest
* persisted vault snapshot.
*
* Unlike sync merge, a concurrent disk delete always wins over a local edit of
* the same id so bulk-delete cannot be resurrected by a stale window write.
*/
export function rebaseSnippetVaultWrite({
base,
ours,
theirs,
}: {
base: readonly Snippet[];
ours: readonly Snippet[];
theirs: readonly Snippet[];
}): Snippet[] {
const baseMap = new Map(base.map((snippet) => [snippet.id, snippet]));
const oursMap = new Map(ours.map((snippet) => [snippet.id, snippet]));
const theirsMap = new Map(theirs.map((snippet) => [snippet.id, snippet]));
const keep = new Map<string, Snippet>();
const allIds = new Set<string>([
...baseMap.keys(),
...oursMap.keys(),
...theirsMap.keys(),
]);
for (const id of allIds) {
if (!id) continue;
const baseItem = baseMap.get(id);
const ourItem = oursMap.get(id);
const theirItem = theirsMap.get(id);
const inBase = baseItem !== undefined;
const inOurs = ourItem !== undefined;
const inTheirs = theirItem !== undefined;
if (!inBase && inOurs && !inTheirs) {
keep.set(id, ourItem);
continue;
}
if (!inBase && !inOurs && inTheirs) {
keep.set(id, theirItem);
continue;
}
if (!inBase && inOurs && inTheirs) {
keep.set(id, ourItem);
continue;
}
if (inBase && inOurs && inTheirs) {
keep.set(id, ourItem);
continue;
}
// Local delete (even if disk still has / edited the row).
if (inBase && !inOurs && inTheirs) continue;
// Concurrent disk delete — do not resurrect from a stale local edit.
if (inBase && inOurs && !inTheirs) continue;
}
const ordered: Snippet[] = [];
const seen = new Set<string>();
for (const snippet of ours) {
const kept = keep.get(snippet.id);
if (!kept || seen.has(snippet.id)) continue;
ordered.push(kept);
seen.add(snippet.id);
}
for (const snippet of theirs) {
const kept = keep.get(snippet.id);
if (!kept || seen.has(snippet.id)) continue;
ordered.push(kept);
seen.add(snippet.id);
}
return ordered;
}