Compare commits

...
Author SHA1 Message Date
netcatty-bot bb014e0df8 fix: address Codex review on PR #2828 2026-08-09 19:32:29 +00:00
netcatty-bot 30822d1262 fix: address Codex review on PR #2828 2026-08-09 19:12:47 +00:00
netcatty-bot 04c139ed90 fix: address Codex review on PR #2828 2026-08-09 18:51:05 +00:00
netcatty-bot 9f5d1c9b59 fix: address Codex review on PR #2828 2026-08-09 18:30:49 +00:00
netcatty-bot ce863f4402 fix: address Codex review on PR #2828 2026-08-09 17:59:51 +00:00
netcatty-bot a70960290c fix: address Codex review on PR #2828 2026-08-09 17:43:26 +00:00
netcatty-bot eb9c84ea9f fix: address Codex review on PR #2828 2026-08-09 17:08:43 +00:00
netcatty-bot 6f8b7d7469 fix: address Codex review on PR #2828 2026-08-09 16:48:33 +00:00
netcatty-bot 5a178ffa20 fix: address Codex review on PR #2828 2026-08-09 16:28:50 +00:00
netcatty-bot 76879ea1a7 fix: address Codex review on PR #2828 2026-08-09 16:11:48 +00:00
netcatty-bot c9bde0222c fix: address Codex review on PR #2828 2026-08-09 15:58:02 +00:00
netcatty-bot b175b5b189 fix: address Codex review on PR #2828 2026-08-09 15:40:31 +00:00
netcatty-bot faf8373e35 fix: address Codex review on PR #2828 2026-08-09 15:23:52 +00:00
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 1568 additions and 76 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,237 @@
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("updateHosts prunes stale snippet bindings under the vault lock", () => {
// Cross-window bulk-delete can land while a host writer is queued with a
// pre-delete encrypted blob. Re-check the live snippet catalog under the lock
// before persisting so login/connect bindings are not restored.
assert.match(
source,
/commitEncryptedHostsUnderVaultLock[\s\S]*pruneHostsStaleSnippetBindings\(hostsToPersist, latestSnippets\)/,
);
assert.match(
source,
/const writePromise = encryptPromise\.then\(async \(enc\) => \{[\s\S]*return commitEncryptedHostsUnderVaultLock\(ver, cleaned, enc\)/,
);
});
test("importData replaces snippets instead of additive-rebasing", () => {
// Sync restore / backup import must match the selected snapshot. Additive
// rebase would keep concurrent disk-only ids and report a successful restore
// that still contains snippets absent from the imported payload.
assert.match(
source,
/updateSnippets\(payload\.snippets,\s*\{\s*replace:\s*true\s*\}\)/,
);
});
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\(\(\s*data: Snippet\[\],\s*options\?: \{ replace\?: boolean \},\s*\) => \{[\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\(\([\s\S]*?\) => \{[\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:/,
);
assert.match(
source,
/snippetsWriteOwnerRef\.current !== ver/,
);
});
test("clearVaultData replaces snippets without additive rebase", () => {
// Clear All Local Data must not preserve concurrent disk-only snippet adds
// that rebaseSnippetVaultWrite would otherwise keep.
assert.match(
source,
/updateSnippets\(\[\],\s*\{\s*replace:\s*true\s*\}\)/,
);
assert.match(
source,
/const replace =\s*options\?\.replace === true \|\| snippetsWriteReplaceRef\.current/,
);
assert.match(
source,
/replace\s*\?\s*cleaned\s*:\s*rebaseSnippetVaultWrite/,
);
assert.match(
source,
/if \(replace\) \{\s*\/\/ Restore\/import\/clear[\s\S]*snippetsWriteBaseRef\.current = null/,
);
});
test("updateSnippets preserves replace mode across superseded local saves", () => {
// A clear/restore queued behind the vault lock must not lose replacement
// intent when a later create/edit becomes the write owner — otherwise that
// save additively rebases against the stale disk catalog and resurrects
// every pre-replacement snippet.
assert.match(
source,
/const snippetsWriteReplaceRef = useRef\(false\)/,
);
assert.match(
source,
/if \(options\?\.replace === true\) \{\s*snippetsWriteReplaceRef\.current = true;\s*\}/,
);
assert.match(
source,
/snippetsWriteBaseRef\.current = null;\s*snippetsWriteReplaceRef\.current = false;/,
);
});
test("updateSnippets keeps the persisted rebase ancestor across superseded saves", () => {
// Two queued local saves must not let the second treat the first save's
// optimistic in-memory array as base — that array never hit disk, so an add
// would look like a concurrent delete and be dropped.
assert.match(
source,
/const snippetsWriteBaseRef = useRef<Snippet\[\] \| null>\(null\)/,
);
assert.match(
source,
/const base = snippetsWriteBaseRef\.current \?\? snippetsRef\.current/,
);
assert.match(
source,
/if \(snippetsWriteBaseRef\.current === null\) \{\s*snippetsWriteBaseRef\.current = base;\s*\}/,
);
assert.match(
source,
/const persisted = localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
);
// Quota failure must keep the ancestor; clearing it would drop a local add on
// the next save after space is freed.
assert.match(
source,
/if \(!persisted\) \{[\s\S]*return "failed" as const;[\s\S]*\}\s*\/\/ Disk caught up[\s\S]*snippetsWriteBaseRef\.current = null/,
);
assert.match(
source,
/if \(!persisted\) \{[\s\S]*Snippets could not be saved/,
);
});
test("snippet storage events keep queued local edits and their rebase ancestor", () => {
// A cross-window write must not clear snippetsWriteBaseRef / replace optimistic
// state while a local owner is still queued — the next edit would supersede
// that owner from a remote-only snapshot and drop the first local mutation.
assert.match(
source,
/if \(key === STORAGE_KEY_SNIPPETS\) \{[\s\S]*const pendingBase = snippetsWriteBaseRef\.current;\s*if \(pendingBase !== null\) \{[\s\S]*rebaseSnippetVaultWrite\(\{\s*base: pendingBase,\s*ours: snippetsRef\.current,\s*theirs: next,\s*\}\)/,
);
assert.doesNotMatch(
source,
/if \(key === STORAGE_KEY_SNIPPETS\) \{[\s\S]*snippetsWriteBaseRef\.current = null;\s*snippetsRef\.current = next/,
);
});
test("snippet storage events retain outstanding replace snapshots", () => {
// clear/restore sets replace with base null. Adopting a remote old catalog
// before the lock is acquired would pollute memory; a later local edit would
// still replace disk with that polluted snapshot.
assert.match(
source,
/if \(key === STORAGE_KEY_SNIPPETS\) \{[\s\S]*if \(snippetsWriteReplaceRef\.current\) \{[\s\S]*return;\s*\}[\s\S]*snippetsRef\.current = next/,
);
});
test("snippet storage events reject payloads older than current disk", () => {
// After a queued replace commits, snippetsWriteReplaceRef is false again. A
// delayed peer StorageEvent still carries the pre-replacement newValue; if
// adopted, the next edit would persist that resurrected catalog.
assert.match(
source,
/if \(key === STORAGE_KEY_SNIPPETS\) \{[\s\S]*event\.newValue !== localStorageAdapter\.readString\(STORAGE_KEY_SNIPPETS\)[\s\S]*return;/,
);
});
test("startup host re-encryption uses the locked prune writer", () => {
// A second renderer migrating saved hosts must not write the pre-delete
// encryption blob unlocked after bulk-delete cleared login/connect bindings.
assert.match(
source,
/migrateHostsFromLegacyLineTimestamps[\s\S]*commitEncryptedHostsUnderVaultLock\(ver, sanitized, enc\)/,
);
assert.match(
source,
/migrateHostsFromLegacyLineTimestamps[\s\S]*hostsWritePendingRef\.current = writePromise/,
);
assert.doesNotMatch(
source,
/migrateHostsFromLegacyLineTimestamps[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_HOSTS, enc\)/,
);
});
test("startup snippet order backfill uses the locked vault writer", () => {
// An initializing renderer must not rewrite a pre-delete snippet snapshot
// unlocked after peer bulk-delete cleared host bindings — that resurrects
// every selected snippet while binding cleanup remains committed.
assert.match(
source,
/needsOrderPersist[\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*readStoredArray<Snippet>\([\s\S]*STORAGE_KEY_SNIPPETS[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, latest\)/,
);
assert.match(
source,
/needsOrderPersist[\s\S]*snippetsWritePendingRef\.current = writePromise/,
);
assert.doesNotMatch(
source,
/const orderedSnippets = normalizeVaultOrder\(savedSnippets\);\s*setSnippets\(orderedSnippets\);\s*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, orderedSnippets\);/,
);
});
+313 -33
View File
@@ -64,6 +64,11 @@ import {
type ConnectionLogTerminalDataMap,
} from "../../domain/connectionLogTerminalData";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
import {
deleteSelectedSnippetsFromVault,
pruneHostsStaleSnippetBindings,
rebaseSnippetVaultWrite,
} from "../../domain/snippetSelection";
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
import {
publishConnectionLogsSnapshot,
@@ -290,12 +295,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 +314,18 @@ 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);
// Last persisted ancestor for in-flight updateSnippets rebases. Superseded
// saves must not advance this to an optimistic in-memory array that never
// landed on disk — that would make a local add look like a concurrent delete.
const snippetsWriteBaseRef = useRef<Snippet[] | null>(null);
// Outstanding clear/restore/import replace must survive a superseding local
// save. Otherwise the later owner rebases additively against the old disk
// catalog and resurrects every pre-replacement snippet.
const snippetsWriteReplaceRef = useRef(false);
const customGroupsWriteVersion = useRef(0);
const groupConfigsWriteVersion = useRef(0);
// Encrypt-phase promises can always be awaited, even under the vault lock.
@@ -320,6 +339,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 +364,7 @@ export const useVaultState = () => {
keysWritePendingRef.current,
identitiesWritePendingRef.current,
groupConfigsWritePendingRef.current,
snippetsWritePendingRef.current,
];
await Promise.all(writePending);
if (
@@ -351,6 +372,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
@@ -451,6 +473,37 @@ export const useVaultState = () => {
return mergeConnectionLogsFromStorage(prev, storedLogs, terminalDataMap);
}, []);
// Encrypt outside the lock, then under the lock prune script bindings against
// the latest snippet catalog so a queued full-array write cannot restore
// login/connect ids cleared by a concurrent bulk-delete in another window.
const commitEncryptedHostsUnderVaultLock = useCallback(async (
ver: number,
hostsToPersist: Host[],
encrypted: Awaited<ReturnType<typeof encryptHosts>>,
) => {
return withVaultImportLock("vault", async () => {
if (ver !== hostsWriteVersion.current) return "superseded" as const;
const latestSnippets = normalizeVaultOrder(
readStoredArray<Snippet>(
STORAGE_KEY_SNIPPETS,
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
),
);
const pruned = pruneHostsStaleSnippetBindings(hostsToPersist, latestSnippets);
let payload = encrypted;
if (pruned !== hostsToPersist) {
const normalized = normalizeVaultOrder(pruned);
payload = await encryptHosts(normalized);
if (ver !== hostsWriteVersion.current) return "superseded" as const;
hostsRef.current = normalized;
setHosts(normalized);
}
return localStorageAdapter.write(STORAGE_KEY_HOSTS, payload)
? "written" as const
: "failed" as const;
});
}, []);
const updateHosts = useCallback((data: Host[] | ((prev: Host[]) => Host[])) => {
// Keep object identity for hosts that did not actually change. Callers that
// do `hosts.map(h => h.id === id ? patch(h) : h)` already pass through
@@ -483,14 +536,11 @@ export const useVaultState = () => {
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
if (ver !== hostsWriteVersion.current) return "superseded" as const;
return withVaultImportLock("vault", async () => {
if (ver !== hostsWriteVersion.current) return "superseded" as const;
return localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
return commitEncryptedHostsUnderVaultLock(ver, cleaned, enc);
});
hostsWritePendingRef.current = writePromise;
return writePromise;
}, []);
}, [commitEncryptedHostsUnderVaultLock]);
const readPersistedHosts = useCallback(async (): Promise<Host[]> => {
// Always drain what is safe to wait for. Under the vault lock this is only
@@ -592,13 +642,166 @@ export const useVaultState = () => {
});
}, []);
const updateSnippets = useCallback((data: Snippet[]) => {
const updateSnippets = useCallback((
data: Snippet[],
options?: { replace?: boolean },
) => {
// 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.
// Keep the first outstanding ancestor across superseded local saves so a
// second edit does not rebase against the first save's optimistic memory.
const replace =
options?.replace === true || snippetsWriteReplaceRef.current;
const base = snippetsWriteBaseRef.current ?? snippetsRef.current;
if (options?.replace === true) {
snippetsWriteReplaceRef.current = true;
}
if (replace) {
// Restore/import/clear must not keep an additive ancestor — storage events
// would otherwise merge disk-only ids back into memory while replace waits.
// Keep this cleared when a later create/edit supersedes the replacement
// owner so that save stays in replace mode instead of capturing []/restored
// memory as an additive rebase base against the stale disk catalog.
snippetsWriteBaseRef.current = null;
} else if (snippetsWriteBaseRef.current === null) {
snippetsWriteBaseRef.current = base;
}
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). Explicit replace (clear / sync restore / import)
// skips the additive rebase so a concurrent disk-only add cannot survive.
const writePromise = withVaultImportLock("vault", async () => {
if (snippetsWriteOwnerRef.current !== ver) return "superseded" as const;
const rebased = normalizeVaultOrder(
replace
? cleaned
: rebaseSnippetVaultWrite({
base,
ours: cleaned,
theirs: normalizeVaultOrder(
readStoredArray<Snippet>(
STORAGE_KEY_SNIPPETS,
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
),
),
}),
);
const persisted = localStorageAdapter.write(STORAGE_KEY_SNIPPETS, rebased);
if (!persisted) {
// Keep the pre-update ancestor. Clearing it here would make the next
// save rebase against the optimistic array while disk still lacks the
// add, so rebase would treat the add as a concurrent delete.
notify.error(
"Snippets could not be saved. Free some local storage space and try again.",
"Scripts",
);
return "failed" as const;
}
// Disk caught up; next save should treat this write as its ancestor.
snippetsWriteBaseRef.current = null;
snippetsWriteReplaceRef.current = false;
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;
// Persisted delete is the new ancestor; drop any stale rebase base from
// a superseded/queued updateSnippets that never landed.
snippetsWriteBaseRef.current = null;
snippetsWriteReplaceRef.current = false;
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);
@@ -835,7 +1038,7 @@ export const useVaultState = () => {
updateKeys([]);
updateIdentities([]);
updateProxyProfiles([]);
updateSnippets([]);
updateSnippets([], { replace: true });
updateSnippetPackages([]);
updateNotes([]);
updateNoteGroups([]);
@@ -1026,17 +1229,14 @@ export const useVaultState = () => {
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
if (ver !== hostsWriteVersion.current) return;
return withVaultImportLock("vault", async () => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
return commitEncryptedHostsUnderVaultLock(ver, updated, enc);
});
hostsWritePendingRef.current = writePromise;
return updated;
});
return newHost;
}, [hosts]);
}, [commitEncryptedHostsUnderVaultLock, hosts]);
useEffect(() => {
let cancelled = false;
@@ -1062,15 +1262,21 @@ export const useVaultState = () => {
readLegacyLineTimestampsEnabled(),
),
);
hostsRef.current = sanitized;
setHosts(sanitized);
// Always re-encrypt the batch. Stale enc:v1 placeholders are left
// unchanged by encryptCredentialValue (no double-wrap); plaintext
// siblings still need encryption when safeStorage was previously
// unavailable for some records.
encryptHosts(sanitized).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
// unavailable for some records. Route through the locked writer so
// a concurrent snippet delete can prune login/connect bindings
// before this migration blob lands.
const encryptPromise = encryptHosts(sanitized);
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
if (ver !== hostsWriteVersion.current) return;
return commitEncryptedHostsUnderVaultLock(ver, sanitized, enc);
});
hostsWritePendingRef.current = writePromise;
}
} else {
updateHosts(INITIAL_HOSTS);
@@ -1163,8 +1369,43 @@ export const useVaultState = () => {
if (savedSnippets) {
const orderedSnippets = normalizeVaultOrder(savedSnippets);
snippetsRef.current = orderedSnippets;
setSnippets(orderedSnippets);
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, orderedSnippets);
// Persist order backfill only when fields changed. Never rewrite the
// pre-lock snapshot unlocked: another renderer can delete under the
// vault lock after this read and before this write, resurrecting
// snippets while host-binding cleanup stays committed. Re-normalize
// the live disk catalog under the same lock as deleteSelectedSnippets.
const needsOrderPersist = orderedSnippets.some(
(snippet, index) => snippet !== savedSnippets[index],
);
if (needsOrderPersist) {
const ver = ++snippetsWriteVersion.current;
snippetsWriteOwnerRef.current = ver;
const writePromise = withVaultImportLock("vault", async () => {
if (snippetsWriteOwnerRef.current !== ver) return "superseded" as const;
const latest = normalizeVaultOrder(
readStoredArray<Snippet>(
STORAGE_KEY_SNIPPETS,
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
),
);
const persisted = localStorageAdapter.write(STORAGE_KEY_SNIPPETS, latest);
if (!persisted) {
notify.error(
"Snippets could not be saved. Free some local storage space and try again.",
"Scripts",
);
return "failed" as const;
}
if (snippetsWriteOwnerRef.current === ver) {
snippetsRef.current = latest;
setSnippets(latest);
}
return "written" as const;
});
snippetsWritePendingRef.current = writePromise;
}
}
else updateSnippets(INITIAL_SNIPPETS);
@@ -1251,7 +1492,7 @@ export const useVaultState = () => {
return () => {
cancelled = true;
};
}, [updateHosts, updateSnippets]);
}, [commitEncryptedHostsUnderVaultLock, updateHosts, updateSnippets]);
useEffect(() => {
if (!isInitialized) return;
@@ -1356,9 +1597,44 @@ export const useVaultState = () => {
}
if (key === STORAGE_KEY_SNIPPETS) {
const next = safeParse<Snippet[]>(event.newValue) ?? [];
// StorageEvent.newValue is the value at fire time. A peer write can be
// delivered only after this window's replace already committed and
// cleared snippetsWriteReplaceRef — adopting that older payload would
// resurrect the pre-replacement catalog in memory (and on the next edit).
if (event.newValue !== localStorageAdapter.readString(STORAGE_KEY_SNIPPETS)) {
return;
}
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));
const pendingBase = snippetsWriteBaseRef.current;
if (pendingBase !== null) {
// A queued local save still owns an outstanding ancestor. Replacing
// optimistic state with the remote snapshot (and clearing that base)
// would make the next local edit derive from remote-only and drop the
// first mutation when it supersedes the queued owner.
const merged = normalizeVaultOrder(
rebaseSnippetVaultWrite({
base: pendingBase,
ours: snippetsRef.current,
theirs: next,
}),
);
snippetsRef.current = merged;
setSnippets(merged);
return;
}
if (snippetsWriteReplaceRef.current) {
// Clear/restore/import left base null while replace is outstanding.
// Adopting the remote catalog here would pollute memory; a later local
// edit would still run in replace mode and overwrite disk with the old
// catalog plus that edit.
return;
}
snippetsRef.current = next;
setSnippets(next);
return;
}
@@ -1473,15 +1749,12 @@ export const useVaultState = () => {
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
if (ver !== hostsWriteVersion.current) return;
return withVaultImportLock("vault", async () => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
return commitEncryptedHostsUnderVaultLock(ver, next, enc);
});
hostsWritePendingRef.current = writePromise;
return next;
});
}, []);
}, [commitEncryptedHostsUnderVaultLock]);
const updateHostDistro = useCallback((hostId: string, distro: string) => {
const normalized = normalizeDistroId(distro);
@@ -1494,15 +1767,12 @@ export const useVaultState = () => {
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
if (ver !== hostsWriteVersion.current) return;
return withVaultImportLock("vault", async () => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
return commitEncryptedHostsUnderVaultLock(ver, next, enc);
});
hostsWritePendingRef.current = writePromise;
return next;
});
}, []);
}, [commitEncryptedHostsUnderVaultLock]);
const exportData = useCallback(
(): ExportableVaultData => ({
@@ -1528,7 +1798,13 @@ 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) {
// Full-snapshot restore/import must replace, not additive-rebase; otherwise
// a concurrent disk-only snippet survives and the restore looks incomplete.
encryptedWrites.push(
updateSnippets(payload.snippets, { replace: true }).then(() => undefined),
);
}
if (payload.customGroups) updateCustomGroups(payload.customGroups);
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
if (payload.notes) updateNotes(payload.notes);
@@ -1675,6 +1951,9 @@ export const useVaultState = () => {
[STORAGE_KEY_GROUP_CONFIGS, encryptedGroupConfigs],
]);
customGroupsRef.current = nextGroups;
snippetsRef.current = nextSnippets;
snippetsWriteBaseRef.current = null;
snippetsWriteReplaceRef.current = false;
setHosts(nextHosts);
setKeys(nextKeys);
setIdentities(nextIdentities);
@@ -1720,6 +1999,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
+312 -1
View File
@@ -1,7 +1,21 @@
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,
pruneHostsStaleSnippetBindings,
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 +71,300 @@ test('deleteSelectedSnippetsFromVault removes host bindings for every selected s
assert.equal(hosts[0].loginScriptId, 'login');
assert.deepEqual(hosts[0].connectScriptIds, ['connect', 'keep']);
});
test('pruneHostsStaleSnippetBindings drops bindings to missing snippets', () => {
const snippets: Snippet[] = [
{
id: 'keep',
label: 'Keep',
command: 'echo keep',
kind: 'script',
trigger: 'onConnect',
},
];
const hosts: Host[] = [
{
id: 'host-a',
name: 'Host A',
host: 'host-a.example.com',
port: 22,
username: 'root',
loginScriptId: 'gone',
connectScriptIds: ['gone', 'keep'],
},
];
const pruned = pruneHostsStaleSnippetBindings(hosts, snippets);
assert.notEqual(pruned, hosts);
assert.equal(pruned[0].loginScriptId, undefined);
assert.deepEqual(pruned[0].connectScriptIds, ['keep']);
assert.equal(
pruneHostsStaleSnippetBindings(pruned, snippets),
pruned,
);
});
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 local adds when base is the persisted ancestor', () => {
// Queued save 1 added X (never persisted); save 2 edited X. Rebase must use
// the disk ancestor — not save 1's optimistic array — or X looks deleted.
const persisted: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
];
const afterAdd: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
{ id: 'x', label: 'X', command: 'echo x' },
];
const afterEdit: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
{ id: 'x', label: 'X edited', command: 'echo x2' },
];
const wrongBase = rebaseSnippetVaultWrite({
base: afterAdd,
ours: afterEdit,
theirs: persisted,
});
assert.deepEqual(wrongBase.map((snippet) => snippet.id), ['a']);
const merged = rebaseSnippetVaultWrite({
base: persisted,
ours: afterEdit,
theirs: persisted,
});
assert.deepEqual(merged.map((snippet) => snippet.id), ['a', 'x']);
assert.equal(merged[1]?.label, 'X edited');
assert.equal(merged[1]?.command, 'echo x2');
});
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 preserves concurrent disk adds across a local clear', () => {
// Documents why clearVaultData must bypass rebase (replace: true): an empty
// local array still looks like "keep theirs-only ids" under 3-way merge.
const base: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
];
const ours: Snippet[] = [];
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), ['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']);
});
test('rebaseSnippetVaultWrite preserves concurrent disk edits of unrelated snippets', () => {
const base: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a', order: 1000 },
{ id: 'b', label: 'B', command: 'echo b', order: 2000 },
];
// Local edit/reorder of A only; B content matches base (order may renumber).
const ours: Snippet[] = [
{ id: 'a', label: 'A edited', command: 'echo a2', order: 1000 },
{ id: 'b', label: 'B', command: 'echo b', order: 2000 },
];
const theirs: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a', order: 1000 },
{ id: 'b', label: 'B edited elsewhere', command: 'echo b2', order: 2000 },
];
const merged = rebaseSnippetVaultWrite({ base, ours, theirs });
assert.deepEqual(merged.map((snippet) => snippet.id), ['a', 'b']);
assert.equal(merged[0]?.label, 'A edited');
assert.equal(merged[1]?.label, 'B edited elsewhere');
assert.equal(merged[1]?.command, 'echo b2');
});
test('rebaseSnippetVaultWrite prefers local when both sides edited the same snippet', () => {
const base: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
];
const ours: Snippet[] = [
{ id: 'a', label: 'A local', command: 'echo local' },
];
const theirs: Snippet[] = [
{ id: 'a', label: 'A remote', command: 'echo remote' },
];
const merged = rebaseSnippetVaultWrite({ base, ours, theirs });
assert.equal(merged[0]?.label, 'A local');
assert.equal(merged[0]?.command, 'echo local');
});
test('rebaseSnippetVaultWrite treats order-only local renumber as unchanged content', () => {
const base: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a', order: 1000 },
{ id: 'b', label: 'B', command: 'echo b', order: 2000 },
];
// Local reorder renumbers every row without editing B's body.
const ours: Snippet[] = [
{ id: 'b', label: 'B', command: 'echo b', order: 1000 },
{ id: 'a', label: 'A', command: 'echo a', order: 2000 },
];
const theirs: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a', order: 1000 },
{ id: 'b', label: 'B disk', command: 'echo b2', order: 2000 },
];
const merged = rebaseSnippetVaultWrite({ base, ours, theirs });
assert.deepEqual(merged.map((snippet) => snippet.id), ['b', 'a']);
assert.equal(merged[0]?.label, 'B disk');
assert.equal(merged[0]?.command, 'echo b2');
// Keep local order field so the reorder is not undone by disk's stale order.
assert.equal(merged[0]?.order, 1000);
assert.equal(merged[1]?.order, 2000);
});
test('rebaseSnippetVaultWrite preserves concurrent disk reorder across unrelated local edits', () => {
const base: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a', order: 1000 },
{ id: 'b', label: 'B', command: 'echo b', order: 2000 },
];
// Local content edit of A only; relative order matches base.
const ours: Snippet[] = [
{ id: 'a', label: 'A edited', command: 'echo a2', order: 1000 },
{ id: 'b', label: 'B', command: 'echo b', order: 2000 },
];
// Another window reordered the list on disk.
const theirs: Snippet[] = [
{ id: 'b', label: 'B', command: 'echo b', order: 1000 },
{ id: 'a', label: 'A', command: 'echo a', order: 2000 },
];
const merged = rebaseSnippetVaultWrite({ base, ours, theirs });
assert.deepEqual(merged.map((snippet) => snippet.id), ['b', 'a']);
assert.equal(merged[0]?.order, 1000);
assert.equal(merged[1]?.label, 'A edited');
assert.equal(merged[1]?.command, 'echo a2');
assert.equal(merged[1]?.order, 2000);
});
test('rebaseSnippetVaultWrite preserves disk insertion position across unrelated local edits', () => {
const base: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a', order: 1000 },
{ id: 'b', label: 'B', command: 'echo b', order: 2000 },
];
// Local content edit only; shared-id sequence still matches base.
const ours: Snippet[] = [
{ id: 'a', label: 'A edited', command: 'echo a2', order: 1000 },
{ id: 'b', label: 'B', command: 'echo b', order: 2000 },
];
// Another window inserted X between A and B and renumbered orders.
const theirs: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a', order: 1000 },
{ id: 'x', label: 'X', command: 'echo x', order: 2000 },
{ id: 'b', label: 'B', command: 'echo b', order: 3000 },
];
const merged = rebaseSnippetVaultWrite({ base, ours, theirs });
assert.deepEqual(merged.map((snippet) => snippet.id), ['a', 'x', 'b']);
assert.equal(merged[0]?.label, 'A edited');
// Renumber so sortByVaultOrder matches the anchored sequence (X must not
// share B's pre-merge order of 2000).
assert.equal(merged[1]?.label, 'X');
assert.equal(merged[0]?.order, 1000);
assert.equal(merged[1]?.order, 2000);
assert.equal(merged[2]?.order, 3000);
});
test('rebaseSnippetVaultWrite preserves remote insertion anchors alongside local additions', () => {
const base: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a', order: 1000 },
{ id: 'b', label: 'B', command: 'echo b', order: 2000 },
];
// Local append — must not force whole-list "ours first" ordering.
const ours: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a', order: 1000 },
{ id: 'b', label: 'B', command: 'echo b', order: 2000 },
{ id: 'l', label: 'Local', command: 'echo l', order: 3000 },
];
// Remote inserted R between A and B.
const theirs: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a', order: 1000 },
{ id: 'r', label: 'Remote', command: 'echo r', order: 2000 },
{ id: 'b', label: 'B', command: 'echo b', order: 3000 },
];
const merged = rebaseSnippetVaultWrite({ base, ours, theirs });
assert.deepEqual(merged.map((snippet) => snippet.id), ['a', 'r', 'b', 'l']);
assert.deepEqual(
merged.map((snippet) => snippet.order),
[1000, 2000, 3000, 4000],
);
});
test('rebaseSnippetVaultWrite preserves disk reorder alongside a local addition', () => {
const base: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a', order: 1000 },
{ id: 'b', label: 'B', command: 'echo b', order: 2000 },
];
// Local window only added X; shared-id sequence still matches base.
const ours: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a', order: 1000 },
{ id: 'b', label: 'B', command: 'echo b', order: 2000 },
{ id: 'x', label: 'X', command: 'echo x', order: 3000 },
];
// Another window reordered existing snippets on disk.
const theirs: Snippet[] = [
{ id: 'b', label: 'B', command: 'echo b', order: 1000 },
{ id: 'a', label: 'A', command: 'echo a', order: 2000 },
];
const merged = rebaseSnippetVaultWrite({ base, ours, theirs });
assert.deepEqual(merged.map((snippet) => snippet.id), ['b', 'a', 'x']);
assert.equal(merged[0]?.order, 1000);
assert.equal(merged[1]?.order, 2000);
assert.equal(merged[2]?.label, 'X');
});
+288
View File
@@ -1,5 +1,20 @@
import type { Host, Snippet } from './models';
import { deleteSnippetFromVault } from './snippetAgentOps.ts';
import { renumberVaultOrder } from './vaultOrder.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[],
@@ -21,3 +36,276 @@ export function deleteSelectedSnippetsFromVault(
return { snippets: nextSnippets, hosts: nextHosts, deletedCount };
}
/**
* Drop login/connect script bindings that point at snippets absent from the
* latest catalog. A queued full-array host write that encrypted before a
* concurrent bulk-delete must not restore those bindings after the delete
* releases the vault lock.
*/
export function pruneStaleHostSnippetBindings(
host: Host,
snippetIds: ReadonlySet<string>,
): Host {
let next = host;
if (host.loginScriptId && !snippetIds.has(host.loginScriptId)) {
next = { ...next, loginScriptId: undefined };
}
const connectIds = next.connectScriptIds;
if (connectIds?.length) {
const pruned = connectIds.filter((id) => Boolean(id) && snippetIds.has(id));
if (
pruned.length !== connectIds.length
|| pruned.some((id, index) => id !== connectIds[index])
) {
next = {
...next,
connectScriptIds: pruned.length > 0 ? pruned : undefined,
};
}
}
return next;
}
/** Returns `hosts` unchanged when every binding still resolves. */
export function pruneHostsStaleSnippetBindings(
hosts: readonly Host[],
snippets: readonly Snippet[],
): Host[] {
const snippetIds = new Set<string>();
for (const snippet of snippets) {
if (snippet.id) snippetIds.add(snippet.id);
}
let changed = false;
const next = hosts.map((host) => {
const pruned = pruneStaleHostSnippetBindings(host, snippetIds);
if (pruned !== host) changed = true;
return pruned;
});
return changed ? next : (hosts as Host[]);
}
/**
* Content fingerprint for three-way rebase. Omits `order` so a local reorder
* (which renumbers every row) does not look like an edit of unrelated snippets.
*/
function snippetContentFingerprint(snippet: Snippet): string {
const { order: _order, ...content } = snippet;
return JSON.stringify(content, (_key, value) => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return Object.keys(value).sort().reduce<Record<string, unknown>>((acc, key) => {
acc[key] = (value as Record<string, unknown>)[key];
return acc;
}, {});
}
return value;
});
}
/** Relative id sequence for ids present on both sides (order-change detector). */
function sharedIdSequence(
snippets: readonly Snippet[],
sharedIds: ReadonlySet<string>,
): string[] {
const sequence: string[] = [];
for (const snippet of snippets) {
if (!snippet.id || !sharedIds.has(snippet.id)) continue;
sequence.push(snippet.id);
}
return sequence;
}
function sameIdSequence(left: readonly string[], right: readonly string[]): boolean {
if (left.length !== right.length) return false;
return left.every((id, index) => id === right[index]);
}
function applyPreferredOrder(
content: Snippet,
ourItem: Snippet,
theirItem: Snippet,
preferTheirSharedOrder: boolean,
): Snippet {
const order = preferTheirSharedOrder ? theirItem.order : ourItem.order;
return content.order === order ? content : { ...content, order };
}
/**
* Place ids that are new relative to `baseIds` into `result`, preserving each
* side's insertion anchors (between left/right neighbors already present).
* A trailing insertion (no right neighbor) appends so a local add stays at the
* end after an unrelated disk reorder of shared ids.
*/
function placeInsertions(
result: string[],
side: readonly Snippet[],
baseIds: ReadonlySet<string>,
keep: ReadonlyMap<string, Snippet>,
): void {
const present = new Set(result);
for (let index = 0; index < side.length; index += 1) {
const id = side[index]?.id;
if (!id || !keep.has(id) || baseIds.has(id) || present.has(id)) continue;
let left: string | null = null;
for (let j = index - 1; j >= 0; j -= 1) {
const prev = side[j]?.id;
if (prev && present.has(prev)) {
left = prev;
break;
}
}
let right: string | null = null;
for (let j = index + 1; j < side.length; j += 1) {
const next = side[j]?.id;
if (next && present.has(next)) {
right = next;
break;
}
}
if (right === null) {
result.push(id);
} else if (left === null) {
result.splice(result.indexOf(right), 0, id);
} else {
const leftIdx = result.indexOf(left);
const rightIdx = result.indexOf(right);
if (leftIdx < rightIdx) {
result.splice(rightIdx, 0, id);
} else {
result.splice(leftIdx + 1, 0, id);
}
}
present.add(id);
}
}
/**
* 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.
* When an id exists on all sides, preserve a disk-only content edit; both-sides
* content conflicts prefer the local write (same as sync merge).
* List order is merged independently: shared-id reorder picks a backbone, then
* each side's insertions are placed by their own anchors so a local append does
* not discard a remote mid-list insertion (and the reverse).
*/
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(),
]);
const baseIds = new Set<string>();
const baseOursIds = new Set<string>();
const baseTheirsIds = new Set<string>();
for (const id of baseMap.keys()) {
if (!id) continue;
baseIds.add(id);
if (oursMap.has(id)) baseOursIds.add(id);
if (theirsMap.has(id)) baseTheirsIds.add(id);
}
// Shared-id sequences ignore insertions. Detect reorder of existing ids
// separately from new-id placement so a local add does not look like a
// reorder that discards an unrelated disk reorder.
const oursSharedReordered = !sameIdSequence(
sharedIdSequence(base, baseOursIds),
sharedIdSequence(ours, baseOursIds),
);
const theirsSharedReordered = !sameIdSequence(
sharedIdSequence(base, baseTheirsIds),
sharedIdSequence(theirs, baseTheirsIds),
);
// Backbone follows disk only for a disk-only shared reorder. Insertions are
// merged by anchor below — not by picking one side's entire list order.
const preferTheirSharedOrder = !oursSharedReordered && theirsSharedReordered;
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) {
const oursChanged =
snippetContentFingerprint(ourItem) !== snippetContentFingerprint(baseItem);
const theirsChanged =
snippetContentFingerprint(theirItem) !== snippetContentFingerprint(baseItem);
if (!oursChanged && theirsChanged) {
// Disk-only content edit: keep their body; order follows shared reorder.
keep.set(
id,
applyPreferredOrder(theirItem, ourItem, theirItem, preferTheirSharedOrder),
);
} else {
// Unchanged, ours-only, or both-changed conflict → local content wins.
keep.set(
id,
applyPreferredOrder(ourItem, ourItem, theirItem, preferTheirSharedOrder),
);
}
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 backboneSide = preferTheirSharedOrder ? theirs : ours;
const secondarySide = preferTheirSharedOrder ? ours : theirs;
const orderedIds: string[] = [];
const seen = new Set<string>();
for (const snippet of backboneSide) {
const id = snippet.id;
if (!id || !keep.has(id) || !baseIds.has(id) || seen.has(id)) continue;
orderedIds.push(id);
seen.add(id);
}
// Place primary then secondary insertions so each side keeps its anchors.
placeInsertions(orderedIds, backboneSide, baseIds, keep);
placeInsertions(orderedIds, secondarySide, baseIds, keep);
const ordered: Snippet[] = [];
for (const id of orderedIds) {
const kept = keep.get(id);
if (!kept) continue;
ordered.push(kept);
}
// Anchored merge can leave duplicate finite `order` values (e.g. remote
// insertion X at 2000 while local B still has 2000). normalizeVaultOrder
// preserves finite orders, so renumber to match the merged sequence.
return renumberVaultOrder(ordered);
}