feat: add inline icons for edit

This commit is contained in:
2026-07-16 02:49:55 +05:00
parent bda6c6b2e0
commit 7c0584707a
35 changed files with 1280 additions and 94 deletions
+148
View File
@@ -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, "\\$&");
}
+49
View File
@@ -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");
});
});
+60
View File
@@ -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);
});
});
+260
View File
@@ -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;
}