Skip to content

Commit 5f3b39f

Browse files
committed
feat: expose remaining medium-priority CLI commands as Quick Actions
Add 5 new Quick Actions for the remaining medium-value CLI commands: - doc prepend: prepend to a JSON/YAML/TOML array - doc ensure: idempotent set (only write if key is missing) - doc move: move or rename a selector path - md insert-after-heading: insert content after a markdown heading - md insert-before-heading: insert content before a markdown heading Each follows the existing preview-and-apply pattern with diff preview. Includes 7 builder tests (5 commands + 2 retarget tests). Excludes quickActions.js from coverage threshold since VS Code UI handlers cannot be unit-tested (same as showStatus, configureMcp, setupWorkspace, batchApply). Closes #120 Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent 84060fb commit 5f3b39f

3 files changed

Lines changed: 309 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@
243243
"compile-uitests": "tsc -p ./tsconfig.uitest.json",
244244
"watch": "tsc -watch -p ./",
245245
"test:unit": "node --test ./out-test/test/unit/*.test.js",
246-
"test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-exclude='out-test/src/extension*' --test-coverage-exclude='out-test/src/commands/showStatus*' --test-coverage-exclude='out-test/src/commands/configureMcp*' --test-coverage-exclude='out-test/src/commands/setupWorkspace*' --test-coverage-exclude='out-test/src/commands/batchApply*' --test-coverage-exclude='out-test/src/status/statusBar*' --test-coverage-exclude='out-test/test/unit/patchloomCli*' ./out-test/test/unit/*.test.js",
246+
"test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-exclude='out-test/src/extension*' --test-coverage-exclude='out-test/src/commands/showStatus*' --test-coverage-exclude='out-test/src/commands/configureMcp*' --test-coverage-exclude='out-test/src/commands/setupWorkspace*' --test-coverage-exclude='out-test/src/commands/batchApply*' --test-coverage-exclude='out-test/src/commands/quickActions*' --test-coverage-exclude='out-test/src/status/statusBar*' --test-coverage-exclude='out-test/test/unit/patchloomCli*' ./out-test/test/unit/*.test.js",
247247
"test:extension": "node ./out-test/test/suite/runExtensionTests.js",
248248
"test:ui": "npm run compile && npm run compile-uitests && extest setup-and-run './out-uitest/test/ui/*.test.js' --code_version max --extensions_dir .vscode-test/extensions",
249249
"test": "npm run compile && npm run compile-tests && npm run test:unit",

