import * as vscode from "vscode"; import { changeStatus } from "@/commands/changeStatus"; import { createTask } from "@/commands/createTask"; import { listLocatedTasks } from "@/commands/listLocatedTasks"; import { resolveTaskLocations } from "@/commands/resolveTaskLocations"; import { buildKanbanBoard } from "@/kanban/buildKanbanBoard"; import { parseKanbanInboundMessage, type KanbanInboundMessage, type KanbanScopeFilter, } from "@/kanban/messages"; import { KanbanOutboundMessage } from "@/kanban/outboundMessages"; import { appError, AppErrorVariant } from "@/error"; import { LocatedTask, TaskLocation, TaskScope } from "@/model/taskLocation"; import { TaskStatus } from "@/model/taskStatus"; import { IConfigProvider, ITaskRepository } from "@/ports"; import { presentError } from "@/ui/presentError"; import { TASK_SCOPE_LABELS } from "@/ui/taskLabels"; 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( "xboctukFlow.kanban", "XBOCTuK 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[] = []; #scopeFilter: KanbanScopeFilter = "all"; #showHidden = false; #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({ variant: "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({ variant: "error", error: parsed.error }); return; } await this.#handleInbound(parsed.value); } async #handleInbound(message: KanbanInboundMessage): Promise { switch (message.variant) { case "ready": case "refresh": await this.reloadTasks(); return; case "moveTask": await this.#moveTask(message.id, message.status); return; case "setScopeFilter": this.#scopeFilter = message.scope; this.#postState(); return; case "setShowHidden": this.#showHidden = message.showHidden; this.#postState(); return; case "createTask": await this.#createInColumn(message.status); return; } } async #moveTask(id: string, status: TaskStatus): Promise { const located = this.#tasks.find((item) => item.task.id === id); if (!located) { this.#post({ variant: "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({ variant: "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?.(); } async #createInColumn(status: TaskStatus): Promise { const location = await this.#pickLocationForCreate(); if (location === undefined) { return; } const title = await vscode.window.showInputBox({ prompt: "Task title", placeHolder: "Enter task title...", }); if (title === undefined) { return; } const result = await createTask(this.#deps.repo, { folderPath: location.folderPath, title, status, }); if (result.isErr()) { presentError(result.error); this.#post({ variant: "error", error: result.error }); return; } await this.reloadTasks(); this.#deps.onTasksMutated?.(); } async #pickLocationForCreate(): Promise { const locations = resolveTaskLocations(this.#deps.config); if (locations.length === 0) { presentError(appError(AppErrorVariant.NO_FOLDER)); return undefined; } if (this.#scopeFilter !== "all") { const match = locations.find((loc) => loc.scope === this.#scopeFilter); if (match) { return match; } } if (locations.length === 1) { return locations[0]; } const picked = await vscode.window.showQuickPick( locations.map((location) => ({ label: TASK_SCOPE_LABELS[location.scope], description: location.folderPath, location, })), { placeHolder: "Create task in…" }, ); return picked?.location; } #filteredTasks(): LocatedTask[] { if (this.#scopeFilter === "all") { return this.#tasks; } return this.#tasks.filter( (item) => item.location.scope === this.#scopeFilter, ); } #availableScopes(): TaskScope[] { const scopes = new Set(this.#tasks.map((item) => item.location.scope)); // Also expose configured locations even if empty (for filter + create). for (const loc of resolveTaskLocations(this.#deps.config)) { scopes.add(loc.scope); } return [...scopes]; } #postState(): void { const board = buildKanbanBoard( this.#filteredTasks().map((item) => item.task), { showHiddenStatuses: this.#showHidden }, ); this.#post({ variant: "state", board, scopeFilter: this.#scopeFilter, showHidden: this.#showHidden, availableScopes: this.#availableScopes(), }); } #post(message: KanbanOutboundMessage): void { if (this.#disposed) return; void this.#panel.webview.postMessage(message); } }