From 7c0584707a0fff6fdb6ef54c18538c6d9a8b2cf9 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Thu, 16 Jul 2026 02:42:27 +0500 Subject: [PATCH] feat: add inline icons for edit --- README.md | 44 ++-- README_ru.md | 44 ++-- docs/PLAN.md | 6 +- language-configuration.json | 26 ++ package.json | 47 +++- src/commands/changeStatus.test.ts | 3 +- src/commands/createTask.test.ts | 3 +- src/commands/deleteTask.test.ts | 3 +- src/commands/listLocatedTasks.test.ts | 3 +- src/commands/loadDashboard.test.ts | 3 +- src/commands/openTask.test.ts | 5 +- src/commands/resolveTaskLocations.test.ts | 1 - src/commands/saveTaskDescription.test.ts | 3 +- src/commands/saveTaskRaw.test.ts | 3 +- src/config.ts | 2 +- src/extension.ts | 11 +- src/frontmatter/frontmatterBlock.ts | 148 +++++++++++ src/frontmatter/frontmatterFields.test.ts | 49 ++++ src/frontmatter/frontmatterFields.ts | 60 +++++ src/frontmatter/validateFrontmatter.test.ts | 96 ++++++++ src/frontmatter/validateFrontmatter.ts | 260 ++++++++++++++++++++ src/model/taskFile.ts | 46 ++++ src/ports.ts | 1 - src/storage/FsTaskRepository.test.ts | 28 ++- src/storage/FsTaskRepository.ts | 5 +- src/storage/VscodeConfigProvider.ts | 7 - src/ui/editFrontmatterField.ts | 155 ++++++++++++ src/ui/getGitUserName.test.ts | 17 ++ src/ui/getGitUserName.ts | 34 +++ src/ui/taskCommands.ts | 22 +- src/ui/taskDecorations.ts | 131 ++++++++++ src/ui/taskDiagnostics.ts | 96 ++++++++ src/utils/markdown.test.ts | 2 +- syntaxes/xflow.tmLanguage.json | 8 + todo.md | 2 +- 35 files changed, 1280 insertions(+), 94 deletions(-) create mode 100644 language-configuration.json create mode 100644 src/frontmatter/frontmatterBlock.ts create mode 100644 src/frontmatter/frontmatterFields.test.ts create mode 100644 src/frontmatter/frontmatterFields.ts create mode 100644 src/frontmatter/validateFrontmatter.test.ts create mode 100644 src/frontmatter/validateFrontmatter.ts create mode 100644 src/model/taskFile.ts create mode 100644 src/ui/editFrontmatterField.ts create mode 100644 src/ui/getGitUserName.test.ts create mode 100644 src/ui/getGitUserName.ts create mode 100644 src/ui/taskDecorations.ts create mode 100644 src/ui/taskDiagnostics.ts create mode 100644 syntaxes/xflow.tmLanguage.json diff --git a/README.md b/README.md index 74debd9..cbe9870 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README_ru.md b/README_ru.md index 61bf1ed..05d36a6 100644 --- a/README_ru.md +++ b/README_ru.md @@ -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 diff --git a/docs/PLAN.md b/docs/PLAN.md index cbe700f..5513b5e 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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 | diff --git a/language-configuration.json b/language-configuration.json new file mode 100644 index 0000000..0da82ac --- /dev/null +++ b/language-configuration.json @@ -0,0 +1,26 @@ +{ + "comments": { + "lineComment": "#" + }, + "brackets": [ + ["{", "}"], + ["[", "]"], + ["(", ")"] + ], + "autoClosingPairs": [ + { "open": "{", "close": "}" }, + { "open": "[", "close": "]" }, + { "open": "(", "close": ")" }, + { "open": "\"", "close": "\"" }, + { "open": "'", "close": "'" }, + { "open": "`", "close": "`" } + ], + "surroundingPairs": [ + ["{", "}"], + ["[", "]"], + ["(", ")"], + ["\"", "\""], + ["'", "'"], + ["`", "`"] + ] +} diff --git a/package.json b/package.json index d9686fd..235098a 100644 --- a/package.json +++ b/package.json @@ -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" } } }, diff --git a/src/commands/changeStatus.test.ts b/src/commands/changeStatus.test.ts index 860a2e8..1b078bd 100644 --- a/src/commands/changeStatus.test.ts +++ b/src/commands/changeStatus.test.ts @@ -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 () => { diff --git a/src/commands/createTask.test.ts b/src/commands/createTask.test.ts index 2e85852..76c7d42 100644 --- a/src/commands/createTask.test.ts +++ b/src/commands/createTask.test.ts @@ -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 () => { diff --git a/src/commands/deleteTask.test.ts b/src/commands/deleteTask.test.ts index 9bf1116..bf8052a 100644 --- a/src/commands/deleteTask.test.ts +++ b/src/commands/deleteTask.test.ts @@ -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 () => { diff --git a/src/commands/listLocatedTasks.test.ts b/src/commands/listLocatedTasks.test.ts index 06c5858..f8757e1 100644 --- a/src/commands/listLocatedTasks.test.ts +++ b/src/commands/listLocatedTasks.test.ts @@ -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 () => { diff --git a/src/commands/loadDashboard.test.ts b/src/commands/loadDashboard.test.ts index 492cba3..4d7c3cc 100644 --- a/src/commands/loadDashboard.test.ts +++ b/src/commands/loadDashboard.test.ts @@ -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 () => { diff --git a/src/commands/openTask.test.ts b/src/commands/openTask.test.ts index 22ce741..7411eaf 100644 --- a/src/commands/openTask.test.ts +++ b/src/commands/openTask.test.ts @@ -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); }); diff --git a/src/commands/resolveTaskLocations.test.ts b/src/commands/resolveTaskLocations.test.ts index 377207d..a047f3f 100644 --- a/src/commands/resolveTaskLocations.test.ts +++ b/src/commands/resolveTaskLocations.test.ts @@ -9,7 +9,6 @@ function config(partial: { return { getProjectTaskPath: () => partial.project, getGlobalTaskPath: () => partial.global, - getFileExtension: () => ".task.md", }; } diff --git a/src/commands/saveTaskDescription.test.ts b/src/commands/saveTaskDescription.test.ts index 550bdae..e061089 100644 --- a/src/commands/saveTaskDescription.test.ts +++ b/src/commands/saveTaskDescription.test.ts @@ -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 () => { diff --git a/src/commands/saveTaskRaw.test.ts b/src/commands/saveTaskRaw.test.ts index 08ef79f..473a70c 100644 --- a/src/commands/saveTaskRaw.test.ts +++ b/src/commands/saveTaskRaw.test.ts @@ -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 () => { diff --git a/src/config.ts b/src/config.ts index 38f44eb..53449fd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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}`; diff --git a/src/extension.ts b/src/extension.ts index cda2d8c..7e8d6e6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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, diff --git a/src/frontmatter/frontmatterBlock.ts b/src/frontmatter/frontmatterBlock.ts new file mode 100644 index 0000000..330567e --- /dev/null +++ b/src/frontmatter/frontmatterBlock.ts @@ -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, "\\$&"); +} diff --git a/src/frontmatter/frontmatterFields.test.ts b/src/frontmatter/frontmatterFields.test.ts new file mode 100644 index 0000000..68ef71e --- /dev/null +++ b/src/frontmatter/frontmatterFields.test.ts @@ -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"); + }); +}); diff --git a/src/frontmatter/frontmatterFields.ts b/src/frontmatter/frontmatterFields.ts new file mode 100644 index 0000000..500fcc2 --- /dev/null +++ b/src/frontmatter/frontmatterFields.ts @@ -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, + }, + }; +} diff --git a/src/frontmatter/validateFrontmatter.test.ts b/src/frontmatter/validateFrontmatter.test.ts new file mode 100644 index 0000000..2d2ad8b --- /dev/null +++ b/src/frontmatter/validateFrontmatter.test.ts @@ -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); + }); +}); diff --git a/src/frontmatter/validateFrontmatter.ts b/src/frontmatter/validateFrontmatter.ts new file mode 100644 index 0000000..4b46da2 --- /dev/null +++ b/src/frontmatter/validateFrontmatter.ts @@ -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; + 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; +} diff --git a/src/model/taskFile.ts b/src/model/taskFile.ts new file mode 100644 index 0000000..df9ab73 --- /dev/null +++ b/src/model/taskFile.ts @@ -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); +} diff --git a/src/ports.ts b/src/ports.ts index 101377a..f35184c 100644 --- a/src/ports.ts +++ b/src/ports.ts @@ -24,5 +24,4 @@ export interface ITaskRepository { export interface IConfigProvider { getProjectTaskPath(): string | undefined; getGlobalTaskPath(): string | undefined; - getFileExtension(): string; } diff --git a/src/storage/FsTaskRepository.test.ts b/src/storage/FsTaskRepository.test.ts index 5d2b018..0858c5d 100644 --- a/src/storage/FsTaskRepository.test.ts +++ b/src/storage/FsTaskRepository.test.ts @@ -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); diff --git a/src/storage/FsTaskRepository.ts b/src/storage/FsTaskRepository.ts index 7628552..d74d01d 100644 --- a/src/storage/FsTaskRepository.ts +++ b/src/storage/FsTaskRepository.ts @@ -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}`); } diff --git a/src/storage/VscodeConfigProvider.ts b/src/storage/VscodeConfigProvider.ts index 51cb5f7..71aa1d9 100644 --- a/src/storage/VscodeConfigProvider.ts +++ b/src/storage/VscodeConfigProvider.ts @@ -21,11 +21,4 @@ export class VscodeConfigProvider implements IConfigProvider { config.get("globalPath") ?? getSettingDefault("globalPath"); return globalPath || undefined; } - - getFileExtension(): string { - const config = vscode.workspace.getConfiguration(getConfigSection()); - return ( - config.get("fileExtension") ?? getSettingDefault("fileExtension") - ); - } } diff --git a/src/ui/editFrontmatterField.ts b/src/ui/editFrontmatterField.ts new file mode 100644 index 0000000..f496e0c --- /dev/null +++ b/src/ui/editFrontmatterField.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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)); +} diff --git a/src/ui/getGitUserName.test.ts b/src/ui/getGitUserName.test.ts new file mode 100644 index 0000000..a331e18 --- /dev/null +++ b/src/ui/getGitUserName.test.ts @@ -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"'); + }); +}); diff --git a/src/ui/getGitUserName.ts b/src/ui/getGitUserName.ts new file mode 100644 index 0000000..cbfa69e --- /dev/null +++ b/src/ui/getGitUserName.ts @@ -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 { + 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); +} diff --git a/src/ui/taskCommands.ts b/src/ui/taskCommands.ts index 4dac342..196c46f 100644 --- a/src/ui/taskCommands.ts +++ b/src/ui/taskCommands.ts @@ -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), ), diff --git a/src/ui/taskDecorations.ts b/src/ui/taskDecorations.ts new file mode 100644 index 0000000..42483c8 --- /dev/null +++ b/src/ui/taskDecorations.ts @@ -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); + } + } + }), + ); +} diff --git a/src/ui/taskDiagnostics.ts b/src/ui/taskDiagnostics.ts new file mode 100644 index 0000000..1d11fe0 --- /dev/null +++ b/src/ui/taskDiagnostics.ts @@ -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>(); + + 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; +} diff --git a/src/utils/markdown.test.ts b/src/utils/markdown.test.ts index bacb6e9..f7f533b 100644 --- a/src/utils/markdown.test.ts +++ b/src/utils/markdown.test.ts @@ -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"', diff --git a/syntaxes/xflow.tmLanguage.json b/syntaxes/xflow.tmLanguage.json new file mode 100644 index 0000000..ea9bb3b --- /dev/null +++ b/syntaxes/xflow.tmLanguage.json @@ -0,0 +1,8 @@ +{ + "scopeName": "text.html.markdown.xflow", + "patterns": [ + { + "include": "text.html.markdown" + } + ] +} diff --git a/todo.md b/todo.md index 0824052..af0a7f8 100644 --- a/todo.md +++ b/todo.md @@ -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.