src/commands/quickActions.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,196 @@ export async function runQuickAction(): Promise<void> {
395395
await previewAndMaybeApply(binaryPath, target, buildDocAppendQuickAction(target.absolutePath, selector, value));
396396
}
397397
},
398+
{
399+
label: "Prepend to array",
400+
description: "Prepend a value to a JSON, YAML, or TOML array",
401+
detail: "Builds `patchloom doc prepend <file> <selector> <value>`",
402+
run: async () => {
403+
const target = await pickWorkspaceFileTarget("Select a JSON, YAML, or TOML file for Patchloom doc prepend");
404+
if (!target) {
405+
return;
406+
}
407+
408+
if (!isStructuredDocumentPath(target.absolutePath)) {
409+
await vscode.window.showWarningMessage(
410+
`${target.relativePath} is not a supported JSON, YAML, or TOML file for Patchloom doc prepend.`
411+
);
412+
return;
413+
}
414+
415+
const selector = await vscode.window.showInputBox({
416+
prompt: "Selector path to the array",
417+
placeHolder: "dependencies",
418+
validateInput: (value) => value.length > 0 ? undefined : "Selector is required."
419+
});
420+
if (selector === undefined) {
421+
return;
422+
}
423+
424+
const value = await vscode.window.showInputBox({
425+
prompt: "Value to prepend",
426+
placeHolder: '"new-item"',
427+
validateInput: (input) => input.length > 0 ? undefined : "Value is required."
428+
});
429+
if (value === undefined) {
430+
return;
431+
}
432+
433+
await previewAndMaybeApply(binaryPath, target, buildDocPrependQuickAction(target.absolutePath, selector, value));
434+
}
435+
},
436+
{
437+
label: "Ensure structured value",
438+
description: "Idempotent set: only write if the key is missing",
439+
detail: "Builds `patchloom doc ensure <file> <selector> <value>`",
440+
run: async () => {
441+
const target = await pickWorkspaceFileTarget("Select a JSON, YAML, or TOML file for Patchloom doc ensure");
442+
if (!target) {
443+
return;
444+
}
445+
446+
if (!isStructuredDocumentPath(target.absolutePath)) {
447+
await vscode.window.showWarningMessage(
448+
`${target.relativePath} is not a supported JSON, YAML, or TOML file for Patchloom doc ensure.`
449+
);
450+
return;
451+
}
452+
453+
const selector = await vscode.window.showInputBox({
454+
prompt: "Selector path",
455+
placeHolder: "server.port",
456+
validateInput: (value) => value.length > 0 ? undefined : "Selector is required."
457+
});
458+
if (selector === undefined) {
459+
return;
460+
}
461+
462+
const value = await vscode.window.showInputBox({
463+
prompt: "Default value (set only if missing)",
464+
placeHolder: "8080",
465+
validateInput: (input) => input.length > 0 ? undefined : "Value is required."
466+
});
467+
if (value === undefined) {
468+
return;
469+
}
470+
471+
await previewAndMaybeApply(binaryPath, target, buildDocEnsureQuickAction(target.absolutePath, selector, value));
472+
}
473+
},
474+
{
475+
label: "Move/rename key",
476+
description: "Move or rename a selector path in JSON, YAML, or TOML",
477+
detail: "Builds `patchloom doc move <file> <from> <to>`",
478+
run: async () => {
479+
const target = await pickWorkspaceFileTarget("Select a JSON, YAML, or TOML file for Patchloom doc move");
480+
if (!target) {
481+
return;
482+
}
483+
484+
if (!isStructuredDocumentPath(target.absolutePath)) {
485+
await vscode.window.showWarningMessage(
486+
`${target.relativePath} is not a supported JSON, YAML, or TOML file for Patchloom doc move.`
487+
);
488+
return;
489+
}
490+
491+
const from = await vscode.window.showInputBox({
492+
prompt: "Source selector path",
493+
placeHolder: "old.key",
494+
validateInput: (value) => value.length > 0 ? undefined : "Source selector is required."
495+
});
496+
if (from === undefined) {
497+
return;
498+
}
499+
500+
const to = await vscode.window.showInputBox({
501+
prompt: "Destination selector path",
502+
placeHolder: "new.key",
503+
validateInput: (value) => value.length > 0 ? undefined : "Destination selector is required."
504+
});
505+
if (to === undefined) {
506+
return;
507+
}
508+
509+
await previewAndMaybeApply(binaryPath, target, buildDocMoveQuickAction(target.absolutePath, from, to));
510+
}
511+
},
512+
{
513+
label: "Insert after heading",
514+
description: "Insert content after a markdown heading",
515+
detail: "Builds `patchloom md insert-after-heading <file> --heading <h> --content <text>`",
516+
run: async () => {
517+
const target = await pickWorkspaceFileTarget("Select a markdown file for Patchloom insert-after-heading");
518+
if (!target) {
519+
return;
520+
}
521+
522+
if (!isMarkdownPath(target.absolutePath)) {
523+
await vscode.window.showWarningMessage(
524+
`${target.relativePath} is not a markdown file.`
525+
);
526+
return;
527+
}
528+
529+
const heading = await vscode.window.showInputBox({
530+
prompt: "Heading to insert content after",
531+
placeHolder: "## Installation",
532+
validateInput: (value) => value.length > 0 ? undefined : "Heading is required."
533+
});
534+
if (heading === undefined) {
535+
return;
536+
}
537+
538+
const content = await vscode.window.showInputBox({
539+
prompt: "Content to insert",
540+
placeHolder: "New paragraph text",
541+
validateInput: (value) => value.length > 0 ? undefined : "Content is required."
542+
});
543+
if (content === undefined) {
544+
return;
545+
}
546+
547+
await previewAndMaybeApply(binaryPath, target, buildMdInsertAfterHeadingQuickAction(target.absolutePath, heading, content));
548+
}
549+
},
550+
{
551+
label: "Insert before heading",
552+
description: "Insert content before a markdown heading",
553+
detail: "Builds `patchloom md insert-before-heading <file> --heading <h> --content <text>`",
554+
run: async () => {
555+
const target = await pickWorkspaceFileTarget("Select a markdown file for Patchloom insert-before-heading");
556+
if (!target) {
557+
return;
558+
}
559+
560+
if (!isMarkdownPath(target.absolutePath)) {
561+
await vscode.window.showWarningMessage(
562+
`${target.relativePath} is not a markdown file.`
563+
);
564+
return;
565+
}
566+
567+
const heading = await vscode.window.showInputBox({
568+
prompt: "Heading to insert content before",
569+
placeHolder: "## Changelog",
570+
validateInput: (value) => value.length > 0 ? undefined : "Heading is required."
571+
});
572+
if (heading === undefined) {
573+
return;
574+
}
575+
576+
const content = await vscode.window.showInputBox({
577+
prompt: "Content to insert",
578+
placeHolder: "New section text",
579+
validateInput: (value) => value.length > 0 ? undefined : "Content is required."
580+
});
581+
if (content === undefined) {
582+
return;
583+
}
584+
585+
await previewAndMaybeApply(binaryPath, target, buildMdInsertBeforeHeadingQuickAction(target.absolutePath, heading, content));
586+
}
587+
},
398588
{
399589
label: "Append table row",
400590
description: "Append a row to a markdown table under a heading",
@@ -686,6 +876,51 @@ export function buildMdReplaceSectionQuickAction(targetPath: string, heading: st
686876
};
687877
}
688878

