From 35465f581f85427b067be6eee23569d560d91e53 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Wed, 15 Jul 2026 01:12:40 +0500 Subject: [PATCH] feat: add kanban board --- docs/PLAN.md | 9 + package.json | 10 ++ src/extension.ts | 7 +- src/kanban/buildKanbanBoard.test.ts | 86 ++++++++++ src/kanban/buildKanbanBoard.ts | 33 ++++ src/kanban/messages.test.ts | 72 ++++++++ src/kanban/messages.ts | 59 +++++++ src/kanban/outboundMessages.ts | 7 + src/ui/taskCommands.ts | 15 ++ src/views/taskKanbanHtml.ts | 246 ++++++++++++++++++++++++++++ src/views/taskKanbanPanel.ts | 164 +++++++++++++++++++ todo.md | 11 ++ 12 files changed, 718 insertions(+), 1 deletion(-) create mode 100644 src/kanban/buildKanbanBoard.test.ts create mode 100644 src/kanban/buildKanbanBoard.ts create mode 100644 src/kanban/messages.test.ts create mode 100644 src/kanban/messages.ts create mode 100644 src/kanban/outboundMessages.ts create mode 100644 src/views/taskKanbanHtml.ts create mode 100644 src/views/taskKanbanPanel.ts diff --git a/docs/PLAN.md b/docs/PLAN.md index 49c7361..d142343 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -124,6 +124,15 @@ Split layout: список (filter/group) + detail (editable markdown body). | Dashboard | multi-folder list; save по location из host cache | | Watchers | на каждый active folder | +### Фаза 7 — Kanban + +| Модуль | Поведение | +| --------------------------- | ------------------------------------------------------------------- | +| `buildKanbanBoard` | 4 колонки status (пустые остаются); tasks by status; `updated` desc | +| `parseKanbanInboundMessage` | ready / refresh / moveTask | +| Host | `TaskKanbanPanel`; move → `changeStatus` + location cache | +| UI | HTML5 DnD; command `openKanban` | + ## Зависимости - `front-matter` — разбор YAML frontmatter (body + attributes) diff --git a/package.json b/package.json index 75347d0..449ab12 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,11 @@ "command": "xboctFlow.openInDashboard", "title": "XBOCT flow: Edit in Dashboard", "icon": "$(edit)" + }, + { + "command": "xboctFlow.openKanban", + "title": "XBOCT flow: Open Kanban", + "icon": "$(layout)" } ], "configuration": { @@ -110,6 +115,11 @@ "command": "xboctFlow.openDashboard", "when": "view == xboctFlow.tasks", "group": "navigation@2" + }, + { + "command": "xboctFlow.openKanban", + "when": "view == xboctFlow.tasks", + "group": "navigation@3" } ], "view/item/context": [ diff --git a/src/extension.ts b/src/extension.ts index d72ddb2..df0db93 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,6 +6,7 @@ import { NodeFileSystem } from "@/storage/NodeFileSystem"; import { VscodeConfigProvider } from "@/storage/VscodeConfigProvider"; import { registerTaskCommands } from "@/ui/taskCommands"; import { TaskDashboardPanel } from "@/views/taskDashboardPanel"; +import { TaskKanbanPanel } from "@/views/taskKanbanPanel"; import { TaskTreeProvider } from "@/views/taskTreeProvider"; export function activate(context: vscode.ExtensionContext) { @@ -17,6 +18,7 @@ export function activate(context: vscode.ExtensionContext) { const onTasksMutated = (): void => { tree.refresh(); TaskDashboardPanel.refreshIfOpen(); + TaskKanbanPanel.refreshIfOpen(); }; context.subscriptions.push( @@ -48,7 +50,10 @@ export function activate(context: vscode.ExtensionContext) { repo, config, tree, - onTasksMutated: () => TaskDashboardPanel.refreshIfOpen(), + onTasksMutated: () => { + TaskDashboardPanel.refreshIfOpen(); + TaskKanbanPanel.refreshIfOpen(); + }, }); } diff --git a/src/kanban/buildKanbanBoard.test.ts b/src/kanban/buildKanbanBoard.test.ts new file mode 100644 index 0000000..8e16a7a --- /dev/null +++ b/src/kanban/buildKanbanBoard.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { Task } from "@/model/task"; +import { buildKanbanBoard } from "./buildKanbanBoard"; + +function task(partial: Partial & Pick): Task { + return { + description: "", + status: "todo", + priority: "medium", + tags: [], + assignee: "", + created: "2026-01-01T00:00:00.000Z", + updated: "2026-01-01T00:00:00.000Z", + ...partial, + }; +} + +describe("buildKanbanBoard", () => { + const tasks: Task[] = [ + task({ + id: "a", + title: "Done one", + status: "done", + updated: "2026-01-01T00:00:02.000Z", + }), + task({ + id: "b", + title: "Todo older", + status: "todo", + updated: "2026-01-01T00:00:01.000Z", + }), + task({ + id: "c", + title: "Todo newer", + status: "todo", + updated: "2026-01-01T00:00:03.000Z", + }), + task({ + id: "d", + title: "In progress", + status: "in-progress", + updated: "2026-01-01T00:00:04.000Z", + }), + ]; + + it("returns all status columns in fixed order, including empty", () => { + const board = buildKanbanBoard(tasks); + + expect(board.columns.map((col) => col.status)).toEqual([ + "todo", + "in-progress", + "done", + "cancelled", + ]); + expect(board.columns.map((col) => col.label)).toEqual([ + "To Do", + "In Progress", + "Done", + "Cancelled", + ]); + expect(board.columns[3].tasks).toEqual([]); + }); + + it("places each task into the column matching its status", () => { + const board = buildKanbanBoard(tasks); + + expect(board.columns[0].tasks.map((t) => t.id)).toEqual(["c", "b"]); + expect(board.columns[1].tasks.map((t) => t.id)).toEqual(["d"]); + expect(board.columns[2].tasks.map((t) => t.id)).toEqual(["a"]); + }); + + it("sorts tasks within a column by updated desc", () => { + const board = buildKanbanBoard(tasks); + const todo = board.columns.find((col) => col.status === "todo"); + expect(todo?.tasks.map((t) => t.id)).toEqual(["c", "b"]); + }); + + it("returns empty columns when there are no tasks", () => { + const board = buildKanbanBoard([]); + + expect(board.columns).toHaveLength(4); + for (const col of board.columns) { + expect(col.tasks).toEqual([]); + } + }); +}); diff --git a/src/kanban/buildKanbanBoard.ts b/src/kanban/buildKanbanBoard.ts new file mode 100644 index 0000000..c7b2640 --- /dev/null +++ b/src/kanban/buildKanbanBoard.ts @@ -0,0 +1,33 @@ +import { Task, TaskStatus } from "@/model/task"; +import { TASK_STATUS_LABELS, TASK_STATUS_ORDER } from "@/ui/taskLabels"; + +export type KanbanColumn = { + status: TaskStatus; + label: string; + tasks: Task[]; +}; + +export type KanbanBoard = { + columns: KanbanColumn[]; +}; + +/** Build fixed-order status columns; empty columns are kept (unlike groupTasks). */ +export function buildKanbanBoard(tasks: Task[]): KanbanBoard { + const columns: KanbanColumn[] = TASK_STATUS_ORDER.map((status) => { + const columnTasks = tasks + .filter((task) => task.status === status) + .slice() + .sort( + (a, b) => + new Date(b.updated).getTime() - new Date(a.updated).getTime(), + ); + + return { + status, + label: TASK_STATUS_LABELS[status], + tasks: columnTasks, + }; + }); + + return { columns }; +} diff --git a/src/kanban/messages.test.ts b/src/kanban/messages.test.ts new file mode 100644 index 0000000..5079a85 --- /dev/null +++ b/src/kanban/messages.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { ok } from "neverthrow"; +import { AppErrorVariant } from "@/error"; +import { + parseKanbanInboundMessage, + type KanbanInboundMessage, +} from "./messages"; + +describe("parseKanbanInboundMessage", () => { + it("parses ready", () => { + expect(parseKanbanInboundMessage({ type: "ready" })).toEqual( + ok({ type: "ready" } satisfies KanbanInboundMessage), + ); + }); + + it("parses refresh", () => { + expect(parseKanbanInboundMessage({ type: "refresh" })).toEqual( + ok({ type: "refresh" }), + ); + }); + + it("parses moveTask", () => { + expect( + parseKanbanInboundMessage({ + type: "moveTask", + id: "abc", + status: "in-progress", + }), + ).toEqual( + ok({ + type: "moveTask", + id: "abc", + status: "in-progress", + } satisfies KanbanInboundMessage), + ); + }); + + it("rejects non-objects", () => { + const result = parseKanbanInboundMessage(null); + expect(result.isErr()).toBe(true); + if (result.isOk()) return; + expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); + }); + + it("rejects unknown type", () => { + const result = parseKanbanInboundMessage({ type: "explode" }); + expect(result.isErr()).toBe(true); + if (result.isOk()) return; + expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); + }); + + it("rejects moveTask without id", () => { + const result = parseKanbanInboundMessage({ + type: "moveTask", + status: "done", + }); + expect(result.isErr()).toBe(true); + if (result.isOk()) return; + expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); + }); + + it("rejects moveTask with invalid status", () => { + const result = parseKanbanInboundMessage({ + type: "moveTask", + id: "abc", + status: "blocked", + }); + expect(result.isErr()).toBe(true); + if (result.isOk()) return; + expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); + }); +}); diff --git a/src/kanban/messages.ts b/src/kanban/messages.ts new file mode 100644 index 0000000..d55657d --- /dev/null +++ b/src/kanban/messages.ts @@ -0,0 +1,59 @@ +import { err, ok, Result } from "neverthrow"; +import { appError, AppError, AppErrorVariant } from "@/error"; +import { TaskStatus } from "@/model/task"; +import { TASK_STATUS_ORDER } from "@/ui/taskLabels"; + +export type KanbanInboundMessage = + | { type: "ready" } + | { type: "refresh" } + | { type: "moveTask"; id: string; status: TaskStatus }; + +const STATUS_VALUES: ReadonlySet = new Set(TASK_STATUS_ORDER); + +function invalid(detail?: string): Result { + return err( + appError(AppErrorVariant.INVALID_MESSAGE, { + detail: detail ?? "", + }), + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function parseKanbanInboundMessage( + raw: unknown, +): Result { + if (!isRecord(raw)) { + return invalid(); + } + + const type = raw.type; + if (typeof type !== "string") { + return invalid(); + } + + switch (type) { + case "ready": + case "refresh": + return ok({ type }); + + case "moveTask": { + if (typeof raw.id !== "string" || raw.id.length === 0) { + return invalid(type); + } + if (typeof raw.status !== "string" || !STATUS_VALUES.has(raw.status)) { + return invalid(type); + } + return ok({ + type: "moveTask", + id: raw.id, + status: raw.status as TaskStatus, + }); + } + + default: + return invalid(type); + } +} diff --git a/src/kanban/outboundMessages.ts b/src/kanban/outboundMessages.ts new file mode 100644 index 0000000..f23c674 --- /dev/null +++ b/src/kanban/outboundMessages.ts @@ -0,0 +1,7 @@ +import { AppError } from "@/error"; +import { KanbanBoard } from "./buildKanbanBoard"; + +/** Host → webview. */ +export type KanbanOutboundMessage = + | { type: "state"; board: KanbanBoard } + | { type: "error"; error: AppError }; diff --git a/src/ui/taskCommands.ts b/src/ui/taskCommands.ts index d06af1b..d52654d 100644 --- a/src/ui/taskCommands.ts +++ b/src/ui/taskCommands.ts @@ -9,6 +9,7 @@ import { TaskLocation } from "@/model/taskLocation"; import { TaskStatus } from "@/model/task"; import { IConfigProvider, ITaskRepository } from "@/ports"; import { TaskDashboardPanel } from "@/views/taskDashboardPanel"; +import { TaskKanbanPanel } from "@/views/taskKanbanPanel"; import { TaskTreeProvider } from "@/views/taskTreeProvider"; import { presentError } from "./presentError"; import { resolveTaskRef } from "./resolveTaskRef"; @@ -205,6 +206,17 @@ export function runOpenDashboard(deps: TaskCommandDeps): void { TaskDashboardPanel.show(dashboardDeps(deps)); } +export function runOpenKanban(deps: TaskCommandDeps): void { + TaskKanbanPanel.show({ + repo: deps.repo, + config: deps.config, + onTasksMutated: () => { + deps.tree.refresh(); + TaskDashboardPanel.refreshIfOpen(); + }, + }); +} + /** Register all task command adapters on the extension host. */ export function registerTaskCommands( context: vscode.ExtensionContext, @@ -233,5 +245,8 @@ export function registerTaskCommands( "xboctFlow.openInDashboard", (item?: unknown) => runOpenInDashboard(deps, item), ), + vscode.commands.registerCommand("xboctFlow.openKanban", () => + runOpenKanban(deps), + ), ); } diff --git a/src/views/taskKanbanHtml.ts b/src/views/taskKanbanHtml.ts new file mode 100644 index 0000000..b6319ae --- /dev/null +++ b/src/views/taskKanbanHtml.ts @@ -0,0 +1,246 @@ +import * as vscode from "vscode"; + +const NONCE_LENGTH = 32; + +/** Kanban board document (columns + HTML5 drag-and-drop). */ +export function getTaskKanbanHtml(webview: vscode.Webview, nonce: string): string { + const csp = [ + "default-src 'none'", + `style-src ${webview.cspSource} 'unsafe-inline'`, + `script-src 'nonce-${nonce}'`, + ].join("; "); + + return ` + + + + + + Kanban + + + +
+

