From 4bd3aa0cd1c0f4358d6d71e77cb07b46cf9c6346 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Tue, 14 Jul 2026 13:37:36 +0500 Subject: [PATCH] feat: add dashboard ui view --- docs/PLAN.md | 16 +- src/dashboard/messages.test.ts | 25 +- src/dashboard/outboundMessages.ts | 17 ++ src/extension.ts | 25 +- src/ui/taskCommands.ts | 13 +- src/views/taskDashboardHtml.ts | 439 ++++++++++++++++++++++++++++++ src/views/taskDashboardPanel.ts | 189 +++++++++++++ todo.md | 53 ++++ 8 files changed, 747 insertions(+), 30 deletions(-) create mode 100644 src/dashboard/outboundMessages.ts create mode 100644 src/views/taskDashboardHtml.ts create mode 100644 src/views/taskDashboardPanel.ts create mode 100644 todo.md diff --git a/docs/PLAN.md b/docs/PLAN.md index be1b24d..8c96ee9 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -56,7 +56,9 @@ src/ │ └── taskCommands.ts # register + run* handlers ├── views/ │ ├── taskTreeItem.ts -│ └── taskTreeProvider.ts +│ ├── taskTreeProvider.ts +│ ├── taskDashboardPanel.ts # WebviewPanel host +│ └── taskDashboardHtml.ts # split layout document └── utils/ tests/helpers/InMemoryFileSystem.ts @@ -102,13 +104,13 @@ Split layout: список (filter/group) + detail (editable markdown body). | `loadDashboard` | folder → `{ tasks }`; `AppError.NO_FOLDER` | | `saveTaskDescription` | folder + id + description → update body; `NOT_FOUND` / validation | -**Unit-тестами не покрываем** (нужен Extension Host / DOM WebView): +**Host (сделано, unit-тестами не покрываем):** -- `WebviewPanel` create/dispose, HTML/CSS split layout, CSP / local resources -- `postMessage` wiring host ↔ webview (кроме parse inbound payload) -- регистрация `projectTasks.openDashboard`, status bar → open panel -- live refresh watcher → push в webview -- визуальный рендер списка/редактора в editor area +- `TaskDashboardPanel` — create/reveal/dispose, state, inbound/outbound +- HTML split: filter/group/list | detail + save description +- `openDashboard` + status bar; watcher / command mutations → `refreshIfOpen` + +**По-прежнему только smoke руками:** визуал, CSP, postMessage round-trip ## Зависимости diff --git a/src/dashboard/messages.test.ts b/src/dashboard/messages.test.ts index 58b513c..1f8bec9 100644 --- a/src/dashboard/messages.test.ts +++ b/src/dashboard/messages.test.ts @@ -63,25 +63,22 @@ describe("parseDashboardInboundMessage", () => { it("rejects non-objects", () => { const result = parseDashboardInboundMessage(null); expect(result.isErr()).toBe(true); - if (result.isErr()) { - expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); - } + if (result.isOk()) return; + expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); }); it("rejects unknown type", () => { const result = parseDashboardInboundMessage({ type: "explode" }); expect(result.isErr()).toBe(true); - if (result.isErr()) { - expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); - } + if (result.isOk()) return; + expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); }); it("rejects selectTask without id", () => { const result = parseDashboardInboundMessage({ type: "selectTask" }); expect(result.isErr()).toBe(true); - if (result.isErr()) { - expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); - } + if (result.isOk()) return; + expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); }); it("rejects setGroupBy with invalid groupBy", () => { @@ -90,9 +87,8 @@ describe("parseDashboardInboundMessage", () => { groupBy: "assignee", }); expect(result.isErr()).toBe(true); - if (result.isErr()) { - expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); - } + if (result.isOk()) return; + expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); }); it("rejects saveDescription with non-string description", () => { @@ -102,8 +98,7 @@ describe("parseDashboardInboundMessage", () => { description: 42, }); expect(result.isErr()).toBe(true); - if (result.isErr()) { - expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); - } + if (result.isOk()) return; + expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); }); }); diff --git a/src/dashboard/outboundMessages.ts b/src/dashboard/outboundMessages.ts new file mode 100644 index 0000000..1b8fc53 --- /dev/null +++ b/src/dashboard/outboundMessages.ts @@ -0,0 +1,17 @@ +import { AppError } from "@/error"; +import { Task } from "@/model/task"; +import { TaskFilter } from "./filterTasks"; +import { GroupBy, TaskGroup } from "./groupTasks"; + +/** Host → webview messages (presentation payload only). */ +export type DashboardOutboundMessage = + | { + type: "state"; + filter: TaskFilter; + groupBy: GroupBy; + selectedId: string | undefined; + groups: TaskGroup[]; + selected: Task | null; + } + | { type: "error"; error: AppError } + | { type: "saved"; task: Task }; diff --git a/src/extension.ts b/src/extension.ts index f7ffe74..8b2dfbc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,6 +4,7 @@ import { FsTaskRepository } from "@/storage/FsTaskRepository"; import { NodeFileSystem } from "@/storage/NodeFileSystem"; import { VscodeConfigProvider } from "@/storage/VscodeConfigProvider"; import { registerTaskCommands } from "@/ui/taskCommands"; +import { TaskDashboardPanel } from "@/views/taskDashboardPanel"; import { TaskTreeProvider } from "@/views/taskTreeProvider"; export function activate(context: vscode.ExtensionContext) { @@ -12,6 +13,11 @@ export function activate(context: vscode.ExtensionContext) { const repo = new FsTaskRepository(fileSystem, config.getFileExtension()); const tree = new TaskTreeProvider(repo, config); + const onTasksMutated = (): void => { + tree.refresh(); + TaskDashboardPanel.refreshIfOpen(); + }; + context.subscriptions.push( vscode.window.registerTreeDataProvider("projectTasks.tasks", tree), ); @@ -21,9 +27,9 @@ export function activate(context: vscode.ExtensionContext) { const ext = config.getFileExtension(); const taskPattern = path.join(projectPath, `*${ext}`).replace(/\\/g, "/"); const watcher = vscode.workspace.createFileSystemWatcher(taskPattern); - watcher.onDidCreate(() => tree.refresh()); - watcher.onDidChange(() => tree.refresh()); - watcher.onDidDelete(() => tree.refresh()); + watcher.onDidCreate(onTasksMutated); + watcher.onDidChange(onTasksMutated); + watcher.onDidDelete(onTasksMutated); context.subscriptions.push(watcher); } @@ -36,11 +42,20 @@ export function activate(context: vscode.ExtensionContext) { statusBarItem.show(); context.subscriptions.push(statusBarItem); - registerTaskCommands(context, { repo, config, tree }); + registerTaskCommands(context, { + repo, + config, + tree, + onTasksMutated: () => TaskDashboardPanel.refreshIfOpen(), + }); context.subscriptions.push( vscode.commands.registerCommand("projectTasks.openDashboard", () => { - void vscode.window.showInformationMessage("Dashboard coming soon!"); + TaskDashboardPanel.show({ + repo, + config, + onTasksMutated: () => tree.refresh(), + }); }), ); } diff --git a/src/ui/taskCommands.ts b/src/ui/taskCommands.ts index 728038d..a037dad 100644 --- a/src/ui/taskCommands.ts +++ b/src/ui/taskCommands.ts @@ -15,8 +15,15 @@ export type TaskCommandDeps = { repo: ITaskRepository; config: IConfigProvider; tree: TaskTreeProvider; + /** Refresh dashboard (and any other listeners) after task mutations. */ + onTasksMutated?: () => void; }; +function notifyMutated(deps: TaskCommandDeps): void { + deps.tree.refresh(); + deps.onTasksMutated?.(); +} + export async function runCreateTask(deps: TaskCommandDeps): Promise { const title = await vscode.window.showInputBox({ prompt: "Task title", @@ -37,7 +44,7 @@ export async function runCreateTask(deps: TaskCommandDeps): Promise { return; } - deps.tree.refresh(); + notifyMutated(deps); void vscode.window.showInformationMessage( `Task created: ${result.value.title}`, ); @@ -98,7 +105,7 @@ export async function runDeleteTask( return; } - deps.tree.refresh(); + notifyMutated(deps); void vscode.window.showInformationMessage("Task deleted"); } @@ -138,7 +145,7 @@ export async function runChangeStatus( return; } - deps.tree.refresh(); + notifyMutated(deps); void vscode.window.showInformationMessage( `Status: ${TASK_STATUS_LABELS[result.value.status]}`, ); diff --git a/src/views/taskDashboardHtml.ts b/src/views/taskDashboardHtml.ts new file mode 100644 index 0000000..f0fe3b2 --- /dev/null +++ b/src/views/taskDashboardHtml.ts @@ -0,0 +1,439 @@ +import * as vscode from "vscode"; + +const NONCE_LENGTH = 32; +const QUERY_DEBOUNCE_MS = 200; + +/** Minimal split-layout document for the Task Dashboard webview. */ +export function getTaskDashboardHtml( + webview: vscode.Webview, + nonce: string, +): string { + const csp = [ + "default-src 'none'", + `style-src ${webview.cspSource} 'unsafe-inline'`, + `script-src 'nonce-${nonce}'`, + ].join("; "); + + return ` + + + + + + Task Dashboard + + + +
+ +
+
Select a task or create one from the sidebar.
+
+

