feat: add dashboard ui view
This commit is contained in:
+9
-7
@@ -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
|
||||
|
||||
## Зависимости
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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]}`,
|
||||
);
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
[ ] **Боковая панель** В боковой панели должны быть
|
||||
|
||||
- кнопка открытия дешборда
|
||||
- кнопки для - удаления, редактирования через дешборд
|
||||
Reference in New Issue
Block a user