fix: tags multiline, edit decorator position
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user