Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,16 @@ src/
workspace/readiness.ts Workspace readiness: environment detection, folder selection
test/
unit/ Unit tests (node:test, dependency-injected, no VS Code API)
batchApply.test.ts Batch template and operation count parsing (14 tests)
batchApply.test.ts Batch template and operation count parsing (15 tests)
binary.test.ts Binary discovery, managed install, compatibility, workspace env (59 tests)
binaryDiscovery.test.ts Real executable discovery on PATH (13 tests)
initializeProject.test.ts Status display, agents file classification, formatError (26 tests)
managedLifecycle.test.ts Managed install with real file I/O (22 tests)
mcpConfig.test.ts MCP config with real temp directories (9 tests)
outputChannel.test.ts Output channel logging wrapper (10 tests)
patchloomCli.test.ts Patchloom CLI integration with real binary + managed install e2e MCP (36 tests incl. e2e)
patchloomCli.test.ts Patchloom CLI integration with real binary + managed install e2e MCP (37 tests incl. e2e)
propertyBased.test.ts Property-based tests with fast-check (13 tests)
quickActions.test.ts Quick action command building, path containment, patch merge (54 tests)
quickActions.test.ts Quick action command building, path containment, patch merge (57 tests)
verifyMcp.test.ts MCP server verify and JSON-RPC response parsing (15 tests)
downloadIntegration.test.ts HTTP download, redirect, streaming SHA-256 (9 tests)
suite/
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ Click it to see full diagnostics, including per-editor MCP configuration status
| Action | What it does |
|--------|-------------|
| **Replace text** | Literal text replacement with diff preview before applying |
| **Insert text after match** | Line-oriented insert after each match (CLI 0.16+) |
| **Insert text before match** | Line-oriented insert before each match (CLI 0.16+) |
| **Tidy file** | Whitespace and newline cleanup with diff preview |
| **Set structured value** | Update a JSON, YAML, or TOML key with diff preview |
| **Search text** | Find pattern matches across workspace files (results in output channel) |
Expand All @@ -110,7 +112,7 @@ Workspace Quick Actions and Batch Apply pass `--contain` so CLI paths stay insid

### Batch operations

`Patchloom: Batch Apply` opens a line-oriented plan template where you can compose multiple operations (replace, tidy, doc set). The extension pipes the plan to `patchloom batch --apply` so all changes land atomically.
`Patchloom: Batch Apply` opens a line-oriented plan template where you can compose multiple operations (replace, fuzzy replace, doc set, multi-doc `doc.merge`, file append, markdown section inserts, tidy). The extension pipes the plan to `patchloom batch --apply` so all changes land atomically.

### Output channel

Expand Down
30 changes: 15 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/commands/batchApply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { activeWorkspaceFolder } from "../workspace/readiness.js";
export const BATCH_TEMPLATE = [
"replace src/example.ts \"old text\" \"new text\"",
"replace src/example.ts \"typo_here\" \"fixed\" --fuzzy --min-fuzzy-score 0.80",
"replace src/example.ts \"anchor_line\" --insert-after=\"new sibling line\"",
"doc.set package.json version \"2.0.0\"",
"doc.merge multi-doc.yaml 0 \"{\\\"debug\\\": true}\"",
"file.append src/example.ts \"new appended line\"",
Expand Down
94 changes: 94 additions & 0 deletions src/commands/quickActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,74 @@ export async function runQuickAction(): Promise<void> {
await previewAndMaybeApply(binaryPath, target, buildReplaceQuickAction(target.absolutePath, from, to));
}
},
{
label: "Insert text after match",
description: "Line-oriented insert after each match (CLI 0.16+)",
detail: "Builds `patchloom replace <pattern> --insert-after <text> <file>`",
run: async () => {
const target = await pickWorkspaceFileTarget("Select a file for Patchloom insert-after");
if (!target) {
return;
}

const pattern = await vscode.window.showInputBox({
prompt: "Text to match (anchor for the insert)",
placeHolder: "existing_line_or_token",
validateInput: (value) => value.length > 0 ? undefined : "Match text is required."
});
if (pattern === undefined) {
return;
}

const content = await vscode.window.showInputBox({
prompt: "Text to insert after each match",
placeHolder: "new_line_or_token"
});
if (content === undefined) {
return;
}

await previewAndMaybeApply(
binaryPath,
target,
buildInsertAfterMatchQuickAction(target.absolutePath, pattern, content)
);
}
},
{
label: "Insert text before match",
description: "Line-oriented insert before each match (CLI 0.16+)",
detail: "Builds `patchloom replace <pattern> --insert-before <text> <file>`",
run: async () => {
const target = await pickWorkspaceFileTarget("Select a file for Patchloom insert-before");
if (!target) {
return;
}

const pattern = await vscode.window.showInputBox({
prompt: "Text to match (anchor for the insert)",
placeHolder: "existing_line_or_token",
validateInput: (value) => value.length > 0 ? undefined : "Match text is required."
});
if (pattern === undefined) {
return;
}

const content = await vscode.window.showInputBox({
prompt: "Text to insert before each match",
placeHolder: "new_line_or_token"
});
if (content === undefined) {
return;
}

await previewAndMaybeApply(
binaryPath,
target,
buildInsertBeforeMatchQuickAction(target.absolutePath, pattern, content)
);
}
},
{
label: "Tidy file",
description: "Whitespace and newline cleanup with diff preview",
Expand Down Expand Up @@ -895,6 +963,32 @@ export function buildReplaceQuickAction(targetPath: string, from: string, to: st
};
}

