fix(vault): finish dynamic group targeting
This commit is contained in:
1 parent
27dc1c62af
commit
189db57625
20 files changed
+370
-85
No files matched your search
@@ -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.',
|
||||
|
||||
@@ -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': '在所選目標主機上執行;勾選「對所有主機生效」時在所有可連線的主機上執行。',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user