From 1382abc26daf5254b49839382eafa02c74b6dba4 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sun, 19 Jul 2026 07:02:07 +0500 Subject: [PATCH] fix: tags multiline, edit decorator position --- src/frontmatter/frontmatterFields.test.ts | 86 +++++++++++++++++++++-- src/frontmatter/frontmatterFields.ts | 63 ++++++++++++++--- src/ui/taskDecorations.ts | 8 ++- 3 files changed, 137 insertions(+), 20 deletions(-) diff --git a/src/frontmatter/frontmatterFields.test.ts b/src/frontmatter/frontmatterFields.test.ts index f5fb778..a8583f3 100644 --- a/src/frontmatter/frontmatterFields.test.ts +++ b/src/frontmatter/frontmatterFields.test.ts @@ -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", () => { diff --git a/src/frontmatter/frontmatterFields.ts b/src/frontmatter/frontmatterFields.ts index f09b219..12eecc2 100644 --- a/src/frontmatter/frontmatterFields.ts +++ b/src/frontmatter/frontmatterFields.ts @@ -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, }, }; } diff --git a/src/ui/taskDecorations.ts b/src/ui/taskDecorations.ts index b5b4923..b97e39b 100644 --- a/src/ui/taskDecorations.ts +++ b/src/ui/taskDecorations.ts @@ -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: {