Skip to content

Commit 3be2018

Browse files
authored
feat: expand Quick Actions with markdown, doc mutations, and undo (#117)
Add 7 new Quick Actions to the command palette picker: Markdown operations (#114): - Append table row (md table-append) - Upsert bullet (md upsert-bullet) - Replace markdown section (md replace-section) Doc mutation operations (#115): - Delete structured value (doc delete) - Merge into structured file (doc merge) - Append to array (doc append) Undo (#116): - Undo last patchloom change (undo --apply) All new operations use the existing preview-and-apply pattern with diff view. Markdown operations include isMarkdownPath validation. Tests: 10 new builder tests, 6 new CLI integration tests covering doc delete, doc merge, doc append, md upsert-bullet, md table-append, and undo round-trip. Closes #114 Closes #115 Closes #116 Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent 9bf7eb1 commit 3be2018

4 files changed

Lines changed: 532 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/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/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: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { activeWorkspaceFolder, describeWorkspaceEnvironment } from "../workspac
1111

1212
const execFileAsync = promisify(execFile);
1313
const STRUCTURED_FILE_EXTENSIONS = new Set([".json", ".yaml", ".yml", ".toml"]);
14+
const MARKDOWN_FILE_EXTENSIONS = new Set([".md", ".markdown", ".mdx"]);
1415

1516
export type TidyFix = "ensure-final-newline" | "trim-trailing-whitespace" | "normalize-eol-lf";
1617

@@ -297,6 +298,255 @@ export async function runQuickAction(): Promise<void> {
297298
await vscode.env.clipboard.writeText(value);
298299
await vscode.window.showInformationMessage(`${selector} = ${value} (copied to clipboard)`);
299300
}
301+
},
302+
{
303+
label: "Delete structured value",
304+
description: "Remove a key from JSON, YAML, or TOML with diff preview",
305+
detail: "Builds `patchloom doc delete <file> <selector>`",
306+
run: async () => {
307+
const target = await pickWorkspaceFileTarget("Select a JSON, YAML, or TOML file for Patchloom doc delete");
308+
if (!target) {
309+
return;
310+
}
311+
312+
if (!isStructuredDocumentPath(target.absolutePath)) {
313+
await vscode.window.showWarningMessage(
314+
`${target.relativePath} is not a supported JSON, YAML, or TOML file for Patchloom doc delete.`
315+
);
316+
return;
317+
}
318+
319+
const selector = await vscode.window.showInputBox({
320+
prompt: "Selector path to delete",
321+
placeHolder: "scripts.deprecated",
322+
validateInput: (value) => value.length > 0 ? undefined : "Selector is required."
323+
});
324+
if (selector === undefined) {
325+
return;
326+
}
327+
328+
await previewAndMaybeApply(binaryPath, target, buildDocDeleteQuickAction(target.absolutePath, selector));
329+
}
330+
},
331+
{
332+
label: "Merge into structured file",
333+
description: "Merge a partial JSON object into a config file",
334+
detail: "Builds `patchloom doc merge <file> --value <json>`",
335+
run: async () => {
336+
const target = await pickWorkspaceFileTarget("Select a JSON, YAML, or TOML file for Patchloom doc merge");
337+
if (!target) {
338+
return;
339+
}
340+
341+
if (!isStructuredDocumentPath(target.absolutePath)) {
342+
await vscode.window.showWarningMessage(
343+
`${target.relativePath} is not a supported JSON, YAML, or TOML file for Patchloom doc merge.`
344+
);
345+
return;
346+
}
347+
348+
const value = await vscode.window.showInputBox({
349+
prompt: "Partial JSON object to merge",
350+
placeHolder: '{"debug": true, "logLevel": "verbose"}',
351+
validateInput: (input) => input.length > 0 ? undefined : "Value is required."
352+
});
353+
if (value === undefined) {
354+
return;
355+
}
356+
357+
await previewAndMaybeApply(binaryPath, target, buildDocMergeQuickAction(target.absolutePath, value));
358+
}
359+
},
360+
{
361+
label: "Append to array",
362+
description: "Append a value to a JSON, YAML, or TOML array",
363+
detail: "Builds `patchloom doc append <file> <selector> <value>`",
364+
run: async () => {
365+
const target = await pickWorkspaceFileTarget("Select a JSON, YAML, or TOML file for Patchloom doc append");
366+
if (!target) {
367+
return;
368+
}
369+
370+
if (!isStructuredDocumentPath(target.absolutePath)) {
371+
await vscode.window.showWarningMessage(
372+
`${target.relativePath} is not a supported JSON, YAML, or TOML file for Patchloom doc append.`
373+
);
374+
return;
375+
}
376+
377+
const selector = await vscode.window.showInputBox({
378+
prompt: "Selector path to the array",
379+
placeHolder: "dependencies",
380+
validateInput: (value) => value.length > 0 ? undefined : "Selector is required."
381+
});
382+
if (selector === undefined) {
383+
return;
384+
}
385+
386+
const value = await vscode.window.showInputBox({
387+
prompt: "Value to append",
388+
placeHolder: '"new-item"',
389+
validateInput: (input) => input.length > 0 ? undefined : "Value is required."
390+
});
391+
if (value === undefined) {
392+
return;
393+
}
394+
395+
await previewAndMaybeApply(binaryPath, target, buildDocAppendQuickAction(target.absolutePath, selector, value));
396+
}
397+
},
398+
{
399+
label: "Append table row",
400+
description: "Append a row to a markdown table under a heading",
401+
detail: "Builds `patchloom md table-append <file> --heading <h> --row <row>`",
402+
run: async () => {
403+
const target = await pickWorkspaceFileTarget("Select a markdown file for Patchloom table-append");
404+
if (!target) {
405+
return;
406+
}
407+
408+
if (!isMarkdownPath(target.absolutePath)) {
409+
await vscode.window.showWarningMessage(
410+
`${target.relativePath} is not a markdown file.`
411+
);
412+
return;
413+
}
414+
415+
const heading = await vscode.window.showInputBox({
416+
prompt: "Heading containing the table",
417+
placeHolder: "## API",
418+
validateInput: (value) => value.length > 0 ? undefined : "Heading is required."
419+
});
420+
if (heading === undefined) {
421+
return;
422+
}
423+
424+
const row = await vscode.window.showInputBox({
425+
prompt: "Table row to append (pipe-delimited)",
426+
placeHolder: "| /users | List users | GET |",
427+
validateInput: (value) => value.length > 0 ? undefined : "Row is required."
428+
});
429+
if (row === undefined) {
430+
return;
431+
}
432+
433+
await previewAndMaybeApply(binaryPath, target, buildMdTableAppendQuickAction(target.absolutePath, heading, row));
434+
}
435+
},
436+
{
437+
label: "Upsert bullet",
438+
description: "Add a bullet under a markdown heading (idempotent)",
439+
detail: "Builds `patchloom md upsert-bullet <file> --heading <h> --bullet <text>`",
440+
run: async () => {
441+
const target = await pickWorkspaceFileTarget("Select a markdown file for Patchloom upsert-bullet");
442+
if (!target) {
443+
return;
444+
}
445+
446+
if (!isMarkdownPath(target.absolutePath)) {
447+
await vscode.window.showWarningMessage(
448+
`${target.relativePath} is not a markdown file.`
449+
);
450+
return;
451+
}
452+
453+
const heading = await vscode.window.showInputBox({
454+
prompt: "Heading to add the bullet under",
455+
placeHolder: "## Rules",
456+
validateInput: (value) => value.length > 0 ? undefined : "Heading is required."
457+
});
458+
if (heading === undefined) {
459+
return;
460+
}
461+
462+
const bullet = await vscode.window.showInputBox({
463+
prompt: "Bullet text (without leading dash)",
464+
placeHolder: "Run make check before committing",
465+
validateInput: (value) => value.length > 0 ? undefined : "Bullet text is required."
466+
});
467+
if (bullet === undefined) {
468+
return;
469+
}
470+
471+
await previewAndMaybeApply(binaryPath, target, buildMdUpsertBulletQuickAction(target.absolutePath, heading, bullet));
472+
}
473+
},
474+
{
475+
label: "Replace markdown section",
476+
description: "Replace content under a markdown heading",
477+
detail: "Builds `patchloom md replace-section <file> --heading <h> --content <text>`",
478+
run: async () => {
479+
const target = await pickWorkspaceFileTarget("Select a markdown file for Patchloom replace-section");
480+
if (!target) {
481+
return;
482+
}
483+
484+
if (!isMarkdownPath(target.absolutePath)) {
485+
await vscode.window.showWarningMessage(
486+
`${target.relativePath} is not a markdown file.`
487+
);
488+
return;
489+
}
490+
491+
const heading = await vscode.window.showInputBox({
492+
prompt: "Heading of the section to replace",
493+
placeHolder: "## Unreleased",
494+
validateInput: (value) => value.length > 0 ? undefined : "Heading is required."
495+
});
496+
if (heading === undefined) {
497+
return;
498+
}
499+
500+
const content = await vscode.window.showInputBox({
501+
prompt: "New section content",
502+
placeHolder: "- New feature added",
503+
validateInput: (value) => value.length > 0 ? undefined : "Content is required."
504+
});
505+
if (content === undefined) {
506+
return;
507+
}
508+
509+
await previewAndMaybeApply(binaryPath, target, buildMdReplaceSectionQuickAction(target.absolutePath, heading, content));
510+
}
511+
},
512+
{
513+
label: "Undo last change",
514+
description: "Restore files from the last patchloom backup",
515+
detail: "Runs `patchloom undo`",
516+
run: async () => {
517+
const folder = await activeWorkspaceFolder({
518+
promptIfMany: true,
519+
placeHolder: "Select workspace folder for Patchloom undo"
520+
});
521+
if (!folder) {
522+
await vscode.window.showWarningMessage("Open a workspace folder before running Patchloom undo.");
523+
return;
524+
}
525+
526+
const confirm = await vscode.window.showWarningMessage(
527+
"Undo the last patchloom edit? This restores files from backup.",
528+
{ modal: true },
529+
"Undo"
530+
);
531+
if (confirm !== "Undo") {
532+
return;
533+
}
534+
535+
const action = buildUndoQuickAction(folder.uri.fsPath);
536+
const result = await executePatchloom(binaryPath, action.args, folder.uri.fsPath);
537+
538+
if (result.exitCode !== 0) {
539+
const message = result.stderr.includes("no backup")
540+
? "No patchloom backup to undo."
541+
: `Patchloom undo failed: ${formatCliOutput(result)}`;
542+
await vscode.window.showWarningMessage(message);
543+
return;
544+
}
545+
546+
const log = getPatchloomLog();
547+
log?.show();
548+
await vscode.window.showInformationMessage("Patchloom undo complete. Restored files shown in the output channel.");
549+
}
300550
}
301551
];
302552