Kanban

+ + +
+
+ + +`; +} + +export function createKanbanNonce(): string { + const alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + for (let i = 0; i < NONCE_LENGTH; i += 1) { + result += alphabet.charAt(Math.floor(Math.random() * alphabet.length)); + } + return result; +} diff --git a/src/views/taskKanbanPanel.ts b/src/views/taskKanbanPanel.ts new file mode 100644 index 0000000..9be0389 --- /dev/null +++ b/src/views/taskKanbanPanel.ts @@ -0,0 +1,164 @@ +import * as vscode from "vscode"; +import { changeStatus } from "@/commands/changeStatus"; +import { listLocatedTasks } from "@/commands/listLocatedTasks"; +import { resolveTaskLocations } from "@/commands/resolveTaskLocations"; +import { buildKanbanBoard } from "@/kanban/buildKanbanBoard"; +import { + parseKanbanInboundMessage, + type KanbanInboundMessage, +} from "@/kanban/messages"; +import { KanbanOutboundMessage } from "@/kanban/outboundMessages"; +import { appError, AppErrorVariant } from "@/error"; +import { LocatedTask } from "@/model/taskLocation"; +import { TaskStatus } from "@/model/task"; +import { IConfigProvider, ITaskRepository } from "@/ports"; +import { presentError } from "@/ui/presentError"; +import { createKanbanNonce, getTaskKanbanHtml } from "./taskKanbanHtml"; + +export type TaskKanbanDeps = { + repo: ITaskRepository; + config: IConfigProvider; + onTasksMutated?: () => void; +}; + +/** + * Host adapter for the Kanban webview. + * Load → buildKanbanBoard; move → changeStatus via location cache. + */ +export class TaskKanbanPanel { + static #current: TaskKanbanPanel | undefined; + + static show(deps: TaskKanbanDeps): void { + if (TaskKanbanPanel.#current) { + TaskKanbanPanel.#current.#panel.reveal(vscode.ViewColumn.One); + void TaskKanbanPanel.#current.reloadTasks(); + return; + } + + const panel = vscode.window.createWebviewPanel( + "xboctFlow.kanban", + "XBOCT flow — Kanban", + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + }, + ); + + TaskKanbanPanel.#current = new TaskKanbanPanel(panel, deps); + } + + static refreshIfOpen(): void { + void TaskKanbanPanel.#current?.reloadTasks(); + } + + readonly #panel: vscode.WebviewPanel; + readonly #deps: TaskKanbanDeps; + + #tasks: LocatedTask[] = []; + #disposed = false; + + constructor(panel: vscode.WebviewPanel, deps: TaskKanbanDeps) { + this.#panel = panel; + this.#deps = deps; + + const nonce = createKanbanNonce(); + this.#panel.webview.html = getTaskKanbanHtml(this.#panel.webview, nonce); + + this.#panel.webview.onDidReceiveMessage((raw: unknown) => { + void this.#onMessage(raw); + }); + + this.#panel.onDidDispose(() => { + this.#disposed = true; + if (TaskKanbanPanel.#current === this) { + TaskKanbanPanel.#current = undefined; + } + }); + } + + async reloadTasks(): Promise { + if (this.#disposed) return; + + const locations = resolveTaskLocations(this.#deps.config); + const result = await listLocatedTasks(this.#deps.repo, locations); + + if (result.isErr()) { + this.#post({ type: "error", error: result.error }); + if (result.error.variant === AppErrorVariant.NO_FOLDER) { + presentError(result.error); + } + return; + } + + this.#tasks = result.value; + this.#postState(); + } + + async #onMessage(raw: unknown): Promise { + const parsed = parseKanbanInboundMessage(raw); + if (parsed.isErr()) { + this.#post({ type: "error", error: parsed.error }); + return; + } + await this.#handleInbound(parsed.value); + } + + async #handleInbound(message: KanbanInboundMessage): Promise { + switch (message.type) { + case "ready": + case "refresh": + await this.reloadTasks(); + return; + + case "moveTask": + await this.#moveTask(message.id, message.status); + return; + } + } + + async #moveTask(id: string, status: TaskStatus): Promise { + const located = this.#tasks.find((item) => item.task.id === id); + if (!located) { + this.#post({ + type: "error", + error: appError(AppErrorVariant.NOT_FOUND, { id }), + }); + return; + } + + if (located.task.status === status) { + return; + } + + const result = await changeStatus(this.#deps.repo, { + folderPath: located.location.folderPath, + id, + status, + }); + + if (result.isErr()) { + this.#post({ type: "error", error: result.error }); + return; + } + + const updated = result.value; + this.#tasks = this.#tasks.map((item) => + item.task.id === updated.id + ? { task: updated, location: item.location } + : item, + ); + this.#postState(); + this.#deps.onTasksMutated?.(); + } + + #postState(): void { + const board = buildKanbanBoard(this.#tasks.map((item) => item.task)); + this.#post({ type: "state", board }); + } + + #post(message: KanbanOutboundMessage): void { + if (this.#disposed) return; + void this.#panel.webview.postMessage(message); + } +} diff --git a/todo.md b/todo.md index 833d00e..d53898f 100644 --- a/todo.md +++ b/todo.md @@ -56,3 +56,14 @@ headless/тестов можно оставить; editor-save — optional path - `+ Task` в toolbar → inbound `createTask` → host `executeCommand("xboctFlow.createTask")` → reload list + +[ ] **Kanban** + +- кнопка переключения global\project +- кнопка создания задачи на каждой колонке +- колонка cancelled - по умолчанию скрыта, добавить кнопку для её отображения + +[ ] **Cтатус backlog** + +- добавить в фильтр +- добавить в канбан