feat: add inline icons for edit
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -61,19 +62,19 @@ Install: Extensions → `...` → Install from VSIX.
|
||||
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.globalPath` | **Absolute** path to global tasks (empty = off) | `""` |
|
||||
| `xboctukFlow.fileExtension` | File extension: `.task.md` \| `.md` \| `.todo.md` | `.task.md` |
|
||||
| Setting | Description | Default |
|
||||
| ------------------------- | --------------------------------------------------- | -------- |
|
||||
| `xboctukFlow.projectPath` | Project tasks folder **relative** to workspace root | `.xflow` |
|
||||
| `xboctukFlow.globalPath` | **Absolute** path to global tasks (empty = off) | `""` |
|
||||
|
||||
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"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -81,15 +82,17 @@ 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: Delete Task** | Delete task |
|
||||
| **XBOCTuK Flow: Open Dashboard** | Dashboard (list + body) |
|
||||
| **XBOCTuK Flow: Open in Dashboard** | Dashboard with the selected task |
|
||||
| **XBOCTuK Flow: Open Kanban** | Kanban board |
|
||||
| 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 |
|
||||
| **XBOCTuK Flow: Open Kanban** | Kanban board |
|
||||
|
||||
Sidebar: title buttons (create / dashboard / kanban) and item actions (open /
|
||||
edit / status / delete).
|
||||
@@ -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
|
||||
|
||||
|
||||
+24
-20
@@ -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) и глобальное (общая папка на
|
||||
машине)
|
||||
|
||||
@@ -61,19 +62,19 @@ npx @vscode/vsce package
|
||||
Settings → **XBOCTuK Flow** (или JSON). Defaults задаются в `package.json`
|
||||
(`contributes.configuration`).
|
||||
|
||||
| Setting | Описание | Default |
|
||||
| --------------------------- | -------------------------------------------------------- | --------------- |
|
||||
| `xboctukFlow.projectPath` | Папка проектных задач **относительно** корня workspace | `.vscode/tasks` |
|
||||
| `xboctukFlow.globalPath` | **Абсолютный** путь к глобальным задачам (пусто = выкл.) | `""` |
|
||||
| `xboctukFlow.fileExtension` | Расширение файлов: `.task.md` \| `.md` \| `.todo.md` | `.task.md` |
|
||||
| Setting | Описание | Default |
|
||||
| ------------------------- | -------------------------------------------------------- | -------- |
|
||||
| `xboctukFlow.projectPath` | Папка проектных задач **относительно** корня workspace | `.xflow` |
|
||||
| `xboctukFlow.globalPath` | **Абсолютный** путь к глобальным задачам (пусто = выкл.) | `""` |
|
||||
|
||||
Файлы задач всегда с расширением **`.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"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -81,15 +82,17 @@ 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: Change Status** | Сменить статус |
|
||||
| **XBOCTuK Flow: Delete Task** | Удалить задачу |
|
||||
| **XBOCTuK Flow: Open Dashboard** | Dashboard (список + body) |
|
||||
| **XBOCTuK Flow: Open in Dashboard** | Dashboard с выбранной задачей |
|
||||
| **XBOCTuK Flow: Open Kanban** | Kanban board |
|
||||
| Команда | Действие |
|
||||
| ----------------------------------------- | ----------------------------------------------- |
|
||||
| **XBOCTuK Flow: Create Task** | Создать задачу (scope, если настроены оба path) |
|
||||
| **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 с выбранной задачей |
|
||||
| **XBOCTuK Flow: Open Kanban** | Kanban board |
|
||||
|
||||
В sidebar: кнопки title (create / dashboard / kanban) и actions на задаче (open
|
||||
/ edit / status / delete).
|
||||
@@ -113,7 +116,8 @@ updated: 2026-07-12T10:00:00.000Z
|
||||
Описание задачи в markdown…
|
||||
```
|
||||
|
||||
Один файл — одна задача. Имя файла строится из title (slug).
|
||||
Один файл — одна задача (`*.xflow.md`). Имя файла строится из title (slug).
|
||||
Макс. длина `title`: 80 символов.
|
||||
|
||||
## Dashboard
|
||||
|
||||
|
||||
+3
-3
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"comments": {
|
||||
"lineComment": "#"
|
||||
},
|
||||
"brackets": [
|
||||
["{", "}"],
|
||||
["[", "]"],
|
||||
["(", ")"]
|
||||
],
|
||||
"autoClosingPairs": [
|
||||
{ "open": "{", "close": "}" },
|
||||
{ "open": "[", "close": "]" },
|
||||
{ "open": "(", "close": ")" },
|
||||
{ "open": "\"", "close": "\"" },
|
||||
{ "open": "'", "close": "'" },
|
||||
{ "open": "`", "close": "`" }
|
||||
],
|
||||
"surroundingPairs": [
|
||||
["{", "}"],
|
||||
["[", "]"],
|
||||
["(", ")"],
|
||||
["\"", "\""],
|
||||
["'", "'"],
|
||||
["`", "`"]
|
||||
]
|
||||
}
|
||||
+35
-12
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -24,5 +24,4 @@ export interface ITaskRepository {
|
||||
export interface IConfigProvider {
|
||||
getProjectTaskPath(): string | undefined;
|
||||
getGlobalTaskPath(): string | undefined;
|
||||
getFileExtension(): string;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"',
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"scopeName": "text.html.markdown.xflow",
|
||||
"patterns": [
|
||||
{
|
||||
"include": "text.html.markdown"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user