feat: add inline icons for edit
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
/** 0-based line/column range in the document. */
|
||||
export type TextRange = {
|
||||
startLine: number;
|
||||
startCol: number;
|
||||
endLine: number;
|
||||
endCol: number;
|
||||
};
|
||||
|
||||
export type FrontmatterBlock = {
|
||||
/** YAML body between fences (no --- lines). */
|
||||
yaml: string;
|
||||
/** Line index of opening `---`. */
|
||||
openLine: number;
|
||||
/** Line index of closing `---`. */
|
||||
closeLine: number;
|
||||
/** Inclusive range covering the whole frontmatter including fences. */
|
||||
range: TextRange;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract YAML frontmatter fenced by `---` lines at the start of the file.
|
||||
* Returns null if there is no opening fence on line 0.
|
||||
*/
|
||||
export function extractFrontmatterBlock(
|
||||
content: string,
|
||||
): FrontmatterBlock | null {
|
||||
const lines = content.split(/\r?\n/);
|
||||
if (lines.length === 0 || lines[0].trim() !== "---") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let closeLine = -1;
|
||||
for (let i = 1; i < lines.length; i += 1) {
|
||||
if (lines[i].trim() === "---") {
|
||||
closeLine = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (closeLine < 0) {
|
||||
return {
|
||||
yaml: lines.slice(1).join("\n"),
|
||||
openLine: 0,
|
||||
closeLine: -1,
|
||||
range: {
|
||||
startLine: 0,
|
||||
startCol: 0,
|
||||
endLine: Math.max(0, lines.length - 1),
|
||||
endCol: lines[lines.length - 1]?.length ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const yamlLines = lines.slice(1, closeLine);
|
||||
return {
|
||||
yaml: yamlLines.join("\n"),
|
||||
openLine: 0,
|
||||
closeLine,
|
||||
range: {
|
||||
startLine: 0,
|
||||
startCol: 0,
|
||||
endLine: closeLine,
|
||||
endCol: lines[closeLine].length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Range of `key:` line value inside frontmatter, or full key line / whole FM. */
|
||||
export function findFieldRange(
|
||||
content: string,
|
||||
key: string,
|
||||
): TextRange | undefined {
|
||||
const block = extractFrontmatterBlock(content);
|
||||
if (!block || block.closeLine < 0) {
|
||||
return block?.range;
|
||||
}
|
||||
|
||||
const lines = content.split(/\r?\n/);
|
||||
const keyPattern = new RegExp(`^(\\s*)(${escapeRegExp(key)})\\s*:\\s*(.*)$`);
|
||||
|
||||
for (let i = block.openLine + 1; i < block.closeLine; i += 1) {
|
||||
const line = lines[i] ?? "";
|
||||
const match = keyPattern.exec(line);
|
||||
if (!match) continue;
|
||||
|
||||
const indent = match[1] ?? "";
|
||||
const valueText = match[3] ?? "";
|
||||
const colonIndex = line.indexOf(":", indent.length);
|
||||
const afterColon = colonIndex + 1;
|
||||
let valueCol = afterColon;
|
||||
while (
|
||||
valueCol < line.length &&
|
||||
(line[valueCol] === " " || line[valueCol] === "\t")
|
||||
) {
|
||||
valueCol += 1;
|
||||
}
|
||||
|
||||
if (valueText.length === 0) {
|
||||
return {
|
||||
startLine: i,
|
||||
startCol: 0,
|
||||
endLine: i,
|
||||
endCol: line.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
startLine: i,
|
||||
startCol: valueCol,
|
||||
endLine: i,
|
||||
endCol: line.length,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Full line range for a frontmatter key (key + value). */
|
||||
export function findFieldLineRange(
|
||||
content: string,
|
||||
key: string,
|
||||
): TextRange | undefined {
|
||||
const block = extractFrontmatterBlock(content);
|
||||
if (!block || block.closeLine < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lines = content.split(/\r?\n/);
|
||||
const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:`);
|
||||
|
||||
for (let i = block.openLine + 1; i < block.closeLine; i += 1) {
|
||||
const line = lines[i] ?? "";
|
||||
if (keyPattern.test(line)) {
|
||||
return {
|
||||
startLine: i,
|
||||
startCol: 0,
|
||||
endLine: i,
|
||||
endCol: line.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findFieldRange, replaceFrontmatterField } from "./frontmatterFields";
|
||||
|
||||
const sample = `---
|
||||
id: abc
|
||||
title: Hello
|
||||
status: todo
|
||||
priority: medium
|
||||
---
|
||||
|
||||
Body
|
||||
`;
|
||||
|
||||
describe("replaceFrontmatterField", () => {
|
||||
it("replaces status value", () => {
|
||||
const result = replaceFrontmatterField(sample, "status", "done");
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.content).toContain("status: done");
|
||||
expect(result.content).toContain("title: Hello");
|
||||
expect(result.content).toContain("Body");
|
||||
});
|
||||
|
||||
it("replaces priority value", () => {
|
||||
const result = replaceFrontmatterField(sample, "priority", "critical");
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.content).toContain("priority: critical");
|
||||
});
|
||||
|
||||
it("fails when field is missing", () => {
|
||||
const result = replaceFrontmatterField(sample, "assignee", "me");
|
||||
expect(result).toEqual({ ok: false, reason: "no-field" });
|
||||
});
|
||||
|
||||
it("fails without frontmatter", () => {
|
||||
const result = replaceFrontmatterField("plain", "status", "todo");
|
||||
expect(result).toEqual({ ok: false, reason: "no-frontmatter" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("findFieldRange", () => {
|
||||
it("points at the value column", () => {
|
||||
const range = findFieldRange(sample, "status");
|
||||
expect(range).toBeDefined();
|
||||
const line = sample.split("\n")[range!.startLine];
|
||||
expect(line.slice(range!.startCol, range!.endCol)).toBe("todo");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
extractFrontmatterBlock,
|
||||
findFieldLineRange,
|
||||
findFieldRange,
|
||||
type TextRange,
|
||||
} from "./frontmatterBlock";
|
||||
|
||||
export { findFieldLineRange, findFieldRange };
|
||||
export type { TextRange };
|
||||
|
||||
export type ReplaceFrontmatterFieldResult =
|
||||
| { ok: true; content: string; range: TextRange }
|
||||
| { ok: false; reason: "no-frontmatter" | "no-field" };
|
||||
|
||||
/**
|
||||
* Replace a scalar frontmatter field value on its line.
|
||||
* Does not re-serialize YAML; only rewrites the value after `:`.
|
||||
*/
|
||||
export function replaceFrontmatterField(
|
||||
content: string,
|
||||
key: string,
|
||||
value: string,
|
||||
): ReplaceFrontmatterFieldResult {
|
||||
const block = extractFrontmatterBlock(content);
|
||||
if (!block || block.closeLine < 0) {
|
||||
return { ok: false, reason: "no-frontmatter" };
|
||||
}
|
||||
|
||||
const lineRange = findFieldLineRange(content, key);
|
||||
if (!lineRange) {
|
||||
return { ok: false, reason: "no-field" };
|
||||
}
|
||||
|
||||
const lines = content.split(/\r?\n/);
|
||||
const line = lines[lineRange.startLine] ?? "";
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex < 0) {
|
||||
return { ok: false, reason: "no-field" };
|
||||
}
|
||||
|
||||
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 eol = content.includes("\r\n") ? "\r\n" : "\n";
|
||||
const next = lines.join(eol);
|
||||
|
||||
const valueRange = findFieldRange(next, key);
|
||||
return {
|
||||
ok: true,
|
||||
content: next,
|
||||
range: valueRange ?? {
|
||||
startLine: lineRange.startLine,
|
||||
startCol: 0,
|
||||
endLine: lineRange.startLine,
|
||||
endCol: newLine.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MAX_TASK_TITLE_LENGTH } from "@/model/taskFile";
|
||||
import { validateFrontmatter } from "./validateFrontmatter";
|
||||
|
||||
const valid = `---
|
||||
id: "550e8400-e29b-41d4-a716-446655440000"
|
||||
title: Fix login
|
||||
status: todo
|
||||
priority: high
|
||||
tags: [bug]
|
||||
assignee: ""
|
||||
created: 2026-07-12T10:00:00.000Z
|
||||
updated: 2026-07-12T10:00:00.000Z
|
||||
---
|
||||
|
||||
Body
|
||||
`;
|
||||
|
||||
describe("validateFrontmatter", () => {
|
||||
it("accepts a valid task file", () => {
|
||||
expect(validateFrontmatter(valid)).toEqual([]);
|
||||
});
|
||||
|
||||
it("errors when frontmatter is missing", () => {
|
||||
const issues = validateFrontmatter("no frontmatter\n");
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].severity).toBe("error");
|
||||
expect(issues[0].message).toMatch(/frontmatter/i);
|
||||
});
|
||||
|
||||
it("errors when frontmatter is unclosed", () => {
|
||||
const issues = validateFrontmatter("---\ntitle: x\n");
|
||||
expect(issues.some((i) => /not closed/i.test(i.message))).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on invalid status", () => {
|
||||
const content = valid.replace("status: todo", "status: nope");
|
||||
const issues = validateFrontmatter(content);
|
||||
const statusIssue = issues.find((i) => i.field === "status");
|
||||
expect(statusIssue).toBeDefined();
|
||||
expect(statusIssue!.severity).toBe("error");
|
||||
expect(statusIssue!.range.startLine).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("errors on invalid priority", () => {
|
||||
const content = valid.replace("priority: high", "priority: urgent");
|
||||
const issues = validateFrontmatter(content);
|
||||
expect(issues.some((i) => i.field === "priority")).toBe(true);
|
||||
});
|
||||
|
||||
it("errors when id is missing", () => {
|
||||
const content = valid.replace(
|
||||
'id: "550e8400-e29b-41d4-a716-446655440000"\n',
|
||||
"",
|
||||
);
|
||||
const issues = validateFrontmatter(content);
|
||||
expect(
|
||||
issues.some((i) => i.field === "id" && /missing/i.test(i.message)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("errors when title exceeds max length", () => {
|
||||
const long = "x".repeat(MAX_TASK_TITLE_LENGTH + 1);
|
||||
const content = valid.replace("title: Fix login", `title: ${long}`);
|
||||
const issues = validateFrontmatter(content);
|
||||
const titleIssue = issues.find((i) => i.field === "title");
|
||||
expect(titleIssue).toBeDefined();
|
||||
expect(titleIssue!.message).toMatch(String(MAX_TASK_TITLE_LENGTH));
|
||||
});
|
||||
|
||||
it("errors when tags is not an array", () => {
|
||||
const content = valid.replace("tags: [bug]", "tags: bug");
|
||||
const issues = validateFrontmatter(content);
|
||||
expect(issues.some((i) => i.field === "tags")).toBe(true);
|
||||
});
|
||||
|
||||
it("warns on unknown fields", () => {
|
||||
const content = valid.replace(
|
||||
"updated: 2026-07-12T10:00:00.000Z",
|
||||
"updated: 2026-07-12T10:00:00.000Z\nfoo: bar",
|
||||
);
|
||||
const issues = validateFrontmatter(content);
|
||||
const unknown = issues.find((i) => i.field === "foo");
|
||||
expect(unknown).toBeDefined();
|
||||
expect(unknown!.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("errors on broken YAML", () => {
|
||||
const content = `---
|
||||
title: [unterminated
|
||||
---
|
||||
`;
|
||||
const issues = validateFrontmatter(content);
|
||||
expect(issues.some((i) => /yaml/i.test(i.message))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import * as yaml from "js-yaml";
|
||||
import { MAX_TASK_TITLE_LENGTH } from "@/model/taskFile";
|
||||
import { isTaskPriority, TASK_PRIORITY_ORDER } from "@/model/taskPriority";
|
||||
import { isTaskStatus, TASK_STATUS_ORDER } from "@/model/taskStatus";
|
||||
import {
|
||||
extractFrontmatterBlock,
|
||||
findFieldRange,
|
||||
type TextRange,
|
||||
} from "./frontmatterBlock";
|
||||
|
||||
export type FrontmatterIssueSeverity = "error" | "warning";
|
||||
|
||||
export type FrontmatterIssue = {
|
||||
field?: string;
|
||||
message: string;
|
||||
severity: FrontmatterIssueSeverity;
|
||||
range: TextRange;
|
||||
};
|
||||
|
||||
const KNOWN_KEYS = new Set([
|
||||
"id",
|
||||
"title",
|
||||
"status",
|
||||
"priority",
|
||||
"tags",
|
||||
"assignee",
|
||||
"created",
|
||||
"updated",
|
||||
]);
|
||||
|
||||
function isParseableDate(value: unknown): boolean {
|
||||
if (value instanceof Date) {
|
||||
return !Number.isNaN(value.getTime());
|
||||
}
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
return false;
|
||||
}
|
||||
const ms = Date.parse(value);
|
||||
return !Number.isNaN(ms);
|
||||
}
|
||||
|
||||
function fieldRangeOrBlock(
|
||||
content: string,
|
||||
key: string,
|
||||
blockRange: TextRange,
|
||||
): TextRange {
|
||||
return findFieldRange(content, key) ?? blockRange;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate task file frontmatter. Pure — no vscode.
|
||||
* Ranges are 0-based line/column.
|
||||
*/
|
||||
export function validateFrontmatter(content: string): FrontmatterIssue[] {
|
||||
const issues: FrontmatterIssue[] = [];
|
||||
const block = extractFrontmatterBlock(content);
|
||||
|
||||
if (!block) {
|
||||
issues.push({
|
||||
message: "Task file must start with YAML frontmatter (---)",
|
||||
severity: "error",
|
||||
range: { startLine: 0, startCol: 0, endLine: 0, endCol: 0 },
|
||||
});
|
||||
return issues;
|
||||
}
|
||||
|
||||
if (block.closeLine < 0) {
|
||||
issues.push({
|
||||
message: "Frontmatter is not closed (missing closing ---)",
|
||||
severity: "error",
|
||||
range: block.range,
|
||||
});
|
||||
return issues;
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = yaml.load(block.yaml);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : "invalid YAML";
|
||||
issues.push({
|
||||
message: `Invalid YAML: ${detail}`,
|
||||
severity: "error",
|
||||
range: block.range,
|
||||
});
|
||||
return issues;
|
||||
}
|
||||
|
||||
if (data === null || data === undefined) {
|
||||
issues.push({
|
||||
message: "Frontmatter is empty",
|
||||
severity: "error",
|
||||
range: block.range,
|
||||
});
|
||||
return issues;
|
||||
}
|
||||
|
||||
if (typeof data !== "object" || Array.isArray(data)) {
|
||||
issues.push({
|
||||
message: "Frontmatter must be a YAML mapping",
|
||||
severity: "error",
|
||||
range: block.range,
|
||||
});
|
||||
return issues;
|
||||
}
|
||||
|
||||
const record = data as Record<string, unknown>;
|
||||
const blockRange = block.range;
|
||||
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!KNOWN_KEYS.has(key)) {
|
||||
issues.push({
|
||||
field: key,
|
||||
message: `Unknown field "${key}"`,
|
||||
severity: "warning",
|
||||
range: fieldRangeOrBlock(content, key, blockRange),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// id
|
||||
if (record.id === undefined) {
|
||||
issues.push({
|
||||
field: "id",
|
||||
message: 'Missing required field "id"',
|
||||
severity: "error",
|
||||
range: blockRange,
|
||||
});
|
||||
} else if (typeof record.id !== "string" || record.id.trim() === "") {
|
||||
issues.push({
|
||||
field: "id",
|
||||
message: '"id" must be a non-empty string',
|
||||
severity: "error",
|
||||
range: fieldRangeOrBlock(content, "id", blockRange),
|
||||
});
|
||||
}
|
||||
|
||||
// title
|
||||
if (record.title === undefined) {
|
||||
issues.push({
|
||||
field: "title",
|
||||
message: 'Missing required field "title"',
|
||||
severity: "error",
|
||||
range: blockRange,
|
||||
});
|
||||
} else if (typeof record.title !== "string" || record.title.trim() === "") {
|
||||
issues.push({
|
||||
field: "title",
|
||||
message: '"title" must be a non-empty string',
|
||||
severity: "error",
|
||||
range: fieldRangeOrBlock(content, "title", blockRange),
|
||||
});
|
||||
} else if (record.title.length > MAX_TASK_TITLE_LENGTH) {
|
||||
issues.push({
|
||||
field: "title",
|
||||
message: `"title" must be at most ${MAX_TASK_TITLE_LENGTH} characters (now ${record.title.length})`,
|
||||
severity: "error",
|
||||
range: fieldRangeOrBlock(content, "title", blockRange),
|
||||
});
|
||||
}
|
||||
|
||||
// status
|
||||
if (record.status === undefined) {
|
||||
issues.push({
|
||||
field: "status",
|
||||
message: 'Missing required field "status"',
|
||||
severity: "error",
|
||||
range: blockRange,
|
||||
});
|
||||
} else if (!isTaskStatus(record.status)) {
|
||||
issues.push({
|
||||
field: "status",
|
||||
message: `"status" must be one of: ${TASK_STATUS_ORDER.join(", ")}`,
|
||||
severity: "error",
|
||||
range: fieldRangeOrBlock(content, "status", blockRange),
|
||||
});
|
||||
}
|
||||
|
||||
// priority
|
||||
if (record.priority === undefined) {
|
||||
issues.push({
|
||||
field: "priority",
|
||||
message: 'Missing required field "priority"',
|
||||
severity: "error",
|
||||
range: blockRange,
|
||||
});
|
||||
} else if (!isTaskPriority(record.priority)) {
|
||||
issues.push({
|
||||
field: "priority",
|
||||
message: `"priority" must be one of: ${TASK_PRIORITY_ORDER.join(", ")}`,
|
||||
severity: "error",
|
||||
range: fieldRangeOrBlock(content, "priority", blockRange),
|
||||
});
|
||||
}
|
||||
|
||||
// tags (optional)
|
||||
if (record.tags !== undefined) {
|
||||
if (!Array.isArray(record.tags)) {
|
||||
issues.push({
|
||||
field: "tags",
|
||||
message: '"tags" must be an array of strings',
|
||||
severity: "error",
|
||||
range: fieldRangeOrBlock(content, "tags", blockRange),
|
||||
});
|
||||
} else if (record.tags.some((t) => typeof t !== "string")) {
|
||||
issues.push({
|
||||
field: "tags",
|
||||
message: '"tags" must be an array of strings',
|
||||
severity: "error",
|
||||
range: fieldRangeOrBlock(content, "tags", blockRange),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// assignee (optional)
|
||||
if (record.assignee !== undefined && typeof record.assignee !== "string") {
|
||||
issues.push({
|
||||
field: "assignee",
|
||||
message: '"assignee" must be a string',
|
||||
severity: "error",
|
||||
range: fieldRangeOrBlock(content, "assignee", blockRange),
|
||||
});
|
||||
}
|
||||
|
||||
// created
|
||||
if (record.created === undefined) {
|
||||
issues.push({
|
||||
field: "created",
|
||||
message: 'Missing required field "created"',
|
||||
severity: "error",
|
||||
range: blockRange,
|
||||
});
|
||||
} else if (!isParseableDate(record.created)) {
|
||||
issues.push({
|
||||
field: "created",
|
||||
message: '"created" must be a parseable date (ISO 8601 recommended)',
|
||||
severity: "error",
|
||||
range: fieldRangeOrBlock(content, "created", blockRange),
|
||||
});
|
||||
}
|
||||
|
||||
// updated
|
||||
if (record.updated === undefined) {
|
||||
issues.push({
|
||||
field: "updated",
|
||||
message: 'Missing required field "updated"',
|
||||
severity: "error",
|
||||
range: blockRange,
|
||||
});
|
||||
} else if (!isParseableDate(record.updated)) {
|
||||
issues.push({
|
||||
field: "updated",
|
||||
message: '"updated" must be a parseable date (ISO 8601 recommended)',
|
||||
severity: "error",
|
||||
range: fieldRangeOrBlock(content, "updated", blockRange),
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
Reference in New Issue
Block a user