diff --git a/README.md b/README.md index 1656645..74debd9 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`): | **XBOCTuK Flow: Change Status** | Change status | | **XBOCTuK Flow: Delete Task** | Delete task | | **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 | 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 - Scope: All / Project / Global - 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 ## Kanban diff --git a/README_ru.md b/README_ru.md index 45a819c..61bf1ed 100644 --- a/README_ru.md +++ b/README_ru.md @@ -88,7 +88,7 @@ Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`): | **XBOCTuK Flow: Change Status** | Сменить статус | | **XBOCTuK Flow: Delete Task** | Удалить задачу | | **XBOCTuK Flow: Open Dashboard** | Dashboard (список + body) | -| **XBOCTuK Flow: Edit in Dashboard** | Dashboard с выбранной задачей | +| **XBOCTuK Flow: Open in Dashboard** | Dashboard с выбранной задачей | | **XBOCTuK Flow: Open Kanban** | Kanban board | В 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 - Scope: All / Project / Global - Sort by **created**: Oldest first (default) / Newest first -- Save description (кнопка / `Ctrl+S` / `Cmd+S`) +- Detail только просмотр; **Edit** открывает файл задачи в editor - `+ Task` — create ## Kanban diff --git a/package.json b/package.json index dfcd0a0..d9686fd 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ }, { "command": "xboctukFlow.openInDashboard", - "title": "XBOCTuK Flow: Edit in Dashboard", + "title": "XBOCTuK Flow: Open in Dashboard", "icon": "$(edit)" }, { diff --git a/src/commands/saveTaskRaw.test.ts b/src/commands/saveTaskRaw.test.ts new file mode 100644 index 0000000..08ef79f --- /dev/null +++ b/src/commands/saveTaskRaw.test.ts @@ -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))); + }); +}); diff --git a/src/commands/saveTaskRaw.ts b/src/commands/saveTaskRaw.ts new file mode 100644 index 0000000..172457f --- /dev/null +++ b/src/commands/saveTaskRaw.ts @@ -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 { + 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 })), + ), + ); + }, + ); +} diff --git a/src/dashboard/applyRawTaskContent.test.ts b/src/dashboard/applyRawTaskContent.test.ts new file mode 100644 index 0000000..bad3b74 --- /dev/null +++ b/src/dashboard/applyRawTaskContent.test.ts @@ -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); + }); +}); diff --git a/src/dashboard/applyRawTaskContent.ts b/src/dashboard/applyRawTaskContent.ts new file mode 100644 index 0000000..4b25c5b --- /dev/null +++ b/src/dashboard/applyRawTaskContent.ts @@ -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 { + 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, + }); +} diff --git a/src/dashboard/messages.test.ts b/src/dashboard/messages.test.ts index cda85fb..2b06d86 100644 --- a/src/dashboard/messages.test.ts +++ b/src/dashboard/messages.test.ts @@ -44,20 +44,13 @@ describe("parseDashboardInboundMessage", () => { ).toEqual(ok({ variant: "setGroupBy", groupBy: GroupBy.STATUS })); }); - it("parses saveDescription", () => { + it("parses editTask", () => { expect( parseDashboardInboundMessage({ - variant: "saveDescription", + variant: "editTask", id: "abc", - description: "updated body", }), - ).toEqual( - ok({ - variant: "saveDescription", - id: "abc", - description: "updated body", - }), - ); + ).toEqual(ok({ variant: "editTask", id: "abc" })); }); it("parses refresh", () => { @@ -137,12 +130,8 @@ describe("parseDashboardInboundMessage", () => { expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); }); - it("rejects saveDescription with non-string description", () => { - const result = parseDashboardInboundMessage({ - variant: "saveDescription", - id: "abc", - description: 42, - }); + it("rejects editTask without id", () => { + const result = parseDashboardInboundMessage({ variant: "editTask" }); expect(result.isErr()).toBe(true); if (result.isOk()) return; expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); @@ -157,4 +146,25 @@ describe("parseDashboardInboundMessage", () => { if (result.isOk()) return; 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); + }); }); diff --git a/src/dashboard/messages.ts b/src/dashboard/messages.ts index 1427949..2e9c3c8 100644 --- a/src/dashboard/messages.ts +++ b/src/dashboard/messages.ts @@ -18,7 +18,7 @@ export type DashboardInboundMessage = | { variant: "selectTask"; id: string } | { variant: "setFilter"; filter: TaskFilter } | { variant: "setGroupBy"; groupBy: GroupBy } - | { variant: "saveDescription"; id: string; description: string } + | { variant: "editTask"; id: string } | { variant: "refresh" } | { variant: "createTask" } | { variant: "setScopeFilter"; scope: DashboardScopeFilter } @@ -78,18 +78,11 @@ export function parseDashboardInboundMessage( return ok({ variant: "setGroupBy", groupBy: raw.groupBy }); } - case "saveDescription": { + case "editTask": { if (typeof raw.id !== "string" || raw.id.length === 0) { return invalid(variant); } - if (typeof raw.description !== "string") { - return invalid(variant); - } - return ok({ - variant: "saveDescription", - id: raw.id, - description: raw.description, - }); + return ok({ variant: "editTask", id: raw.id }); } case "setScopeFilter": { diff --git a/src/dashboard/outboundMessages.ts b/src/dashboard/outboundMessages.ts index 969caeb..05a47fc 100644 --- a/src/dashboard/outboundMessages.ts +++ b/src/dashboard/outboundMessages.ts @@ -30,5 +30,4 @@ export type DashboardOutboundMessage = sortDirection: TaskSortDirection; availableScopes: TaskScope[]; } - | { variant: "error"; error: AppError } - | { variant: "saved"; task: Task }; + | { variant: "error"; error: AppError }; diff --git a/src/error.ts b/src/error.ts index f7547d1..f982780 100644 --- a/src/error.ts +++ b/src/error.ts @@ -3,9 +3,11 @@ export const AppErrorVariant = { NO_ID: "no-id", NO_STATUS: "no-status", NO_DESCRIPTION: "no-description", + NO_RAW: "no-raw", EMPTY_TITLE: "empty-title", NOT_FOUND: "not-found", INVALID_MESSAGE: "invalid-message", + INVALID_TASK_FILE: "invalid-task-file", } as const; export type AppErrorVariant = @@ -24,9 +26,11 @@ const APP_ERROR_MESSAGES: Record = { [AppErrorVariant.NO_ID]: "Task id is required", [AppErrorVariant.NO_STATUS]: "Task status 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.NOT_FOUND]: "Task not found: {id}", [AppErrorVariant.INVALID_MESSAGE]: "Invalid message: {detail}", + [AppErrorVariant.INVALID_TASK_FILE]: "Invalid task file: {detail}", }; export function formatMessage( diff --git a/src/views/taskDashboardHtml.ts b/src/views/taskDashboardHtml.ts index 28273cb..bc9cdb1 100644 --- a/src/views/taskDashboardHtml.ts +++ b/src/views/taskDashboardHtml.ts @@ -47,7 +47,7 @@ function sortDirectionOptionsHtml(): string { ).join(""); } -/** Minimal split-layout document for the Task Dashboard webview. */ +/** List + read-only detail document for the Task Dashboard webview. */ export function getTaskDashboardHtml( webview: vscode.Webview, nonce: string, @@ -106,7 +106,7 @@ export function getTaskDashboardHtml( flex-wrap: wrap; align-items: center; } - input[type="search"], select, textarea, button { + input[type="search"], select, button { font: inherit; color: var(--vscode-input-foreground); background: var(--vscode-input-background); @@ -212,19 +212,30 @@ export function getTaskDashboardHtml( flex: 1; min-height: 160px; width: 100%; - resize: vertical; + overflow: auto; + margin: 0; padding: 10px; 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 { display: flex; gap: 8px; justify-content: flex-end; + align-items: center; } #status-line { min-height: 1.2em; font-size: 0.85em; opacity: 0.85; + flex: 1; + text-align: left; } #status-line.error { color: var(--vscode-errorForeground); } @@ -263,10 +274,10 @@ export function getTaskDashboardHtml(