@@ -382,6 +632,73 @@ export function buildDocGetQuickAction(targetPath: string, selector: string): Pl
382632
};
383633
}
384634

635+
export function buildDocDeleteQuickAction(targetPath: string, selector: string): PlannedQuickAction {
636+
return {
637+
title: `Delete ${selector} from ${path.basename(targetPath)}`,
638+
targetPath,
639+
targetArgIndices: [2],
640+
args: ["doc", "delete", targetPath, selector]
641+
};
642+
}
643+
644+
export function buildDocMergeQuickAction(targetPath: string, value: string): PlannedQuickAction {
645+
return {
646+
title: `Merge into ${path.basename(targetPath)}`,
647+
targetPath,
648+
targetArgIndices: [2],
649+
args: ["doc", "merge", targetPath, "--value", value]
650+
};
651+
}
652+
653+
export function buildDocAppendQuickAction(targetPath: string, selector: string, value: string): PlannedQuickAction {
654+
return {
655+
title: `Append to ${selector} in ${path.basename(targetPath)}`,
656+
targetPath,
657+
targetArgIndices: [2],
658+
args: ["doc", "append", targetPath, selector, value]
659+
};
660+
}
661+
662+
export function buildMdTableAppendQuickAction(targetPath: string, heading: string, row: string): PlannedQuickAction {
663+
return {
664+
title: `Append table row under "${heading}" in ${path.basename(targetPath)}`,
665+
targetPath,
666+
targetArgIndices: [2],
667+
args: ["md", "table-append", targetPath, "--heading", heading, "--row", row]
668+
};
669+
}
670+
671+
export function buildMdUpsertBulletQuickAction(targetPath: string, heading: string, bullet: string): PlannedQuickAction {
672+
return {
673+
title: `Upsert bullet under "${heading}" in ${path.basename(targetPath)}`,
674+
targetPath,
675+
targetArgIndices: [2],
676+
args: ["md", "upsert-bullet", targetPath, "--heading", heading, "--bullet", bullet]
677+
};
678+
}
679+
680+
export function buildMdReplaceSectionQuickAction(targetPath: string, heading: string, content: string): PlannedQuickAction {
681+
return {
682+
title: `Replace "${heading}" in ${path.basename(targetPath)}`,
683+
targetPath,
684+
targetArgIndices: [2],
685+
args: ["md", "replace-section", targetPath, "--heading", heading, "--content", content]
686+
};
687+
}
688+
689+
export function buildUndoQuickAction(workspacePath: string): PlannedQuickAction {
690+
return {
691+
title: "Undo last patchloom change",
692+
targetPath: workspacePath,
693+
targetArgIndices: [],
694+
args: ["undo", "--apply"]
695+
};
696+
}
697+
698+
export function isMarkdownPath(filePath: string): boolean {
699+
return MARKDOWN_FILE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
700+
}
701+
385702
export function isStructuredDocumentPath(filePath: string): boolean {
386703
return STRUCTURED_FILE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
387704
}

0 commit comments

Comments
 (0)