diff --git a/src/nodepilot-ui/src/__tests__/components/overlays/FindReplaceOverlay.test.tsx b/src/nodepilot-ui/src/__tests__/components/overlays/FindReplaceOverlay.test.tsx
index 928c3090..033e3059 100644
--- a/src/nodepilot-ui/src/__tests__/components/overlays/FindReplaceOverlay.test.tsx
+++ b/src/nodepilot-ui/src/__tests__/components/overlays/FindReplaceOverlay.test.tsx
@@ -114,6 +114,29 @@ describe('FindReplaceOverlay', () => {
expect(onClose).not.toHaveBeenCalled();
});
+ it('replacingTheSelectedConfigMatch_changesOnlyThatField', () => {
+ const onApply = vi.fn();
+ render(
+
+ );
+
+ fireEvent.change(screen.getByPlaceholderText(/Find/i), { target: { value: 'prod' } });
+ fireEvent.change(screen.getByPlaceholderText(/Replace with/i), { target: { value: 'test' } });
+ fireEvent.click(screen.getByText('Deploy › workingDirectory'));
+ fireEvent.click(screen.getByTitle(/Replace current match/i));
+
+ const [appliedNodes] = onApply.mock.calls[0] as [Node[], Edge[]];
+ expect((appliedNodes[0].data as { config: Record }).config).toEqual({
+ script: 'Restart-Service prod-api',
+ workingDirectory: '/srv/test',
+ });
+ });
+
it('replaceButtonsDisabledWhenNoMatches', () => {
render(
diff --git a/src/nodepilot-ui/src/__tests__/lib/findReplace.test.ts b/src/nodepilot-ui/src/__tests__/lib/findReplace.test.ts
index 7c427d6d..e200723b 100644
--- a/src/nodepilot-ui/src/__tests__/lib/findReplace.test.ts
+++ b/src/nodepilot-ui/src/__tests__/lib/findReplace.test.ts
@@ -125,6 +125,42 @@ describe('applyReplaceAll', () => {
expect(cfg.headers[0].value).toBe('still-New');
});
+ it('replacingOneConfigMatch_leavesOtherFieldsOfTheSameNodeUnchanged', () => {
+ const nodes = [node('n1', 'Deploy prod', { workingDirectory: '/srv/prod/app', script:'Restart-Service prod-api' })];
+ const selected = findMatches('prod', nodes, [], ALL_SCOPES)
+ .filter((m) => m.configKeyPath === 'workingDirectory');
+
+ const { nodes: out } = applyReplaceAll(selected, 'prod', 'test', nodes, []);
+
+ const data = out[0].data as { label: string; config: Record };
+ expect(data.config.workingDirectory).toBe('/srv/test/app');
+ expect(data.config.script).toBe('Restart-Service prod-api');
+ expect(data.label).toBe('Deploy prod');
+ });
+
+ it('replacingALabelMatch_leavesConfigUnchanged', () => {
+ const nodes = [node('n1', 'Old step', { script: 'Old-Command' })];
+ const labelOnly = findMatches('Old', nodes, [], ALL_SCOPES).filter((m) => m.kind === 'node-label');
+
+ const { nodes: out } = applyReplaceAll(labelOnly, 'Old', 'New', nodes, []);
+
+ const data = out[0].data as { label: string; config: Record };
+ expect(data.label).toBe('New step');
+ expect(data.config.script).toBe('Old-Command');
+ });
+
+ it('replacesAtAConfigKeyThatContainsADot', () => {
+ const nodes = [node('n1', 'Call', { headers: { 'X.Old': 'Old-value', X: { Old: 'Old-nested' } } })];
+ const selected = findMatches('Old', nodes, [], ALL_SCOPES)
+ .filter((m) => m.configPath?.join('/') === 'headers/X.Old');
+
+ const { nodes: out } = applyReplaceAll(selected, 'Old', 'New', nodes, []);
+
+ const cfg = (out[0].data as { config: { headers: Record } }).config;
+ expect(cfg.headers['X.Old']).toBe('New-value');
+ expect(cfg.headers.X).toEqual({ Old: 'Old-nested' });
+ });
+
it('doesNotMutateOriginalArrays', () => {
// applyReplaceAll must produce new arrays instead of splicing the inputs in place.
const nodes = [node('n1', 'OldText')];
diff --git a/src/nodepilot-ui/src/lib/findReplace.ts b/src/nodepilot-ui/src/lib/findReplace.ts
index fa77f351..32a13e7a 100644
--- a/src/nodepilot-ui/src/lib/findReplace.ts
+++ b/src/nodepilot-ui/src/lib/findReplace.ts
@@ -6,8 +6,10 @@ export interface MatchLocation {
kind: MatchKind;
nodeId?: string;
edgeId?: string;
- /** Dot-path into config, e.g. "script" or "headers.0.value" */
+ /** Dot-path into config for display, e.g. "script" or "headers.0.value" */
configKeyPath?: string;
+ /** The same path as segments; replacement uses it, because a key may itself contain a dot. */
+ configPath?: string[];
/** The matched substring */
matchedText: string;
/** Short context around the match for display */
@@ -35,11 +37,11 @@ function traverseObject(
obj: unknown,
query: string,
pathParts: string[],
- results: Array<{ path: string; text: string; context: string }>,
+ results: Array<{ path: string[]; text: string; context: string }>,
): void {
if (typeof obj === 'string') {
if (obj.toLowerCase().includes(query.toLowerCase())) {
- results.push({ path: pathParts.join('.'), text: obj, context: snippet(obj, query) });
+ results.push({ path: pathParts, text: obj, context: snippet(obj, query) });
}
return;
}
@@ -98,17 +100,19 @@ export function findMatches(
for (const n of nodes) {
const config = (n.data as Record).config;
if (!config) continue;
- const hits: Array<{ path: string; text: string; context: string }> = [];
+ const hits: Array<{ path: string[]; text: string; context: string }> = [];
traverseObject(config, query, [], hits);
const nodeLabel = (n.data as Record).label as string | undefined;
for (const h of hits) {
+ const keyPath = h.path.join('.');
results.push({
kind: 'config',
nodeId: n.id,
- configKeyPath: h.path,
+ configKeyPath: keyPath,
+ configPath: h.path,
matchedText: h.text,
contextSnippet: h.context,
- displayName: `${nodeLabel || n.id} › ${h.path || 'config'}`,
+ displayName: `${nodeLabel || n.id} › ${keyPath || 'config'}`,
});
}
}
@@ -117,17 +121,18 @@ export function findMatches(
return results.slice(0, 50);
}
-function replaceInValue(value: unknown, search: string, replacement: string): unknown {
- if (typeof value === 'string') {
- return value.replaceAll(search, replacement);
- }
+/** Replaces inside the one string at `path`; every other config value is left untouched. */
+function replaceAtPath(value: unknown, path: string[], search: string, replacement: string): unknown {
+ if (path.length === 0) return typeof value === 'string' ? value.replaceAll(search, replacement) : value;
+ const [head, ...rest] = path;
if (Array.isArray(value)) {
- return value.map((v) => replaceInValue(v, search, replacement));
+ const index = Number(head);
+ if (!Number.isInteger(index) || index < 0 || index >= value.length) return value;
+ return value.map((item, i) => (i === index ? replaceAtPath(item, rest, search, replacement) : item));
}
- if (value && typeof value === 'object') {
- return Object.fromEntries(
- Object.entries(value as Record).map(([k, v]) => [k, replaceInValue(v, search, replacement)])
- );
+ if (value && typeof value === 'object' && Object.hasOwn(value, head)) {
+ const record = value as Record;
+ return { ...record, [head]: replaceAtPath(record[head], rest, search, replacement) };
}
return value;
}
@@ -145,13 +150,13 @@ export function applyReplaceAll(
const newNodes = nodes.map((n) => {
if (!nodeIds.has(n.id)) return n;
const hasLabelMatch = matches.some((m) => m.nodeId === n.id && m.kind === 'node-label');
- const hasConfigMatch = matches.some((m) => m.nodeId === n.id && m.kind === 'config');
let data = n.data as Record;
if (hasLabelMatch && typeof data.label === 'string') {
data = { ...data, label: data.label.replaceAll(search, replacement) };
}
- if (hasConfigMatch && data.config) {
- data = { ...data, config: replaceInValue(data.config, search, replacement) };
+ for (const m of matches) {
+ if (m.nodeId !== n.id || m.kind !== 'config' || !m.configPath) continue;
+ data = { ...data, config: replaceAtPath(data.config, m.configPath, search, replacement) };
}
return { ...n, data };
});