- +

         
- +
@@ -289,7 +300,6 @@ export function getTaskDashboardHtml( selectedId: undefined, groups: [], selected: null, - dirty: false, scopeFilter: "all", sortDirection: SORT_ASC, availableScopes: [], @@ -311,7 +321,7 @@ export function getTaskDashboardHtml( title: document.getElementById("detail-title"), meta: document.getElementById("detail-meta"), description: document.getElementById("description"), - save: document.getElementById("save"), + edit: document.getElementById("edit"), 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() { state.filter = {}; state.groupBy = GROUP_BY_NONE; @@ -447,11 +449,6 @@ export function getTaskDashboardHtml( escapeHtml(task.priority) + ""; 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 }); }); el.list.appendChild(btn); @@ -477,9 +474,7 @@ export function getTaskDashboardHtml( task.priority + (task.tags && task.tags.length ? " · " + task.tags.join(", ") : "") + (task.assignee ? " · @" + task.assignee : ""); - if (!state.dirty) { - el.description.value = task.description || ""; - } + el.description.textContent = task.description || ""; } function escapeHtml(value) { @@ -499,15 +494,14 @@ export function getTaskDashboardHtml( state.scopeFilter = message.scopeFilter || "all"; state.sortDirection = message.sortDirection || SORT_ASC; state.availableScopes = message.availableScopes || []; - state.dirty = false; el.query.value = state.filter.query || ""; el.groupBy.value = state.groupBy; el.sortDirection.value = state.sortDirection; renderScopeFilter(); renderFilterChips(); renderList(); - renderDetail(); setStatus(""); + renderDetail(); } let queryTimer; @@ -533,7 +527,6 @@ export function getTaskDashboardHtml( }); el.refresh.addEventListener("click", () => { - state.dirty = false; post({ variant: "refresh" }); }); @@ -545,20 +538,9 @@ export function getTaskDashboardHtml( resetFilters(); }); - el.description.addEventListener("input", () => { - state.dirty = true; - setStatus("Unsaved changes"); - }); - - el.save.addEventListener("click", () => { - saveDescription(); - }); - - window.addEventListener("keydown", (event) => { - if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { - event.preventDefault(); - saveDescription(); - } + el.edit.addEventListener("click", () => { + if (!state.selected) return; + post({ variant: "editTask", id: state.selected.id }); }); window.addEventListener("message", (event) => { @@ -568,15 +550,6 @@ export function getTaskDashboardHtml( applyState(message); } else if (message.variant === "error") { 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(); } }); diff --git a/src/views/taskDashboardPanel.ts b/src/views/taskDashboardPanel.ts index f7d930e..92842d6 100644 --- a/src/views/taskDashboardPanel.ts +++ b/src/views/taskDashboardPanel.ts @@ -1,7 +1,7 @@ import * as vscode from "vscode"; import { listLocatedTasks } from "@/commands/listLocatedTasks"; +import { openTask } from "@/commands/openTask"; import { resolveTaskLocations } from "@/commands/resolveTaskLocations"; -import { saveTaskDescription } from "@/commands/saveTaskDescription"; import { buildDashboardView } from "@/dashboard/buildDashboardView"; import { filterLocatedByScope } from "@/dashboard/filterLocatedByScope"; import { TaskFilter } from "@/dashboard/filterTasks"; @@ -25,7 +25,7 @@ import { createNonce, getTaskDashboardHtml } from "./taskDashboardHtml"; export type TaskDashboardDeps = { repo: ITaskRepository; config: IConfigProvider; - /** Called after dashboard mutates tasks (e.g. save description). */ + /** Called after dashboard mutates tasks (e.g. create). */ onTasksMutated?: () => void; }; @@ -34,10 +34,7 @@ export type TaskDashboardShowOptions = { selectedId?: string; }; -/** - * Host adapter for the Task Dashboard webview. - * Owns panel lifecycle and message wiring; business rules stay in use-cases. - */ +/** Host adapter for the Task Dashboard webview. */ export class TaskDashboardPanel { static #current: TaskDashboardPanel | undefined; @@ -176,18 +173,21 @@ export class TaskDashboardPanel { this.#postState(); return; - case "saveDescription": - await this.#saveDescription(message.id, message.description); + case "editTask": + this.#selectedId = message.id; + this.#postState(); + await this.#openTaskInEditor(message.id); return; case "createTask": await vscode.commands.executeCommand("xboctukFlow.createTask"); await this.reloadTasks(); + this.#deps.onTasksMutated?.(); return; } } - async #saveDescription(id: string, description: string): Promise { + async #openTaskInEditor(id: string): Promise { const located = this.#tasks.find((item) => item.task.id === id); if (!located) { this.#post({ @@ -197,27 +197,20 @@ export class TaskDashboardPanel { return; } - const result = await saveTaskDescription(this.#deps.repo, { + const result = await openTask(this.#deps.repo, { folderPath: located.location.folderPath, - id, - description, + id: located.task.id, }); - if (result.isErr()) { this.#post({ variant: "error", error: result.error }); return; } - const saved = result.value; - this.#tasks = this.#tasks.map((item) => - item.task.id === saved.id - ? { task: saved, location: item.location } - : item, - ); - this.#selectedId = saved.id; - this.#post({ variant: "saved", task: saved }); - this.#postState(); - this.#deps.onTasksMutated?.(); + const uri = vscode.Uri.file(result.value.path); + await vscode.window.showTextDocument(uri, { + preview: false, + preserveFocus: false, + }); } #scopeById(): Map { diff --git a/todo.md b/todo.md index d16935b..0824052 100644 --- a/todo.md +++ b/todo.md @@ -2,31 +2,10 @@ ## Dashboard -[x] **Сохранение по Ctrl+S** +[x] **Редактирование в editor (не Save в webview)** -- В 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 не даст. +- Dashboard view-only; Edit → файл в editor → Ctrl+S / format-on-save как обычно + в VS Code. [x] **Кнопка сброса фильтров** @@ -37,18 +16,18 @@ headless/тестов можно оставить; editor-save — optional path - Chips: critical / high / medium / low → `filter.priorities`. -[ ] **Dashboard: raw MD ↔ view** +[x] **Dashboard: view-only + Edit в editor** -- В detail переключатель режимов: **view** (текущий UI: meta + textarea body) и - **raw** (весь файл как markdown + frontmatter, как в editor). -- Редактирование/save в raw — отдельный контракт (parse → task / serialize), не - ломать format-on-save историю: raw-save тоже лучше через editor path, если - нужен паритет с VS Code. +- **View:** meta + body (read-only) в webview. +- **Edit:** кнопка → `openTask` + `showTextDocument` — полный `.task.md` в + editor. +- Use-case `saveTaskRaw` / `applyRawTaskContent` / `saveTaskDescription` + остаются для non-webview path. [x] **Боковая панель** - кнопка открытия dashboard (view title → `xboctukFlow.openDashboard`) -- кнопки на задаче (inline): open file, edit in dashboard +- кнопки на задаче (inline): open file, open in dashboard (`xboctukFlow.openInDashboard` + preselect), delete - change status остаётся в context menu @@ -75,3 +54,7 @@ headless/тестов можно оставить; editor-save — optional path - в списке и detail — scope (Project / Global) - сортировка по **created**: по умолчанию старые сверху (ASC), toggle Newest first + +[ ] **Боковая панель** + +- при изменении настроек должна обновляться (или добавить кнопку refresh)