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
|
Local-first task management for solo developers and small teams — a VS Code
|
||||||
extension.
|
extension.
|
||||||
|
|
||||||
Tasks are plain Markdown files with YAML frontmatter (default extension
|
Tasks are plain Markdown files with YAML frontmatter (extension `.xflow.md`). No
|
||||||
`.task.md`). No cloud, no server: git-friendly, editable in any editor.
|
cloud, no server: git-friendly, editable in any editor.
|
||||||
|
|
||||||
[Русская версия](README_ru.md)
|
[Русская версия](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)
|
- **Dashboard** — task list + description (filters, scope, sort, group)
|
||||||
- **Kanban** — status columns, drag-and-drop, create-in-column
|
- **Kanban** — status columns, drag-and-drop, create-in-column
|
||||||
- **Commands** — create / open / change status / delete
|
- **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
|
- **Two stores** — project folder (workspace) and global folder (shared on the
|
||||||
machine)
|
machine)
|
||||||
|
|
||||||
@@ -61,19 +62,19 @@ Install: Extensions → `...` → Install from VSIX.
|
|||||||
Settings → **XBOCTuK Flow** (or `settings.json`). Defaults live in
|
Settings → **XBOCTuK Flow** (or `settings.json`). Defaults live in
|
||||||
`package.json` (`contributes.configuration`).
|
`package.json` (`contributes.configuration`).
|
||||||
|
|
||||||
| Setting | Description | Default |
|
| 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.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:
|
Example:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"xboctukFlow.projectPath": ".vscode/tasks",
|
"xboctukFlow.projectPath": ".xflow",
|
||||||
"xboctukFlow.globalPath": "C:\\Users\\You\\Documents\\xboctuk-tasks",
|
"xboctukFlow.globalPath": "C:\\Users\\You\\Documents\\xboctuk-flow"
|
||||||
"xboctukFlow.fileExtension": ".task.md"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -81,15 +82,17 @@ Example:
|
|||||||
|
|
||||||
Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`):
|
Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`):
|
||||||
|
|
||||||
| Command | Action |
|
| Command | Action |
|
||||||
| ----------------------------------- | ------------------------------------------------ |
|
| ----------------------------------------- | ------------------------------------------------ |
|
||||||
| **XBOCTuK Flow: Create Task** | Create a task (scope pick if both paths are set) |
|
| **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: Open Task** | Open the task file in the editor |
|
||||||
| **XBOCTuK Flow: Change Status** | Change status |
|
| **XBOCTuK Flow: Change Status** | Change status |
|
||||||
| **XBOCTuK Flow: Delete Task** | Delete task |
|
| **XBOCTuK Flow: Edit Status (in file)** | Frontmatter status via Quick Pick |
|
||||||
| **XBOCTuK Flow: Open Dashboard** | Dashboard (list + body) |
|
| **XBOCTuK Flow: Edit Priority (in file)** | Frontmatter priority via Quick Pick |
|
||||||
| **XBOCTuK Flow: Open in Dashboard** | Dashboard with the selected task |
|
| **XBOCTuK Flow: Delete Task** | Delete task |
|
||||||
| **XBOCTuK Flow: Open Kanban** | Kanban board |
|
| **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 /
|
Sidebar: title buttons (create / dashboard / kanban) and item actions (open /
|
||||||
edit / status / delete).
|
edit / status / delete).
|
||||||
@@ -113,7 +116,8 @@ updated: 2026-07-12T10:00:00.000Z
|
|||||||
Task description in markdown…
|
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
|
## Dashboard
|
||||||
|
|
||||||
|
|||||||
+24
-20
@@ -5,8 +5,8 @@ extension.
|
|||||||
|
|
||||||
[English version](README.md)
|
[English version](README.md)
|
||||||
|
|
||||||
Задачи хранятся как обычные Markdown-файлы с YAML frontmatter (по умолчанию
|
Задачи хранятся как обычные Markdown-файлы с YAML frontmatter (расширение
|
||||||
`.task.md`). Без облака и отдельного сервера: git-friendly, редактируются в
|
`.xflow.md`). Без облака и отдельного сервера: git-friendly, редактируются в
|
||||||
любом редакторе.
|
любом редакторе.
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
@@ -15,6 +15,7 @@ extension.
|
|||||||
- **Dashboard** — список + описание задачи (фильтры, scope, sort, group)
|
- **Dashboard** — список + описание задачи (фильтры, scope, sort, group)
|
||||||
- **Kanban** — колонки по статусу, drag-and-drop, create в колонке
|
- **Kanban** — колонки по статусу, drag-and-drop, create в колонке
|
||||||
- **Команды** — create / open / change status / delete
|
- **Команды** — create / open / change status / delete
|
||||||
|
- **In-editor** — lint frontmatter + Quick Pick status/priority (decorations)
|
||||||
- **Два хранилища** — проектное (в workspace) и глобальное (общая папка на
|
- **Два хранилища** — проектное (в workspace) и глобальное (общая папка на
|
||||||
машине)
|
машине)
|
||||||
|
|
||||||
@@ -61,19 +62,19 @@ npx @vscode/vsce package
|
|||||||
Settings → **XBOCTuK Flow** (или JSON). Defaults задаются в `package.json`
|
Settings → **XBOCTuK Flow** (или JSON). Defaults задаются в `package.json`
|
||||||
(`contributes.configuration`).
|
(`contributes.configuration`).
|
||||||
|
|
||||||
| Setting | Описание | Default |
|
| Setting | Описание | Default |
|
||||||
| --------------------------- | -------------------------------------------------------- | --------------- |
|
| ------------------------- | -------------------------------------------------------- | -------- |
|
||||||
| `xboctukFlow.projectPath` | Папка проектных задач **относительно** корня workspace | `.vscode/tasks` |
|
| `xboctukFlow.projectPath` | Папка проектных задач **относительно** корня workspace | `.xflow` |
|
||||||
| `xboctukFlow.globalPath` | **Абсолютный** путь к глобальным задачам (пусто = выкл.) | `""` |
|
| `xboctukFlow.globalPath` | **Абсолютный** путь к глобальным задачам (пусто = выкл.) | `""` |
|
||||||
| `xboctukFlow.fileExtension` | Расширение файлов: `.task.md` \| `.md` \| `.todo.md` | `.task.md` |
|
|
||||||
|
Файлы задач всегда с расширением **`.xflow.md`**.
|
||||||
|
|
||||||
Пример:
|
Пример:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"xboctukFlow.projectPath": ".vscode/tasks",
|
"xboctukFlow.projectPath": ".xflow",
|
||||||
"xboctukFlow.globalPath": "C:\\Users\\You\\Documents\\xboctuk-tasks",
|
"xboctukFlow.globalPath": "C:\\Users\\You\\Documents\\xboctuk-flow"
|
||||||
"xboctukFlow.fileExtension": ".task.md"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -81,15 +82,17 @@ Settings → **XBOCTuK Flow** (или JSON). Defaults задаются в `packa
|
|||||||
|
|
||||||
Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`):
|
Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`):
|
||||||
|
|
||||||
| Команда | Действие |
|
| Команда | Действие |
|
||||||
| ----------------------------------- | ----------------------------------------------- |
|
| ----------------------------------------- | ----------------------------------------------- |
|
||||||
| **XBOCTuK Flow: Create Task** | Создать задачу (scope, если настроены оба path) |
|
| **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: Change Status** | Сменить статус |
|
||||||
| **XBOCTuK Flow: Delete Task** | Удалить задачу |
|
| **XBOCTuK Flow: Edit Status (in file)** | Status во frontmatter через Quick Pick |
|
||||||
| **XBOCTuK Flow: Open Dashboard** | Dashboard (список + body) |
|
| **XBOCTuK Flow: Edit Priority (in file)** | Priority во frontmatter через Quick Pick |
|
||||||
| **XBOCTuK Flow: Open in Dashboard** | Dashboard с выбранной задачей |
|
| **XBOCTuK Flow: Delete Task** | Удалить задачу |
|
||||||
| **XBOCTuK Flow: Open Kanban** | Kanban board |
|
| **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
|
В sidebar: кнопки title (create / dashboard / kanban) и actions на задаче (open
|
||||||
/ edit / status / delete).
|
/ edit / status / delete).
|
||||||
@@ -113,7 +116,8 @@ updated: 2026-07-12T10:00:00.000Z
|
|||||||
Описание задачи в markdown…
|
Описание задачи в markdown…
|
||||||
```
|
```
|
||||||
|
|
||||||
Один файл — одна задача. Имя файла строится из title (slug).
|
Один файл — одна задача (`*.xflow.md`). Имя файла строится из title (slug).
|
||||||
|
Макс. длина `title`: 80 символов.
|
||||||
|
|
||||||
## Dashboard
|
## Dashboard
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Хранение
|
## Хранение
|
||||||
|
|
||||||
Формат: Markdown + YAML frontmatter (один `.task.md` файл на задачу)
|
Формат: Markdown + YAML frontmatter (один `.xflow.md` файл на задачу)
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
---
|
---
|
||||||
@@ -21,7 +21,7 @@ updated: 2026-07-12T10:00:00Z
|
|||||||
|
|
||||||
**Структура папок:**
|
**Структура папок:**
|
||||||
|
|
||||||
- `.vscode/tasks/` — проектные задачи
|
- `.xflow` — проектные задачи
|
||||||
- глобальная папка — настраивается (`xboctukFlow.globalPath`)
|
- глобальная папка — настраивается (`xboctukFlow.globalPath`)
|
||||||
|
|
||||||
Оба пути настраиваются через VS Code settings.
|
Оба пути настраиваются через VS Code settings.
|
||||||
@@ -72,7 +72,7 @@ tests/helpers/InMemoryFileSystem.ts
|
|||||||
| Фаза | Что делаем |
|
| Фаза | Что делаем |
|
||||||
| ------------------------ | ---------------------------------------------------------------------- |
|
| ------------------------ | ---------------------------------------------------------------------- |
|
||||||
| **1. Scaffold** | Extension, TypeScript, сборка, package.json |
|
| **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 |
|
| **3. TreeView** | sidebar по статусу, file watcher |
|
||||||
| **4. Команды** | createTask, deleteTask, changeStatus, openTask (use-cases → wiring UI) |
|
| **4. Команды** | createTask, deleteTask, changeStatus, openTask (use-cases → wiring UI) |
|
||||||
| **5. Task Dashboard** | WebView split layout |
|
| **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"
|
"project-management"
|
||||||
],
|
],
|
||||||
"activationEvents": [
|
"activationEvents": [
|
||||||
"onStartupFinished"
|
"onStartupFinished",
|
||||||
|
"onLanguage:xflow"
|
||||||
],
|
],
|
||||||
"main": "./out/extension.js",
|
"main": "./out/extension.js",
|
||||||
"contributes": {
|
"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": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"command": "xboctukFlow.createTask",
|
"command": "xboctukFlow.createTask",
|
||||||
@@ -44,6 +65,18 @@
|
|||||||
"title": "XBOCTuK Flow: Change Status",
|
"title": "XBOCTuK Flow: Change Status",
|
||||||
"icon": "$(sync)"
|
"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",
|
"command": "xboctukFlow.openDashboard",
|
||||||
"title": "XBOCTuK Flow: Open Dashboard",
|
"title": "XBOCTuK Flow: Open Dashboard",
|
||||||
@@ -65,23 +98,13 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"xboctukFlow.projectPath": {
|
"xboctukFlow.projectPath": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": ".vscode/tasks",
|
"default": ".xflow",
|
||||||
"description": "Path to project-level tasks folder (relative to workspace root)"
|
"description": "Path to project-level tasks folder (relative to workspace root)"
|
||||||
},
|
},
|
||||||
"xboctukFlow.globalPath": {
|
"xboctukFlow.globalPath": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"default": "",
|
||||||
"description": "Path to global tasks folder (absolute path, shared across projects)"
|
"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 { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
|
||||||
import { err } from "neverthrow";
|
import { err } from "neverthrow";
|
||||||
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
||||||
import { appError, AppErrorVariant } from "@/error";
|
import { appError, AppErrorVariant } from "@/error";
|
||||||
@@ -10,7 +11,7 @@ describe("changeStatus", () => {
|
|||||||
let repo: FsTaskRepository;
|
let repo: FsTaskRepository;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
|
repo = new FsTaskRepository(new InMemoryFileSystem(), TASK_FILE_EXTENSION);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates status of an existing task", async () => {
|
it("updates status of an existing task", async () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
|
||||||
import { err } from "neverthrow";
|
import { err } from "neverthrow";
|
||||||
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
||||||
import { appError, AppErrorVariant } from "@/error";
|
import { appError, AppErrorVariant } from "@/error";
|
||||||
@@ -11,7 +12,7 @@ describe("createTask", () => {
|
|||||||
let repo: FsTaskRepository;
|
let repo: FsTaskRepository;
|
||||||
|
|
||||||
beforeEach(() => {
|
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 () => {
|
it("creates a todo/medium task from title", async () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
|
||||||
import { err, ok } from "neverthrow";
|
import { err, ok } from "neverthrow";
|
||||||
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
||||||
import { appError, AppErrorVariant } from "@/error";
|
import { appError, AppErrorVariant } from "@/error";
|
||||||
@@ -10,7 +11,7 @@ describe("deleteTask", () => {
|
|||||||
let repo: FsTaskRepository;
|
let repo: FsTaskRepository;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
|
repo = new FsTaskRepository(new InMemoryFileSystem(), TASK_FILE_EXTENSION);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deletes an existing task", async () => {
|
it("deletes an existing task", async () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
|
||||||
import { err } from "neverthrow";
|
import { err } from "neverthrow";
|
||||||
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
||||||
import { appError, AppErrorVariant } from "@/error";
|
import { appError, AppErrorVariant } from "@/error";
|
||||||
@@ -11,7 +12,7 @@ describe("listLocatedTasks", () => {
|
|||||||
let repo: FsTaskRepository;
|
let repo: FsTaskRepository;
|
||||||
|
|
||||||
beforeEach(() => {
|
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 () => {
|
it("merges tasks from project and global with location", async () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
|
||||||
import { err } from "neverthrow";
|
import { err } from "neverthrow";
|
||||||
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
||||||
import { appError, AppErrorVariant } from "@/error";
|
import { appError, AppErrorVariant } from "@/error";
|
||||||
@@ -10,7 +11,7 @@ describe("loadDashboard", () => {
|
|||||||
let repo: FsTaskRepository;
|
let repo: FsTaskRepository;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
|
repo = new FsTaskRepository(new InMemoryFileSystem(), TASK_FILE_EXTENSION);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads all tasks for a folder", async () => {
|
it("loads all tasks for a folder", async () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
|
||||||
import { err } from "neverthrow";
|
import { err } from "neverthrow";
|
||||||
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
||||||
import { appError, AppErrorVariant } from "@/error";
|
import { appError, AppErrorVariant } from "@/error";
|
||||||
@@ -10,7 +11,7 @@ describe("openTask", () => {
|
|||||||
let repo: FsTaskRepository;
|
let repo: FsTaskRepository;
|
||||||
|
|
||||||
beforeEach(() => {
|
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 () => {
|
it("returns file path and task for an existing id", async () => {
|
||||||
@@ -30,7 +31,7 @@ describe("openTask", () => {
|
|||||||
|
|
||||||
expect(result.isOk()).toBe(true);
|
expect(result.isOk()).toBe(true);
|
||||||
if (result.isErr()) return;
|
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);
|
expect(result.value.task).toEqual(created);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ function config(partial: {
|
|||||||
return {
|
return {
|
||||||
getProjectTaskPath: () => partial.project,
|
getProjectTaskPath: () => partial.project,
|
||||||
getGlobalTaskPath: () => partial.global,
|
getGlobalTaskPath: () => partial.global,
|
||||||
getFileExtension: () => ".task.md",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
|
||||||
import { err } from "neverthrow";
|
import { err } from "neverthrow";
|
||||||
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
||||||
import { appError, AppErrorVariant } from "@/error";
|
import { appError, AppErrorVariant } from "@/error";
|
||||||
@@ -10,7 +11,7 @@ describe("saveTaskDescription", () => {
|
|||||||
let repo: FsTaskRepository;
|
let repo: FsTaskRepository;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
|
repo = new FsTaskRepository(new InMemoryFileSystem(), TASK_FILE_EXTENSION);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates description of an existing task", async () => {
|
it("updates description of an existing task", async () => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { appError, AppErrorVariant } from "@/error";
|
|||||||
import { TaskStatus } from "@/model/taskStatus";
|
import { TaskStatus } from "@/model/taskStatus";
|
||||||
import { FsTaskRepository } from "@/storage/FsTaskRepository";
|
import { FsTaskRepository } from "@/storage/FsTaskRepository";
|
||||||
import { serializeTask } from "@/utils/markdown";
|
import { serializeTask } from "@/utils/markdown";
|
||||||
|
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
|
||||||
import { saveTaskRaw } from "./saveTaskRaw";
|
import { saveTaskRaw } from "./saveTaskRaw";
|
||||||
|
|
||||||
describe("saveTaskRaw", () => {
|
describe("saveTaskRaw", () => {
|
||||||
@@ -12,7 +13,7 @@ describe("saveTaskRaw", () => {
|
|||||||
let repo: FsTaskRepository;
|
let repo: FsTaskRepository;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
|
repo = new FsTaskRepository(new InMemoryFileSystem(), TASK_FILE_EXTENSION);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates task from raw markdown", async () => {
|
it("updates task from raw markdown", async () => {
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ export function getConfigSection(): string {
|
|||||||
return firstKey.slice(0, dot);
|
return firstKey.slice(0, dot);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SettingKey = "projectPath" | "globalPath" | "fileExtension";
|
export type SettingKey = "projectPath" | "globalPath";
|
||||||
|
|
||||||
function property(key: SettingKey): ConfigProperty {
|
function property(key: SettingKey): ConfigProperty {
|
||||||
const fullKey = `${getConfigSection()}.${key}`;
|
const fullKey = `${getConfigSection()}.${key}`;
|
||||||
|
|||||||
+8
-3
@@ -1,10 +1,13 @@
|
|||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
|
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
|
||||||
|
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
|
||||||
import { FsTaskRepository } from "@/storage/FsTaskRepository";
|
import { FsTaskRepository } from "@/storage/FsTaskRepository";
|
||||||
import { NodeFileSystem } from "@/storage/NodeFileSystem";
|
import { NodeFileSystem } from "@/storage/NodeFileSystem";
|
||||||
import { VscodeConfigProvider } from "@/storage/VscodeConfigProvider";
|
import { VscodeConfigProvider } from "@/storage/VscodeConfigProvider";
|
||||||
import { registerTaskCommands } from "@/ui/taskCommands";
|
import { registerTaskCommands } from "@/ui/taskCommands";
|
||||||
|
import { registerTaskDecorations } from "@/ui/taskDecorations";
|
||||||
|
import { registerTaskDiagnostics } from "@/ui/taskDiagnostics";
|
||||||
import { TaskDashboardPanel } from "@/views/taskDashboardPanel";
|
import { TaskDashboardPanel } from "@/views/taskDashboardPanel";
|
||||||
import { TaskKanbanPanel } from "@/views/taskKanbanPanel";
|
import { TaskKanbanPanel } from "@/views/taskKanbanPanel";
|
||||||
import { TaskTreeProvider } from "@/views/taskTreeProvider";
|
import { TaskTreeProvider } from "@/views/taskTreeProvider";
|
||||||
@@ -12,7 +15,7 @@ import { TaskTreeProvider } from "@/views/taskTreeProvider";
|
|||||||
export function activate(context: vscode.ExtensionContext) {
|
export function activate(context: vscode.ExtensionContext) {
|
||||||
const fileSystem = new NodeFileSystem();
|
const fileSystem = new NodeFileSystem();
|
||||||
const config = new VscodeConfigProvider();
|
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 tree = new TaskTreeProvider(repo, config);
|
||||||
|
|
||||||
const onTasksMutated = (): void => {
|
const onTasksMutated = (): void => {
|
||||||
@@ -25,10 +28,9 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
vscode.window.registerTreeDataProvider("xboctukFlow.tasks", tree),
|
vscode.window.registerTreeDataProvider("xboctukFlow.tasks", tree),
|
||||||
);
|
);
|
||||||
|
|
||||||
const ext = config.getFileExtension();
|
|
||||||
for (const location of resolveTaskLocations(config)) {
|
for (const location of resolveTaskLocations(config)) {
|
||||||
const taskPattern = path
|
const taskPattern = path
|
||||||
.join(location.folderPath, `*${ext}`)
|
.join(location.folderPath, `*${TASK_FILE_EXTENSION}`)
|
||||||
.replace(/\\/g, "/");
|
.replace(/\\/g, "/");
|
||||||
const watcher = vscode.workspace.createFileSystemWatcher(taskPattern);
|
const watcher = vscode.workspace.createFileSystemWatcher(taskPattern);
|
||||||
watcher.onDidCreate(onTasksMutated);
|
watcher.onDidCreate(onTasksMutated);
|
||||||
@@ -46,6 +48,9 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
statusBarItem.show();
|
statusBarItem.show();
|
||||||
context.subscriptions.push(statusBarItem);
|
context.subscriptions.push(statusBarItem);
|
||||||
|
|
||||||
|
registerTaskDiagnostics(context);
|
||||||
|
registerTaskDecorations(context);
|
||||||
|
|
||||||
registerTaskCommands(context, {
|
registerTaskCommands(context, {
|
||||||
repo,
|
repo,
|
||||||
config,
|
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 {
|
export interface IConfigProvider {
|
||||||
getProjectTaskPath(): string | undefined;
|
getProjectTaskPath(): string | undefined;
|
||||||
getGlobalTaskPath(): string | undefined;
|
getGlobalTaskPath(): string | undefined;
|
||||||
getFileExtension(): string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Task } from "@/model/task";
|
import { Task } from "@/model/task";
|
||||||
import { ITaskRepository } from "@/ports";
|
import { ITaskRepository } from "@/ports";
|
||||||
|
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
||||||
import { FsTaskRepository } from "./FsTaskRepository";
|
import { FsTaskRepository } from "./FsTaskRepository";
|
||||||
@@ -30,14 +31,14 @@ describe("FsTaskRepository", () => {
|
|||||||
fs = new InMemoryFileSystem();
|
fs = new InMemoryFileSystem();
|
||||||
clockStep = 0;
|
clockStep = 0;
|
||||||
const timestamps = [t0, t1, t2];
|
const timestamps = [t0, t1, t2];
|
||||||
repo = new FsTaskRepository(fs, ".task.md", () => {
|
repo = new FsTaskRepository(fs, TASK_FILE_EXTENSION, () => {
|
||||||
const value = timestamps[clockStep] ?? t2;
|
const value = timestamps[clockStep] ?? t2;
|
||||||
clockStep += 1;
|
clockStep += 1;
|
||||||
return value;
|
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);
|
const task = await repo.create(folder, sampleTask);
|
||||||
|
|
||||||
expect(task.id).toMatch(UUID_RE);
|
expect(task.id).toMatch(UUID_RE);
|
||||||
@@ -46,9 +47,11 @@ describe("FsTaskRepository", () => {
|
|||||||
expect(task.updated).toBe(t0);
|
expect(task.updated).toBe(t0);
|
||||||
|
|
||||||
const files = await fs.readdir(folder);
|
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("Fix login bug");
|
||||||
expect(content).toContain("status: todo");
|
expect(content).toContain("status: todo");
|
||||||
});
|
});
|
||||||
@@ -91,12 +94,14 @@ describe("FsTaskRepository", () => {
|
|||||||
|
|
||||||
it("update renames file when title changes", async () => {
|
it("update renames file when title changes", async () => {
|
||||||
const task = await repo.create(folder, sampleTask);
|
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!" });
|
await repo.update(folder, { ...task, title: "Fixed!" });
|
||||||
|
|
||||||
const files = await fs.readdir(folder);
|
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);
|
const found = await repo.getById(folder, task.id);
|
||||||
expect(found!.title).toBe("Fixed!");
|
expect(found!.title).toBe("Fixed!");
|
||||||
expect(found!.updated).toBe(t1);
|
expect(found!.updated).toBe(t1);
|
||||||
@@ -119,7 +124,7 @@ describe("FsTaskRepository", () => {
|
|||||||
title: "Orphan",
|
title: "Orphan",
|
||||||
updated: t0,
|
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 () => {
|
it("delete removes the task file", async () => {
|
||||||
@@ -159,9 +164,12 @@ describe("FsTaskRepository", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("skips files without id", async () => {
|
it("skips files without id", async () => {
|
||||||
await fs.writeFile(`${folder}/no-id.task.md`, "not valid frontmatter");
|
|
||||||
await fs.writeFile(
|
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",
|
"---\ntitle: No Id\n---\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -179,7 +187,7 @@ describe("FsTaskRepository", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const files = await fs.readdir(folder);
|
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);
|
const tasks = await repo.list(folder);
|
||||||
expect(tasks).toHaveLength(1);
|
expect(tasks).toHaveLength(1);
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Task } from "@/model/task";
|
import { Task } from "@/model/task";
|
||||||
|
import { MAX_TASK_TITLE_LENGTH } from "@/model/taskFile";
|
||||||
import { IFileSystem, ITaskRepository } from "@/ports";
|
import { IFileSystem, ITaskRepository } from "@/ports";
|
||||||
import { parseTaskFile, serializeTask } from "@/utils/markdown";
|
import { parseTaskFile, serializeTask } from "@/utils/markdown";
|
||||||
import { generateId } from "@/utils/uuid";
|
import { generateId } from "@/utils/uuid";
|
||||||
|
|
||||||
const MAX_SAFE_NAME_LENGTH = 80;
|
|
||||||
|
|
||||||
export type NowFn = () => string;
|
export type NowFn = () => string;
|
||||||
|
|
||||||
export class FsTaskRepository implements ITaskRepository {
|
export class FsTaskRepository implements ITaskRepository {
|
||||||
@@ -106,7 +105,7 @@ export class FsTaskRepository implements ITaskRepository {
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9\u0400-\u04FF]+/g, "-")
|
.replace(/[^a-z0-9\u0400-\u04FF]+/g, "-")
|
||||||
.replace(/^-|-$/g, "")
|
.replace(/^-|-$/g, "")
|
||||||
.slice(0, MAX_SAFE_NAME_LENGTH);
|
.slice(0, MAX_TASK_TITLE_LENGTH);
|
||||||
|
|
||||||
return this.joinPath(folderPath, `${safeName}${this.#fileExtension}`);
|
return this.joinPath(folderPath, `${safeName}${this.#fileExtension}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,4 @@ export class VscodeConfigProvider implements IConfigProvider {
|
|||||||
config.get<string>("globalPath") ?? getSettingDefault("globalPath");
|
config.get<string>("globalPath") ?? getSettingDefault("globalPath");
|
||||||
return globalPath || undefined;
|
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 { TaskDashboardPanel } from "@/views/taskDashboardPanel";
|
||||||
import { TaskKanbanPanel } from "@/views/taskKanbanPanel";
|
import { TaskKanbanPanel } from "@/views/taskKanbanPanel";
|
||||||
import { TaskTreeProvider } from "@/views/taskTreeProvider";
|
import { TaskTreeProvider } from "@/views/taskTreeProvider";
|
||||||
|
import {
|
||||||
|
runEditTaskPriority,
|
||||||
|
runEditTaskStatus,
|
||||||
|
runSetAssigneeMe,
|
||||||
|
} from "./editFrontmatterField";
|
||||||
import { presentError } from "./presentError";
|
import { presentError } from "./presentError";
|
||||||
import { resolveTaskRef } from "./resolveTaskRef";
|
import { resolveTaskRef } from "./resolveTaskRef";
|
||||||
import {
|
import {
|
||||||
@@ -232,13 +237,26 @@ export function registerTaskCommands(
|
|||||||
vscode.commands.registerCommand("xboctukFlow.openTask", (item?: unknown) =>
|
vscode.commands.registerCommand("xboctukFlow.openTask", (item?: unknown) =>
|
||||||
runOpenTask(deps, item),
|
runOpenTask(deps, item),
|
||||||
),
|
),
|
||||||
vscode.commands.registerCommand("xboctukFlow.deleteTask", (item?: unknown) =>
|
vscode.commands.registerCommand(
|
||||||
runDeleteTask(deps, item),
|
"xboctukFlow.deleteTask",
|
||||||
|
(item?: unknown) => runDeleteTask(deps, item),
|
||||||
),
|
),
|
||||||
vscode.commands.registerCommand(
|
vscode.commands.registerCommand(
|
||||||
"xboctukFlow.changeStatus",
|
"xboctukFlow.changeStatus",
|
||||||
(item?: unknown) => runChangeStatus(deps, item),
|
(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", () =>
|
vscode.commands.registerCommand("xboctukFlow.openDashboard", () =>
|
||||||
runOpenDashboard(deps),
|
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", () => {
|
describe("markdown", () => {
|
||||||
it("parses a .task.md file with full frontmatter", () => {
|
it("parses a task file with full frontmatter", () => {
|
||||||
const content = [
|
const content = [
|
||||||
"---",
|
"---",
|
||||||
'id: "550e8400-e29b-41d4-a716-446655440000"',
|
'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**
|
[x] **Dashboard: view-only + Edit в editor**
|
||||||
|
|
||||||
- **View:** meta + body (read-only) в webview.
|
- **View:** meta + body (read-only) в webview.
|
||||||
- **Edit:** кнопка → `openTask` + `showTextDocument` — полный `.task.md` в
|
- **Edit:** кнопка → `openTask` + `showTextDocument` — полный `.xflow.md` в
|
||||||
editor.
|
editor.
|
||||||
- Use-case `saveTaskRaw` / `applyRawTaskContent` / `saveTaskDescription`
|
- Use-case `saveTaskRaw` / `applyRawTaskContent` / `saveTaskDescription`
|
||||||
остаются для non-webview path.
|
остаются для non-webview path.
|
||||||
|
|||||||
Reference in New Issue
Block a user