feat: update dashboard view
This commit is contained in:
@@ -88,7 +88,7 @@ Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`):
|
|||||||
| **XBOCTuK Flow: Change Status** | Change status |
|
| **XBOCTuK Flow: Change Status** | Change status |
|
||||||
| **XBOCTuK Flow: Delete Task** | Delete task |
|
| **XBOCTuK Flow: Delete Task** | Delete task |
|
||||||
| **XBOCTuK Flow: Open Dashboard** | Dashboard (list + body) |
|
| **XBOCTuK Flow: Open Dashboard** | Dashboard (list + body) |
|
||||||
| **XBOCTuK Flow: Edit in Dashboard** | Dashboard with the selected task |
|
| **XBOCTuK Flow: Open in Dashboard** | Dashboard with the selected task |
|
||||||
| **XBOCTuK Flow: Open Kanban** | Kanban board |
|
| **XBOCTuK Flow: Open Kanban** | Kanban board |
|
||||||
|
|
||||||
Sidebar: title buttons (create / dashboard / kanban) and item actions (open /
|
Sidebar: title buttons (create / dashboard / kanban) and item actions (open /
|
||||||
@@ -120,7 +120,7 @@ One file per task. File name is derived from the title (slug).
|
|||||||
- Search title/body; status/priority chips; group none/status/priority
|
- Search title/body; status/priority chips; group none/status/priority
|
||||||
- Scope: All / Project / Global
|
- Scope: All / Project / Global
|
||||||
- Sort by **created**: Oldest first (default) / Newest first
|
- Sort by **created**: Oldest first (default) / Newest first
|
||||||
- Save description (button / `Ctrl+S` / `Cmd+S`)
|
- View-only detail; **Edit** opens the task file in the editor
|
||||||
- `+ Task` — create
|
- `+ Task` — create
|
||||||
|
|
||||||
## Kanban
|
## Kanban
|
||||||
|
|||||||
+2
-2
@@ -88,7 +88,7 @@ Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`):
|
|||||||
| **XBOCTuK Flow: Change Status** | Сменить статус |
|
| **XBOCTuK Flow: Change Status** | Сменить статус |
|
||||||
| **XBOCTuK Flow: Delete Task** | Удалить задачу |
|
| **XBOCTuK Flow: Delete Task** | Удалить задачу |
|
||||||
| **XBOCTuK Flow: Open Dashboard** | Dashboard (список + body) |
|
| **XBOCTuK Flow: Open Dashboard** | Dashboard (список + body) |
|
||||||
| **XBOCTuK Flow: Edit in Dashboard** | Dashboard с выбранной задачей |
|
| **XBOCTuK Flow: Open in Dashboard** | Dashboard с выбранной задачей |
|
||||||
| **XBOCTuK Flow: Open Kanban** | Kanban board |
|
| **XBOCTuK Flow: Open Kanban** | Kanban board |
|
||||||
|
|
||||||
В sidebar: кнопки title (create / dashboard / kanban) и actions на задаче (open
|
В sidebar: кнопки title (create / dashboard / kanban) и actions на задаче (open
|
||||||
@@ -120,7 +120,7 @@ updated: 2026-07-12T10:00:00.000Z
|
|||||||
- Поиск по title/body, chips status/priority, group none/status/priority
|
- Поиск по title/body, chips status/priority, group none/status/priority
|
||||||
- Scope: All / Project / Global
|
- Scope: All / Project / Global
|
||||||
- Sort by **created**: Oldest first (default) / Newest first
|
- Sort by **created**: Oldest first (default) / Newest first
|
||||||
- Save description (кнопка / `Ctrl+S` / `Cmd+S`)
|
- Detail только просмотр; **Edit** открывает файл задачи в editor
|
||||||
- `+ Task` — create
|
- `+ Task` — create
|
||||||
|
|
||||||
## Kanban
|
## Kanban
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "xboctukFlow.openInDashboard",
|
"command": "xboctukFlow.openInDashboard",
|
||||||
"title": "XBOCTuK Flow: Edit in Dashboard",
|
"title": "XBOCTuK Flow: Open in Dashboard",
|
||||||
"icon": "$(edit)"
|
"icon": "$(edit)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { err } from "neverthrow";
|
||||||
|
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
||||||
|
import { appError, AppErrorVariant } from "@/error";
|
||||||
|
import { TaskStatus } from "@/model/taskStatus";
|
||||||
|
import { FsTaskRepository } from "@/storage/FsTaskRepository";
|
||||||
|
import { serializeTask } from "@/utils/markdown";
|
||||||
|
import { saveTaskRaw } from "./saveTaskRaw";
|
||||||
|
|
||||||
|
describe("saveTaskRaw", () => {
|
||||||
|
const folder = "/tasks";
|
||||||
|
let repo: FsTaskRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates task from raw markdown", async () => {
|
||||||
|
const created = await repo.create(folder, {
|
||||||
|
title: "Raw me",
|
||||||
|
description: "old",
|
||||||
|
status: TaskStatus.TODO,
|
||||||
|
priority: "medium",
|
||||||
|
tags: [],
|
||||||
|
assignee: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const raw = serializeTask({
|
||||||
|
...created,
|
||||||
|
title: "Raw me renamed",
|
||||||
|
description: "new body",
|
||||||
|
status: TaskStatus.IN_PROGRESS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await saveTaskRaw(repo, {
|
||||||
|
folderPath: folder,
|
||||||
|
id: created.id,
|
||||||
|
raw,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
if (result.isErr()) return;
|
||||||
|
expect(result.value.id).toBe(created.id);
|
||||||
|
expect(result.value.title).toBe("Raw me renamed");
|
||||||
|
expect(result.value.description).toBe("new body");
|
||||||
|
expect(result.value.status).toBe(TaskStatus.IN_PROGRESS);
|
||||||
|
|
||||||
|
const stored = await repo.getById(folder, created.id);
|
||||||
|
expect(stored?.title).toBe("Raw me renamed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns not found for unknown id", async () => {
|
||||||
|
const result = await saveTaskRaw(repo, {
|
||||||
|
folderPath: folder,
|
||||||
|
id: "missing",
|
||||||
|
raw: "---\ntitle: x\n---\n",
|
||||||
|
});
|
||||||
|
expect(result).toEqual(
|
||||||
|
err(appError(AppErrorVariant.NOT_FOUND, { id: "missing" })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing folder", async () => {
|
||||||
|
const result = await saveTaskRaw(repo, {
|
||||||
|
folderPath: undefined,
|
||||||
|
id: "x",
|
||||||
|
raw: "---\ntitle: x\n---\n",
|
||||||
|
});
|
||||||
|
expect(result).toEqual(err(appError(AppErrorVariant.NO_FOLDER)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing id", async () => {
|
||||||
|
const result = await saveTaskRaw(repo, {
|
||||||
|
folderPath: folder,
|
||||||
|
id: undefined,
|
||||||
|
raw: "---\ntitle: x\n---\n",
|
||||||
|
});
|
||||||
|
expect(result).toEqual(err(appError(AppErrorVariant.NO_ID)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing raw", async () => {
|
||||||
|
const result = await saveTaskRaw(repo, {
|
||||||
|
folderPath: folder,
|
||||||
|
id: "x",
|
||||||
|
raw: undefined,
|
||||||
|
});
|
||||||
|
expect(result).toEqual(err(appError(AppErrorVariant.NO_RAW)));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { errAsync, okAsync, ResultAsync } from "neverthrow";
|
||||||
|
import { applyRawTaskContent } from "@/dashboard/applyRawTaskContent";
|
||||||
|
import { appError, AppError, AppErrorVariant } from "@/error";
|
||||||
|
import { Task } from "@/model/task";
|
||||||
|
import { ITaskRepository } from "@/ports";
|
||||||
|
|
||||||
|
export type SaveTaskRawInput = {
|
||||||
|
folderPath?: string;
|
||||||
|
id?: string;
|
||||||
|
raw?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function saveTaskRaw(
|
||||||
|
repo: ITaskRepository,
|
||||||
|
input: SaveTaskRawInput,
|
||||||
|
): ResultAsync<Task, AppError> {
|
||||||
|
if (input.folderPath === undefined) {
|
||||||
|
return errAsync(appError(AppErrorVariant.NO_FOLDER));
|
||||||
|
}
|
||||||
|
if (input.id === undefined) {
|
||||||
|
return errAsync(appError(AppErrorVariant.NO_ID));
|
||||||
|
}
|
||||||
|
if (input.raw === undefined) {
|
||||||
|
return errAsync(appError(AppErrorVariant.NO_RAW));
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderPath = input.folderPath;
|
||||||
|
const id = input.id;
|
||||||
|
const raw = input.raw;
|
||||||
|
|
||||||
|
return ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen(
|
||||||
|
(existing) => {
|
||||||
|
if (!existing) {
|
||||||
|
return errAsync(appError(AppErrorVariant.NOT_FOUND, { id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const applied = applyRawTaskContent(raw, existing);
|
||||||
|
if (applied.isErr()) {
|
||||||
|
return errAsync(applied.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = applied.value;
|
||||||
|
return ResultAsync.fromSafePromise(repo.update(folderPath, next)).andThen(
|
||||||
|
() =>
|
||||||
|
ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen(
|
||||||
|
(updated) =>
|
||||||
|
updated
|
||||||
|
? okAsync(updated)
|
||||||
|
: errAsync(appError(AppErrorVariant.NOT_FOUND, { id })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { err } from "neverthrow";
|
||||||
|
import { appError, AppErrorVariant } from "@/error";
|
||||||
|
import { Task } from "@/model/task";
|
||||||
|
import { TaskStatus } from "@/model/taskStatus";
|
||||||
|
import { serializeTask } from "@/utils/markdown";
|
||||||
|
import { applyRawTaskContent } from "./applyRawTaskContent";
|
||||||
|
|
||||||
|
const previous: Task = {
|
||||||
|
id: "keep-me",
|
||||||
|
title: "Old title",
|
||||||
|
description: "old body",
|
||||||
|
status: TaskStatus.TODO,
|
||||||
|
priority: "medium",
|
||||||
|
tags: [],
|
||||||
|
assignee: "",
|
||||||
|
created: "2026-01-01T00:00:00.000Z",
|
||||||
|
updated: "2026-01-02T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("applyRawTaskContent", () => {
|
||||||
|
it("parses raw file and applies fields", () => {
|
||||||
|
const raw = serializeTask({
|
||||||
|
...previous,
|
||||||
|
id: "ignored-id",
|
||||||
|
title: "New title",
|
||||||
|
description: "new body",
|
||||||
|
status: TaskStatus.DONE,
|
||||||
|
priority: "high",
|
||||||
|
tags: ["a"],
|
||||||
|
assignee: "bob",
|
||||||
|
created: "2099-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = applyRawTaskContent(raw, previous);
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
if (result.isErr()) return;
|
||||||
|
|
||||||
|
expect(result.value.id).toBe(previous.id);
|
||||||
|
expect(result.value.created).toBe(previous.created);
|
||||||
|
expect(result.value.title).toBe("New title");
|
||||||
|
expect(result.value.description).toBe("new body");
|
||||||
|
expect(result.value.status).toBe(TaskStatus.DONE);
|
||||||
|
expect(result.value.priority).toBe("high");
|
||||||
|
expect(result.value.tags).toEqual(["a"]);
|
||||||
|
expect(result.value.assignee).toBe("bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty title", () => {
|
||||||
|
const raw = `---
|
||||||
|
id: "x"
|
||||||
|
title: " "
|
||||||
|
status: todo
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
body
|
||||||
|
`;
|
||||||
|
const result = applyRawTaskContent(raw, previous);
|
||||||
|
expect(result).toEqual(err(appError(AppErrorVariant.EMPTY_TITLE)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unparseable content", () => {
|
||||||
|
// Invalid YAML inside frontmatter — front-matter / yaml throws.
|
||||||
|
const raw = "---\n:\n---\n";
|
||||||
|
const result = applyRawTaskContent(raw, previous);
|
||||||
|
expect(result.isErr()).toBe(true);
|
||||||
|
if (result.isOk()) return;
|
||||||
|
expect(result.error.variant).toBe(AppErrorVariant.INVALID_TASK_FILE);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { err, ok, Result } from "neverthrow";
|
||||||
|
import { appError, AppError, AppErrorVariant } from "@/error";
|
||||||
|
import { Task } from "@/model/task";
|
||||||
|
import { parseTaskFile } from "@/utils/markdown";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse full task file text into a Task, keeping stable id/created from previous.
|
||||||
|
*/
|
||||||
|
export function applyRawTaskContent(
|
||||||
|
raw: string,
|
||||||
|
previous: Task,
|
||||||
|
): Result<Task, AppError> {
|
||||||
|
let parsed: Task;
|
||||||
|
try {
|
||||||
|
parsed = parseTaskFile(raw);
|
||||||
|
} catch (cause) {
|
||||||
|
const detail =
|
||||||
|
cause instanceof Error ? cause.message : "unable to parse markdown";
|
||||||
|
return err(appError(AppErrorVariant.INVALID_TASK_FILE, { detail }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = parsed.title?.trim() ?? "";
|
||||||
|
if (!title) {
|
||||||
|
return err(appError(AppErrorVariant.EMPTY_TITLE));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
...parsed,
|
||||||
|
title,
|
||||||
|
id: previous.id,
|
||||||
|
created: previous.created,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -44,20 +44,13 @@ describe("parseDashboardInboundMessage", () => {
|
|||||||
).toEqual(ok({ variant: "setGroupBy", groupBy: GroupBy.STATUS }));
|
).toEqual(ok({ variant: "setGroupBy", groupBy: GroupBy.STATUS }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses saveDescription", () => {
|
it("parses editTask", () => {
|
||||||
expect(
|
expect(
|
||||||
parseDashboardInboundMessage({
|
parseDashboardInboundMessage({
|
||||||
variant: "saveDescription",
|
variant: "editTask",
|
||||||
id: "abc",
|
id: "abc",
|
||||||
description: "updated body",
|
|
||||||
}),
|
}),
|
||||||
).toEqual(
|
).toEqual(ok({ variant: "editTask", id: "abc" }));
|
||||||
ok({
|
|
||||||
variant: "saveDescription",
|
|
||||||
id: "abc",
|
|
||||||
description: "updated body",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses refresh", () => {
|
it("parses refresh", () => {
|
||||||
@@ -137,12 +130,8 @@ describe("parseDashboardInboundMessage", () => {
|
|||||||
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects saveDescription with non-string description", () => {
|
it("rejects editTask without id", () => {
|
||||||
const result = parseDashboardInboundMessage({
|
const result = parseDashboardInboundMessage({ variant: "editTask" });
|
||||||
variant: "saveDescription",
|
|
||||||
id: "abc",
|
|
||||||
description: 42,
|
|
||||||
});
|
|
||||||
expect(result.isErr()).toBe(true);
|
expect(result.isErr()).toBe(true);
|
||||||
if (result.isOk()) return;
|
if (result.isOk()) return;
|
||||||
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
||||||
@@ -157,4 +146,25 @@ describe("parseDashboardInboundMessage", () => {
|
|||||||
if (result.isOk()) return;
|
if (result.isOk()) return;
|
||||||
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects saveDescription (view-only dashboard)", () => {
|
||||||
|
const result = parseDashboardInboundMessage({
|
||||||
|
variant: "saveDescription",
|
||||||
|
id: "abc",
|
||||||
|
description: "x",
|
||||||
|
});
|
||||||
|
expect(result.isErr()).toBe(true);
|
||||||
|
if (result.isOk()) return;
|
||||||
|
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects setDetailMode (no raw mode)", () => {
|
||||||
|
const result = parseDashboardInboundMessage({
|
||||||
|
variant: "setDetailMode",
|
||||||
|
mode: "raw",
|
||||||
|
});
|
||||||
|
expect(result.isErr()).toBe(true);
|
||||||
|
if (result.isOk()) return;
|
||||||
|
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export type DashboardInboundMessage =
|
|||||||
| { variant: "selectTask"; id: string }
|
| { variant: "selectTask"; id: string }
|
||||||
| { variant: "setFilter"; filter: TaskFilter }
|
| { variant: "setFilter"; filter: TaskFilter }
|
||||||
| { variant: "setGroupBy"; groupBy: GroupBy }
|
| { variant: "setGroupBy"; groupBy: GroupBy }
|
||||||
| { variant: "saveDescription"; id: string; description: string }
|
| { variant: "editTask"; id: string }
|
||||||
| { variant: "refresh" }
|
| { variant: "refresh" }
|
||||||
| { variant: "createTask" }
|
| { variant: "createTask" }
|
||||||
| { variant: "setScopeFilter"; scope: DashboardScopeFilter }
|
| { variant: "setScopeFilter"; scope: DashboardScopeFilter }
|
||||||
@@ -78,18 +78,11 @@ export function parseDashboardInboundMessage(
|
|||||||
return ok({ variant: "setGroupBy", groupBy: raw.groupBy });
|
return ok({ variant: "setGroupBy", groupBy: raw.groupBy });
|
||||||
}
|
}
|
||||||
|
|
||||||
case "saveDescription": {
|
case "editTask": {
|
||||||
if (typeof raw.id !== "string" || raw.id.length === 0) {
|
if (typeof raw.id !== "string" || raw.id.length === 0) {
|
||||||
return invalid(variant);
|
return invalid(variant);
|
||||||
}
|
}
|
||||||
if (typeof raw.description !== "string") {
|
return ok({ variant: "editTask", id: raw.id });
|
||||||
return invalid(variant);
|
|
||||||
}
|
|
||||||
return ok({
|
|
||||||
variant: "saveDescription",
|
|
||||||
id: raw.id,
|
|
||||||
description: raw.description,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "setScopeFilter": {
|
case "setScopeFilter": {
|
||||||
|
|||||||
@@ -30,5 +30,4 @@ export type DashboardOutboundMessage =
|
|||||||
sortDirection: TaskSortDirection;
|
sortDirection: TaskSortDirection;
|
||||||
availableScopes: TaskScope[];
|
availableScopes: TaskScope[];
|
||||||
}
|
}
|
||||||
| { variant: "error"; error: AppError }
|
| { variant: "error"; error: AppError };
|
||||||
| { variant: "saved"; task: Task };
|
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ export const AppErrorVariant = {
|
|||||||
NO_ID: "no-id",
|
NO_ID: "no-id",
|
||||||
NO_STATUS: "no-status",
|
NO_STATUS: "no-status",
|
||||||
NO_DESCRIPTION: "no-description",
|
NO_DESCRIPTION: "no-description",
|
||||||
|
NO_RAW: "no-raw",
|
||||||
EMPTY_TITLE: "empty-title",
|
EMPTY_TITLE: "empty-title",
|
||||||
NOT_FOUND: "not-found",
|
NOT_FOUND: "not-found",
|
||||||
INVALID_MESSAGE: "invalid-message",
|
INVALID_MESSAGE: "invalid-message",
|
||||||
|
INVALID_TASK_FILE: "invalid-task-file",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AppErrorVariant =
|
export type AppErrorVariant =
|
||||||
@@ -24,9 +26,11 @@ const APP_ERROR_MESSAGES: Record<AppErrorVariant, string> = {
|
|||||||
[AppErrorVariant.NO_ID]: "Task id is required",
|
[AppErrorVariant.NO_ID]: "Task id is required",
|
||||||
[AppErrorVariant.NO_STATUS]: "Task status is required",
|
[AppErrorVariant.NO_STATUS]: "Task status is required",
|
||||||
[AppErrorVariant.NO_DESCRIPTION]: "Task description is required",
|
[AppErrorVariant.NO_DESCRIPTION]: "Task description is required",
|
||||||
|
[AppErrorVariant.NO_RAW]: "Task raw content is required",
|
||||||
[AppErrorVariant.EMPTY_TITLE]: "Task title is empty",
|
[AppErrorVariant.EMPTY_TITLE]: "Task title is empty",
|
||||||
[AppErrorVariant.NOT_FOUND]: "Task not found: {id}",
|
[AppErrorVariant.NOT_FOUND]: "Task not found: {id}",
|
||||||
[AppErrorVariant.INVALID_MESSAGE]: "Invalid message: {detail}",
|
[AppErrorVariant.INVALID_MESSAGE]: "Invalid message: {detail}",
|
||||||
|
[AppErrorVariant.INVALID_TASK_FILE]: "Invalid task file: {detail}",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatMessage(
|
export function formatMessage(
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function sortDirectionOptionsHtml(): string {
|
|||||||
).join("");
|
).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal split-layout document for the Task Dashboard webview. */
|
/** List + read-only detail document for the Task Dashboard webview. */
|
||||||
export function getTaskDashboardHtml(
|
export function getTaskDashboardHtml(
|
||||||
webview: vscode.Webview,
|
webview: vscode.Webview,
|
||||||
nonce: string,
|
nonce: string,
|
||||||
@@ -106,7 +106,7 @@ export function getTaskDashboardHtml(
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
input[type="search"], select, textarea, button {
|
input[type="search"], select, button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: var(--vscode-input-foreground);
|
color: var(--vscode-input-foreground);
|
||||||
background: var(--vscode-input-background);
|
background: var(--vscode-input-background);
|
||||||
@@ -212,19 +212,30 @@ export function getTaskDashboardHtml(
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 160px;
|
min-height: 160px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
resize: vertical;
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: var(--vscode-editor-font-family, var(--vscode-font-family));
|
||||||
|
font-size: var(--vscode-editor-font-size, inherit);
|
||||||
|
background: var(--vscode-textCodeBlock-background, var(--vscode-input-background));
|
||||||
|
border: 1px solid var(--vscode-input-border, transparent);
|
||||||
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
#status-line {
|
#status-line {
|
||||||
min-height: 1.2em;
|
min-height: 1.2em;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
#status-line.error { color: var(--vscode-errorForeground); }
|
#status-line.error { color: var(--vscode-errorForeground); }
|
||||||
</style>
|
</style>
|
||||||
@@ -263,10 +274,10 @@ export function getTaskDashboardHtml(
|
|||||||
<div id="detail">
|
<div id="detail">
|
||||||
<h1 id="detail-title"></h1>
|
<h1 id="detail-title"></h1>
|
||||||
<div id="detail-meta"></div>
|
<div id="detail-meta"></div>
|
||||||
<textarea id="description" spellcheck="true" placeholder="Task description (markdown)…"></textarea>
|
<pre id="description"></pre>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<span id="status-line"></span>
|
<span id="status-line"></span>
|
||||||
<button id="save" type="button">Save</button>
|
<button id="edit" type="button">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -289,7 +300,6 @@ export function getTaskDashboardHtml(
|
|||||||
selectedId: undefined,
|
selectedId: undefined,
|
||||||
groups: [],
|
groups: [],
|
||||||
selected: null,
|
selected: null,
|
||||||
dirty: false,
|
|
||||||
scopeFilter: "all",
|
scopeFilter: "all",
|
||||||
sortDirection: SORT_ASC,
|
sortDirection: SORT_ASC,
|
||||||
availableScopes: [],
|
availableScopes: [],
|
||||||
@@ -311,7 +321,7 @@ export function getTaskDashboardHtml(
|
|||||||
title: document.getElementById("detail-title"),
|
title: document.getElementById("detail-title"),
|
||||||
meta: document.getElementById("detail-meta"),
|
meta: document.getElementById("detail-meta"),
|
||||||
description: document.getElementById("description"),
|
description: document.getElementById("description"),
|
||||||
save: document.getElementById("save"),
|
edit: document.getElementById("edit"),
|
||||||
statusLine: document.getElementById("status-line"),
|
statusLine: document.getElementById("status-line"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -399,14 +409,6 @@ export function getTaskDashboardHtml(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveDescription() {
|
|
||||||
if (!state.selected) return;
|
|
||||||
post({ variant: "saveDescription",
|
|
||||||
id: state.selected.id,
|
|
||||||
description: el.description.value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
state.filter = {};
|
state.filter = {};
|
||||||
state.groupBy = GROUP_BY_NONE;
|
state.groupBy = GROUP_BY_NONE;
|
||||||
@@ -447,11 +449,6 @@ export function getTaskDashboardHtml(
|
|||||||
escapeHtml(task.priority) +
|
escapeHtml(task.priority) +
|
||||||
"</div>";
|
"</div>";
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
if (state.dirty) {
|
|
||||||
const okLeave = window.confirm("Discard unsaved description changes?");
|
|
||||||
if (!okLeave) return;
|
|
||||||
state.dirty = false;
|
|
||||||
}
|
|
||||||
post({ variant: "selectTask", id: task.id });
|
post({ variant: "selectTask", id: task.id });
|
||||||
});
|
});
|
||||||
el.list.appendChild(btn);
|
el.list.appendChild(btn);
|
||||||
@@ -477,9 +474,7 @@ export function getTaskDashboardHtml(
|
|||||||
task.priority +
|
task.priority +
|
||||||
(task.tags && task.tags.length ? " · " + task.tags.join(", ") : "") +
|
(task.tags && task.tags.length ? " · " + task.tags.join(", ") : "") +
|
||||||
(task.assignee ? " · @" + task.assignee : "");
|
(task.assignee ? " · @" + task.assignee : "");
|
||||||
if (!state.dirty) {
|
el.description.textContent = task.description || "";
|
||||||
el.description.value = task.description || "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
@@ -499,15 +494,14 @@ export function getTaskDashboardHtml(
|
|||||||
state.scopeFilter = message.scopeFilter || "all";
|
state.scopeFilter = message.scopeFilter || "all";
|
||||||
state.sortDirection = message.sortDirection || SORT_ASC;
|
state.sortDirection = message.sortDirection || SORT_ASC;
|
||||||
state.availableScopes = message.availableScopes || [];
|
state.availableScopes = message.availableScopes || [];
|
||||||
state.dirty = false;
|
|
||||||
el.query.value = state.filter.query || "";
|
el.query.value = state.filter.query || "";
|
||||||
el.groupBy.value = state.groupBy;
|
el.groupBy.value = state.groupBy;
|
||||||
el.sortDirection.value = state.sortDirection;
|
el.sortDirection.value = state.sortDirection;
|
||||||
renderScopeFilter();
|
renderScopeFilter();
|
||||||
renderFilterChips();
|
renderFilterChips();
|
||||||
renderList();
|
renderList();
|
||||||
renderDetail();
|
|
||||||
setStatus("");
|
setStatus("");
|
||||||
|
renderDetail();
|
||||||
}
|
}
|
||||||
|
|
||||||
let queryTimer;
|
let queryTimer;
|
||||||
@@ -533,7 +527,6 @@ export function getTaskDashboardHtml(
|
|||||||
});
|
});
|
||||||
|
|
||||||
el.refresh.addEventListener("click", () => {
|
el.refresh.addEventListener("click", () => {
|
||||||
state.dirty = false;
|
|
||||||
post({ variant: "refresh" });
|
post({ variant: "refresh" });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -545,20 +538,9 @@ export function getTaskDashboardHtml(
|
|||||||
resetFilters();
|
resetFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
el.description.addEventListener("input", () => {
|
el.edit.addEventListener("click", () => {
|
||||||
state.dirty = true;
|
if (!state.selected) return;
|
||||||
setStatus("Unsaved changes");
|
post({ variant: "editTask", id: state.selected.id });
|
||||||
});
|
|
||||||
|
|
||||||
el.save.addEventListener("click", () => {
|
|
||||||
saveDescription();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("keydown", (event) => {
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
|
|
||||||
event.preventDefault();
|
|
||||||
saveDescription();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("message", (event) => {
|
window.addEventListener("message", (event) => {
|
||||||
@@ -568,15 +550,6 @@ export function getTaskDashboardHtml(
|
|||||||
applyState(message);
|
applyState(message);
|
||||||
} else if (message.variant === "error") {
|
} else if (message.variant === "error") {
|
||||||
setStatus((message.error && message.error.message) || "Error", true);
|
setStatus((message.error && message.error.message) || "Error", true);
|
||||||
} else if (message.variant === "saved") {
|
|
||||||
state.dirty = false;
|
|
||||||
if (state.selected && state.selected.id === message.task.id) {
|
|
||||||
state.selected = Object.assign({}, message.task, {
|
|
||||||
scope: state.selected.scope,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setStatus("Saved");
|
|
||||||
renderDetail();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import { listLocatedTasks } from "@/commands/listLocatedTasks";
|
import { listLocatedTasks } from "@/commands/listLocatedTasks";
|
||||||
|
import { openTask } from "@/commands/openTask";
|
||||||
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
|
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
|
||||||
import { saveTaskDescription } from "@/commands/saveTaskDescription";
|
|
||||||
import { buildDashboardView } from "@/dashboard/buildDashboardView";
|
import { buildDashboardView } from "@/dashboard/buildDashboardView";
|
||||||
import { filterLocatedByScope } from "@/dashboard/filterLocatedByScope";
|
import { filterLocatedByScope } from "@/dashboard/filterLocatedByScope";
|
||||||
import { TaskFilter } from "@/dashboard/filterTasks";
|
import { TaskFilter } from "@/dashboard/filterTasks";
|
||||||
@@ -25,7 +25,7 @@ import { createNonce, getTaskDashboardHtml } from "./taskDashboardHtml";
|
|||||||
export type TaskDashboardDeps = {
|
export type TaskDashboardDeps = {
|
||||||
repo: ITaskRepository;
|
repo: ITaskRepository;
|
||||||
config: IConfigProvider;
|
config: IConfigProvider;
|
||||||
/** Called after dashboard mutates tasks (e.g. save description). */
|
/** Called after dashboard mutates tasks (e.g. create). */
|
||||||
onTasksMutated?: () => void;
|
onTasksMutated?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,10 +34,7 @@ export type TaskDashboardShowOptions = {
|
|||||||
selectedId?: string;
|
selectedId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Host adapter for the Task Dashboard webview. */
|
||||||
* Host adapter for the Task Dashboard webview.
|
|
||||||
* Owns panel lifecycle and message wiring; business rules stay in use-cases.
|
|
||||||
*/
|
|
||||||
export class TaskDashboardPanel {
|
export class TaskDashboardPanel {
|
||||||
static #current: TaskDashboardPanel | undefined;
|
static #current: TaskDashboardPanel | undefined;
|
||||||
|
|
||||||
@@ -176,18 +173,21 @@ export class TaskDashboardPanel {
|
|||||||
this.#postState();
|
this.#postState();
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case "saveDescription":
|
case "editTask":
|
||||||
await this.#saveDescription(message.id, message.description);
|
this.#selectedId = message.id;
|
||||||
|
this.#postState();
|
||||||
|
await this.#openTaskInEditor(message.id);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case "createTask":
|
case "createTask":
|
||||||
await vscode.commands.executeCommand("xboctukFlow.createTask");
|
await vscode.commands.executeCommand("xboctukFlow.createTask");
|
||||||
await this.reloadTasks();
|
await this.reloadTasks();
|
||||||
|
this.#deps.onTasksMutated?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async #saveDescription(id: string, description: string): Promise<void> {
|
async #openTaskInEditor(id: string): Promise<void> {
|
||||||
const located = this.#tasks.find((item) => item.task.id === id);
|
const located = this.#tasks.find((item) => item.task.id === id);
|
||||||
if (!located) {
|
if (!located) {
|
||||||
this.#post({
|
this.#post({
|
||||||
@@ -197,27 +197,20 @@ export class TaskDashboardPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await saveTaskDescription(this.#deps.repo, {
|
const result = await openTask(this.#deps.repo, {
|
||||||
folderPath: located.location.folderPath,
|
folderPath: located.location.folderPath,
|
||||||
id,
|
id: located.task.id,
|
||||||
description,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
this.#post({ variant: "error", error: result.error });
|
this.#post({ variant: "error", error: result.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const saved = result.value;
|
const uri = vscode.Uri.file(result.value.path);
|
||||||
this.#tasks = this.#tasks.map((item) =>
|
await vscode.window.showTextDocument(uri, {
|
||||||
item.task.id === saved.id
|
preview: false,
|
||||||
? { task: saved, location: item.location }
|
preserveFocus: false,
|
||||||
: item,
|
});
|
||||||
);
|
|
||||||
this.#selectedId = saved.id;
|
|
||||||
this.#post({ variant: "saved", task: saved });
|
|
||||||
this.#postState();
|
|
||||||
this.#deps.onTasksMutated?.();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#scopeById(): Map<string, TaskScope> {
|
#scopeById(): Map<string, TaskScope> {
|
||||||
|
|||||||
@@ -2,31 +2,10 @@
|
|||||||
|
|
||||||
## Dashboard
|
## Dashboard
|
||||||
|
|
||||||
[x] **Сохранение по Ctrl+S**
|
[x] **Редактирование в editor (не Save в webview)**
|
||||||
|
|
||||||
- В detail (textarea description) — hotkey `Ctrl+S` / `Cmd+S` → тот же flow, что
|
- Dashboard view-only; Edit → файл в editor → Ctrl+S / format-on-save как обычно
|
||||||
кнопка Save (`saveDescription`).
|
в VS Code.
|
||||||
|
|
||||||
[ ] **Форматирование при сохранении (как в 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 не даст.
|
|
||||||
|
|
||||||
[x] **Кнопка сброса фильтров**
|
[x] **Кнопка сброса фильтров**
|
||||||
|
|
||||||
@@ -37,18 +16,18 @@ headless/тестов можно оставить; editor-save — optional path
|
|||||||
|
|
||||||
- Chips: critical / high / medium / low → `filter.priorities`.
|
- Chips: critical / high / medium / low → `filter.priorities`.
|
||||||
|
|
||||||
[ ] **Dashboard: raw MD ↔ view**
|
[x] **Dashboard: view-only + Edit в editor**
|
||||||
|
|
||||||
- В detail переключатель режимов: **view** (текущий UI: meta + textarea body) и
|
- **View:** meta + body (read-only) в webview.
|
||||||
**raw** (весь файл как markdown + frontmatter, как в editor).
|
- **Edit:** кнопка → `openTask` + `showTextDocument` — полный `.task.md` в
|
||||||
- Редактирование/save в raw — отдельный контракт (parse → task / serialize), не
|
editor.
|
||||||
ломать format-on-save историю: raw-save тоже лучше через editor path, если
|
- Use-case `saveTaskRaw` / `applyRawTaskContent` / `saveTaskDescription`
|
||||||
нужен паритет с VS Code.
|
остаются для non-webview path.
|
||||||
|
|
||||||
[x] **Боковая панель**
|
[x] **Боковая панель**
|
||||||
|
|
||||||
- кнопка открытия dashboard (view title → `xboctukFlow.openDashboard`)
|
- кнопка открытия dashboard (view title → `xboctukFlow.openDashboard`)
|
||||||
- кнопки на задаче (inline): open file, edit in dashboard
|
- кнопки на задаче (inline): open file, open in dashboard
|
||||||
(`xboctukFlow.openInDashboard` + preselect), delete
|
(`xboctukFlow.openInDashboard` + preselect), delete
|
||||||
- change status остаётся в context menu
|
- change status остаётся в context menu
|
||||||
|
|
||||||
@@ -75,3 +54,7 @@ headless/тестов можно оставить; editor-save — optional path
|
|||||||
- в списке и detail — scope (Project / Global)
|
- в списке и detail — scope (Project / Global)
|
||||||
- сортировка по **created**: по умолчанию старые сверху (ASC), toggle Newest
|
- сортировка по **created**: по умолчанию старые сверху (ASC), toggle Newest
|
||||||
first
|
first
|
||||||
|
|
||||||
|
[ ] **Боковая панель**
|
||||||
|
|
||||||
|
- при изменении настроек должна обновляться (или добавить кнопку refresh)
|
||||||
|
|||||||
Reference in New Issue
Block a user