import * as vscode from "vscode"; import { listLocatedTasks } from "@/commands/listLocatedTasks"; import { resolveTaskLocations } from "@/commands/resolveTaskLocations"; import { saveTaskDescription } from "@/commands/saveTaskDescription"; import { buildDashboardView } from "@/dashboard/buildDashboardView"; import { filterLocatedByScope } from "@/dashboard/filterLocatedByScope"; import { TaskFilter } from "@/dashboard/filterTasks"; import { GroupBy } from "@/dashboard/groupBy"; import { parseDashboardInboundMessage, type DashboardInboundMessage, type DashboardScopeFilter, } from "@/dashboard/messages"; import { DashboardListTask, DashboardOutboundMessage, } from "@/dashboard/outboundMessages"; import { sortTasks, TaskSortDirection } from "@/dashboard/sortTasks"; import { appError, AppErrorVariant } from "@/error"; import { LocatedTask, TaskScope } from "@/model/taskLocation"; import { IConfigProvider, ITaskRepository } from "@/ports"; import { presentError } from "@/ui/presentError"; import { createNonce, getTaskDashboardHtml } from "./taskDashboardHtml"; export type TaskDashboardDeps = { repo: ITaskRepository; config: IConfigProvider; /** Called after dashboard mutates tasks (e.g. save description). */ onTasksMutated?: () => void; }; export type TaskDashboardShowOptions = { /** Pre-select this task after load (e.g. open from tree). */ selectedId?: string; }; /** * Host adapter for the Task Dashboard webview. * Owns panel lifecycle and message wiring; business rules stay in use-cases. */ export class TaskDashboardPanel { static #current: TaskDashboardPanel | undefined; static show( deps: TaskDashboardDeps, options: TaskDashboardShowOptions = {}, ): void { if (TaskDashboardPanel.#current) { const current = TaskDashboardPanel.#current; if (options.selectedId !== undefined) { current.#selectedId = options.selectedId; } current.#panel.reveal(vscode.ViewColumn.One); void current.reloadTasks(); return; } const panel = vscode.window.createWebviewPanel( "xboctukFlow.dashboard", "XBOCTuK Flow", vscode.ViewColumn.One, { enableScripts: true, retainContextWhenHidden: true, }, ); TaskDashboardPanel.#current = new TaskDashboardPanel( panel, deps, options.selectedId, ); } static refreshIfOpen(): void { void TaskDashboardPanel.#current?.reloadTasks(); } readonly #panel: vscode.WebviewPanel; readonly #deps: TaskDashboardDeps; #tasks: LocatedTask[] = []; #filter: TaskFilter = {}; #groupBy: GroupBy = GroupBy.NONE; #scopeFilter: DashboardScopeFilter = "all"; #sortDirection: TaskSortDirection = TaskSortDirection.ASC; #selectedId: string | undefined; #disposed = false; constructor( panel: vscode.WebviewPanel, deps: TaskDashboardDeps, selectedId?: string, ) { this.#panel = panel; this.#deps = deps; this.#selectedId = selectedId; const nonce = createNonce(); this.#panel.webview.html = getTaskDashboardHtml(this.#panel.webview, nonce); this.#panel.webview.onDidReceiveMessage((raw: unknown) => { void this.#onMessage(raw); }); this.#panel.onDidDispose(() => { this.#disposed = true; if (TaskDashboardPanel.#current === this) { TaskDashboardPanel.#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; if ( this.#selectedId && !this.#tasks.some((item) => item.task.id === this.#selectedId) ) { this.#selectedId = undefined; } this.#postState(); } async #onMessage(raw: unknown): Promise { const parsed = parseDashboardInboundMessage(raw); if (parsed.isErr()) { this.#post({ variant: "error", error: parsed.error }); return; } await this.#handleInbound(parsed.value); } async #handleInbound(message: DashboardInboundMessage): Promise { switch (message.variant) { case "ready": case "refresh": await this.reloadTasks(); return; case "selectTask": this.#selectedId = message.id; this.#postState(); return; case "setFilter": this.#filter = message.filter; this.#postState(); return; case "setGroupBy": this.#groupBy = message.groupBy; this.#postState(); return; case "setScopeFilter": this.#scopeFilter = message.scope; this.#postState(); return; case "setSortDirection": this.#sortDirection = message.direction; this.#postState(); return; case "saveDescription": await this.#saveDescription(message.id, message.description); return; case "createTask": await vscode.commands.executeCommand("xboctukFlow.createTask"); await this.reloadTasks(); return; } } async #saveDescription(id: string, description: string): Promise { const located = this.#tasks.find((item) => item.task.id === id); if (!located) { this.#post({ variant: "error", error: appError(AppErrorVariant.NOT_FOUND, { id }), }); return; } const result = await saveTaskDescription(this.#deps.repo, { folderPath: located.location.folderPath, id, description, }); if (result.isErr()) { this.#post({ variant: "error", error: result.error }); return; } const saved = result.value; this.#tasks = this.#tasks.map((item) => item.task.id === saved.id ? { task: saved, location: item.location } : item, ); this.#selectedId = saved.id; this.#post({ variant: "saved", task: saved }); this.#postState(); this.#deps.onTasksMutated?.(); } #scopeById(): Map { return new Map( this.#tasks.map((item) => [item.task.id, item.location.scope]), ); } #withScope(task: import("@/model/task").Task): DashboardListTask | null { const scope = this.#scopeById().get(task.id); if (scope === undefined) { return null; } return { ...task, scope }; } #availableScopes(): TaskScope[] { const scopes = new Set(this.#tasks.map((item) => item.location.scope)); for (const loc of resolveTaskLocations(this.#deps.config)) { scopes.add(loc.scope); } return [...scopes]; } #postState(): void { const scoped = filterLocatedByScope(this.#tasks, this.#scopeFilter); const plainSorted = sortTasks( scoped.map((item) => item.task), this.#sortDirection, ); const view = buildDashboardView(plainSorted, { filter: this.#filter, groupBy: this.#groupBy, selectedId: this.#selectedId, }); const groups = view.groups.map((group) => ({ key: group.key, label: group.label, tasks: group.tasks .map((task) => this.#withScope(task)) .filter((task): task is DashboardListTask => task !== null), })); const selected = view.selected !== undefined ? this.#withScope(view.selected) : null; this.#post({ variant: "state", filter: this.#filter, groupBy: this.#groupBy, selectedId: this.#selectedId, groups, selected, scopeFilter: this.#scopeFilter, sortDirection: this.#sortDirection, availableScopes: this.#availableScopes(), }); } #post(message: DashboardOutboundMessage): void { if (this.#disposed) return; void this.#panel.webview.postMessage(message); } }