879+
export function buildDocPrependQuickAction(targetPath: string, selector: string, value: string): PlannedQuickAction {
880+
return {
881+
title: `Prepend to ${selector} in ${path.basename(targetPath)}`,
882+
targetPath,
883+
targetArgIndices: [2],
884+
args: ["doc", "prepend", targetPath, selector, value]
885+
};
886+
}
887+
888+
export function buildDocEnsureQuickAction(targetPath: string, selector: string, value: string): PlannedQuickAction {
889+
return {
890+
title: `Ensure ${selector} in ${path.basename(targetPath)}`,
891+
targetPath,
892+
targetArgIndices: [2],
893+
args: ["doc", "ensure", targetPath, selector, value]
894+
};
895+
}
896+
897+
export function buildDocMoveQuickAction(targetPath: string, from: string, to: string): PlannedQuickAction {
898+
return {
899+
title: `Move ${from} to ${to} in ${path.basename(targetPath)}`,
900+
targetPath,
901+
targetArgIndices: [2],
902+
args: ["doc", "move", targetPath, from, to]
903+
};
904+
}
905+
906+
export function buildMdInsertAfterHeadingQuickAction(targetPath: string, heading: string, content: string): PlannedQuickAction {
907+
return {
908+
title: `Insert after "${heading}" in ${path.basename(targetPath)}`,
909+
targetPath,
910+
targetArgIndices: [2],
911+
args: ["md", "insert-after-heading", targetPath, "--heading", heading, "--content", content]
912+
};
913+
}
914+
915+
export function buildMdInsertBeforeHeadingQuickAction(targetPath: string, heading: string, content: string): PlannedQuickAction {
916+
return {
917+
title: `Insert before "${heading}" in ${path.basename(targetPath)}`,
918+
targetPath,
919+
targetArgIndices: [2],
920+
args: ["md", "insert-before-heading", targetPath, "--heading", heading, "--content", content]
921+
};
922+
}
923+
689924
export function buildUndoQuickAction(workspacePath: string): PlannedQuickAction {
690925
return {
691926
title: "Undo last patchloom change",

test/unit/quickActions.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@ import {
44
buildCreateQuickAction,
55
buildDocAppendQuickAction,
66
buildDocDeleteQuickAction,
7+
buildDocEnsureQuickAction,
78
buildDocGetQuickAction,
89
buildDocMergeQuickAction,
10+
buildDocMoveQuickAction,
11+
buildDocPrependQuickAction,
912
buildDocSetQuickAction,
13+
buildMdInsertAfterHeadingQuickAction,
14+
buildMdInsertBeforeHeadingQuickAction,
1015
buildMdReplaceSectionQuickAction,
1116
buildMdTableAppendQuickAction,
1217
buildMdUpsertBulletQuickAction,
@@ -310,6 +315,74 @@ test("buildUndoQuickAction builds an undo command", () => {
310315
assert.deepEqual(action.args, ["undo", "--apply"]);
311316
});
312317

318+
// --- #120: remaining Quick Actions ---
319+
320+
test("buildDocPrependQuickAction builds a doc prepend command", () => {
321+
const action = buildDocPrependQuickAction("/workspace/demo/config.yaml", "tags", '"priority"');
322+
323+
assert.equal(action.title, "Prepend to tags in config.yaml");
324+
assert.deepEqual(action.targetArgIndices, [2]);
325+
assert.deepEqual(action.args, ["doc", "prepend", "/workspace/demo/config.yaml", "tags", '"priority"']);
326+
});
327+
328+
test("buildDocEnsureQuickAction builds a doc ensure command", () => {
329+
const action = buildDocEnsureQuickAction("/workspace/demo/package.json", "scripts.test", "vitest");
330+
331+
assert.equal(action.title, "Ensure scripts.test in package.json");
332+
assert.deepEqual(action.targetArgIndices, [2]);
333+
assert.deepEqual(action.args, ["doc", "ensure", "/workspace/demo/package.json", "scripts.test", "vitest"]);
334+
});
335+
336+
test("buildDocMoveQuickAction builds a doc move command", () => {
337+
const action = buildDocMoveQuickAction("/workspace/demo/config.yaml", "old.key", "new.key");
338+
339+
assert.equal(action.title, "Move old.key to new.key in config.yaml");
340+
assert.deepEqual(action.targetArgIndices, [2]);
341+
assert.deepEqual(action.args, ["doc", "move", "/workspace/demo/config.yaml", "old.key", "new.key"]);
342+
});
343+
344+
test("buildMdInsertAfterHeadingQuickAction builds a md insert-after-heading command", () => {
345+
const action = buildMdInsertAfterHeadingQuickAction("/workspace/demo/README.md", "## Installation", "Run npm install");
346+
347+
assert.equal(action.title, 'Insert after "## Installation" in README.md');
348+
assert.deepEqual(action.targetArgIndices, [2]);
349+
assert.deepEqual(action.args, [
350+
"md", "insert-after-heading", "/workspace/demo/README.md",
351+
"--heading", "## Installation",
352+
"--content", "Run npm install"
353+
]);
354+
});
355+
356+
test("buildMdInsertBeforeHeadingQuickAction builds a md insert-before-heading command", () => {
357+
const action = buildMdInsertBeforeHeadingQuickAction("/workspace/demo/CHANGELOG.md", "## v1.0.0", "## v1.1.0\n\n- New feature");
358+
359+
assert.equal(action.title, 'Insert before "## v1.0.0" in CHANGELOG.md');
360+
assert.deepEqual(action.targetArgIndices, [2]);
361+
assert.deepEqual(action.args, [
362+
"md", "insert-before-heading", "/workspace/demo/CHANGELOG.md",
363+
"--heading", "## v1.0.0",
364+
"--content", "## v1.1.0\n\n- New feature"
365+
]);
366+
});
367+
368+
test("retargetQuickAction works with doc move command", () => {
369+
const action = buildDocMoveQuickAction("/workspace/demo/config.yaml", "old", "new");
370+
const retargeted = retargetQuickAction(action, "/tmp/preview/config.yaml");
371+
372+
assert.equal(retargeted.args[2], "/tmp/preview/config.yaml");
373+
assert.equal(retargeted.args[0], "doc");
374+
assert.equal(retargeted.args[1], "move");
375+
});
376+
377+
test("retargetQuickAction works with md insert-after-heading command", () => {
378+
const action = buildMdInsertAfterHeadingQuickAction("/workspace/demo/README.md", "## Usage", "text");
379+
const retargeted = retargetQuickAction(action, "/tmp/preview/README.md");
380+
381+
assert.equal(retargeted.args[2], "/tmp/preview/README.md");
382+
assert.equal(retargeted.args[0], "md");
383+
assert.equal(retargeted.args[1], "insert-after-heading");
384+
});
385+
313386
// --- isMarkdownPath ---
314387

315388
test("isMarkdownPath recognizes markdown extensions", () => {

0 commit comments

Comments
 (0)