fix(vault): finish dynamic group targeting

This commit is contained in:
bincxz committed 2026-08-12 22:00:18 +08:00
1 parent 27dc1c62af
commit 189db57625
20 files changed
+370 -85

No files matched your search

+1
View File
@@ -20,6 +20,7 @@ export const enScriptsMessages = {
'scripts.editor.resize': 'Resize editor',
'scripts.targets.hint': 'Selected groups are resolved dynamically, so hosts added later are included automatically.',
'scripts.targets.connectOrderHint': 'Run order for connect scripts is configured per host under Host details → Automation.',
'scripts.targets.currentHostMismatch': 'This script is not assigned to the current host.',
'hostDetails.automation.groupScripts': 'Inherited group scripts',
'hostDetails.automation.groupScriptsHint': 'These scripts follow the host group dynamically and are ordered before the host-specific queue.',
'scripts.actions.runNowHint': 'Run on selected targets, or on all connectable hosts when that option is enabled.',
+1
View File
@@ -20,6 +20,7 @@ export const ruScriptsMessages = {
'scripts.editor.resize': 'Изменить размер редактора',
'scripts.targets.hint': 'Выбранные группы разрешаются динамически, поэтому новые хосты включаются автоматически.',
'scripts.targets.connectOrderHint': 'Порядок запуска скриптов подключения настраивается для каждого хоста в разделе «Сведения о хосте → Автоматизация».',
'scripts.targets.currentHostMismatch': 'Этот скрипт не назначен текущему хосту.',
'hostDetails.automation.groupScripts': 'Унаследованные скрипты группы',
'hostDetails.automation.groupScriptsHint': 'Эти скрипты динамически следуют за группой хоста и выполняются перед очередью хоста.',
'scripts.actions.runNowHint': 'Запускается для выбранных целей либо для всех доступных для подключения хостов, если включена эта настройка.',
@@ -20,6 +20,7 @@ export const zhCNScriptsMessages = {
'scripts.editor.resize': '调整编辑器高度',
'scripts.targets.hint': '所选分组会动态匹配,之后加入分组的主机也会自动生效。',
'scripts.targets.connectOrderHint': '连接脚本的执行顺序请在主机详情 → 自动化 中调整。',
'scripts.targets.currentHostMismatch': '此脚本未分配给当前主机。',
'hostDetails.automation.groupScripts': '分组继承脚本',
'hostDetails.automation.groupScriptsHint': '这些脚本动态跟随主机所在分组,并在主机专属队列之前执行。',
'scripts.actions.runNowHint': '在所选目标主机上运行;勾选「对所有主机生效」时在所有可连接主机上运行。',
@@ -20,6 +20,7 @@ export const zhTWScriptsMessages = {
'scripts.editor.resize': '調整編輯器高度',
'scripts.targets.hint': '所選分組會動態比對,之後加入分組的主機也會自動生效。',
'scripts.targets.connectOrderHint': '連線指令碼的執行順序請在主機詳情 → 自動化 中調整。',
'scripts.targets.currentHostMismatch': '此指令碼未分配給目前主機。',
'hostDetails.automation.groupScripts': '分組繼承指令碼',
'hostDetails.automation.groupScriptsHint': '這些指令碼會動態跟隨主機所在分組,並在主機專屬佇列之前執行。',
'scripts.actions.runNowHint': '在所選目標主機上執行;勾選「對所有主機生效」時在所有可連線的主機上執行。',
+52 -20
View File
@@ -301,6 +301,7 @@ export const useVaultState = () => {
const managedSourcesRef = useRef<ManagedSource[]>([]);
const hostsRef = useRef<Host[]>([]);
const snippetsRef = useRef<Snippet[]>([]);
const groupConfigsRef = useRef<GroupConfig[]>([]);
const notesRef = useRef<VaultNote[]>([]);
const noteGroupsRef = useRef<string[]>([]);
const notesPersistFailureNotifiedAtRef = useRef(0);
@@ -308,6 +309,7 @@ export const useVaultState = () => {
managedSourcesRef.current = managedSources;
hostsRef.current = hosts;
snippetsRef.current = snippets;
groupConfigsRef.current = groupConfigs;
notesRef.current = notes;
noteGroupsRef.current = noteGroups;
@@ -870,6 +872,7 @@ export const useVaultState = () => {
setCustomGroups(next);
const cleanedGroupConfigs = buildGroupConfigsForGroups(next, groupConfigs);
groupConfigsRef.current = cleanedGroupConfigs;
setGroupConfigs(cleanedGroupConfigs);
const configsVer = ++groupConfigsWriteVersion.current;
const encryptPromise = encryptGroupConfigs(cleanedGroupConfigs);
@@ -914,14 +917,30 @@ export const useVaultState = () => {
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
lock?: VaultLockHandle | null,
): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> => {
const runCommit = async (): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> => {
const versions = {
hosts: hostsWriteVersion.current,
snippets: snippetsWriteVersion.current,
groups: customGroupsWriteVersion.current,
configs: groupConfigsWriteVersion.current,
sources: managedSourcesWriteVersion.current,
};
const captureVersions = () => ({
hosts: hostsWriteVersion.current,
snippets: snippetsWriteVersion.current,
groups: customGroupsWriteVersion.current,
configs: groupConfigsWriteVersion.current,
sources: managedSourcesWriteVersion.current,
});
const versionsAreCurrent = (versions: ReturnType<typeof captureVersions>) => (
versions.hosts === hostsWriteVersion.current
&& versions.snippets === snippetsWriteVersion.current
&& versions.groups === customGroupsWriteVersion.current
&& versions.configs === groupConfigsWriteVersion.current
&& versions.sources === managedSourcesWriteVersion.current
);
const stateMatchesLiveSnapshot = (current: VaultGroupMutationState) => (
JSON.stringify(current.hosts) === JSON.stringify(hostsRef.current)
&& JSON.stringify(current.snippets) === JSON.stringify(snippetsRef.current)
&& JSON.stringify(current.groups) === JSON.stringify(customGroupsRef.current)
&& JSON.stringify(current.configs) === JSON.stringify(groupConfigsRef.current)
&& JSON.stringify(current.managedSources) === JSON.stringify(managedSourcesRef.current)
);
const runCommit = async (
versions: ReturnType<typeof captureVersions>,
): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> => {
const result = await commitVaultGroupMutationPersistence({
storage: localStorageAdapter,
mutate,
@@ -943,13 +962,8 @@ export const useVaultState = () => {
),
encryptHosts,
encryptConfigs: encryptGroupConfigs,
isCurrent: () => (
versions.hosts === hostsWriteVersion.current
&& versions.snippets === snippetsWriteVersion.current
&& versions.groups === customGroupsWriteVersion.current
&& versions.configs === groupConfigsWriteVersion.current
&& versions.sources === managedSourcesWriteVersion.current
),
isCurrent: () => versionsAreCurrent(versions),
validateCurrent: lock ? stateMatchesLiveSnapshot : undefined,
});
if (!result.ok) return result;
const nextState = result.state;
@@ -970,13 +984,18 @@ export const useVaultState = () => {
setCustomGroups(nextState.groups);
setManagedSources(nextState.managedSources);
setGroupConfigs(nextState.configs);
groupConfigsRef.current = nextState.configs;
return { ok: true, state: nextState };
};
if (lock) return withVaultImportLockIfNeeded("vault", runCommit, lock);
if (lock) {
const versions = captureVersions();
return withVaultImportLockIfNeeded("vault", () => runCommit(versions), lock);
}
while (true) {
await waitForPendingVaultWrites();
const result = await withVaultImportLock("vault", runCommit);
const versions = captureVersions();
const result = await withVaultImportLock("vault", () => runCommit(versions));
if (!("superseded" in result)) return result;
}
}, [waitForPendingVaultWrites]);
@@ -1090,6 +1109,8 @@ export const useVaultState = () => {
++groupConfigsWriteVersion.current;
customGroupsRef.current = result.groups;
managedSourcesRef.current = result.sources;
hostsRef.current = cleanedHosts;
groupConfigsRef.current = nextGroupConfigs;
setHosts(cleanedHosts);
setCustomGroups(result.groups);
setManagedSources(result.sources);
@@ -1112,6 +1133,7 @@ export const useVaultState = () => {
// sit in memory and re-persist with `fontFamilyOverride: true` until
// the next reload. Mirrors updateHosts → sanitizeHost.
const cleaned = normalizeVaultOrder(data.map(sanitizeGroupConfig));
groupConfigsRef.current = cleaned;
setGroupConfigs(cleaned);
const ver = ++groupConfigsWriteVersion.current;
const encryptPromise = encryptGroupConfigs(cleaned);
@@ -1555,7 +1577,10 @@ export const useVaultState = () => {
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
STORAGE_KEY_MANAGED_SOURCES,
);
if (savedManagedSources) setManagedSources(savedManagedSources);
if (savedManagedSources) {
managedSourcesRef.current = savedManagedSources;
setManagedSources(savedManagedSources);
}
// Load group configs
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
@@ -1565,6 +1590,7 @@ export const useVaultState = () => {
if (cancelled) return;
if (gcVer === groupConfigsWriteVersion.current) {
const sanitizedGC = normalizeVaultOrder(decryptedGC.map(sanitizeGroupConfig));
groupConfigsRef.current = sanitizedGC;
setGroupConfigs(sanitizedGC);
encryptGroupConfigs(sanitizedGC).then((enc) => {
if (gcVer === groupConfigsWriteVersion.current)
@@ -1735,6 +1761,7 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_GROUPS) {
const next = safeParse<string[]>(event.newValue) ?? [];
++customGroupsWriteVersion.current;
customGroupsRef.current = next;
setCustomGroups(next);
return;
}
@@ -1801,8 +1828,11 @@ export const useVaultState = () => {
const seq = ++groupConfigsReadSeq.current;
const writeAtStart = groupConfigsWriteVersion.current;
decryptGroupConfigs(next).then((dec) => {
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
setGroupConfigs(normalizeVaultOrder(dec.map(sanitizeGroupConfig)));
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current) {
const normalized = normalizeVaultOrder(dec.map(sanitizeGroupConfig));
groupConfigsRef.current = normalized;
setGroupConfigs(normalized);
}
});
return;
}
@@ -2048,6 +2078,8 @@ export const useVaultState = () => {
]);
customGroupsRef.current = nextGroups;
snippetsRef.current = nextSnippets;
hostsRef.current = nextHosts;
groupConfigsRef.current = nextGroupConfigs;
snippetsWriteBaseRef.current = null;
snippetsWriteReplaceRef.current = false;
setHosts(nextHosts);
@@ -156,3 +156,65 @@ test('group rename preserves a concurrent snippet update from the locked snapsho
['production/child'],
]);
});
test('group delete cleans targets from the latest persisted snippet snapshot', async () => {
const concurrentSnippet = { ...initialSnippet, command: 'nct.log(2)' };
const addedSnippet: Snippet = {
id: 'script-2', label: 'Check', command: 'nct.log(3)', kind: 'script',
targetGroups: ['prod/child', 'staging'],
};
const storage = createStorage({
...initialVault(),
[STORAGE_KEY_SNIPPETS]: [concurrentSnippet, addedSnippet],
});
const result = await commitVaultGroupMutationPersistence({
storage,
mutate: (current) => ({
ok: true,
state: {
...current,
groups: [],
snippets: removeSnippetTargetGroupPaths(current.snippets, ['prod']),
},
}),
prepareState,
decryptHosts: identity,
decryptConfigs: identity,
encryptHosts: identity,
encryptConfigs: identity,
isCurrent: () => true,
});
assert.equal(result.ok, true);
const saved = storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS) ?? [];
assert.equal(saved[0]?.command, 'nct.log(2)');
assert.deepEqual(saved.map((snippet) => snippet.targetGroups), [
[],
['staging'],
]);
});
test('group mutation stops before writing when memory is ahead of persisted Vault state', async () => {
const storage = createStorage(initialVault());
let mutateCalled = false;
const result = await commitVaultGroupMutationPersistence({
storage,
mutate: (current) => {
mutateCalled = true;
return { ok: true, state: current };
},
prepareState,
decryptHosts: identity,
decryptConfigs: identity,
encryptHosts: identity,
encryptConfigs: identity,
isCurrent: () => true,
validateCurrent: (current) => current.snippets[0]?.command === 'nct.log(2)',
});
assert.deepEqual(result, { ok: false, superseded: true });
assert.equal(mutateCalled, false);
assert.deepEqual(storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS), [initialSnippet]);
});
@@ -38,6 +38,7 @@ export async function commitVaultGroupMutationPersistence({
encryptHosts,
encryptConfigs,
isCurrent,
validateCurrent,
}: {
storage: TransactionStorage;
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult;
@@ -47,6 +48,7 @@ export async function commitVaultGroupMutationPersistence({
encryptHosts: (hosts: Host[]) => Promise<unknown>;
encryptConfigs: (configs: GroupConfig[]) => Promise<unknown>;
isCurrent: () => boolean;
validateCurrent?: (current: VaultGroupMutationState) => boolean;
}): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> {
const raw = new Map(GROUP_MUTATION_KEYS.map((key) => [key, storage.readString(key)]));
const [hosts, configs] = await Promise.all([
@@ -66,6 +68,9 @@ export async function commitVaultGroupMutationPersistence({
),
snippets: readStoredArray<Snippet>(STORAGE_KEY_SNIPPETS, raw.get(STORAGE_KEY_SNIPPETS) ?? null),
};
if (!isCurrent() || (validateCurrent && !validateCurrent(current))) {
return { ok: false, superseded: true };
}
const mutation = mutate(current);
if (!mutation.ok) return mutation;
@@ -12,7 +12,7 @@ export interface VaultHostTreeActions {
onNewGroup: (parentPath?: string) => void;
onRenameGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
commitInlineGroupRename: (name: string) => void;
commitInlineGroupRename: (name: string) => boolean | void | Promise<boolean | void>;
cancelInlineGroupEdit: () => void;
commitInlineHostRename: (name: string) => void;
cancelInlineHostEdit: () => void;
+2 -2
View File
@@ -290,7 +290,7 @@ interface HostTreeViewProps {
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void;
commitInlineGroupRename?: (name: string) => void;
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
cancelInlineGroupEdit?: () => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
@@ -330,7 +330,7 @@ interface TreeNodeProps {
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void;
commitInlineGroupRename?: (name: string) => void;
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
cancelInlineGroupEdit?: () => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
+6 -1
View File
@@ -121,6 +121,7 @@ import { useVaultSnapshotField } from "@/application/state/vaultSnapshotStore.ts
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
import { isScriptSnippet } from "@/domain/snippetScript.ts";
import { snippetCanRunInTerminal } from "@/domain/snippetTargets.ts";
import { useOutputTriggers } from "@/application/state/useOutputTriggers.ts";
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
@@ -3013,6 +3014,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const executeSnippet = useCallback(async (snippet: Snippet) => {
if (isScriptSnippet(snippet)) {
if (!snippetCanRunInTerminal(snippet, host)) {
toast.error(t('scripts.targets.currentHostMismatch'));
return;
}
try {
await runAutomationScript({
snippet,
@@ -3035,7 +3040,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
executeSnippetCommand(command, snippet.noAutoRun, {
multiLineRunMode: snippet.multiLineRunMode,
});
}, [executeSnippetCommand, host.hostname, host.username, scriptSessionName, sessionId, t]);
}, [executeSnippetCommand, host, scriptSessionName, sessionId, t]);
const onSnippetShortkeyRef = useRef(executeSnippet);
onSnippetShortkeyRef.current = executeSnippet;
@@ -1,27 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("./VaultView.tsx", import.meta.url), "utf8");
test("deferred group deletion cleans target paths from the latest snippet snapshot", () => {
assert.match(
source,
/handleDeletedGroupPaths[\s\S]*onUpdateSnippets\(\(currentSnippets\) => \([\s\S]*removeSnippetTargetGroupPaths\(currentSnippets, selectedRoots\)/,
);
assert.doesNotMatch(
source,
/handleDeletedGroupPaths[\s\S]*removeSnippetTargetGroupPaths\(snippets, selectedRoots\)/,
);
});
test("group rename and move remap the latest snippet snapshot", () => {
const functionalRemaps = source.match(
/onUpdateSnippets\(\(currentSnippets\) => \(\s*remapSnippetTargetGroupPaths\(currentSnippets,/g,
);
assert.equal(functionalRemaps?.length, 3);
assert.doesNotMatch(
source,
/remapSnippetTargetGroupPaths\(snippets, (?:renameTargetPath|oldPath|sourcePath),/,
);
});
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { HostTreeGroupInlineRenameInput } from './HostTreeGroupInlineRenameInput';
test('inline group rename can retry after an asynchronous commit failure', async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | null = null;
let attempts = 0;
try {
await act(async () => {
renderer = create(React.createElement(HostTreeGroupInlineRenameInput, {
initialName: 'prod',
onCommit: async () => {
attempts += 1;
return attempts > 1;
},
onCancel: () => undefined,
}));
});
const input = renderer!.root.findByType('input');
await act(async () => {
input.props.onKeyDown({
key: 'Enter',
preventDefault: () => undefined,
stopPropagation: () => undefined,
});
await Promise.resolve();
});
await act(async () => {
input.props.onKeyDown({
key: 'Enter',
preventDefault: () => undefined,
stopPropagation: () => undefined,
});
await Promise.resolve();
});
assert.equal(attempts, 2);
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
@@ -4,7 +4,7 @@ import { cn } from '../../lib/utils';
type HostTreeGroupInlineRenameInputProps = {
initialName: string;
onCommit: (name: string) => void;
onCommit: (name: string) => boolean | void | Promise<boolean | void>;
onCancel: () => void;
className?: string;
style?: React.CSSProperties;
@@ -19,7 +19,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState(initialName);
const committedRef = useRef(false);
const submittingRef = useRef(false);
useEffect(() => {
const input = inputRef.current;
@@ -28,15 +28,20 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
input.select();
}, []);
const commit = () => {
if (committedRef.current) return;
committedRef.current = true;
onCommit(value);
const commit = async () => {
if (submittingRef.current) return;
submittingRef.current = true;
try {
const committed = await onCommit(value);
if (committed === false) submittingRef.current = false;
} catch {
submittingRef.current = false;
}
};
const cancel = () => {
if (committedRef.current) return;
committedRef.current = true;
if (submittingRef.current) return;
submittingRef.current = true;
onCancel();
};
@@ -49,7 +54,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
onChange={(event) => setValue(event.target.value)}
onBlur={() => {
queueMicrotask(() => {
commit();
void commit();
});
}}
onClick={(event) => event.stopPropagation()}
@@ -64,7 +69,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
event.stopPropagation();
if (event.key === 'Enter') {
event.preventDefault();
commit();
void commit();
}
if (event.key === 'Escape') {
event.preventDefault();
+43
View File
@@ -2,6 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import {
VaultTreeGroupRow,
@@ -94,3 +95,45 @@ test("VaultTreeInlineRenameInput uses shared inline edit marker", () => {
assert.match(markup, /data-vault-tree-inline-edit="true"/);
assert.match(markup, /value="Ops"/);
});
test("VaultTreeInlineRenameInput can retry after an asynchronous commit failure", async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | null = null;
let attempts = 0;
try {
await act(async () => {
renderer = create(
<VaultTreeInlineRenameInput
initialName="Ops"
onCommit={async () => {
attempts += 1;
return attempts > 1;
}}
onCancel={() => undefined}
/>,
);
});
const input = renderer!.root.findByType("input");
const pressEnter = async () => {
input.props.onKeyDown({
key: "Enter",
preventDefault: () => undefined,
stopPropagation: () => undefined,
});
await Promise.resolve();
};
await act(pressEnter);
await act(pressEnter);
assert.equal(attempts, 2);
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
+17 -12
View File
@@ -5,7 +5,7 @@ import { cn } from "../../lib/utils";
type VaultTreeInlineRenameInputProps = {
initialName: string;
onCommit: (name: string) => void;
onCommit: (name: string) => boolean | void | Promise<boolean | void>;
onCancel: () => void;
className?: string;
style?: React.CSSProperties;
@@ -20,7 +20,7 @@ export const VaultTreeInlineRenameInput: React.FC<VaultTreeInlineRenameInputProp
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState(initialName);
const committedRef = useRef(false);
const submittingRef = useRef(false);
useEffect(() => {
const input = inputRef.current;
@@ -29,15 +29,20 @@ export const VaultTreeInlineRenameInput: React.FC<VaultTreeInlineRenameInputProp
input.select();
}, []);
const commit = () => {
if (committedRef.current) return;
committedRef.current = true;
onCommit(value);
const commit = async () => {
if (submittingRef.current) return;
submittingRef.current = true;
try {
const committed = await onCommit(value);
if (committed === false) submittingRef.current = false;
} catch {
submittingRef.current = false;
}
};
const cancel = () => {
if (committedRef.current) return;
committedRef.current = true;
if (submittingRef.current) return;
submittingRef.current = true;
onCancel();
};
@@ -51,7 +56,7 @@ export const VaultTreeInlineRenameInput: React.FC<VaultTreeInlineRenameInputProp
onChange={(event) => setValue(event.target.value)}
onBlur={() => {
queueMicrotask(() => {
commit();
void commit();
});
}}
onClick={(event) => event.stopPropagation()}
@@ -66,7 +71,7 @@ export const VaultTreeInlineRenameInput: React.FC<VaultTreeInlineRenameInputProp
event.stopPropagation();
if (event.key === "Enter") {
event.preventDefault();
commit();
void commit();
}
if (event.key === "Escape") {
event.preventDefault();
@@ -91,7 +96,7 @@ type VaultTreeGroupRowProps = Omit<React.HTMLAttributes<HTMLDivElement>, "childr
hasChildren?: boolean;
editing?: boolean;
editingInitialName?: string;
onRenameCommit?: (name: string) => void;
onRenameCommit?: (name: string) => boolean | void | Promise<boolean | void>;
onRenameCancel?: () => void;
actions?: React.ReactNode;
labelActions?: React.ReactNode;
@@ -190,7 +195,7 @@ type VaultTreeItemRowProps = Omit<React.HTMLAttributes<HTMLDivElement>, "childre
actions?: React.ReactNode;
editing?: boolean;
editingInitialName?: string;
onRenameCommit?: (name: string) => void;
onRenameCommit?: (name: string) => boolean | void | Promise<boolean | void>;
onRenameCancel?: () => void;
content?: React.ReactNode;
};
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { hostTreeInlineGroupEditStore } from '../../application/state/hostTreeInlineGroupEditStore';
import { useHostTreeInlineGroupActions } from './useHostTreeInlineGroupActions';
test('inline group rename stays open when the Vault commit is superseded', async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | null = null;
let startRename: ((groupPath: string) => void) | undefined;
let commitRename: ((name: string) => Promise<boolean>) | undefined;
const selectedPaths: Array<string | null> = [];
const Probe = () => {
const actions = useHostTreeInlineGroupActions({
customGroups: ['prod'],
hosts: [],
managedSources: [],
onUpdateCustomGroups: () => undefined,
onCommitGroupPathChange: async () => ({ ok: false, superseded: true }),
selectedGroupPath: 'prod',
setSelectedGroupPath: (path) => selectedPaths.push(path),
ensurePathExpanded: () => undefined,
unnamedGroupLabel: 'New group',
t: (key) => key,
});
startRename = actions.startInlineRenameGroup;
commitRename = actions.commitInlineGroupRename;
return null;
};
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
act(() => {
startRename?.('prod');
});
await act(async () => {
await commitRename?.('production');
});
assert.deepEqual(selectedPaths, []);
assert.equal(hostTreeInlineGroupEditStore.getEdit()?.groupPath, 'prod');
} finally {
hostTreeInlineGroupEditStore.clear();
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
@@ -20,7 +20,10 @@ type UseHostTreeInlineGroupActionsParams = {
onCommitGroupPathChange: (
sourcePath: string,
nextPath: string,
) => Promise<{ ok: true } | { ok: false; error?: string }>;
) => Promise<
| { ok: true }
| { ok: false; error?: string; superseded?: true }
>;
selectedGroupPath: string | null;
setSelectedGroupPath: (path: string | null) => void;
ensurePathExpanded: (path: string) => void;
@@ -74,9 +77,9 @@ export function useHostTreeInlineGroupActions({
hostTreeInlineGroupEditStore.clear();
}, [customGroups, onUpdateCustomGroups]);
const commitInlineGroupRename = useCallback(async (rawName: string) => {
const commitInlineGroupRename = useCallback(async (rawName: string): Promise<boolean> => {
const edit = hostTreeInlineGroupEditStore.getEdit();
if (!edit) return;
if (!edit) return false;
const result = applyGroupPathRename({
renameTargetPath: edit.groupPath,
@@ -89,31 +92,31 @@ export function useHostTreeInlineGroupActions({
if (result.ok === false) {
if (result.error === 'unchanged') {
hostTreeInlineGroupEditStore.clear();
return;
return true;
}
if (result.error === 'required') {
if (edit.isNew) {
cancelInlineGroupEdit();
return;
return true;
}
toast.error(t('vault.groups.errors.required'));
return;
return false;
}
if (result.error === 'invalidChars') {
toast.error(t('vault.groups.errors.invalidChars'));
return;
return false;
}
if (result.error === 'duplicatePath') {
toast.error(t('vault.groups.errors.duplicatePath'));
return;
return false;
}
return;
return false;
}
const committed = await onCommitGroupPathChange(edit.groupPath, result.nextPath);
if ("error" in committed) {
if (!committed.ok) {
toast.error(committed.error || t('common.error'));
return;
return false;
}
if (
@@ -128,6 +131,7 @@ export function useHostTreeInlineGroupActions({
}
hostTreeInlineGroupEditStore.clear();
return true;
}, [
cancelInlineGroupEdit,
customGroups,
@@ -23,7 +23,7 @@ type RegisterVaultHostTreeActionsParams = {
startInlineNewGroup: (parentPath?: string) => void;
startInlineRenameGroup: (groupPath: string) => void;
startInlineDeleteGroup: (groupPath: string) => void;
commitInlineGroupRename: (name: string) => void;
commitInlineGroupRename: (name: string) => boolean | void | Promise<boolean | void>;
cancelInlineGroupEdit: () => void;
commitInlineHostRename: (name: string) => void;
cancelInlineHostEdit: () => void;
+21
View File
@@ -4,6 +4,7 @@ import {
getRunnableHostsForSnippet,
snippetAppliesToHost,
snippetAppliesToOutputTrigger,
snippetCanRunInTerminal,
snippetHasRunTargets,
resolveSnippetTargetGroupsForSave,
} from './snippetTargets.ts';
@@ -57,6 +58,26 @@ test('snippetAppliesToHost matches all hosts when targetsAllHosts is set', () =>
assert.equal(snippetAppliesToHost(snippet, undefined), false);
});
test('snippetCanRunInTerminal enforces explicit host and dynamic group scopes', () => {
assert.equal(snippetCanRunInTerminal(baseSnippet, hosts[0]!), true);
assert.equal(snippetCanRunInTerminal({
...baseSnippet,
targets: ['host-b'],
}, hosts[0]!), false);
assert.equal(snippetCanRunInTerminal({
...baseSnippet,
targetGroups: ['Production'],
}, hosts[0]!), true);
assert.equal(snippetCanRunInTerminal({
...baseSnippet,
targetGroups: ['Staging'],
}, hosts[0]!), false);
assert.equal(snippetCanRunInTerminal({
...baseSnippet,
targetGroups: [],
}, hosts[0]!), false);
});
test('snippetHasRunTargets requires explicit scope', () => {
assert.equal(snippetHasRunTargets(baseSnippet), false);
assert.equal(snippetHasRunTargets({ ...baseSnippet, targets: ['host-a'] }), true);
+12
View File
@@ -74,6 +74,18 @@ export function snippetAppliesToHost(
|| snippetTargetsHostGroup(snippet, host);
}
/** Whether a library/shortcut invocation may run in the current terminal. */
export function snippetCanRunInTerminal(
snippet: Pick<Snippet, 'targets' | 'targetGroups' | 'targetsAllHosts'>,
host: SnippetTargetHost,
): boolean {
if (snippet.targetsAllHosts) return true;
const hasScopedTargets = Boolean(
snippet.targets?.length || snippet.targetGroups !== undefined,
);
return !hasScopedTargets || snippetAppliesToHost(snippet, host);
}
/**
* onOutput triggers listen on the active terminal session. When no explicit targets
* are configured, they apply to whichever host the session is connected to.