From fc6b8e86332d74e6a953ea768f7e754840f0263b Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Fri, 28 Aug 2026 14:17:54 -0700 Subject: [PATCH 1/2] fix: put remaining Quick Action paths after -- Create, append, prepend, tidy, apply-fragment, md, patch, and doc merge still put the file path before options. A path of --apply made clap treat it as the apply flag. Same -- terminator as #256. Signed-off-by: Sebastien Tardif --- src/commands/quickActions.ts | 104 ++++++++++++++++-------- test/unit/quickActions.test.ts | 141 ++++++++++++++++++--------------- 2 files changed, 148 insertions(+), 97 deletions(-) diff --git a/src/commands/quickActions.ts b/src/commands/quickActions.ts index c381f4a..1f7dc3a 100644 --- a/src/commands/quickActions.ts +++ b/src/commands/quickActions.ts @@ -1262,30 +1262,35 @@ export function buildApplyFragmentQuickAction( ): PlannedQuickAction { const flag = placement === "after" ? "--after" : placement === "before" ? "--before" : "--old"; + const args = withEndOfOptions( + ["apply-fragment", flag, anchor, "--fragment", fragment], + [targetPath] + ); return { title: `Apply fragment in ${path.basename(targetPath)}`, targetPath, - targetArgIndices: [1], - args: ["apply-fragment", targetPath, flag, anchor, "--fragment", fragment] + targetArgIndices: [args.length - 1], + args }; } export function buildTidyQuickAction(targetPath: string, fixes: readonly TidyFix[]): PlannedQuickAction { - const args = ["tidy", "fix", targetPath]; + const head = ["tidy", "fix"]; if (fixes.includes("ensure-final-newline")) { - args.push("--ensure-final-newline"); + head.push("--ensure-final-newline"); } if (fixes.includes("trim-trailing-whitespace")) { - args.push("--trim-trailing-whitespace"); + head.push("--trim-trailing-whitespace"); } if (fixes.includes("normalize-eol-lf")) { - args.push("--normalize-eol", "lf"); + head.push("--normalize-eol", "lf"); } + const args = withEndOfOptions(head, [targetPath]); return { title: `Tidy ${path.basename(targetPath)}`, targetPath, - targetArgIndices: [2], + targetArgIndices: [args.length - 1], args }; } @@ -1337,31 +1342,33 @@ export function buildSearchQuickAction( } export function buildCreateQuickAction(filePath: string, content = ""): PlannedQuickAction { + const args = withEndOfOptions(["create", "--content", content], [filePath]); return { title: `Create ${path.basename(filePath)}`, targetPath: filePath, - targetArgIndices: [1], - // CLI requires --content/--stdin and --apply; preview-only create returns exit 2 and does not write. - args: ["create", filePath, "--content", content], + targetArgIndices: [args.length - 1], + args, apply: true }; } export function buildAppendQuickAction(targetPath: string, content: string): PlannedQuickAction { + const args = withEndOfOptions(["append", "--content", content], [targetPath]); return { title: `Append to ${path.basename(targetPath)}`, targetPath, - targetArgIndices: [1], - args: ["append", targetPath, "--content", content] + targetArgIndices: [args.length - 1], + args }; } export function buildPrependQuickAction(targetPath: string, content: string): PlannedQuickAction { + const args = withEndOfOptions(["prepend", "--content", content], [targetPath]); return { title: `Prepend to ${path.basename(targetPath)}`, targetPath, - targetArgIndices: [1], - args: ["prepend", targetPath, "--content", content] + targetArgIndices: [args.length - 1], + args }; } @@ -1404,11 +1411,12 @@ export function buildDocMergeQuickAction( selector?: string ): PlannedQuickAction { const trimmedSelector = selector?.trim() ?? ""; - const args = ["doc", "merge", targetPath]; + const head = ["doc", "merge"]; if (trimmedSelector.length > 0) { - args.push("--selector", trimmedSelector); + head.push("--selector", trimmedSelector); } - args.push("--value", value); + head.push("--value", value); + const args = withEndOfOptions(head, [targetPath]); const title = trimmedSelector.length > 0 ? `Merge into ${trimmedSelector} in ${path.basename(targetPath)}` @@ -1417,7 +1425,7 @@ export function buildDocMergeQuickAction( return { title, targetPath, - targetArgIndices: [2], + targetArgIndices: [args.length - 1], args }; } @@ -1432,29 +1440,41 @@ export function buildDocAppendQuickAction(targetPath: string, selector: string, } export function buildMdTableAppendQuickAction(targetPath: string, heading: string, row: string): PlannedQuickAction { + const args = withEndOfOptions( + ["md", "table-append", "--heading", heading, "--row", row], + [targetPath] + ); return { title: `Append table row under "${heading}" in ${path.basename(targetPath)}`, targetPath, - targetArgIndices: [2], - args: ["md", "table-append", targetPath, "--heading", heading, "--row", row] + targetArgIndices: [args.length - 1], + args }; } export function buildMdUpsertBulletQuickAction(targetPath: string, heading: string, bullet: string): PlannedQuickAction { + const args = withEndOfOptions( + ["md", "upsert-bullet", "--heading", heading, "--bullet", bullet], + [targetPath] + ); return { title: `Upsert bullet under "${heading}" in ${path.basename(targetPath)}`, targetPath, - targetArgIndices: [2], - args: ["md", "upsert-bullet", targetPath, "--heading", heading, "--bullet", bullet] + targetArgIndices: [args.length - 1], + args }; } export function buildMdReplaceSectionQuickAction(targetPath: string, heading: string, content: string): PlannedQuickAction { + const args = withEndOfOptions( + ["md", "replace-section", "--heading", heading, "--content", content], + [targetPath] + ); return { title: `Replace "${heading}" in ${path.basename(targetPath)}`, targetPath, - targetArgIndices: [2], - args: ["md", "replace-section", targetPath, "--heading", heading, "--content", content] + targetArgIndices: [args.length - 1], + args }; } @@ -1486,51 +1506,65 @@ export function buildDocMoveQuickAction(targetPath: string, from: string, to: st } export function buildMdInsertAfterHeadingQuickAction(targetPath: string, heading: string, content: string): PlannedQuickAction { + const args = withEndOfOptions( + ["md", "insert-after-heading", "--heading", heading, "--content", content], + [targetPath] + ); return { title: `Insert after "${heading}" in ${path.basename(targetPath)}`, targetPath, - targetArgIndices: [2], - args: ["md", "insert-after-heading", targetPath, "--heading", heading, "--content", content] + targetArgIndices: [args.length - 1], + args }; } export function buildMdInsertAfterSectionQuickAction(targetPath: string, heading: string, content: string): PlannedQuickAction { + const args = withEndOfOptions( + ["md", "insert-after-section", "--heading", heading, "--content", content], + [targetPath] + ); return { title: `Insert after section "${heading}" in ${path.basename(targetPath)}`, targetPath, - targetArgIndices: [2], - args: ["md", "insert-after-section", targetPath, "--heading", heading, "--content", content] + targetArgIndices: [args.length - 1], + args }; } export function buildMdInsertBeforeHeadingQuickAction(targetPath: string, heading: string, content: string): PlannedQuickAction { + const args = withEndOfOptions( + ["md", "insert-before-heading", "--heading", heading, "--content", content], + [targetPath] + ); return { title: `Insert before "${heading}" in ${path.basename(targetPath)}`, targetPath, - targetArgIndices: [2], - args: ["md", "insert-before-heading", targetPath, "--heading", heading, "--content", content] + targetArgIndices: [args.length - 1], + args }; } export function buildPatchApplyQuickAction(patchPath: string): PlannedQuickAction { + const args = withEndOfOptions(["patch", "apply"], [patchPath]); return { title: `Apply patch ${path.basename(patchPath)}`, targetPath: patchPath, - targetArgIndices: [2], - args: ["patch", "apply", patchPath], + targetArgIndices: [args.length - 1], + args, apply: true }; } export function buildPatchMergeQuickAction(patchPath: string, allowConflicts: boolean): PlannedQuickAction { - const args: string[] = ["patch", "merge", patchPath]; + const head = ["patch", "merge"]; if (allowConflicts) { - args.push("--allow-conflicts"); + head.push("--allow-conflicts"); } + const args = withEndOfOptions(head, [patchPath]); return { title: `Merge patch ${path.basename(patchPath)}`, targetPath: patchPath, - targetArgIndices: [2], + targetArgIndices: [args.length - 1], args, apply: true }; diff --git a/test/unit/quickActions.test.ts b/test/unit/quickActions.test.ts index 29f814f..7503d68 100644 --- a/test/unit/quickActions.test.ts +++ b/test/unit/quickActions.test.ts @@ -101,26 +101,27 @@ test("buildApplyFragmentQuickAction builds apply-fragment --after (CLI 0.22+)", ); assert.equal(action.title, "Apply fragment in lib.rs"); - assert.deepEqual(action.targetArgIndices, [1]); + assert.deepEqual(action.targetArgIndices, [6]); assert.deepEqual(action.args, [ "apply-fragment", - "/workspace/demo/lib.rs", "--after", "fn foo() {", "--fragment", - " let x = 1;" + " let x = 1;", + "--", + "/workspace/demo/lib.rs" ]); }); test("buildApplyFragmentQuickAction supports --before and --old placements", () => { const before = buildApplyFragmentQuickAction("/ws/a.ts", "before", "return true;", "// note"); assert.deepEqual(before.args, [ - "apply-fragment", "/ws/a.ts", "--before", "return true;", "--fragment", "// note" + "apply-fragment", "--before", "return true;", "--fragment", "// note", "--", "/ws/a.ts" ]); const replaceSpan = buildApplyFragmentQuickAction("/ws/a.ts", "old", "old_span", "new_span"); assert.deepEqual(replaceSpan.args, [ - "apply-fragment", "/ws/a.ts", "--old", "old_span", "--fragment", "new_span" + "apply-fragment", "--old", "old_span", "--fragment", "new_span", "--", "/ws/a.ts" ]); }); @@ -130,7 +131,7 @@ test("retargetQuickAction preserves apply-fragment path index", () => { assert.equal(retargeted.targetPath, "/workspace/demo/b.ts"); assert.deepEqual(retargeted.args, [ - "apply-fragment", "/workspace/demo/b.ts", "--after", "anchor", "--fragment", "frag" + "apply-fragment", "--after", "anchor", "--fragment", "frag", "--", "/workspace/demo/b.ts" ]); }); @@ -152,15 +153,16 @@ test("buildTidyQuickAction includes selected tidy flags", () => { ]); assert.equal(action.title, "Tidy file.txt"); - assert.deepEqual(action.targetArgIndices, [2]); + assert.deepEqual(action.targetArgIndices, [7]); assert.deepEqual(action.args, [ "tidy", "fix", - "/workspace/demo/file.txt", "--ensure-final-newline", "--trim-trailing-whitespace", "--normalize-eol", - "lf" + "lf", + "--", + "/workspace/demo/file.txt" ]); }); @@ -290,11 +292,12 @@ test("buildPrependQuickAction builds a file prepend command", () => { assert.equal(action.title, "Prepend to main.ts"); assert.deepEqual(action.args, [ "prepend", - "/workspace/demo/src/main.ts", "--content", - "// copyright\n" + "// copyright\n", + "--", + "/workspace/demo/src/main.ts" ]); - assert.deepEqual(action.targetArgIndices, [1]); + assert.deepEqual(action.targetArgIndices, [4]); }); test("isStructuredDocumentPath recognizes supported structured formats", () => { @@ -331,9 +334,10 @@ test("buildTidyQuickAction with a single fix omits unselected flags", () => { assert.deepEqual(action.args, [ "tidy", "fix", - "/workspace/demo/file.txt", "--normalize-eol", - "lf" + "lf", + "--", + "/workspace/demo/file.txt" ]); }); @@ -381,21 +385,23 @@ test("buildCreateQuickAction builds a create command with content and apply", () assert.equal(action.title, "Create newfile.ts"); assert.deepEqual(action.args, [ "create", - "/workspace/demo/src/newfile.ts", "--content", - "hello" + "hello", + "--", + "/workspace/demo/src/newfile.ts" ]); assert.equal(action.apply, true); - assert.deepEqual(action.targetArgIndices, [1]); + assert.deepEqual(action.targetArgIndices, [4]); }); test("buildCreateQuickAction allows empty content", () => { const action = buildCreateQuickAction("/workspace/demo/empty.txt"); assert.deepEqual(action.args, [ "create", - "/workspace/demo/empty.txt", "--content", - "" + "", + "--", + "/workspace/demo/empty.txt" ]); assert.equal(action.apply, true); }); @@ -476,9 +482,10 @@ test("buildCreateQuickAction with spaces in path", () => { assert.equal(action.title, "Create new file.ts"); assert.deepEqual(action.args, [ "create", - "/workspace/my project/src/new file.ts", "--content", - "x" + "x", + "--", + "/workspace/my project/src/new file.ts" ]); assert.equal(action.apply, true); }); @@ -494,9 +501,10 @@ test("buildCreateQuickAction with unicode filename", () => { assert.equal(action.title, "Create 日本語.md"); assert.deepEqual(action.args, [ "create", - "/workspace/demo/docs/日本語.md", "--content", - "# title" + "# title", + "--", + "/workspace/demo/docs/日本語.md" ]); assert.equal(action.apply, true); }); @@ -536,8 +544,8 @@ test("buildDocMergeQuickAction builds a doc merge command", () => { const action = buildDocMergeQuickAction("/workspace/demo/package.json", '{"debug": true}'); assert.equal(action.title, "Merge into package.json"); - assert.deepEqual(action.targetArgIndices, [2]); - assert.deepEqual(action.args, ["doc", "merge", "/workspace/demo/package.json", "--value", '{"debug": true}']); + assert.deepEqual(action.targetArgIndices, [5]); + assert.deepEqual(action.args, ["doc", "merge", "--value", '{"debug": true}', "--", "/workspace/demo/package.json"]); }); test("buildDocMergeQuickAction includes --selector for multi-doc merge (CLI 0.16+)", () => { @@ -548,11 +556,12 @@ test("buildDocMergeQuickAction includes --selector for multi-doc merge (CLI 0.16 ); assert.equal(action.title, "Merge into 0 in multi.yaml"); - assert.deepEqual(action.targetArgIndices, [2]); + assert.deepEqual(action.targetArgIndices, [7]); assert.deepEqual(action.args, [ - "doc", "merge", "/workspace/demo/multi.yaml", + "doc", "merge", "--selector", "0", - "--value", '{"debug": true}' + "--value", '{"debug": true}', + "--", "/workspace/demo/multi.yaml" ]); }); @@ -561,8 +570,9 @@ test("buildDocMergeQuickAction omits --selector when selector is blank", () => { assert.equal(action.title, "Merge into config.toml"); assert.deepEqual(action.args, [ - "doc", "merge", "/workspace/demo/config.toml", - "--value", '{"x": 1}' + "doc", "merge", + "--value", '{"x": 1}', + "--", "/workspace/demo/config.toml" ]); assert.ok(!action.args.includes("--selector")); }); @@ -573,9 +583,10 @@ test("retargetQuickAction preserves doc merge selector flags", () => { assert.equal(retargeted.targetPath, "/workspace/demo/b.yaml"); assert.deepEqual(retargeted.args, [ - "doc", "merge", "/workspace/demo/b.yaml", + "doc", "merge", "--selector", "[0]", - "--value", '{"k": true}' + "--value", '{"k": true}', + "--", "/workspace/demo/b.yaml" ]); }); @@ -593,11 +604,12 @@ test("buildMdTableAppendQuickAction builds a md table-append command", () => { const action = buildMdTableAppendQuickAction("/workspace/demo/README.md", "## API", "| /users | List users |"); assert.equal(action.title, 'Append table row under "## API" in README.md'); - assert.deepEqual(action.targetArgIndices, [2]); + assert.deepEqual(action.targetArgIndices, [7]); assert.deepEqual(action.args, [ - "md", "table-append", "/workspace/demo/README.md", + "md", "table-append", "--heading", "## API", - "--row", "| /users | List users |" + "--row", "| /users | List users |", + "--", "/workspace/demo/README.md" ]); }); @@ -605,11 +617,12 @@ test("buildMdUpsertBulletQuickAction builds a md upsert-bullet command", () => { const action = buildMdUpsertBulletQuickAction("/workspace/demo/AGENTS.md", "## Rules", "Run make check"); assert.equal(action.title, 'Upsert bullet under "## Rules" in AGENTS.md'); - assert.deepEqual(action.targetArgIndices, [2]); + assert.deepEqual(action.targetArgIndices, [7]); assert.deepEqual(action.args, [ - "md", "upsert-bullet", "/workspace/demo/AGENTS.md", + "md", "upsert-bullet", "--heading", "## Rules", - "--bullet", "Run make check" + "--bullet", "Run make check", + "--", "/workspace/demo/AGENTS.md" ]); }); @@ -617,11 +630,12 @@ test("buildMdReplaceSectionQuickAction builds a md replace-section command", () const action = buildMdReplaceSectionQuickAction("/workspace/demo/CHANGELOG.md", "## Unreleased", "- New feature"); assert.equal(action.title, 'Replace "## Unreleased" in CHANGELOG.md'); - assert.deepEqual(action.targetArgIndices, [2]); + assert.deepEqual(action.targetArgIndices, [7]); assert.deepEqual(action.args, [ - "md", "replace-section", "/workspace/demo/CHANGELOG.md", + "md", "replace-section", "--heading", "## Unreleased", - "--content", "- New feature" + "--content", "- New feature", + "--", "/workspace/demo/CHANGELOG.md" ]); }); @@ -667,11 +681,12 @@ test("buildMdInsertAfterHeadingQuickAction builds a md insert-after-heading comm const action = buildMdInsertAfterHeadingQuickAction("/workspace/demo/README.md", "## Installation", "Run npm install"); assert.equal(action.title, 'Insert after "## Installation" in README.md'); - assert.deepEqual(action.targetArgIndices, [2]); + assert.deepEqual(action.targetArgIndices, [7]); assert.deepEqual(action.args, [ - "md", "insert-after-heading", "/workspace/demo/README.md", + "md", "insert-after-heading", "--heading", "## Installation", - "--content", "Run npm install" + "--content", "Run npm install", + "--", "/workspace/demo/README.md" ]); }); @@ -683,11 +698,12 @@ test("buildMdInsertAfterSectionQuickAction builds a md insert-after-section comm ); assert.equal(action.title, 'Insert after section "## Config" in README.md'); - assert.deepEqual(action.targetArgIndices, [2]); + assert.deepEqual(action.targetArgIndices, [7]); assert.deepEqual(action.args, [ - "md", "insert-after-section", "/workspace/demo/README.md", + "md", "insert-after-section", "--heading", "## Config", - "--content", "## FAQ\n\nCommon questions." + "--content", "## FAQ\n\nCommon questions.", + "--", "/workspace/demo/README.md" ]); }); @@ -695,11 +711,12 @@ test("buildMdInsertBeforeHeadingQuickAction builds a md insert-before-heading co const action = buildMdInsertBeforeHeadingQuickAction("/workspace/demo/CHANGELOG.md", "## v1.0.0", "## v1.1.0\n\n- New feature"); assert.equal(action.title, 'Insert before "## v1.0.0" in CHANGELOG.md'); - assert.deepEqual(action.targetArgIndices, [2]); + assert.deepEqual(action.targetArgIndices, [7]); assert.deepEqual(action.args, [ - "md", "insert-before-heading", "/workspace/demo/CHANGELOG.md", + "md", "insert-before-heading", "--heading", "## v1.0.0", - "--content", "## v1.1.0\n\n- New feature" + "--content", "## v1.1.0\n\n- New feature", + "--", "/workspace/demo/CHANGELOG.md" ]); }); @@ -716,7 +733,7 @@ test("retargetQuickAction works with md insert-after-heading command", () => { const action = buildMdInsertAfterHeadingQuickAction("/workspace/demo/README.md", "## Usage", "text"); const retargeted = retargetQuickAction(action, "/tmp/preview/README.md"); - assert.equal(retargeted.args[2], "/tmp/preview/README.md"); + assert.equal(retargeted.args[retargeted.args.length - 1], "/tmp/preview/README.md"); assert.equal(retargeted.args[0], "md"); assert.equal(retargeted.args[1], "insert-after-heading"); }); @@ -740,7 +757,7 @@ test("retargetQuickAction works with md commands", () => { const action = buildMdTableAppendQuickAction("/workspace/demo/README.md", "## API", "| col |"); const retargeted = retargetQuickAction(action, "/tmp/preview/README.md"); - assert.equal(retargeted.args[2], "/tmp/preview/README.md"); + assert.equal(retargeted.args[retargeted.args.length - 1], "/tmp/preview/README.md"); assert.equal(retargeted.args[0], "md"); assert.equal(retargeted.args[1], "table-append"); }); @@ -751,8 +768,8 @@ test("buildPatchApplyQuickAction builds a patch apply command", () => { const action = buildPatchApplyQuickAction("/workspace/demo/changes.patch"); assert.equal(action.title, "Apply patch changes.patch"); - assert.deepEqual(action.targetArgIndices, [2]); - assert.deepEqual(action.args, ["patch", "apply", "/workspace/demo/changes.patch"]); + assert.deepEqual(action.targetArgIndices, [3]); + assert.deepEqual(action.args, ["patch", "apply", "--", "/workspace/demo/changes.patch"]); assert.equal(action.apply, true); }); @@ -760,7 +777,7 @@ test("retargetQuickAction works with patch apply command", () => { const action = buildPatchApplyQuickAction("/workspace/demo/fix.patch"); const retargeted = retargetQuickAction(action, "/tmp/preview/fix.patch"); - assert.equal(retargeted.args[2], "/tmp/preview/fix.patch"); + assert.equal(retargeted.args[3], "/tmp/preview/fix.patch"); assert.equal(retargeted.args[0], "patch"); assert.equal(retargeted.args[1], "apply"); assert.equal(retargeted.apply, true); @@ -772,8 +789,8 @@ test("buildPatchMergeQuickAction builds a patch merge command", () => { const action = buildPatchMergeQuickAction("/workspace/demo/changes.patch", false); assert.equal(action.title, "Merge patch changes.patch"); - assert.deepEqual(action.targetArgIndices, [2]); - assert.deepEqual(action.args, ["patch", "merge", "/workspace/demo/changes.patch"]); + assert.deepEqual(action.targetArgIndices, [3]); + assert.deepEqual(action.args, ["patch", "merge", "--", "/workspace/demo/changes.patch"]); assert.equal(action.apply, true); }); @@ -781,8 +798,8 @@ test("buildPatchMergeQuickAction includes allow-conflicts flag when enabled", () const action = buildPatchMergeQuickAction("/workspace/demo/stale.diff", true); assert.equal(action.title, "Merge patch stale.diff"); - assert.deepEqual(action.targetArgIndices, [2]); - assert.deepEqual(action.args, ["patch", "merge", "/workspace/demo/stale.diff", "--allow-conflicts"]); + assert.deepEqual(action.targetArgIndices, [4]); + assert.deepEqual(action.args, ["patch", "merge", "--allow-conflicts", "--", "/workspace/demo/stale.diff"]); assert.equal(action.apply, true); }); @@ -790,7 +807,7 @@ test("retargetQuickAction works with patch merge command", () => { const action = buildPatchMergeQuickAction("/workspace/demo/fix.patch", false); const retargeted = retargetQuickAction(action, "/tmp/preview/fix.patch"); - assert.equal(retargeted.args[2], "/tmp/preview/fix.patch"); + assert.equal(retargeted.args[3], "/tmp/preview/fix.patch"); assert.equal(retargeted.args[0], "patch"); assert.equal(retargeted.args[1], "merge"); }); @@ -801,8 +818,8 @@ test("buildAppendQuickAction builds an append command", () => { const action = buildAppendQuickAction("/workspace/demo/log.txt", "new log entry"); assert.equal(action.title, "Append to log.txt"); - assert.deepEqual(action.targetArgIndices, [1]); - assert.deepEqual(action.args, ["append", "/workspace/demo/log.txt", "--content", "new log entry"]); + assert.deepEqual(action.targetArgIndices, [4]); + assert.deepEqual(action.args, ["append", "--content", "new log entry", "--", "/workspace/demo/log.txt"]); }); // --- result presenters (search / patch-merge / undo) --- From 45b0d109485162c18275058ca5cef5a4ef2882a2 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Fri, 28 Aug 2026 14:30:39 -0700 Subject: [PATCH 2/2] fix: pass Quick Action option values as --flag=value Clap treats a following `--` or `-n` as end-of-options or another flag, so create/replace content starting with a dash failed. Bind user option values with equals form. Signed-off-by: Sebastien Tardif --- src/commands/quickActions.ts | 41 ++++++---- test/unit/quickActions.test.ts | 145 +++++++++++++++++---------------- 2 files changed, 99 insertions(+), 87 deletions(-) diff --git a/src/commands/quickActions.ts b/src/commands/quickActions.ts index 1f7dc3a..87a6fb5 100644 --- a/src/commands/quickActions.ts +++ b/src/commands/quickActions.ts @@ -65,6 +65,11 @@ export function withEndOfOptions(head: readonly string[], operands: readonly str return [...head, "--", ...operands]; } +/** `--flag=value` so clap does not treat `--` / `-n` as options. */ +export function withOption(flag: string, value: string): string { + return `${flag}=${value}`; +} + export function presentSearchOutcome( log: PatchloomLog | undefined, result: { exitCode: number; stdout: string; stderr: string } @@ -1211,7 +1216,7 @@ export async function runQuickAction(): Promise { } export function buildReplaceQuickAction(targetPath: string, from: string, to: string): PlannedQuickAction { - const args = withEndOfOptions(["replace", "--new", to], [from, targetPath]); + const args = withEndOfOptions(["replace", withOption("--new", to)], [from, targetPath]); return { title: `Replace text in ${path.basename(targetPath)}`, targetPath, @@ -1225,7 +1230,7 @@ export function buildInsertAfterMatchQuickAction( pattern: string, content: string ): PlannedQuickAction { - const args = withEndOfOptions(["replace", "--insert-after", content], [pattern, targetPath]); + const args = withEndOfOptions(["replace", withOption("--insert-after", content)], [pattern, targetPath]); return { title: `Insert after match in ${path.basename(targetPath)}`, targetPath, @@ -1239,7 +1244,7 @@ export function buildInsertBeforeMatchQuickAction( pattern: string, content: string ): PlannedQuickAction { - const args = withEndOfOptions(["replace", "--insert-before", content], [pattern, targetPath]); + const args = withEndOfOptions(["replace", withOption("--insert-before", content)], [pattern, targetPath]); return { title: `Insert before match in ${path.basename(targetPath)}`, targetPath, @@ -1263,7 +1268,7 @@ export function buildApplyFragmentQuickAction( const flag = placement === "after" ? "--after" : placement === "before" ? "--before" : "--old"; const args = withEndOfOptions( - ["apply-fragment", flag, anchor, "--fragment", fragment], + ["apply-fragment", withOption(flag, anchor), withOption("--fragment", fragment)], [targetPath] ); return { @@ -1326,7 +1331,7 @@ export function buildSearchQuickAction( head.push("--files-without-match"); } if (glob) { - head.push("--glob", glob); + head.push(withOption("--glob", glob)); } const args = withEndOfOptions(head, [pattern, workspacePath]); const targetIndex = args.length - 1; @@ -1342,7 +1347,7 @@ export function buildSearchQuickAction( } export function buildCreateQuickAction(filePath: string, content = ""): PlannedQuickAction { - const args = withEndOfOptions(["create", "--content", content], [filePath]); + const args = withEndOfOptions(["create", withOption("--content", content)], [filePath]); return { title: `Create ${path.basename(filePath)}`, targetPath: filePath, @@ -1353,7 +1358,7 @@ export function buildCreateQuickAction(filePath: string, content = ""): PlannedQ } export function buildAppendQuickAction(targetPath: string, content: string): PlannedQuickAction { - const args = withEndOfOptions(["append", "--content", content], [targetPath]); + const args = withEndOfOptions(["append", withOption("--content", content)], [targetPath]); return { title: `Append to ${path.basename(targetPath)}`, targetPath, @@ -1363,7 +1368,7 @@ export function buildAppendQuickAction(targetPath: string, content: string): Pla } export function buildPrependQuickAction(targetPath: string, content: string): PlannedQuickAction { - const args = withEndOfOptions(["prepend", "--content", content], [targetPath]); + const args = withEndOfOptions(["prepend", withOption("--content", content)], [targetPath]); return { title: `Prepend to ${path.basename(targetPath)}`, targetPath, @@ -1400,8 +1405,8 @@ export function buildDocDeleteWhereQuickAction( return { title: `Delete where ${predicate} from ${selector} in ${path.basename(targetPath)}`, targetPath, - targetArgIndices: [5], - args: withEndOfOptions(["doc", "delete-where", "--predicate", predicate], [targetPath, selector]) + targetArgIndices: [4], + args: withEndOfOptions(["doc", "delete-where", withOption("--predicate", predicate)], [targetPath, selector]) }; } @@ -1413,9 +1418,9 @@ export function buildDocMergeQuickAction( const trimmedSelector = selector?.trim() ?? ""; const head = ["doc", "merge"]; if (trimmedSelector.length > 0) { - head.push("--selector", trimmedSelector); + head.push(withOption("--selector", trimmedSelector)); } - head.push("--value", value); + head.push(withOption("--value", value)); const args = withEndOfOptions(head, [targetPath]); const title = trimmedSelector.length > 0 @@ -1441,7 +1446,7 @@ export function buildDocAppendQuickAction(targetPath: string, selector: string, export function buildMdTableAppendQuickAction(targetPath: string, heading: string, row: string): PlannedQuickAction { const args = withEndOfOptions( - ["md", "table-append", "--heading", heading, "--row", row], + ["md", "table-append", withOption("--heading", heading), withOption("--row", row)], [targetPath] ); return { @@ -1454,7 +1459,7 @@ export function buildMdTableAppendQuickAction(targetPath: string, heading: strin export function buildMdUpsertBulletQuickAction(targetPath: string, heading: string, bullet: string): PlannedQuickAction { const args = withEndOfOptions( - ["md", "upsert-bullet", "--heading", heading, "--bullet", bullet], + ["md", "upsert-bullet", withOption("--heading", heading), withOption("--bullet", bullet)], [targetPath] ); return { @@ -1467,7 +1472,7 @@ export function buildMdUpsertBulletQuickAction(targetPath: string, heading: stri export function buildMdReplaceSectionQuickAction(targetPath: string, heading: string, content: string): PlannedQuickAction { const args = withEndOfOptions( - ["md", "replace-section", "--heading", heading, "--content", content], + ["md", "replace-section", withOption("--heading", heading), withOption("--content", content)], [targetPath] ); return { @@ -1507,7 +1512,7 @@ export function buildDocMoveQuickAction(targetPath: string, from: string, to: st export function buildMdInsertAfterHeadingQuickAction(targetPath: string, heading: string, content: string): PlannedQuickAction { const args = withEndOfOptions( - ["md", "insert-after-heading", "--heading", heading, "--content", content], + ["md", "insert-after-heading", withOption("--heading", heading), withOption("--content", content)], [targetPath] ); return { @@ -1520,7 +1525,7 @@ export function buildMdInsertAfterHeadingQuickAction(targetPath: string, heading export function buildMdInsertAfterSectionQuickAction(targetPath: string, heading: string, content: string): PlannedQuickAction { const args = withEndOfOptions( - ["md", "insert-after-section", "--heading", heading, "--content", content], + ["md", "insert-after-section", withOption("--heading", heading), withOption("--content", content)], [targetPath] ); return { @@ -1533,7 +1538,7 @@ export function buildMdInsertAfterSectionQuickAction(targetPath: string, heading export function buildMdInsertBeforeHeadingQuickAction(targetPath: string, heading: string, content: string): PlannedQuickAction { const args = withEndOfOptions( - ["md", "insert-before-heading", "--heading", heading, "--content", content], + ["md", "insert-before-heading", withOption("--heading", heading), withOption("--content", content)], [targetPath] ); return { diff --git a/test/unit/quickActions.test.ts b/test/unit/quickActions.test.ts index 7503d68..e0f3714 100644 --- a/test/unit/quickActions.test.ts +++ b/test/unit/quickActions.test.ts @@ -46,7 +46,8 @@ import { resolveWorkspaceRelativePath, retargetQuickAction, serializePatchloomArgs, - withEndOfOptions + withEndOfOptions, + withOption } from "../../src/commands/quickActions.js"; import type { PatchloomLog } from "../../src/logging/outputChannel.js"; @@ -68,17 +69,17 @@ test("buildReplaceQuickAction builds a replace command for one file", () => { const action = buildReplaceQuickAction("/workspace/demo/README.md", "old", "new"); assert.equal(action.title, "Replace text in README.md"); - assert.deepEqual(action.targetArgIndices, [5]); - assert.deepEqual(action.args, ["replace", "--new", "new", "--", "old", "/workspace/demo/README.md"]); + assert.deepEqual(action.targetArgIndices, [4]); + assert.deepEqual(action.args, ["replace", "--new=new", "--", "old", "/workspace/demo/README.md"]); }); test("buildInsertAfterMatchQuickAction builds replace --insert-after (CLI 0.16+)", () => { const action = buildInsertAfterMatchQuickAction("/workspace/demo/app.ts", "const x = 1;", "const y = 2;"); assert.equal(action.title, "Insert after match in app.ts"); - assert.deepEqual(action.targetArgIndices, [5]); + assert.deepEqual(action.targetArgIndices, [4]); assert.deepEqual(action.args, [ - "replace", "--insert-after", "const y = 2;", "--", "const x = 1;", "/workspace/demo/app.ts" + "replace", "--insert-after=const y = 2;", "--", "const x = 1;", "/workspace/demo/app.ts" ]); }); @@ -86,9 +87,9 @@ test("buildInsertBeforeMatchQuickAction builds replace --insert-before (CLI 0.16 const action = buildInsertBeforeMatchQuickAction("/workspace/demo/app.ts", "return true;", "// checked"); assert.equal(action.title, "Insert before match in app.ts"); - assert.deepEqual(action.targetArgIndices, [5]); + assert.deepEqual(action.targetArgIndices, [4]); assert.deepEqual(action.args, [ - "replace", "--insert-before", "// checked", "--", "return true;", "/workspace/demo/app.ts" + "replace", "--insert-before=// checked", "--", "return true;", "/workspace/demo/app.ts" ]); }); @@ -101,13 +102,11 @@ test("buildApplyFragmentQuickAction builds apply-fragment --after (CLI 0.22+)", ); assert.equal(action.title, "Apply fragment in lib.rs"); - assert.deepEqual(action.targetArgIndices, [6]); + assert.deepEqual(action.targetArgIndices, [4]); assert.deepEqual(action.args, [ "apply-fragment", - "--after", - "fn foo() {", - "--fragment", - " let x = 1;", + "--after=fn foo() {", + "--fragment= let x = 1;", "--", "/workspace/demo/lib.rs" ]); @@ -116,12 +115,12 @@ test("buildApplyFragmentQuickAction builds apply-fragment --after (CLI 0.22+)", test("buildApplyFragmentQuickAction supports --before and --old placements", () => { const before = buildApplyFragmentQuickAction("/ws/a.ts", "before", "return true;", "// note"); assert.deepEqual(before.args, [ - "apply-fragment", "--before", "return true;", "--fragment", "// note", "--", "/ws/a.ts" + "apply-fragment", "--before=return true;", "--fragment=// note", "--", "/ws/a.ts" ]); const replaceSpan = buildApplyFragmentQuickAction("/ws/a.ts", "old", "old_span", "new_span"); assert.deepEqual(replaceSpan.args, [ - "apply-fragment", "--old", "old_span", "--fragment", "new_span", "--", "/ws/a.ts" + "apply-fragment", "--old=old_span", "--fragment=new_span", "--", "/ws/a.ts" ]); }); @@ -131,7 +130,7 @@ test("retargetQuickAction preserves apply-fragment path index", () => { assert.equal(retargeted.targetPath, "/workspace/demo/b.ts"); assert.deepEqual(retargeted.args, [ - "apply-fragment", "--after", "anchor", "--fragment", "frag", "--", "/workspace/demo/b.ts" + "apply-fragment", "--after=anchor", "--fragment=frag", "--", "/workspace/demo/b.ts" ]); }); @@ -141,7 +140,7 @@ test("retargetQuickAction preserves insert-after args", () => { assert.equal(retargeted.targetPath, "/workspace/demo/b.ts"); assert.deepEqual(retargeted.args, [ - "replace", "--insert-after", "bar", "--", "foo", "/workspace/demo/b.ts" + "replace", "--insert-after=bar", "--", "foo", "/workspace/demo/b.ts" ]); }); @@ -244,8 +243,7 @@ test("retargetQuickAction swaps only the target path arguments", () => { assert.deepEqual(retargeted.args, [ "replace", - "--new", - "new", + "--new=new", "--", "/workspace/demo/README.md", "/tmp/preview/README.md" @@ -269,35 +267,49 @@ test("serializePatchloomArgs writes flags from booleans, not from scanning args" test("buildReplaceQuickAction keeps --apply as OLD after end-of-options", () => { const action = buildReplaceQuickAction("/workspace/demo/flags.md", "--apply", "X"); - assert.deepEqual(action.args, ["replace", "--new", "X", "--", "--apply", "/workspace/demo/flags.md"]); + assert.deepEqual(action.args, ["replace", "--new=X", "--", "--apply", "/workspace/demo/flags.md"]); assert.deepEqual( serializePatchloomArgs({ args: action.args, apply: true, contain: true }), - ["--contain", "replace", "--new", "X", "--apply", "--", "--apply", "/workspace/demo/flags.md"] + ["--contain", "replace", "--new=X", "--apply", "--", "--apply", "/workspace/demo/flags.md"] ); }); test("serializePatchloomArgs puts --apply before -- so dash operands stay operands", () => { assert.deepEqual( serializePatchloomArgs({ - args: withEndOfOptions(["replace", "--new", "X"], ["--apply", "f.txt"]), + args: withEndOfOptions(["replace", withOption("--new", "X")], ["--apply", "f.txt"]), apply: true, contain: true }), - ["--contain", "replace", "--new", "X", "--apply", "--", "--apply", "f.txt"] + ["--contain", "replace", "--new=X", "--apply", "--", "--apply", "f.txt"] ); }); +test("builders pass dash-looking option values as --flag=value", () => { + const created = buildCreateQuickAction("/workspace/demo/f.txt", "--"); + assert.deepEqual(created.args, ["create", "--content=--", "--", "/workspace/demo/f.txt"]); + assert.deepEqual( + serializePatchloomArgs({ args: created.args, apply: true, contain: true }), + ["--contain", "create", "--content=--", "--apply", "--", "/workspace/demo/f.txt"] + ); + + const replaced = buildReplaceQuickAction("/workspace/demo/f.txt", "hello", "-n"); + assert.deepEqual(replaced.args, ["replace", "--new=-n", "--", "hello", "/workspace/demo/f.txt"]); + + const applyNamed = buildReplaceQuickAction("/workspace/demo/f.txt", "hello", "--apply"); + assert.deepEqual(applyNamed.args, ["replace", "--new=--apply", "--", "hello", "/workspace/demo/f.txt"]); +}); + test("buildPrependQuickAction builds a file prepend command", () => { const action = buildPrependQuickAction("/workspace/demo/src/main.ts", "// copyright\n"); assert.equal(action.title, "Prepend to main.ts"); assert.deepEqual(action.args, [ "prepend", - "--content", - "// copyright\n", + "--content=// copyright\n", "--", "/workspace/demo/src/main.ts" ]); - assert.deepEqual(action.targetArgIndices, [4]); + assert.deepEqual(action.targetArgIndices, [3]); }); test("isStructuredDocumentPath recognizes supported structured formats", () => { @@ -353,8 +365,8 @@ test("buildSearchQuickAction includes glob when provided", () => { const action = buildSearchQuickAction("/workspace/demo", "TODO", "*.ts"); assert.equal(action.title, 'Search for "TODO"'); - assert.deepEqual(action.args, ["search", "--glob", "*.ts", "--", "TODO", "/workspace/demo"]); - assert.deepEqual(action.targetArgIndices, [5]); + assert.deepEqual(action.args, ["search", "--glob=*.ts", "--", "TODO", "/workspace/demo"]); + assert.deepEqual(action.targetArgIndices, [4]); }); test("buildSearchQuickAction includes --files-without-match (CLI 0.29+)", () => { @@ -374,9 +386,9 @@ test("buildSearchQuickAction combines --files-without-match with glob", () => { assert.equal(action.title, 'Search files without "TODO"'); assert.deepEqual(action.args, [ - "search", "--files-without-match", "--glob", "*.ts", "--", "TODO", "/workspace/demo" + "search", "--files-without-match", "--glob=*.ts", "--", "TODO", "/workspace/demo" ]); - assert.deepEqual(action.targetArgIndices, [6]); + assert.deepEqual(action.targetArgIndices, [5]); }); test("buildCreateQuickAction builds a create command with content and apply", () => { @@ -385,21 +397,19 @@ test("buildCreateQuickAction builds a create command with content and apply", () assert.equal(action.title, "Create newfile.ts"); assert.deepEqual(action.args, [ "create", - "--content", - "hello", + "--content=hello", "--", "/workspace/demo/src/newfile.ts" ]); assert.equal(action.apply, true); - assert.deepEqual(action.targetArgIndices, [4]); + assert.deepEqual(action.targetArgIndices, [3]); }); test("buildCreateQuickAction allows empty content", () => { const action = buildCreateQuickAction("/workspace/demo/empty.txt"); assert.deepEqual(action.args, [ "create", - "--content", - "", + "--content=", "--", "/workspace/demo/empty.txt" ]); @@ -482,8 +492,7 @@ test("buildCreateQuickAction with spaces in path", () => { assert.equal(action.title, "Create new file.ts"); assert.deepEqual(action.args, [ "create", - "--content", - "x", + "--content=x", "--", "/workspace/my project/src/new file.ts" ]); @@ -501,8 +510,7 @@ test("buildCreateQuickAction with unicode filename", () => { assert.equal(action.title, "Create 日本語.md"); assert.deepEqual(action.args, [ "create", - "--content", - "# title", + "--content=# title", "--", "/workspace/demo/docs/日本語.md" ]); @@ -528,12 +536,11 @@ test("buildDocDeleteWhereQuickAction builds a doc delete-where command", () => { const action = buildDocDeleteWhereQuickAction("/workspace/demo/data.json", "items", "name=stale"); assert.equal(action.title, "Delete where name=stale from items in data.json"); - assert.deepEqual(action.targetArgIndices, [5]); + assert.deepEqual(action.targetArgIndices, [4]); assert.deepEqual(action.args, [ "doc", "delete-where", - "--predicate", - "name=stale", + "--predicate=name=stale", "--", "/workspace/demo/data.json", "items" @@ -544,8 +551,8 @@ test("buildDocMergeQuickAction builds a doc merge command", () => { const action = buildDocMergeQuickAction("/workspace/demo/package.json", '{"debug": true}'); assert.equal(action.title, "Merge into package.json"); - assert.deepEqual(action.targetArgIndices, [5]); - assert.deepEqual(action.args, ["doc", "merge", "--value", '{"debug": true}', "--", "/workspace/demo/package.json"]); + assert.deepEqual(action.targetArgIndices, [4]); + assert.deepEqual(action.args, ["doc", "merge", "--value={\"debug\": true}", "--", "/workspace/demo/package.json"]); }); test("buildDocMergeQuickAction includes --selector for multi-doc merge (CLI 0.16+)", () => { @@ -556,11 +563,11 @@ test("buildDocMergeQuickAction includes --selector for multi-doc merge (CLI 0.16 ); assert.equal(action.title, "Merge into 0 in multi.yaml"); - assert.deepEqual(action.targetArgIndices, [7]); + assert.deepEqual(action.targetArgIndices, [5]); assert.deepEqual(action.args, [ "doc", "merge", - "--selector", "0", - "--value", '{"debug": true}', + "--selector=0", + "--value={\"debug\": true}", "--", "/workspace/demo/multi.yaml" ]); }); @@ -571,7 +578,7 @@ test("buildDocMergeQuickAction omits --selector when selector is blank", () => { assert.equal(action.title, "Merge into config.toml"); assert.deepEqual(action.args, [ "doc", "merge", - "--value", '{"x": 1}', + "--value={\"x\": 1}", "--", "/workspace/demo/config.toml" ]); assert.ok(!action.args.includes("--selector")); @@ -584,8 +591,8 @@ test("retargetQuickAction preserves doc merge selector flags", () => { assert.equal(retargeted.targetPath, "/workspace/demo/b.yaml"); assert.deepEqual(retargeted.args, [ "doc", "merge", - "--selector", "[0]", - "--value", '{"k": true}', + "--selector=[0]", + "--value={\"k\": true}", "--", "/workspace/demo/b.yaml" ]); }); @@ -604,11 +611,11 @@ test("buildMdTableAppendQuickAction builds a md table-append command", () => { const action = buildMdTableAppendQuickAction("/workspace/demo/README.md", "## API", "| /users | List users |"); assert.equal(action.title, 'Append table row under "## API" in README.md'); - assert.deepEqual(action.targetArgIndices, [7]); + assert.deepEqual(action.targetArgIndices, [5]); assert.deepEqual(action.args, [ "md", "table-append", - "--heading", "## API", - "--row", "| /users | List users |", + "--heading=## API", + "--row=| /users | List users |", "--", "/workspace/demo/README.md" ]); }); @@ -617,11 +624,11 @@ test("buildMdUpsertBulletQuickAction builds a md upsert-bullet command", () => { const action = buildMdUpsertBulletQuickAction("/workspace/demo/AGENTS.md", "## Rules", "Run make check"); assert.equal(action.title, 'Upsert bullet under "## Rules" in AGENTS.md'); - assert.deepEqual(action.targetArgIndices, [7]); + assert.deepEqual(action.targetArgIndices, [5]); assert.deepEqual(action.args, [ "md", "upsert-bullet", - "--heading", "## Rules", - "--bullet", "Run make check", + "--heading=## Rules", + "--bullet=Run make check", "--", "/workspace/demo/AGENTS.md" ]); }); @@ -630,11 +637,11 @@ test("buildMdReplaceSectionQuickAction builds a md replace-section command", () const action = buildMdReplaceSectionQuickAction("/workspace/demo/CHANGELOG.md", "## Unreleased", "- New feature"); assert.equal(action.title, 'Replace "## Unreleased" in CHANGELOG.md'); - assert.deepEqual(action.targetArgIndices, [7]); + assert.deepEqual(action.targetArgIndices, [5]); assert.deepEqual(action.args, [ "md", "replace-section", - "--heading", "## Unreleased", - "--content", "- New feature", + "--heading=## Unreleased", + "--content=- New feature", "--", "/workspace/demo/CHANGELOG.md" ]); }); @@ -681,11 +688,11 @@ test("buildMdInsertAfterHeadingQuickAction builds a md insert-after-heading comm const action = buildMdInsertAfterHeadingQuickAction("/workspace/demo/README.md", "## Installation", "Run npm install"); assert.equal(action.title, 'Insert after "## Installation" in README.md'); - assert.deepEqual(action.targetArgIndices, [7]); + assert.deepEqual(action.targetArgIndices, [5]); assert.deepEqual(action.args, [ "md", "insert-after-heading", - "--heading", "## Installation", - "--content", "Run npm install", + "--heading=## Installation", + "--content=Run npm install", "--", "/workspace/demo/README.md" ]); }); @@ -698,11 +705,11 @@ test("buildMdInsertAfterSectionQuickAction builds a md insert-after-section comm ); assert.equal(action.title, 'Insert after section "## Config" in README.md'); - assert.deepEqual(action.targetArgIndices, [7]); + assert.deepEqual(action.targetArgIndices, [5]); assert.deepEqual(action.args, [ "md", "insert-after-section", - "--heading", "## Config", - "--content", "## FAQ\n\nCommon questions.", + "--heading=## Config", + "--content=## FAQ\n\nCommon questions.", "--", "/workspace/demo/README.md" ]); }); @@ -711,11 +718,11 @@ test("buildMdInsertBeforeHeadingQuickAction builds a md insert-before-heading co const action = buildMdInsertBeforeHeadingQuickAction("/workspace/demo/CHANGELOG.md", "## v1.0.0", "## v1.1.0\n\n- New feature"); assert.equal(action.title, 'Insert before "## v1.0.0" in CHANGELOG.md'); - assert.deepEqual(action.targetArgIndices, [7]); + assert.deepEqual(action.targetArgIndices, [5]); assert.deepEqual(action.args, [ "md", "insert-before-heading", - "--heading", "## v1.0.0", - "--content", "## v1.1.0\n\n- New feature", + "--heading=## v1.0.0", + "--content=## v1.1.0\n\n- New feature", "--", "/workspace/demo/CHANGELOG.md" ]); }); @@ -818,8 +825,8 @@ test("buildAppendQuickAction builds an append command", () => { const action = buildAppendQuickAction("/workspace/demo/log.txt", "new log entry"); assert.equal(action.title, "Append to log.txt"); - assert.deepEqual(action.targetArgIndices, [4]); - assert.deepEqual(action.args, ["append", "--content", "new log entry", "--", "/workspace/demo/log.txt"]); + assert.deepEqual(action.targetArgIndices, [3]); + assert.deepEqual(action.args, ["append", "--content=new log entry", "--", "/workspace/demo/log.txt"]); }); // --- result presenters (search / patch-merge / undo) ---