+
+ +
+ + +
+
+
+
+ + +`; +} + +export function createNonce(): 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; +} diff --git a/src/views/taskDashboardPanel.ts b/src/views/taskDashboardPanel.ts new file mode 100644 index 0000000..83c5dcd --- /dev/null +++ b/src/views/taskDashboardPanel.ts @@ -0,0 +1,189 @@ +import * as vscode from "vscode"; +import { loadDashboard } from "@/commands/loadDashboard"; +import { saveTaskDescription } from "@/commands/saveTaskDescription"; +import { buildDashboardView } from "@/dashboard/buildDashboardView"; +import { TaskFilter } from "@/dashboard/filterTasks"; +import { GroupBy } from "@/dashboard/groupTasks"; +import { + parseDashboardInboundMessage, + type DashboardInboundMessage, +} from "@/dashboard/messages"; +import { DashboardOutboundMessage } from "@/dashboard/outboundMessages"; +import { AppErrorVariant } from "@/error"; +import { Task } from "@/model/task"; +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; +}; + +/** + * 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): void { + if (TaskDashboardPanel.#current) { + TaskDashboardPanel.#current.#panel.reveal(vscode.ViewColumn.One); + void TaskDashboardPanel.#current.reloadTasks(); + return; + } + + const panel = vscode.window.createWebviewPanel( + "projectTasks.dashboard", + "Task Dashboard", + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + }, + ); + + TaskDashboardPanel.#current = new TaskDashboardPanel(panel, deps); + } + + static refreshIfOpen(): void { + void TaskDashboardPanel.#current?.reloadTasks(); + } + + readonly #panel: vscode.WebviewPanel; + readonly #deps: TaskDashboardDeps; + + #tasks: Task[] = []; + #filter: TaskFilter = {}; + #groupBy: GroupBy = "none"; + #selectedId: string | undefined; + #disposed = false; + + constructor(panel: vscode.WebviewPanel, deps: TaskDashboardDeps) { + this.#panel = panel; + this.#deps = deps; + + 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 folderPath = this.#deps.config.getProjectTaskPath(); + const result = await loadDashboard(this.#deps.repo, { folderPath }); + + 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.tasks; + if ( + this.#selectedId && + !this.#tasks.some((task) => task.id === this.#selectedId) + ) { + this.#selectedId = undefined; + } + this.#postState(); + } + + async #onMessage(raw: unknown): Promise { + const parsed = parseDashboardInboundMessage(raw); + if (parsed.isErr()) { + this.#post({ type: "error", error: parsed.error }); + return; + } + await this.#handleInbound(parsed.value); + } + + async #handleInbound(message: DashboardInboundMessage): Promise { + switch (message.type) { + 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 "saveDescription": + await this.#saveDescription(message.id, message.description); + return; + } + } + + async #saveDescription(id: string, description: string): Promise { + const folderPath = this.#deps.config.getProjectTaskPath(); + const result = await saveTaskDescription(this.#deps.repo, { + folderPath, + id, + description, + }); + + if (result.isErr()) { + this.#post({ type: "error", error: result.error }); + return; + } + + const saved = result.value; + this.#tasks = this.#tasks.map((task) => + task.id === saved.id ? saved : task, + ); + this.#selectedId = saved.id; + this.#post({ type: "saved", task: saved }); + this.#postState(); + this.#deps.onTasksMutated?.(); + } + + #postState(): void { + const view = buildDashboardView(this.#tasks, { + filter: this.#filter, + groupBy: this.#groupBy, + selectedId: this.#selectedId, + }); + + this.#post({ + type: "state", + filter: this.#filter, + groupBy: this.#groupBy, + selectedId: this.#selectedId, + groups: view.groups, + selected: view.selected ?? null, + }); + } + + #post(message: DashboardOutboundMessage): void { + if (this.#disposed) return; + void this.#panel.webview.postMessage(message); + } +} diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..4e2d649 --- /dev/null +++ b/todo.md @@ -0,0 +1,53 @@ +# Мелкие замечания / backlog + +## Dashboard + +[ ] **Сохранение по Ctrl+S** + +- В detail (textarea description) — hotkey `Ctrl+S` / `Cmd+S` → тот же flow, что + кнопка Save (`saveDescription`). + +[ ] **Форматирование при сохранении (как в VS Code, без force-format)** + +**Почему сейчас не работает:** кнопка Save в Dashboard пишет через +`saveTaskDescription` → `repo.update` → `fs.writeFile`. Это **не** save +текстового документа в editor. `editor.formatOnSave` / formatters цепляются к +`TextDocument` save (`onWillSaveTextDocument` и т.п.), а не к произвольной +записи на диск. Обычный Ctrl+S в `.md` / `.task.md` идёт через editor → format +применяется. Наша кнопка — другой путь. + +**Не делать:** вручную вызывать prettier/`formatDocument` «всегда» — это не +«как в VS Code», а свой форс, игнорит user settings (off / другой formatter). + +**Как сделать «как в VS Code» (если захотим паритет):** UI-адаптер save +открывает/берёт `TextDocument` файла задачи, правит body через +`WorkspaceEdit`, затем `document.save()` — тогда сработают format-on-save и +остальные will-save хуки, **только если** они включены у пользователя. +Use-case/repo для headless/тестов можно оставить; editor-save — optional path +из host. + +**Пока:** не блокер; format при правке файла в editor уже ок. Ctrl+S в +Dashboard без editor-path format не даст. + +[ ] **Кнопка сброса фильтров** + +- В toolbar Dashboard: Reset → `filter = {}`, query/chips/groupBy defaults, + `setFilter` + при необходимости сброс groupBy. + +[ ] **Фильтр по приоритету** + +- Chips как у status: low / medium / high / critical → `filter.priorities` (уже + есть в `TaskFilter` / `filterTasks`). + +[ ] **Dashboard: raw MD ↔ view** + +- В detail переключатель режимов: **view** (текущий UI: meta + textarea body) и + **raw** (весь файл как markdown + frontmatter, как в editor). +- Редактирование/save в raw — отдельный контракт (parse → task / serialize), не + ломать format-on-save историю: raw-save тоже лучше через editor path, если + нужен паритет с VS Code. + +[ ] **Боковая панель** В боковой панели должны быть + +- кнопка открытия дешборда +- кнопки для - удаления, редактирования через дешборд