270 lines
6.7 KiB
TypeScript
270 lines
6.7 KiB
TypeScript
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(
|
|
"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[] = [];
|
|
#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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<TaskLocation | undefined> {
|
|
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);
|
|
}
|
|
}
|