feat: add kanban board
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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": [
|
||||
|
||||
+6
-1
@@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Task } from "@/model/task";
|
||||
import { buildKanbanBoard } from "./buildKanbanBoard";
|
||||
|
||||
function task(partial: Partial<Task> & Pick<Task, "id" | "title">): 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([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string> = new Set(TASK_STATUS_ORDER);
|
||||
|
||||
function invalid(detail?: string): Result<KanbanInboundMessage, AppError> {
|
||||
return err(
|
||||
appError(AppErrorVariant.INVALID_MESSAGE, {
|
||||
detail: detail ?? "",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function parseKanbanInboundMessage(
|
||||
raw: unknown,
|
||||
): Result<KanbanInboundMessage, AppError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp}" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kanban</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-editor-background);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border, var(--vscode-widget-border));
|
||||
}
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 4px 10px;
|
||||
color: var(--vscode-button-foreground);
|
||||
background: var(--vscode-button-background);
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
button.secondary {
|
||||
background: var(--vscode-button-secondaryBackground, var(--vscode-input-background));
|
||||
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
|
||||
}
|
||||
#status-line {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.85;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
#status-line.error { color: var(--vscode-errorForeground); }
|
||||
#board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
height: calc(100% - 48px);
|
||||
overflow: auto;
|
||||
align-items: stretch;
|
||||
}
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 200px;
|
||||
border: 1px solid var(--vscode-panel-border, var(--vscode-widget-border));
|
||||
border-radius: 4px;
|
||||
background: var(--vscode-sideBar-background, transparent);
|
||||
}
|
||||
.column.drag-over {
|
||||
outline: 2px solid var(--vscode-focusBorder);
|
||||
}
|
||||
.column-header {
|
||||
padding: 8px 10px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
border-bottom: 1px solid var(--vscode-panel-border, var(--vscode-widget-border));
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.column-header .count {
|
||||
opacity: 0.7;
|
||||
font-weight: 400;
|
||||
}
|
||||
.column-body {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow: auto;
|
||||
min-height: 80px;
|
||||
}
|
||||
.card {
|
||||
padding: 8px 10px;
|
||||
border-radius: 3px;
|
||||
background: var(--vscode-editor-background);
|
||||
border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border));
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
.card:active { cursor: grabbing; }
|
||||
.card .meta {
|
||||
margin-top: 4px;
|
||||
font-size: 0.85em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Kanban</h1>
|
||||
<span id="status-line"></span>
|
||||
<button id="refresh" type="button" class="secondary" title="Refresh">↻</button>
|
||||
</header>
|
||||
<div id="board"></div>
|
||||
<script nonce="${nonce}">
|
||||
const vscode = acquireVsCodeApi();
|
||||
const boardEl = document.getElementById("board");
|
||||
const statusLine = document.getElementById("status-line");
|
||||
const refreshBtn = document.getElementById("refresh");
|
||||
|
||||
let board = { columns: [] };
|
||||
let dragTaskId = null;
|
||||
|
||||
function post(message) {
|
||||
vscode.postMessage(message);
|
||||
}
|
||||
|
||||
function setStatus(text, isError) {
|
||||
statusLine.textContent = text || "";
|
||||
statusLine.classList.toggle("error", Boolean(isError));
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function render() {
|
||||
boardEl.innerHTML = "";
|
||||
for (const column of board.columns || []) {
|
||||
const col = document.createElement("section");
|
||||
col.className = "column";
|
||||
col.dataset.status = column.status;
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "column-header";
|
||||
header.innerHTML =
|
||||
"<span>" + escapeHtml(column.label) + "</span>" +
|
||||
'<span class="count">' + column.tasks.length + "</span>";
|
||||
col.appendChild(header);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "column-body";
|
||||
body.dataset.status = column.status;
|
||||
|
||||
body.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
col.classList.add("drag-over");
|
||||
});
|
||||
body.addEventListener("dragleave", () => {
|
||||
col.classList.remove("drag-over");
|
||||
});
|
||||
body.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
col.classList.remove("drag-over");
|
||||
const id = event.dataTransfer.getData("text/task-id") || dragTaskId;
|
||||
const status = column.status;
|
||||
if (!id) return;
|
||||
post({ type: "moveTask", id: id, status: status });
|
||||
dragTaskId = null;
|
||||
});
|
||||
|
||||
for (const task of column.tasks) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "card";
|
||||
card.draggable = true;
|
||||
card.dataset.id = task.id;
|
||||
card.innerHTML =
|
||||
"<div>" + escapeHtml(task.title) + "</div>" +
|
||||
'<div class="meta">' + escapeHtml(task.priority) + "</div>";
|
||||
card.addEventListener("dragstart", (event) => {
|
||||
dragTaskId = task.id;
|
||||
event.dataTransfer.setData("text/task-id", task.id);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
card.addEventListener("dragend", () => {
|
||||
dragTaskId = null;
|
||||
document.querySelectorAll(".column.drag-over").forEach((el) => {
|
||||
el.classList.remove("drag-over");
|
||||
});
|
||||
});
|
||||
body.appendChild(card);
|
||||
}
|
||||
|
||||
col.appendChild(body);
|
||||
boardEl.appendChild(col);
|
||||
}
|
||||
}
|
||||
|
||||
refreshBtn.addEventListener("click", () => {
|
||||
post({ type: "refresh" });
|
||||
});
|
||||
|
||||
window.addEventListener("message", (event) => {
|
||||
const message = event.data;
|
||||
if (!message || typeof message !== "object") return;
|
||||
if (message.type === "state") {
|
||||
board = message.board || { columns: [] };
|
||||
setStatus("");
|
||||
render();
|
||||
} else if (message.type === "error") {
|
||||
setStatus((message.error && message.error.message) || "Error", true);
|
||||
}
|
||||
});
|
||||
|
||||
post({ type: "ready" });
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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**
|
||||
|
||||
- добавить в фильтр
|
||||
- добавить в канбан
|
||||
|
||||
Reference in New Issue
Block a user