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 { TaskPriority } from "@/model/taskPriority";
import { TaskStatus } from "@/model/taskStatus"; import { TaskStatus } from "@/model/taskStatus";
import { describe, expect, it } from "vitest";
import { import {
extractFrontmatterBlock, extractFrontmatterBlock,
findFieldRange, findFieldRange,
@@ -20,6 +20,17 @@ tags: [old]
Body Body
`; `;
const blockTagsSample = `---
priority: medium
tags:
- bug
- issue
status: todo
---
Body
`;
describe("replaceFrontmatterField", () => { describe("replaceFrontmatterField", () => {
it("replaces status value", () => { it("replaces status value", () => {
const result = replaceFrontmatterField(sample, "status", TaskStatus.DONE); const result = replaceFrontmatterField(sample, "status", TaskStatus.DONE);
@@ -41,6 +52,20 @@ describe("replaceFrontmatterField", () => {
expect(result.content).toContain("priority: critical"); 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", () => { it("fails when field is missing", () => {
const result = replaceFrontmatterField(sample, "assignee", "me"); const result = replaceFrontmatterField(sample, "assignee", "me");
expect(result).toEqual({ ok: false, reason: "no-field" }); expect(result).toEqual({ ok: false, reason: "no-field" });
@@ -59,27 +84,74 @@ describe("findFieldRange", () => {
const line = sample.split("\n")[range!.startLine]; const line = sample.split("\n")[range!.startLine];
expect(line.slice(range!.startCol, range!.endCol)).toBe("todo"); 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", () => { describe("formatTagsYamlValue", () => {
it("formats empty and simple lists", () => { it("formats empty list as inline []", () => {
expect(formatTagsYamlValue([])).toBe("[]"); expect(formatTagsYamlValue([])).toBe("[]");
expect(formatTagsYamlValue(["bug", "frontend"])).toBe("[bug, frontend]");
}); });
it("quotes tags with spaces", () => { it("formats single tag as block-style list", () => {
expect(formatTagsYamlValue(["my tag"])).toBe('["my tag"]'); 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", () => { describe("setFrontmatterTags", () => {
it("replaces the tags line", () => { it("replaces the tags line with block-style list", () => {
const result = setFrontmatterTags(sample, ["bug", "docs"]); const result = setFrontmatterTags(sample, ["bug", "docs"]);
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
if (!result.ok) return; 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]"); 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", () => { describe("extractFrontmatterBlock", () => {
+53 -10
View File
@@ -93,11 +93,25 @@ export function findFieldRange(
} }
if (valueText.length === 0) { 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 { return {
startLine: i, startLine: i,
startCol: 0, startCol: valueCol,
endLine: i, endLine,
endCol: line.length, endCol,
}; };
} }
@@ -143,12 +157,12 @@ export type ReplaceFrontmatterFieldResult =
| { ok: true; content: string; range: TextRange } | { ok: true; content: string; range: TextRange }
| { ok: false; reason: "no-frontmatter" | "no-field" }; | { 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 { export function formatTagsYamlValue(tags: string[]): string {
if (tags.length === 0) { if (tags.length === 0) {
return "[]"; return "[]";
} }
return `[${tags.map((t) => yamlInlineScalar(t)).join(", ")}]`; return "\n" + tags.map((t) => ` - ${yamlInlineScalar(t)}`).join("\n");
} }
export function setFrontmatterTags( export function setFrontmatterTags(
@@ -161,6 +175,7 @@ export function setFrontmatterTags(
/** /**
* Replace a scalar frontmatter field value on its line. * Replace a scalar frontmatter field value on its line.
* Does not re-serialize YAML; only rewrites the value after `:`. * Does not re-serialize YAML; only rewrites the value after `:`.
* Handles multi-line block-style values (value starting with \n).
*/ */
export function replaceFrontmatterField( export function replaceFrontmatterField(
content: string, content: string,
@@ -178,6 +193,7 @@ export function replaceFrontmatterField(
} }
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
const eol = content.includes("\r\n") ? "\r\n" : "\n";
const line = lines[lineRange.startLine] ?? ""; const line = lines[lineRange.startLine] ?? "";
const colonIndex = line.indexOf(":"); const colonIndex = line.indexOf(":");
if (colonIndex < 0) { if (colonIndex < 0) {
@@ -185,11 +201,38 @@ export function replaceFrontmatterField(
} }
const prefix = line.slice(0, colonIndex + 1); const prefix = line.slice(0, colonIndex + 1);
const spacing = line.slice(colonIndex + 1).match(/^\s*/)?.[0] ?? " "; const keyIndent = line.match(/^\s*/)?.[0].length ?? 0;
const newLine = `${prefix}${spacing || " "}${value}`;
lines[lineRange.startLine] = newLine; 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 next = lines.join(eol);
const valueRange = findFieldRange(next, key); const valueRange = findFieldRange(next, key);
@@ -200,7 +243,7 @@ export function replaceFrontmatterField(
startLine: lineRange.startLine, startLine: lineRange.startLine,
startCol: 0, startCol: 0,
endLine: lineRange.startLine, 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); const tagsRange = findFieldRange(text, EditableField.TAGS);
if (tagsRange) { if (tagsRange) {
const lines = text.split(/\r?\n/);
const lineEnd = lines[tagsRange.startLine]?.length ?? 0;
options.push({ options.push({
range: new vscode.Range( range: new vscode.Range(
tagsRange.startLine, tagsRange.startLine,
tagsRange.startCol, lineEnd,
tagsRange.endLine, tagsRange.startLine,
tagsRange.endCol, lineEnd,
), ),
hoverMessage: hoverTags(editor.document), hoverMessage: hoverTags(editor.document),
renderOptions: { renderOptions: {