feat: add dashboard ui view

This commit is contained in:
2026-07-14 23:38:54 +05:00
parent cbb97a99ce
commit 4bd3aa0cd1
8 changed files with 747 additions and 30 deletions
+9 -7
View File
@@ -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
## Зависимости
+10 -15
View File
@@ -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);
});
});
+17
View File
@@ -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 };
+20 -5
View File
@@ -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(),
});
}),
);
}
+10 -3
View File
@@ -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<void> {
const title = await vscode.window.showInputBox({
prompt: "Task title",
@@ -37,7 +44,7 @@ export async function runCreateTask(deps: TaskCommandDeps): Promise<void> {
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]}`,
);
+439
View File
@@ -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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="${csp}" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Task Dashboard</title>
<style>
:root {
color-scheme: light dark;
}
* { box-sizing: border-box; }
html, body {
height: 100%;
margin: 0;
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-foreground);
background: var(--vscode-editor-background);
}
#app {
display: grid;
grid-template-columns: minmax(220px, 32%) 1fr;
height: 100%;
min-height: 0;
}
aside, main {
min-height: 0;
display: flex;
flex-direction: column;
}
aside {
border-right: 1px solid var(--vscode-panel-border, var(--vscode-widget-border));
background: var(--vscode-sideBar-background, transparent);
}
.toolbar {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border-bottom: 1px solid var(--vscode-panel-border, var(--vscode-widget-border));
}
.row {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
input[type="search"], select, textarea, button {
font: inherit;
color: var(--vscode-input-foreground);
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border, transparent);
border-radius: 2px;
}
input[type="search"], select {
flex: 1;
min-width: 0;
padding: 4px 8px;
}
button {
cursor: pointer;
padding: 4px 10px;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border-color: transparent;
}
button.secondary {
background: var(--vscode-button-secondaryBackground, var(--vscode-input-background));
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
}
button:hover {
background: var(--vscode-button-hoverBackground);
}
.chip {
padding: 2px 8px;
border-radius: 10px;
border: 1px solid var(--vscode-input-border, var(--vscode-widget-border));
background: transparent;
color: inherit;
cursor: pointer;
font-size: 0.9em;
}
.chip.active {
background: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
border-color: transparent;
}
#list {
overflow: auto;
padding: 6px 0 12px;
flex: 1;
}
.group-label {
padding: 8px 12px 4px;
font-size: 0.85em;
opacity: 0.75;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.task {
display: block;
width: 100%;
text-align: left;
padding: 8px 12px;
border: none;
border-left: 3px solid transparent;
background: transparent;
color: inherit;
cursor: pointer;
}
.task:hover {
background: var(--vscode-list-hoverBackground);
}
.task.selected {
background: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
border-left-color: var(--vscode-focusBorder);
}
.task .meta {
opacity: 0.75;
font-size: 0.85em;
margin-top: 2px;
}
main {
padding: 12px 16px 16px;
}
#empty {
margin: auto;
opacity: 0.7;
text-align: center;
padding: 24px;
}
#detail {
display: none;
flex-direction: column;
gap: 10px;
min-height: 0;
flex: 1;
}
#detail.visible { display: flex; }
#detail-title {
margin: 0;
font-size: 1.2em;
font-weight: 600;
}
#detail-meta {
opacity: 0.8;
font-size: 0.9em;
}
#description {
flex: 1;
min-height: 160px;
width: 100%;
resize: vertical;
padding: 10px;
line-height: 1.45;
}
.actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
#status-line {
min-height: 1.2em;
font-size: 0.85em;
opacity: 0.85;
}
#status-line.error { color: var(--vscode-errorForeground); }
</style>
</head>
<body>
<div id="app">
<aside>
<div class="toolbar">
<div class="row">
<input id="query" type="search" placeholder="Search title or body…" />
</div>
<div class="row">
<label for="groupBy">Group</label>
<select id="groupBy">
<option value="none">None</option>
<option value="status">Status</option>
<option value="priority">Priority</option>
</select>
<button id="refresh" type="button" class="secondary" title="Refresh">↻</button>
</div>
<div class="row" id="status-filters" aria-label="Status filters"></div>
</div>
<div id="list"></div>
</aside>
<main>
<div id="empty">Select a task or create one from the sidebar.</div>
<div id="detail">
<h1 id="detail-title"></h1>
<div id="detail-meta"></div>
<textarea id="description" spellcheck="true" placeholder="Task description (markdown)…"></textarea>
<div class="actions">
<span id="status-line"></span>
<button id="save" type="button">Save</button>
</div>
</div>
</main>
</div>
<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
const STATUSES = [
{ value: "todo", label: "To Do" },
{ value: "in-progress", label: "In Progress" },
{ value: "done", label: "Done" },
{ value: "cancelled", label: "Cancelled" },
];
const state = {
filter: { statuses: [], query: "" },
groupBy: "none",
selectedId: undefined,
groups: [],
selected: null,
dirty: false,
};
const el = {
list: document.getElementById("list"),
query: document.getElementById("query"),
groupBy: document.getElementById("groupBy"),
refresh: document.getElementById("refresh"),
statusFilters: document.getElementById("status-filters"),
empty: document.getElementById("empty"),
detail: document.getElementById("detail"),
title: document.getElementById("detail-title"),
meta: document.getElementById("detail-meta"),
description: document.getElementById("description"),
save: document.getElementById("save"),
statusLine: document.getElementById("status-line"),
};
function post(message) {
vscode.postMessage(message);
}
function setStatus(text, isError) {
el.statusLine.textContent = text || "";
el.statusLine.classList.toggle("error", Boolean(isError));
}
function selectedStatuses() {
return state.filter.statuses || [];
}
function renderStatusChips() {
el.statusFilters.innerHTML = "";
for (const s of STATUSES) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "chip" + (selectedStatuses().includes(s.value) ? " active" : "");
btn.textContent = s.label;
btn.addEventListener("click", () => {
const current = new Set(selectedStatuses());
if (current.has(s.value)) current.delete(s.value);
else current.add(s.value);
state.filter = {
...state.filter,
statuses: Array.from(current),
};
post({ type: "setFilter", filter: normalizeFilter(state.filter) });
});
el.statusFilters.appendChild(btn);
}
}
function normalizeFilter(filter) {
const next = {};
if (filter.statuses && filter.statuses.length) next.statuses = filter.statuses;
if (filter.priorities && filter.priorities.length) next.priorities = filter.priorities;
if (filter.query && filter.query.trim()) next.query = filter.query.trim();
if (filter.tags && filter.tags.length) next.tags = filter.tags;
return next;
}
function renderList() {
el.list.innerHTML = "";
if (!state.groups.length) {
const empty = document.createElement("div");
empty.className = "group-label";
empty.textContent = "No tasks";
el.list.appendChild(empty);
return;
}
for (const group of state.groups) {
if (state.groupBy !== "none") {
const label = document.createElement("div");
label.className = "group-label";
label.textContent = group.label + " (" + group.tasks.length + ")";
el.list.appendChild(label);
}
for (const task of group.tasks) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "task" + (task.id === state.selectedId ? " selected" : "");
btn.innerHTML =
"<div>" + escapeHtml(task.title) + "</div>" +
'<div class="meta">' + escapeHtml(task.status) + " · " + escapeHtml(task.priority) + "</div>";
btn.addEventListener("click", () => {
if (state.dirty) {
const okLeave = window.confirm("Discard unsaved description changes?");
if (!okLeave) return;
state.dirty = false;
}
post({ type: "selectTask", id: task.id });
});
el.list.appendChild(btn);
}
}
}
function renderDetail() {
const task = state.selected;
if (!task) {
el.empty.style.display = "block";
el.detail.classList.remove("visible");
return;
}
el.empty.style.display = "none";
el.detail.classList.add("visible");
el.title.textContent = task.title;
el.meta.textContent =
task.status + " · " + task.priority +
(task.tags && task.tags.length ? " · " + task.tags.join(", ") : "") +
(task.assignee ? " · @" + task.assignee : "");
if (!state.dirty) {
el.description.value = task.description || "";
}
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function applyState(message) {
state.filter = message.filter || {};
state.groupBy = message.groupBy || "none";
state.selectedId = message.selectedId;
state.groups = message.groups || [];
state.selected = message.selected;
state.dirty = false;
el.query.value = state.filter.query || "";
el.groupBy.value = state.groupBy;
renderStatusChips();
renderList();
renderDetail();
setStatus("");
}
let queryTimer;
el.query.addEventListener("input", () => {
const query = el.query.value;
window.clearTimeout(queryTimer);
queryTimer = window.setTimeout(() => {
state.filter = { ...state.filter, query };
post({ type: "setFilter", filter: normalizeFilter(state.filter) });
}, ${QUERY_DEBOUNCE_MS});
});
el.groupBy.addEventListener("change", () => {
post({ type: "setGroupBy", groupBy: el.groupBy.value });
});
el.refresh.addEventListener("click", () => {
state.dirty = false;
post({ type: "refresh" });
});
el.description.addEventListener("input", () => {
state.dirty = true;
setStatus("Unsaved changes");
});
el.save.addEventListener("click", () => {
if (!state.selected) return;
post({
type: "saveDescription",
id: state.selected.id,
description: el.description.value,
});
});
window.addEventListener("message", (event) => {
const message = event.data;
if (!message || typeof message !== "object") return;
if (message.type === "state") {
applyState(message);
} else if (message.type === "error") {
setStatus((message.error && message.error.message) || "Error", true);
} else if (message.type === "saved") {
state.dirty = false;
state.selected = message.task;
setStatus("Saved");
renderDetail();
}
});
renderStatusChips();
post({ type: "ready" });
</script>
</body>
</html>`;
}
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;
}
+189
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
+53
View File
@@ -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.
[ ] **Боковая панель** В боковой панели должны быть
- кнопка открытия дешборда
- кнопки для - удаления, редактирования через дешборд