export function buildInsertAfterMatchQuickAction(
targetPath: string,
pattern: string,
content: string
): PlannedQuickAction {
return {
title: `Insert after match in ${path.basename(targetPath)}`,
targetPath,
targetArgIndices: [4],
args: ["replace", pattern, "--insert-after", content, targetPath]
};
}

export function buildInsertBeforeMatchQuickAction(
targetPath: string,
pattern: string,
content: string
): PlannedQuickAction {
return {
title: `Insert before match in ${path.basename(targetPath)}`,
targetPath,
targetArgIndices: [4],
args: ["replace", pattern, "--insert-before", content, targetPath]
};
}

export function buildTidyQuickAction(targetPath: string, fixes: readonly TidyFix[]): PlannedQuickAction {
const args = ["tidy", "fix", targetPath];
if (fixes.includes("ensure-final-newline")) {
Expand Down
26 changes: 19 additions & 7 deletions test/unit/batchApply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,19 @@ import {
parseBatchOperationCount
} from "../../src/commands/batchApply.js";

test("buildBatchTemplate returns line-oriented format with seven operations", () => {
test("buildBatchTemplate returns line-oriented format with eight operations", () => {
const template = buildBatchTemplate();
const lines = template.split("\n").filter((line) => line.trim().length > 0);

assert.equal(lines.length, 7);
assert.equal(lines.length, 8);
assert.ok(lines[0].startsWith("replace "), "first line should be a replace operation");
assert.ok(lines[1].startsWith("replace ") && lines[1].includes("--fuzzy"), "second line should be fuzzy replace");
assert.ok(lines[2].startsWith("doc.set "), "third line should be a doc.set operation");
assert.ok(lines[3].startsWith("doc.merge "), "fourth line should be multi-doc doc.merge");
assert.ok(lines[4].startsWith("file.append "), "fifth line should be a file.append operation");
assert.ok(lines[5].startsWith("md.insert_after_section "), "sixth line should be md.insert_after_section");
assert.ok(lines[6].startsWith("tidy.fix "), "seventh line should be a tidy.fix operation");
assert.ok(lines[2].startsWith("replace ") && lines[2].includes("--insert-after"), "third line should be insert-after");
assert.ok(lines[3].startsWith("doc.set "), "fourth line should be a doc.set operation");
assert.ok(lines[4].startsWith("doc.merge "), "fifth line should be multi-doc doc.merge");
assert.ok(lines[5].startsWith("file.append "), "sixth line should be a file.append operation");
assert.ok(lines[6].startsWith("md.insert_after_section "), "seventh line should be md.insert_after_section");
assert.ok(lines[7].startsWith("tidy.fix "), "eighth line should be a tidy.fix operation");
});

test("buildBatchTemplate ends with a newline", () => {
Expand Down Expand Up @@ -107,6 +108,17 @@ test("buildBatchTemplate doc.merge line uses path selector value (CLI 0.16 multi
assert.match(mergeLine, /\s0\s/, "example should merge into document 0");
});

test("buildBatchTemplate includes replace --insert-after example (CLI 0.16)", () => {
const lines = buildBatchTemplate().split("\n");
const insertLine = lines.find((l) => l.includes("--insert-after"));
assert.ok(insertLine, "template should contain an insert-after example");
assert.match(
insertLine,
/replace \S+ ".+" --insert-after=/,
"insert-after should use batch flag form path pattern --insert-after=payload"
);
});

test("buildBatchApplyArgs prefixes global --contain before batch --apply", () => {
assert.deepEqual(buildBatchApplyArgs(), ["--contain", "batch", "--apply"]);
});
21 changes: 21 additions & 0 deletions test/unit/patchloomCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { classifyAgentsFile } from "../../src/commands/initializeProject.js";
import { buildStatusDetails, preferredStatusAction } from "../../src/commands/showStatus.js";
import {
buildDocMergeQuickAction,
buildInsertAfterMatchQuickAction,
buildReplaceQuickAction,
retargetQuickAction,
withApplyFlag
Expand Down Expand Up @@ -539,6 +540,26 @@ describe("patchloom CLI integration", async () => {
});
});

test("replace --insert-after via Quick Action args is line-oriented (CLI 0.16+)", async (t) => {
const { stdout, stderr } = await execFileAsync(binaryPath, ["--version"], { timeout: 5000 });
const version = parsePatchloomVersion(`${stdout}${stderr}`);
if (!version || comparePatchloomVersions(version, "0.16.0") < 0) {
t.skip(`requires patchloom >= 0.16.0 (found ${version ?? "unknown"})`);
return;
}

await withTempDir(async (dir) => {
const file = path.join(dir, "notes.txt");
await fs.writeFile(file, "alpha\nbeta\n", "utf8");

const action = buildInsertAfterMatchQuickAction(file, "beta", "gamma");
await execFileAsync(binaryPath, withApplyFlag(action.args), { timeout: 5000 });

const content = await fs.readFile(file, "utf8");
assert.equal(content, "alpha\nbeta\ngamma\n");
});
});

test("doc append adds an item to a JSON array", async () => {
await withTempDir(async (dir) => {
const file = path.join(dir, "config.json");
Expand Down
32 changes: 32 additions & 0 deletions test/unit/quickActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import test from "node:test";
import {
buildAppendQuickAction,
buildCreateQuickAction,
buildInsertAfterMatchQuickAction,
buildInsertBeforeMatchQuickAction,
buildPrependQuickAction,
buildDocAppendQuickAction,
buildDocDeleteQuickAction,
Expand Down Expand Up @@ -39,6 +41,36 @@ test("buildReplaceQuickAction builds a replace command for one file", () => {
assert.deepEqual(action.args, ["replace", "old", "--new", "new", "/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, [4]);
assert.deepEqual(action.args, [
"replace", "const x = 1;", "--insert-after", "const y = 2;", "/workspace/demo/app.ts"
]);
});

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, [4]);
assert.deepEqual(action.args, [
"replace", "return true;", "--insert-before", "// checked", "/workspace/demo/app.ts"
]);
});

test("retargetQuickAction preserves insert-after args", () => {
const action = buildInsertAfterMatchQuickAction("/workspace/demo/a.ts", "foo", "bar");
const retargeted = retargetQuickAction(action, "/workspace/demo/b.ts");

assert.equal(retargeted.targetPath, "/workspace/demo/b.ts");
assert.deepEqual(retargeted.args, [
"replace", "foo", "--insert-after", "bar", "/workspace/demo/b.ts"
]);
});

test("buildTidyQuickAction includes selected tidy flags", () => {
const action = buildTidyQuickAction("/workspace/demo/file.txt", [
"ensure-final-newline",
Expand Down
Loading