mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa8412d70c | ||
|
|
7bb4e96c55 | ||
|
|
7a66161355 | ||
|
|
edfe5ba517 | ||
|
|
7bce9e55e3 | ||
|
|
0cb692b17e | ||
|
|
337191382a | ||
|
|
18b19b1ccc | ||
|
|
ce2611d868 | ||
|
|
a0dce456fa | ||
|
|
2c7c0f9fc4 | ||
|
|
db2152dcfa | ||
|
|
877df24f59 | ||
|
|
40d114eab6 |
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/,
|
||||
);
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user