fix: tags multiline, edit decorator position

This commit is contained in:
2026-07-19 07:09:13 +05:00
parent 70968c450e
commit 1382abc26d
3 changed files with 137 additions and 20 deletions
+79 -7
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { TaskPriority } from "@/model/taskPriority";
import { TaskStatus } from "@/model/taskStatus";
import { describe, expect, it } from "vitest";
import {
extractFrontmatterBlock,
findFieldRange,
@@ -20,6 +20,17 @@ tags: [old]
Body
`;
const blockTagsSample = `---
priority: medium
tags:
- bug
- issue
status: todo
---
Body
`;
describe("replaceFrontmatterField", () => {
it("replaces status value", () => {
const result = replaceFrontmatterField(sample, "status", TaskStatus.DONE);
@@ -41,6 +52,20 @@ describe("replaceFrontmatterField", () => {
expect(result.content).toContain("priority: critical");
});
it("replaces block-style tags without leaving leftover list items", () => {
const result = replaceFrontmatterField(
blockTagsSample,
"tags",
"\n - bug\n - issue\n - new_issue",
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.content).toContain(
"tags:\n - bug\n - issue\n - new_issue",
);
expect(result.content).not.toContain(" - bug\n - bug");
});
it("fails when field is missing", () => {
const result = replaceFrontmatterField(sample, "assignee", "me");
expect(result).toEqual({ ok: false, reason: "no-field" });
@@ -59,27 +84,74 @@ describe("findFieldRange", () => {
const line = sample.split("\n")[range!.startLine];
expect(line.slice(range!.startCol, range!.endCol)).toBe("todo");
});
it("returns range covering multi-line block-style tags value", () => {
const range = findFieldRange(blockTagsSample, "tags");
expect(range).toBeDefined();
expect(range!.startLine).toBe(2);
expect(range!.startCol).toBe(5);
expect(range!.endLine).toBe(4);
expect(range!.endCol).toBe(9);
});
it("range from findFieldRange replaces entire block value", () => {
const range = findFieldRange(blockTagsSample, "tags");
expect(range).toBeDefined();
const lines = blockTagsSample.split("\n");
// Replace the range with new block-style value
const before = lines.slice(0, range!.startLine);
const after = lines.slice(range!.endLine + 1);
const replaced = [...before, "tags:\n - bug\n - issue", ...after].join(
"\n",
);
expect(replaced).not.toMatch(/^- /m);
});
});
describe("formatTagsYamlValue", () => {
it("formats empty and simple lists", () => {
it("formats empty list as inline []", () => {
expect(formatTagsYamlValue([])).toBe("[]");
expect(formatTagsYamlValue(["bug", "frontend"])).toBe("[bug, frontend]");
});
it("quotes tags with spaces", () => {
expect(formatTagsYamlValue(["my tag"])).toBe('["my tag"]');
it("formats single tag as block-style list", () => {
expect(formatTagsYamlValue(["bug"])).toBe("\n - bug");
});
it("formats multiple tags as block-style list", () => {
expect(formatTagsYamlValue(["bug", "frontend"])).toBe(
"\n - bug\n - frontend",
);
});
it("quotes tags with spaces in block-style", () => {
expect(formatTagsYamlValue(["my tag", "plain"])).toBe(
'\n - "my tag"\n - plain',
);
});
});
describe("setFrontmatterTags", () => {
it("replaces the tags line", () => {
it("replaces the tags line with block-style list", () => {
const result = setFrontmatterTags(sample, ["bug", "docs"]);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.content).toContain("tags: [bug, docs]");
expect(result.content).toContain("tags:\n - bug\n - docs");
expect(result.content).not.toContain("tags: [old]");
});
it("replaces block-style tags without leaving orphaned list items", () => {
const result = setFrontmatterTags(blockTagsSample, [
"bug",
"issue",
"new_issue",
]);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.content).toContain(
"tags:\n - bug\n - issue\n - new_issue",
);
expect(result.content).not.toContain(" - bug\n - bug");
});
});
describe("extractFrontmatterBlock", () => {
+53 -10
View File
@@ -93,11 +93,25 @@ export function findFieldRange(
}
if (valueText.length === 0) {
// Block-style value — extend to continuation lines
const keyIndent = indent.length;
let endLine = i;
let endCol = line.length;
for (let j = i + 1; j < block.closeLine; j += 1) {
const nextLine = lines[j] ?? "";
const nextIndent = nextLine.match(/^\s*/)?.[0].length ?? 0;
if (nextIndent > keyIndent) {
endLine = j;
endCol = nextLine.length;
} else {
break;
}
}
return {
startLine: i,
startCol: 0,
endLine: i,
endCol: line.length,
startCol: valueCol,
endLine,
endCol,
};
}
@@ -143,12 +157,12 @@ export type ReplaceFrontmatterFieldResult =
| { ok: true; content: string; range: TextRange }
| { ok: false; reason: "no-frontmatter" | "no-field" };
/** Inline YAML list for frontmatter `tags: […]`. */
/** Block-style YAML list for frontmatter `tags:\n - item`. */
export function formatTagsYamlValue(tags: string[]): string {
if (tags.length === 0) {
return "[]";
}
return `[${tags.map((t) => yamlInlineScalar(t)).join(", ")}]`;
return "\n" + tags.map((t) => ` - ${yamlInlineScalar(t)}`).join("\n");
}
export function setFrontmatterTags(
@@ -161,6 +175,7 @@ export function setFrontmatterTags(
/**
* Replace a scalar frontmatter field value on its line.
* Does not re-serialize YAML; only rewrites the value after `:`.
* Handles multi-line block-style values (value starting with \n).
*/
export function replaceFrontmatterField(
content: string,
@@ -178,6 +193,7 @@ export function replaceFrontmatterField(
}
const lines = content.split(/\r?\n/);
const eol = content.includes("\r\n") ? "\r\n" : "\n";
const line = lines[lineRange.startLine] ?? "";
const colonIndex = line.indexOf(":");
if (colonIndex < 0) {
@@ -185,11 +201,38 @@ export function replaceFrontmatterField(
}
const prefix = line.slice(0, colonIndex + 1);
const spacing = line.slice(colonIndex + 1).match(/^\s*/)?.[0] ?? " ";
const newLine = `${prefix}${spacing || " "}${value}`;
lines[lineRange.startLine] = newLine;
const keyIndent = line.match(/^\s*/)?.[0].length ?? 0;
if (value.startsWith("\n")) {
// Block-style value: replace key line and remove old continuation lines
lines[lineRange.startLine] = prefix;
// Find and remove old continuation lines
let continuationEnd = lineRange.startLine;
for (let j = lineRange.startLine + 1; j < block.closeLine; j += 1) {
const nextLine = lines[j] ?? "";
const nextIndent = nextLine.match(/^\s*/)?.[0].length ?? 0;
if (nextIndent > keyIndent) {
continuationEnd = j;
} else {
break;
}
}
const removeCount = continuationEnd - lineRange.startLine;
if (removeCount > 0) {
lines.splice(lineRange.startLine + 1, removeCount);
}
// Append the value lines (skip leading \n)
const valueLines = value.slice(1).split("\n");
for (let k = valueLines.length - 1; k >= 0; k -= 1) {
lines.splice(lineRange.startLine + 1, 0, valueLines[k]);
}
} else {
const spacing = line.slice(colonIndex + 1).match(/^\s*/)?.[0] ?? " ";
lines[lineRange.startLine] = `${prefix}${spacing || " "}${value}`;
}
const eol = content.includes("\r\n") ? "\r\n" : "\n";
const next = lines.join(eol);
const valueRange = findFieldRange(next, key);
@@ -200,7 +243,7 @@ export function replaceFrontmatterField(
startLine: lineRange.startLine,
startCol: 0,
endLine: lineRange.startLine,
endCol: newLine.length,
endCol: lines[lineRange.startLine].length,
},
};
}
+5 -3
View File
@@ -118,12 +118,14 @@ export function registerTaskDecorations(
const tagsRange = findFieldRange(text, EditableField.TAGS);
if (tagsRange) {
const lines = text.split(/\r?\n/);
const lineEnd = lines[tagsRange.startLine]?.length ?? 0;
options.push({
range: new vscode.Range(
tagsRange.startLine,
tagsRange.startCol,
tagsRange.endLine,
tagsRange.endCol,
lineEnd,
tagsRange.startLine,
lineEnd,
),
hoverMessage: hoverTags(editor.document),
renderOptions: {