286 lines
7.3 KiB
TypeScript
286 lines
7.3 KiB
TypeScript
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<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;
|
|
if (
|
|
this.#selectedId &&
|
|
!this.#tasks.some((item) => item.task.id === this.#selectedId)
|
|
) {
|
|
this.#selectedId = undefined;
|
|
}
|
|
this.#postState();
|
|
}
|
|
|
|
async #onMessage(raw: unknown): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string, TaskScope> {
|
|
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);
|
|
}
|
|
}
|