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
+14 -10
View File
@@ -3,8 +3,8 @@
Local-first task management for solo developers and small teams — a VS Code
extension.
Tasks are plain Markdown files with YAML frontmatter (default extension
`.task.md`). No cloud, no server: git-friendly, editable in any editor.
Tasks are plain Markdown files with YAML frontmatter (extension `.xflow.md`). No
cloud, no server: git-friendly, editable in any editor.
[Русская версия](README_ru.md)
@@ -14,6 +14,7 @@ Tasks are plain Markdown files with YAML frontmatter (default extension
- **Dashboard** — task list + description (filters, scope, sort, group)
- **Kanban** — status columns, drag-and-drop, create-in-column
- **Commands** — create / open / change status / delete
- **In-editor** — frontmatter lint + status/priority Quick Pick (decorations)
- **Two stores** — project folder (workspace) and global folder (shared on the
machine)
@@ -62,18 +63,18 @@ Settings → **XBOCTuK Flow** (or `settings.json`). Defaults live in
`package.json` (`contributes.configuration`).
| Setting | Description | Default |
| --------------------------- | --------------------------------------------------- | --------------- |
| `xboctukFlow.projectPath` | Project tasks folder **relative** to workspace root | `.vscode/tasks` |
| ------------------------- | --------------------------------------------------- | -------- |
| `xboctukFlow.projectPath` | Project tasks folder **relative** to workspace root | `.xflow` |
| `xboctukFlow.globalPath` | **Absolute** path to global tasks (empty = off) | `""` |
| `xboctukFlow.fileExtension` | File extension: `.task.md` \| `.md` \| `.todo.md` | `.task.md` |
Task files always use the extension **`.xflow.md`**.
Example:
```json
{
"xboctukFlow.projectPath": ".vscode/tasks",
"xboctukFlow.globalPath": "C:\\Users\\You\\Documents\\xboctuk-tasks",
"xboctukFlow.fileExtension": ".task.md"
"xboctukFlow.projectPath": ".xflow",
"xboctukFlow.globalPath": "C:\\Users\\You\\Documents\\xboctuk-flow"
}
```
@@ -82,10 +83,12 @@ Example:
Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`):
| Command | Action |
| ----------------------------------- | ------------------------------------------------ |
| ----------------------------------------- | ------------------------------------------------ |
| **XBOCTuK Flow: Create Task** | Create a task (scope pick if both paths are set) |
| **XBOCTuK Flow: Open Task** | Open the task file in the editor |
| **XBOCTuK Flow: Change Status** | Change status |
| **XBOCTuK Flow: Edit Status (in file)** | Frontmatter status via Quick Pick |
| **XBOCTuK Flow: Edit Priority (in file)** | Frontmatter priority via Quick Pick |
| **XBOCTuK Flow: Delete Task** | Delete task |
| **XBOCTuK Flow: Open Dashboard** | Dashboard (list + body) |
| **XBOCTuK Flow: Open in Dashboard** | Dashboard with the selected task |
@@ -113,7 +116,8 @@ updated: 2026-07-12T10:00:00.000Z
Task description in markdown…
```
One file per task. File name is derived from the title (slug).
One file per task (`*.xflow.md`). File name is derived from the title (slug).
`title` max length: 80 characters.
## Dashboard
+15 -11
View File
@@ -5,8 +5,8 @@ extension.
[English version](README.md)
Задачи хранятся как обычные Markdown-файлы с YAML frontmatter (по умолчанию
`.task.md`). Без облака и отдельного сервера: git-friendly, редактируются в
Задачи хранятся как обычные Markdown-файлы с YAML frontmatter (расширение
`.xflow.md`). Без облака и отдельного сервера: git-friendly, редактируются в
любом редакторе.
## Возможности
@@ -15,6 +15,7 @@ extension.
- **Dashboard** — список + описание задачи (фильтры, scope, sort, group)
- **Kanban** — колонки по статусу, drag-and-drop, create в колонке
- **Команды** — create / open / change status / delete
- **In-editor** — lint frontmatter + Quick Pick status/priority (decorations)
- **Два хранилища** — проектное (в workspace) и глобальное (общая папка на
машине)
@@ -62,18 +63,18 @@ Settings → **XBOCTuK Flow** (или JSON). Defaults задаются в `packa
(`contributes.configuration`).
| Setting | Описание | Default |
| --------------------------- | -------------------------------------------------------- | --------------- |
| `xboctukFlow.projectPath` | Папка проектных задач **относительно** корня workspace | `.vscode/tasks` |
| ------------------------- | -------------------------------------------------------- | -------- |
| `xboctukFlow.projectPath` | Папка проектных задач **относительно** корня workspace | `.xflow` |
| `xboctukFlow.globalPath` | **Абсолютный** путь к глобальным задачам (пусто = выкл.) | `""` |
| `xboctukFlow.fileExtension` | Расширение файлов: `.task.md` \| `.md` \| `.todo.md` | `.task.md` |
Файлы задач всегда с расширением **`.xflow.md`**.
Пример:
```json
{
"xboctukFlow.projectPath": ".vscode/tasks",
"xboctukFlow.globalPath": "C:\\Users\\You\\Documents\\xboctuk-tasks",
"xboctukFlow.fileExtension": ".task.md"
"xboctukFlow.projectPath": ".xflow",
"xboctukFlow.globalPath": "C:\\Users\\You\\Documents\\xboctuk-flow"
}
```
@@ -82,10 +83,12 @@ Settings → **XBOCTuK Flow** (или JSON). Defaults задаются в `packa
Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`):
| Команда | Действие |
| ----------------------------------- | ----------------------------------------------- |
| ----------------------------------------- | ----------------------------------------------- |
| **XBOCTuK Flow: Create Task** | Создать задачу (scope, если настроены оба path) |
| **XBOCTuK Flow: Open Task** | Открыть `.task.md` в editor |
| **XBOCTuK Flow: Open Task** | Открыть `.xflow.md` в editor |
| **XBOCTuK Flow: Change Status** | Сменить статус |
| **XBOCTuK Flow: Edit Status (in file)** | Status во frontmatter через Quick Pick |
| **XBOCTuK Flow: Edit Priority (in file)** | Priority во frontmatter через Quick Pick |
| **XBOCTuK Flow: Delete Task** | Удалить задачу |
| **XBOCTuK Flow: Open Dashboard** | Dashboard (список + body) |
| **XBOCTuK Flow: Open in Dashboard** | Dashboard с выбранной задачей |
@@ -113,7 +116,8 @@ updated: 2026-07-12T10:00:00.000Z
Описание задачи в markdown…
```
Один файл — одна задача. Имя файла строится из title (slug).
Один файл — одна задача (`*.xflow.md`). Имя файла строится из title (slug).
Макс. длина `title`: 80 символов.
## Dashboard
+3 -3
View File
@@ -2,7 +2,7 @@
## Хранение
Формат: Markdown + YAML frontmatter (один `.task.md` файл на задачу)
Формат: Markdown + YAML frontmatter (один `.xflow.md` файл на задачу)
```markdown
---
@@ -21,7 +21,7 @@ updated: 2026-07-12T10:00:00Z
**Структура папок:**
- `.vscode/tasks/` — проектные задачи
- `.xflow` — проектные задачи
- глобальная папка — настраивается (`xboctukFlow.globalPath`)
Оба пути настраиваются через VS Code settings.
@@ -72,7 +72,7 @@ tests/helpers/InMemoryFileSystem.ts
| Фаза | Что делаем |
| ------------------------ | ---------------------------------------------------------------------- |
| **1. Scaffold** | Extension, TypeScript, сборка, package.json |
| **2. Storage** | CRUD `.task.md`, парсинг frontmatter, unit-тесты storage |
| **2. Storage** | CRUD `.xflow.md`, парсинг frontmatter, unit-тесты storage |
| **3. TreeView** | sidebar по статусу, file watcher |
| **4. Команды** | createTask, deleteTask, changeStatus, openTask (use-cases → wiring UI) |
| **5. Task Dashboard** | WebView split layout |
+26
View File
@@ -0,0 +1,26 @@
{
"comments": {
"lineComment": "#"
},
"brackets": [
["{", "}"],
["[", "]"],
["(", ")"]
],
"autoClosingPairs": [
{ "open": "{", "close": "}" },
{ "open": "[", "close": "]" },
{ "open": "(", "close": ")" },
{ "open": "\"", "close": "\"" },
{ "open": "'", "close": "'" },
{ "open": "`", "close": "`" }
],
"surroundingPairs": [
["{", "}"],
["[", "]"],
["(", ")"],
["\"", "\""],
["'", "'"],
["`", "`"]
]
}
+35 -12
View File
@@ -19,10 +19,31 @@
"project-management"
],
"activationEvents": [
"onStartupFinished"
"onStartupFinished",
"onLanguage:xflow"
],
"main": "./out/extension.js",
"contributes": {
"languages": [
{
"id": "xflow",
"aliases": [
"XBOCTuK Flow",
"xflow"
],
"extensions": [
".xflow.md"
],
"configuration": "./language-configuration.json"
}
],
"grammars": [
{
"language": "xflow",
"scopeName": "text.html.markdown.xflow",
"path": "./syntaxes/xflow.tmLanguage.json"
}
],
"commands": [
{
"command": "xboctukFlow.createTask",
@@ -44,6 +65,18 @@
"title": "XBOCTuK Flow: Change Status",
"icon": "$(sync)"
},
{
"command": "xboctukFlow.editTaskStatus",
"title": "XBOCTuK Flow: Edit Status (in file)"
},
{
"command": "xboctukFlow.editTaskPriority",
"title": "XBOCTuK Flow: Edit Priority (in file)"
},
{
"command": "xboctukFlow.setAssigneeMe",
"title": "XBOCTuK Flow: Set Assignee to Me"
},
{
"command": "xboctukFlow.openDashboard",
"title": "XBOCTuK Flow: Open Dashboard",
@@ -65,23 +98,13 @@
"properties": {
"xboctukFlow.projectPath": {
"type": "string",
"default": ".vscode/tasks",
"default": ".xflow",
"description": "Path to project-level tasks folder (relative to workspace root)"
},
"xboctukFlow.globalPath": {
"type": "string",
"default": "",
"description": "Path to global tasks folder (absolute path, shared across projects)"
},
"xboctukFlow.fileExtension": {
"type": "string",
"default": ".task.md",
"enum": [
".task.md",
".md",
".todo.md"
],
"description": "File extension for task files"
}
}
},
+2 -1
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from "vitest";
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
import { err } from "neverthrow";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { appError, AppErrorVariant } from "@/error";
@@ -10,7 +11,7 @@ describe("changeStatus", () => {
let repo: FsTaskRepository;
beforeEach(() => {
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
repo = new FsTaskRepository(new InMemoryFileSystem(), TASK_FILE_EXTENSION);
});
it("updates status of an existing task", async () => {
+2 -1
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from "vitest";
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
import { err } from "neverthrow";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { appError, AppErrorVariant } from "@/error";
@@ -11,7 +12,7 @@ describe("createTask", () => {
let repo: FsTaskRepository;
beforeEach(() => {
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
repo = new FsTaskRepository(new InMemoryFileSystem(), TASK_FILE_EXTENSION);
});
it("creates a todo/medium task from title", async () => {
+2 -1
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from "vitest";
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
import { err, ok } from "neverthrow";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { appError, AppErrorVariant } from "@/error";
@@ -10,7 +11,7 @@ describe("deleteTask", () => {
let repo: FsTaskRepository;
beforeEach(() => {
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
repo = new FsTaskRepository(new InMemoryFileSystem(), TASK_FILE_EXTENSION);
});
it("deletes an existing task", async () => {
+2 -1
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from "vitest";
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
import { err } from "neverthrow";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { appError, AppErrorVariant } from "@/error";
@@ -11,7 +12,7 @@ describe("listLocatedTasks", () => {
let repo: FsTaskRepository;
beforeEach(() => {
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
repo = new FsTaskRepository(new InMemoryFileSystem(), TASK_FILE_EXTENSION);
});
it("merges tasks from project and global with location", async () => {
+2 -1
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from "vitest";
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
import { err } from "neverthrow";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { appError, AppErrorVariant } from "@/error";
@@ -10,7 +11,7 @@ describe("loadDashboard", () => {
let repo: FsTaskRepository;
beforeEach(() => {
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
repo = new FsTaskRepository(new InMemoryFileSystem(), TASK_FILE_EXTENSION);
});
it("loads all tasks for a folder", async () => {
+3 -2
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from "vitest";
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
import { err } from "neverthrow";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { appError, AppErrorVariant } from "@/error";
@@ -10,7 +11,7 @@ describe("openTask", () => {
let repo: FsTaskRepository;
beforeEach(() => {
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
repo = new FsTaskRepository(new InMemoryFileSystem(), TASK_FILE_EXTENSION);
});
it("returns file path and task for an existing id", async () => {
@@ -30,7 +31,7 @@ describe("openTask", () => {
expect(result.isOk()).toBe(true);
if (result.isErr()) return;
expect(result.value.path).toBe("/tasks/open-me.task.md");
expect(result.value.path).toBe(`/tasks/open-me${TASK_FILE_EXTENSION}`);
expect(result.value.task).toEqual(created);
});
@@ -9,7 +9,6 @@ function config(partial: {
return {
getProjectTaskPath: () => partial.project,
getGlobalTaskPath: () => partial.global,
getFileExtension: () => ".task.md",
};
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from "vitest";
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
import { err } from "neverthrow";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { appError, AppErrorVariant } from "@/error";
@@ -10,7 +11,7 @@ describe("saveTaskDescription", () => {
let repo: FsTaskRepository;
beforeEach(() => {
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
repo = new FsTaskRepository(new InMemoryFileSystem(), TASK_FILE_EXTENSION);
});
it("updates description of an existing task", async () => {
+2 -1
View File
@@ -5,6 +5,7 @@ import { appError, AppErrorVariant } from "@/error";
import { TaskStatus } from "@/model/taskStatus";
import { FsTaskRepository } from "@/storage/FsTaskRepository";
import { serializeTask } from "@/utils/markdown";
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
import { saveTaskRaw } from "./saveTaskRaw";
describe("saveTaskRaw", () => {
@@ -12,7 +13,7 @@ describe("saveTaskRaw", () => {
let repo: FsTaskRepository;
beforeEach(() => {
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
repo = new FsTaskRepository(new InMemoryFileSystem(), TASK_FILE_EXTENSION);
});
it("updates task from raw markdown", async () => {
+1 -1
View File
@@ -36,7 +36,7 @@ export function getConfigSection(): string {
return firstKey.slice(0, dot);
}
export type SettingKey = "projectPath" | "globalPath" | "fileExtension";
export type SettingKey = "projectPath" | "globalPath";
function property(key: SettingKey): ConfigProperty {
const fullKey = `${getConfigSection()}.${key}`;
+8 -3
View File
@@ -1,10 +1,13 @@
import * as path from "path";
import * as vscode from "vscode";
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
import { FsTaskRepository } from "@/storage/FsTaskRepository";
import { NodeFileSystem } from "@/storage/NodeFileSystem";
import { VscodeConfigProvider } from "@/storage/VscodeConfigProvider";
import { registerTaskCommands } from "@/ui/taskCommands";
import { registerTaskDecorations } from "@/ui/taskDecorations";
import { registerTaskDiagnostics } from "@/ui/taskDiagnostics";
import { TaskDashboardPanel } from "@/views/taskDashboardPanel";
import { TaskKanbanPanel } from "@/views/taskKanbanPanel";
import { TaskTreeProvider } from "@/views/taskTreeProvider";
@@ -12,7 +15,7 @@ import { TaskTreeProvider } from "@/views/taskTreeProvider";
export function activate(context: vscode.ExtensionContext) {
const fileSystem = new NodeFileSystem();
const config = new VscodeConfigProvider();
const repo = new FsTaskRepository(fileSystem, config.getFileExtension());
const repo = new FsTaskRepository(fileSystem, TASK_FILE_EXTENSION);
const tree = new TaskTreeProvider(repo, config);
const onTasksMutated = (): void => {
@@ -25,10 +28,9 @@ export function activate(context: vscode.ExtensionContext) {
vscode.window.registerTreeDataProvider("xboctukFlow.tasks", tree),
);
const ext = config.getFileExtension();
for (const location of resolveTaskLocations(config)) {
const taskPattern = path
.join(location.folderPath, `*${ext}`)
.join(location.folderPath, `*${TASK_FILE_EXTENSION}`)
.replace(/\\/g, "/");
const watcher = vscode.workspace.createFileSystemWatcher(taskPattern);
watcher.onDidCreate(onTasksMutated);
@@ -46,6 +48,9 @@ export function activate(context: vscode.ExtensionContext) {
statusBarItem.show();
context.subscriptions.push(statusBarItem);
registerTaskDiagnostics(context);
registerTaskDecorations(context);
registerTaskCommands(context, {
repo,
config,
+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;
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Task file identity from package.json `contributes.languages` (single source of truth).
*/
import packageJson from "../../package.json";
type LanguageContribution = {
id?: string;
extensions?: string[];
};
type PackageJsonShape = {
contributes?: {
languages?: LanguageContribution[];
};
};
function readTaskLanguage(): { id: string; extension: string } {
const languages = (packageJson as PackageJsonShape).contributes?.languages;
const lang = languages?.[0];
const id = lang?.id;
const extension = lang?.extensions?.[0];
if (!id || !extension) {
throw new Error(
"package.json contributes.languages[0] must define id and extensions[0]",
);
}
return { id, extension };
}
const taskLanguage = readTaskLanguage();
/** Task file extension (from package.json languages). */
export const TASK_FILE_EXTENSION = taskLanguage.extension;
/** VS Code language id for task files (from package.json languages). */
export const TASK_LANGUAGE_ID = taskLanguage.id;
/**
* Max length for `title` in frontmatter.
* Aligns with slug truncation used for on-disk file names (~readable one-liner).
*/
export const MAX_TASK_TITLE_LENGTH = 80;
export function isTaskFileName(fileName: string): boolean {
return fileName.endsWith(TASK_FILE_EXTENSION);
}
-1
View File
@@ -24,5 +24,4 @@ export interface ITaskRepository {
export interface IConfigProvider {
getProjectTaskPath(): string | undefined;
getGlobalTaskPath(): string | undefined;
getFileExtension(): string;
}
+18 -10
View File
@@ -1,5 +1,6 @@
import { Task } from "@/model/task";
import { ITaskRepository } from "@/ports";
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
import { beforeEach, describe, expect, it } from "vitest";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { FsTaskRepository } from "./FsTaskRepository";
@@ -30,14 +31,14 @@ describe("FsTaskRepository", () => {
fs = new InMemoryFileSystem();
clockStep = 0;
const timestamps = [t0, t1, t2];
repo = new FsTaskRepository(fs, ".task.md", () => {
repo = new FsTaskRepository(fs, TASK_FILE_EXTENSION, () => {
const value = timestamps[clockStep] ?? t2;
clockStep += 1;
return value;
});
});
it("creates a task and writes it as a .task.md file", async () => {
it("creates a task and writes it as a task file", async () => {
const task = await repo.create(folder, sampleTask);
expect(task.id).toMatch(UUID_RE);
@@ -46,9 +47,11 @@ describe("FsTaskRepository", () => {
expect(task.updated).toBe(t0);
const files = await fs.readdir(folder);
expect(files).toEqual(["fix-login-bug.task.md"]);
expect(files).toEqual([`fix-login-bug${TASK_FILE_EXTENSION}`]);
const content = await fs.readFile(`${folder}/fix-login-bug.task.md`);
const content = await fs.readFile(
`${folder}/fix-login-bug${TASK_FILE_EXTENSION}`,
);
expect(content).toContain("Fix login bug");
expect(content).toContain("status: todo");
});
@@ -91,12 +94,14 @@ describe("FsTaskRepository", () => {
it("update renames file when title changes", async () => {
const task = await repo.create(folder, sampleTask);
expect(await fs.readdir(folder)).toEqual(["fix-login-bug.task.md"]);
expect(await fs.readdir(folder)).toEqual([
`fix-login-bug${TASK_FILE_EXTENSION}`,
]);
await repo.update(folder, { ...task, title: "Fixed!" });
const files = await fs.readdir(folder);
expect(files).toEqual(["fixed.task.md"]);
expect(files).toEqual([`fixed${TASK_FILE_EXTENSION}`]);
const found = await repo.getById(folder, task.id);
expect(found!.title).toBe("Fixed!");
expect(found!.updated).toBe(t1);
@@ -119,7 +124,7 @@ describe("FsTaskRepository", () => {
title: "Orphan",
updated: t0,
});
expect(await fs.readdir(folder)).toEqual(["orphan.task.md"]);
expect(await fs.readdir(folder)).toEqual([`orphan${TASK_FILE_EXTENSION}`]);
});
it("delete removes the task file", async () => {
@@ -159,9 +164,12 @@ describe("FsTaskRepository", () => {
});
it("skips files without id", async () => {
await fs.writeFile(`${folder}/no-id.task.md`, "not valid frontmatter");
await fs.writeFile(
`${folder}/empty-id.task.md`,
`${folder}/no-id${TASK_FILE_EXTENSION}`,
"not valid frontmatter",
);
await fs.writeFile(
`${folder}/empty-id${TASK_FILE_EXTENSION}`,
"---\ntitle: No Id\n---\n",
);
@@ -179,7 +187,7 @@ describe("FsTaskRepository", () => {
});
const files = await fs.readdir(folder);
expect(files).toEqual(["fix-login-bug.task.md"]);
expect(files).toEqual([`fix-login-bug${TASK_FILE_EXTENSION}`]);
const tasks = await repo.list(folder);
expect(tasks).toHaveLength(1);
+2 -3
View File
@@ -1,10 +1,9 @@
import { Task } from "@/model/task";
import { MAX_TASK_TITLE_LENGTH } from "@/model/taskFile";
import { IFileSystem, ITaskRepository } from "@/ports";
import { parseTaskFile, serializeTask } from "@/utils/markdown";
import { generateId } from "@/utils/uuid";
const MAX_SAFE_NAME_LENGTH = 80;
export type NowFn = () => string;
export class FsTaskRepository implements ITaskRepository {
@@ -106,7 +105,7 @@ export class FsTaskRepository implements ITaskRepository {
.toLowerCase()
.replace(/[^a-z0-9\u0400-\u04FF]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, MAX_SAFE_NAME_LENGTH);
.slice(0, MAX_TASK_TITLE_LENGTH);
return this.joinPath(folderPath, `${safeName}${this.#fileExtension}`);
}
-7
View File
@@ -21,11 +21,4 @@ export class VscodeConfigProvider implements IConfigProvider {
config.get<string>("globalPath") ?? getSettingDefault("globalPath");
return globalPath || undefined;
}
getFileExtension(): string {
const config = vscode.workspace.getConfiguration(getConfigSection());
return (
config.get<string>("fileExtension") ?? getSettingDefault("fileExtension")
);
}
}
+155
View File
@@ -0,0 +1,155 @@
import * as vscode from "vscode";
import {
findFieldRange,
replaceFrontmatterField,
} from "@/frontmatter/frontmatterFields";
import {
isTaskFileName,
TASK_FILE_EXTENSION,
TASK_LANGUAGE_ID,
} from "@/model/taskFile";
import { getGitUserName, yamlInlineScalar } from "./getGitUserName";
import {
TASK_PRIORITY_LABELS,
TASK_PRIORITY_ORDER,
TASK_STATUS_LABELS,
TASK_STATUS_ORDER,
} from "./taskLabels";
export const EditableField = {
STATUS: "status",
PRIORITY: "priority",
ASSIGNEE: "assignee",
} as const;
export type EditableField = (typeof EditableField)[keyof typeof EditableField];
function isTaskDocument(document: vscode.TextDocument): boolean {
if (document.languageId === TASK_LANGUAGE_ID) {
return true;
}
return isTaskFileName(document.fileName);
}
async function resolveDocument(
uriArg?: string,
): Promise<vscode.TextDocument | undefined> {
if (typeof uriArg === "string" && uriArg.length > 0) {
const uri = vscode.Uri.parse(uriArg);
return vscode.workspace.openTextDocument(uri);
}
const active = vscode.window.activeTextEditor?.document;
if (active && isTaskDocument(active)) {
return active;
}
return undefined;
}
function workspaceCwdFor(document: vscode.TextDocument): string | undefined {
const folder = vscode.workspace.getWorkspaceFolder(document.uri);
return folder?.uri.fsPath;
}
async function applyFieldValue(
document: vscode.TextDocument,
key: EditableField,
value: string,
): Promise<boolean> {
const text = document.getText();
const result = replaceFrontmatterField(text, key, value);
if (!result.ok) {
void vscode.window.showErrorMessage(
result.reason === "no-field"
? `Field "${key}" not found in frontmatter`
: "No YAML frontmatter in this file",
);
return false;
}
const valueRange = findFieldRange(text, key);
if (!valueRange) {
// Fallback: replace whole document
const full = new vscode.Range(
document.positionAt(0),
document.positionAt(text.length),
);
const edit = new vscode.WorkspaceEdit();
edit.replace(document.uri, full, result.content);
return vscode.workspace.applyEdit(edit);
}
const range = new vscode.Range(
valueRange.startLine,
valueRange.startCol,
valueRange.endLine,
valueRange.endCol,
);
const edit = new vscode.WorkspaceEdit();
edit.replace(document.uri, range, value);
return vscode.workspace.applyEdit(edit);
}
export async function runEditTaskStatus(uriArg?: string): Promise<void> {
const document = await resolveDocument(uriArg);
if (!document) {
void vscode.window.showErrorMessage(
`Open a ${TASK_FILE_EXTENSION} task file first`,
);
return;
}
const picked = await vscode.window.showQuickPick(
TASK_STATUS_ORDER.map((status) => ({
label: TASK_STATUS_LABELS[status],
description: status,
status,
})),
{ placeHolder: "Status" },
);
if (!picked) return;
await applyFieldValue(document, EditableField.STATUS, picked.status);
}
export async function runEditTaskPriority(uriArg?: string): Promise<void> {
const document = await resolveDocument(uriArg);
if (!document) {
void vscode.window.showErrorMessage(
`Open a ${TASK_FILE_EXTENSION} task file first`,
);
return;
}
const picked = await vscode.window.showQuickPick(
TASK_PRIORITY_ORDER.map((priority) => ({
label: TASK_PRIORITY_LABELS[priority],
description: priority,
priority,
})),
{ placeHolder: "Priority" },
);
if (!picked) return;
await applyFieldValue(document, EditableField.PRIORITY, picked.priority);
}
/** Set `assignee` from `git config user.name`. */
export async function runSetAssigneeMe(uriArg?: string): Promise<void> {
const document = await resolveDocument(uriArg);
if (!document) {
void vscode.window.showErrorMessage(
`Open a ${TASK_FILE_EXTENSION} task file first`,
);
return;
}
const name = await getGitUserName(workspaceCwdFor(document));
if (!name) {
void vscode.window.showErrorMessage(
"Could not read git user.name (set git config user.name)",
);
return;
}
await applyFieldValue(document, EditableField.ASSIGNEE, yamlInlineScalar(name));
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { yamlInlineScalar } from "./getGitUserName";
describe("yamlInlineScalar", () => {
it("quotes empty string", () => {
expect(yamlInlineScalar("")).toBe('""');
});
it("leaves simple tokens unquoted", () => {
expect(yamlInlineScalar("ivanov")).toBe("ivanov");
expect(yamlInlineScalar("user.name")).toBe("user.name");
});
it("quotes values with spaces", () => {
expect(yamlInlineScalar("John Doe")).toBe('"John Doe"');
});
});
+34
View File
@@ -0,0 +1,34 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/**
* Resolve `git config user.name` (local then global), optional cwd for local config.
*/
export async function getGitUserName(
cwd?: string,
): Promise<string | undefined> {
try {
const { stdout } = await execFileAsync("git", ["config", "user.name"], {
cwd,
windowsHide: true,
});
const name = stdout.trim();
return name.length > 0 ? name : undefined;
} catch {
return undefined;
}
}
/** Quote a YAML scalar for a single-line frontmatter value. */
export function yamlInlineScalar(value: string): string {
if (value === "") {
return '""';
}
// Safe unquoted token (no spaces / special YAML chars).
if (/^[A-Za-z0-9_./+-]+$/.test(value)) {
return value;
}
return JSON.stringify(value);
}
+20 -2
View File
@@ -10,6 +10,11 @@ import { IConfigProvider, ITaskRepository } from "@/ports";
import { TaskDashboardPanel } from "@/views/taskDashboardPanel";
import { TaskKanbanPanel } from "@/views/taskKanbanPanel";
import { TaskTreeProvider } from "@/views/taskTreeProvider";
import {
runEditTaskPriority,
runEditTaskStatus,
runSetAssigneeMe,
} from "./editFrontmatterField";
import { presentError } from "./presentError";
import { resolveTaskRef } from "./resolveTaskRef";
import {
@@ -232,13 +237,26 @@ export function registerTaskCommands(
vscode.commands.registerCommand("xboctukFlow.openTask", (item?: unknown) =>
runOpenTask(deps, item),
),
vscode.commands.registerCommand("xboctukFlow.deleteTask", (item?: unknown) =>
runDeleteTask(deps, item),
vscode.commands.registerCommand(
"xboctukFlow.deleteTask",
(item?: unknown) => runDeleteTask(deps, item),
),
vscode.commands.registerCommand(
"xboctukFlow.changeStatus",
(item?: unknown) => runChangeStatus(deps, item),
),
vscode.commands.registerCommand(
"xboctukFlow.editTaskStatus",
(uri?: string) => runEditTaskStatus(uri),
),
vscode.commands.registerCommand(
"xboctukFlow.editTaskPriority",
(uri?: string) => runEditTaskPriority(uri),
),
vscode.commands.registerCommand(
"xboctukFlow.setAssigneeMe",
(uri?: string) => runSetAssigneeMe(uri),
),
vscode.commands.registerCommand("xboctukFlow.openDashboard", () =>
runOpenDashboard(deps),
),
+131
View File
@@ -0,0 +1,131 @@
import * as vscode from "vscode";
import { findFieldRange } from "@/frontmatter/frontmatterFields";
import { isTaskFileName, TASK_LANGUAGE_ID } from "@/model/taskFile";
import { EditableField } from "./editFrontmatterField";
function isTaskEditor(editor: vscode.TextEditor): boolean {
const { document } = editor;
if (document.languageId === TASK_LANGUAGE_ID) {
return true;
}
return isTaskFileName(document.fileName);
}
function commandUri(command: string, args: unknown[]): string {
return `command:${command}?${encodeURIComponent(JSON.stringify(args))}`;
}
function hoverEditField(
document: vscode.TextDocument,
field: typeof EditableField.STATUS | typeof EditableField.PRIORITY,
): vscode.MarkdownString {
const command =
field === EditableField.STATUS
? "xboctukFlow.editTaskStatus"
: "xboctukFlow.editTaskPriority";
const label =
field === EditableField.STATUS ? "Change status…" : "Change priority…";
const md = new vscode.MarkdownString(
`[$(edit) ${label}](${commandUri(command, [document.uri.toString()])})`,
);
md.isTrusted = true;
md.supportThemeIcons = true;
return md;
}
function hoverAssigneeMe(document: vscode.TextDocument): vscode.MarkdownString {
const md = new vscode.MarkdownString(
`[$(account) me](${commandUri("xboctukFlow.setAssigneeMe", [document.uri.toString()])}) — set assignee from \`git config user.name\``,
);
md.isTrusted = true;
md.supportThemeIcons = true;
return md;
}
export function registerTaskDecorations(
context: vscode.ExtensionContext,
): void {
const decorationType = vscode.window.createTextEditorDecorationType({
after: {
margin: "0 0 0 1.5em",
color: new vscode.ThemeColor("editorCodeLens.foreground"),
},
});
context.subscriptions.push(decorationType);
const refresh = (editor: vscode.TextEditor | undefined): void => {
if (!editor || !isTaskEditor(editor)) {
if (editor) {
editor.setDecorations(decorationType, []);
}
return;
}
const text = editor.document.getText();
const options: vscode.DecorationOptions[] = [];
for (const field of [
EditableField.STATUS,
EditableField.PRIORITY,
] as const) {
const range = findFieldRange(text, field);
if (!range) continue;
options.push({
range: new vscode.Range(
range.startLine,
range.startCol,
range.endLine,
range.endCol,
),
hoverMessage: hoverEditField(editor.document, field),
renderOptions: {
after: {
contentText: " ✎",
},
},
});
}
const assigneeRange = findFieldRange(text, EditableField.ASSIGNEE);
if (assigneeRange) {
options.push({
range: new vscode.Range(
assigneeRange.startLine,
assigneeRange.startCol,
assigneeRange.endLine,
assigneeRange.endCol,
),
hoverMessage: hoverAssigneeMe(editor.document),
renderOptions: {
after: {
contentText: " me",
color: new vscode.ThemeColor("textLink.foreground"),
},
},
});
}
editor.setDecorations(decorationType, options);
};
const refreshVisible = (): void => {
for (const editor of vscode.window.visibleTextEditors) {
refresh(editor);
}
};
refreshVisible();
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(refresh),
vscode.window.onDidChangeVisibleTextEditors(() => refreshVisible()),
vscode.workspace.onDidChangeTextDocument((event) => {
for (const editor of vscode.window.visibleTextEditors) {
if (editor.document === event.document) {
refresh(editor);
}
}
}),
);
}
+96
View File
@@ -0,0 +1,96 @@
import * as vscode from "vscode";
import {
validateFrontmatter,
type FrontmatterIssue,
} from "@/frontmatter/validateFrontmatter";
import { isTaskFileName, TASK_LANGUAGE_ID } from "@/model/taskFile";
const DEBOUNCE_MS = 200;
function isTaskDocument(document: vscode.TextDocument): boolean {
if (document.uri.scheme !== "file" && document.uri.scheme !== "untitled") {
return false;
}
if (document.languageId === TASK_LANGUAGE_ID) {
return true;
}
return isTaskFileName(document.fileName);
}
function toRange(issue: FrontmatterIssue): vscode.Range {
return new vscode.Range(
issue.range.startLine,
issue.range.startCol,
issue.range.endLine,
issue.range.endCol,
);
}
function toDiagnostic(issue: FrontmatterIssue): vscode.Diagnostic {
const severity =
issue.severity === "warning"
? vscode.DiagnosticSeverity.Warning
: vscode.DiagnosticSeverity.Error;
const diagnostic = new vscode.Diagnostic(
toRange(issue),
issue.message,
severity,
);
diagnostic.source = "xboctuk-flow";
if (issue.field) {
diagnostic.code = issue.field;
}
return diagnostic;
}
export function registerTaskDiagnostics(
context: vscode.ExtensionContext,
): vscode.DiagnosticCollection {
const collection =
vscode.languages.createDiagnosticCollection("xboctuk-flow");
context.subscriptions.push(collection);
const timers = new Map<string, ReturnType<typeof setTimeout>>();
const lint = (document: vscode.TextDocument): void => {
if (!isTaskDocument(document)) {
collection.delete(document.uri);
return;
}
const issues = validateFrontmatter(document.getText());
collection.set(document.uri, issues.map(toDiagnostic));
};
const lintDebounced = (document: vscode.TextDocument): void => {
const key = document.uri.toString();
const existing = timers.get(key);
if (existing) clearTimeout(existing);
timers.set(
key,
setTimeout(() => {
timers.delete(key);
lint(document);
}, DEBOUNCE_MS),
);
};
for (const document of vscode.workspace.textDocuments) {
lint(document);
}
context.subscriptions.push(
vscode.workspace.onDidOpenTextDocument(lint),
vscode.workspace.onDidChangeTextDocument((event) => {
lintDebounced(event.document);
}),
vscode.workspace.onDidCloseTextDocument((document) => {
const key = document.uri.toString();
const existing = timers.get(key);
if (existing) clearTimeout(existing);
timers.delete(key);
collection.delete(document.uri);
}),
);
return collection;
}
+1 -1
View File
@@ -17,7 +17,7 @@ const fullTask: Task = {
};
describe("markdown", () => {
it("parses a .task.md file with full frontmatter", () => {
it("parses a task file with full frontmatter", () => {
const content = [
"---",
'id: "550e8400-e29b-41d4-a716-446655440000"',
+8
View File
@@ -0,0 +1,8 @@
{
"scopeName": "text.html.markdown.xflow",
"patterns": [
{
"include": "text.html.markdown"
}
]
}
+1 -1
View File
@@ -19,7 +19,7 @@
[x] **Dashboard: view-only + Edit в editor**
- **View:** meta + body (read-only) в webview.
- **Edit:** кнопка → `openTask` + `showTextDocument` — полный `.task.md` в
- **Edit:** кнопка → `openTask` + `showTextDocument` — полный `.xflow.md` в
editor.
- Use-case `saveTaskRaw` / `applyRawTaskContent` / `saveTaskDescription`
остаются для non-webview path.