diff --git a/src/commands/createTask.test.ts b/src/commands/createTask.test.ts index e0e2fac..2e85852 100644 --- a/src/commands/createTask.test.ts +++ b/src/commands/createTask.test.ts @@ -2,6 +2,7 @@ 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 { createTask } from "./createTask"; @@ -58,4 +59,15 @@ describe("createTask", () => { }); expect(result).toEqual(err(appError(AppErrorVariant.NO_FOLDER))); }); + + it("creates with explicit status", async () => { + const result = await createTask(repo, { + folderPath: folder, + title: "In column", + status: TaskStatus.IN_PROGRESS, + }); + expect(result.isOk()).toBe(true); + if (result.isErr()) return; + expect(result.value.status).toBe(TaskStatus.IN_PROGRESS); + }); }); diff --git a/src/commands/createTask.ts b/src/commands/createTask.ts index b356db3..b251dad 100644 --- a/src/commands/createTask.ts +++ b/src/commands/createTask.ts @@ -2,12 +2,14 @@ import { errAsync, ResultAsync } from "neverthrow"; import { appError, AppError, AppErrorVariant } from "@/error"; import { Task } from "@/model/task"; import { TaskPriority } from "@/model/taskPriority"; -import { TaskStatus } from "@/model/taskStatus"; +import { isTaskStatus, TaskStatus } from "@/model/taskStatus"; import { ITaskRepository } from "@/ports"; export type CreateTaskInput = { folderPath?: string; title?: string; + /** Defaults to TODO when omitted. */ + status?: TaskStatus; }; export function createTask( @@ -23,11 +25,18 @@ export function createTask( return errAsync(appError(AppErrorVariant.EMPTY_TITLE)); } + const status = + input.status === undefined + ? TaskStatus.TODO + : isTaskStatus(input.status) + ? input.status + : TaskStatus.TODO; + return ResultAsync.fromSafePromise( repo.create(input.folderPath, { title, description: "", - status: TaskStatus.TODO, + status, priority: TaskPriority.MEDIUM, tags: [], assignee: "", diff --git a/src/commands/resolveTaskLocations.ts b/src/commands/resolveTaskLocations.ts index ba82087..5ee9b6c 100644 --- a/src/commands/resolveTaskLocations.ts +++ b/src/commands/resolveTaskLocations.ts @@ -1,4 +1,4 @@ -import { TaskLocation } from "@/model/taskLocation"; +import { TaskLocation, TaskScope } from "@/model/taskLocation"; import { IConfigProvider } from "@/ports"; /** Active task folders from config (project first, then global). */ @@ -7,12 +7,12 @@ export function resolveTaskLocations(config: IConfigProvider): TaskLocation[] { const projectPath = config.getProjectTaskPath(); if (projectPath) { - locations.push({ scope: "project", folderPath: projectPath }); + locations.push({ scope: TaskScope.PROJECT, folderPath: projectPath }); } const globalPath = config.getGlobalTaskPath(); if (globalPath) { - locations.push({ scope: "global", folderPath: globalPath }); + locations.push({ scope: TaskScope.GLOBAL, folderPath: globalPath }); } return locations; diff --git a/src/dashboard/messages.test.ts b/src/dashboard/messages.test.ts index 3dcafde..3811e9d 100644 --- a/src/dashboard/messages.test.ts +++ b/src/dashboard/messages.test.ts @@ -9,25 +9,23 @@ import { describe("parseDashboardInboundMessage", () => { it("parses ready", () => { - expect(parseDashboardInboundMessage({ type: "ready" })).toEqual( - ok({ type: "ready" } satisfies DashboardInboundMessage), + expect(parseDashboardInboundMessage({ variant: "ready" })).toEqual( + ok({ variant: "ready" } satisfies DashboardInboundMessage), ); }); it("parses selectTask", () => { expect( - parseDashboardInboundMessage({ type: "selectTask", id: "abc" }), - ).toEqual(ok({ type: "selectTask", id: "abc" })); + parseDashboardInboundMessage({ variant: "selectTask", id: "abc" }), + ).toEqual(ok({ variant: "selectTask", id: "abc" })); }); it("parses setFilter", () => { - const raw = { - type: "setFilter", + const raw = { variant: "setFilter", filter: { statuses: ["todo"], query: "login" }, }; expect(parseDashboardInboundMessage(raw)).toEqual( - ok({ - type: "setFilter", + ok({ variant: "setFilter", filter: { statuses: ["todo"], query: "login" }, }), ); @@ -35,23 +33,20 @@ describe("parseDashboardInboundMessage", () => { it("parses setGroupBy", () => { expect( - parseDashboardInboundMessage({ - type: "setGroupBy", + parseDashboardInboundMessage({ variant: "setGroupBy", groupBy: GroupBy.STATUS, }), - ).toEqual(ok({ type: "setGroupBy", groupBy: GroupBy.STATUS })); + ).toEqual(ok({ variant: "setGroupBy", groupBy: GroupBy.STATUS })); }); it("parses saveDescription", () => { expect( - parseDashboardInboundMessage({ - type: "saveDescription", + parseDashboardInboundMessage({ variant: "saveDescription", id: "abc", description: "updated body", }), ).toEqual( - ok({ - type: "saveDescription", + ok({ variant: "saveDescription", id: "abc", description: "updated body", }), @@ -59,14 +54,14 @@ describe("parseDashboardInboundMessage", () => { }); it("parses refresh", () => { - expect(parseDashboardInboundMessage({ type: "refresh" })).toEqual( - ok({ type: "refresh" }), + expect(parseDashboardInboundMessage({ variant: "refresh" })).toEqual( + ok({ variant: "refresh" }), ); }); it("parses createTask", () => { - expect(parseDashboardInboundMessage({ type: "createTask" })).toEqual( - ok({ type: "createTask" } satisfies DashboardInboundMessage), + expect(parseDashboardInboundMessage({ variant: "createTask" })).toEqual( + ok({ variant: "createTask" } satisfies DashboardInboundMessage), ); }); @@ -78,22 +73,21 @@ describe("parseDashboardInboundMessage", () => { }); it("rejects unknown type", () => { - const result = parseDashboardInboundMessage({ type: "explode" }); + const result = parseDashboardInboundMessage({ variant: "explode" }); expect(result.isErr()).toBe(true); if (result.isOk()) return; expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); }); it("rejects selectTask without id", () => { - const result = parseDashboardInboundMessage({ type: "selectTask" }); + const result = parseDashboardInboundMessage({ variant: "selectTask" }); expect(result.isErr()).toBe(true); if (result.isOk()) return; expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); }); it("rejects setGroupBy with invalid groupBy", () => { - const result = parseDashboardInboundMessage({ - type: "setGroupBy", + const result = parseDashboardInboundMessage({ variant: "setGroupBy", groupBy: "__not_a_group_by__", }); expect(result.isErr()).toBe(true); @@ -102,8 +96,7 @@ describe("parseDashboardInboundMessage", () => { }); it("rejects saveDescription with non-string description", () => { - const result = parseDashboardInboundMessage({ - type: "saveDescription", + const result = parseDashboardInboundMessage({ variant: "saveDescription", id: "abc", description: 42, }); diff --git a/src/dashboard/messages.ts b/src/dashboard/messages.ts index 766565d..2099940 100644 --- a/src/dashboard/messages.ts +++ b/src/dashboard/messages.ts @@ -4,13 +4,13 @@ import { TaskFilter } from "./filterTasks"; import { GroupBy, isGroupBy } from "./groupBy"; export type DashboardInboundMessage = - | { type: "ready" } - | { type: "selectTask"; id: string } - | { type: "setFilter"; filter: TaskFilter } - | { type: "setGroupBy"; groupBy: GroupBy } - | { type: "saveDescription"; id: string; description: string } - | { type: "refresh" } - | { type: "createTask" }; + | { variant: "ready" } + | { variant: "selectTask"; id: string } + | { variant: "setFilter"; filter: TaskFilter } + | { variant: "setGroupBy"; groupBy: GroupBy } + | { variant: "saveDescription"; id: string; description: string } + | { variant: "refresh" } + | { variant: "createTask" }; function invalid(detail?: string): Result { return err( @@ -31,56 +31,54 @@ export function parseDashboardInboundMessage( return invalid(); } - const type = raw.type; - if (typeof type !== "string") { + const variant = raw.variant; + if (typeof variant !== "string") { return invalid(); } - switch (type) { + switch (variant) { case "ready": case "refresh": case "createTask": - return ok({ type }); + return ok({ variant }); case "selectTask": { if (typeof raw.id !== "string" || raw.id.length === 0) { - return invalid(type); + return invalid(variant); } - return ok({ type: "selectTask", id: raw.id }); + return ok({ variant: "selectTask", id: raw.id }); } case "setFilter": { if (!isRecord(raw.filter)) { - return invalid(type); + return invalid(variant); } - return ok({ - type: "setFilter", + return ok({ variant: "setFilter", filter: raw.filter as TaskFilter, }); } case "setGroupBy": { if (!isGroupBy(raw.groupBy)) { - return invalid(type); + return invalid(variant); } - return ok({ type: "setGroupBy", groupBy: raw.groupBy }); + return ok({ variant: "setGroupBy", groupBy: raw.groupBy }); } case "saveDescription": { if (typeof raw.id !== "string" || raw.id.length === 0) { - return invalid(type); + return invalid(variant); } if (typeof raw.description !== "string") { - return invalid(type); + return invalid(variant); } - return ok({ - type: "saveDescription", + return ok({ variant: "saveDescription", id: raw.id, description: raw.description, }); } default: - return invalid(type); + return invalid(variant); } } diff --git a/src/dashboard/outboundMessages.ts b/src/dashboard/outboundMessages.ts index 1b8fc53..4f2e856 100644 --- a/src/dashboard/outboundMessages.ts +++ b/src/dashboard/outboundMessages.ts @@ -5,13 +5,12 @@ import { GroupBy, TaskGroup } from "./groupTasks"; /** Host → webview messages (presentation payload only). */ export type DashboardOutboundMessage = - | { - type: "state"; + | { variant: "state"; filter: TaskFilter; groupBy: GroupBy; selectedId: string | undefined; groups: TaskGroup[]; selected: Task | null; } - | { type: "error"; error: AppError } - | { type: "saved"; task: Task }; + | { variant: "error"; error: AppError } + | { variant: "saved"; task: Task }; diff --git a/src/kanban/messages.test.ts b/src/kanban/messages.test.ts index 59e673e..0bda6e4 100644 --- a/src/kanban/messages.test.ts +++ b/src/kanban/messages.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { ok } from "neverthrow"; import { AppErrorVariant } from "@/error"; +import { TaskScope } from "@/model/taskLocation"; +import { TaskStatus } from "@/model/taskStatus"; import { parseKanbanInboundMessage, type KanbanInboundMessage, @@ -8,33 +10,60 @@ import { describe("parseKanbanInboundMessage", () => { it("parses ready", () => { - expect(parseKanbanInboundMessage({ type: "ready" })).toEqual( - ok({ type: "ready" } satisfies KanbanInboundMessage), + expect(parseKanbanInboundMessage({ variant: "ready" })).toEqual( + ok({ variant: "ready" } satisfies KanbanInboundMessage), ); }); it("parses refresh", () => { - expect(parseKanbanInboundMessage({ type: "refresh" })).toEqual( - ok({ type: "refresh" }), + expect(parseKanbanInboundMessage({ variant: "refresh" })).toEqual( + ok({ variant: "refresh" }), ); }); it("parses moveTask", () => { expect( parseKanbanInboundMessage({ - type: "moveTask", + variant: "moveTask", id: "abc", - status: "in-progress", + status: TaskStatus.IN_PROGRESS, }), ).toEqual( ok({ - type: "moveTask", + variant: "moveTask", id: "abc", - status: "in-progress", + status: TaskStatus.IN_PROGRESS, } satisfies KanbanInboundMessage), ); }); + it("parses setScopeFilter", () => { + expect( + parseKanbanInboundMessage({ + variant: "setScopeFilter", + scope: TaskScope.GLOBAL, + }), + ).toEqual(ok({ variant: "setScopeFilter", scope: TaskScope.GLOBAL })); + expect( + parseKanbanInboundMessage({ variant: "setScopeFilter", scope: "all" }), + ).toEqual(ok({ variant: "setScopeFilter", scope: "all" })); + }); + + it("parses setShowHidden", () => { + expect( + parseKanbanInboundMessage({ variant: "setShowHidden", showHidden: true }), + ).toEqual(ok({ variant: "setShowHidden", showHidden: true })); + }); + + it("parses createTask", () => { + expect( + parseKanbanInboundMessage({ + variant: "createTask", + status: TaskStatus.BACKLOG, + }), + ).toEqual(ok({ variant: "createTask", status: TaskStatus.BACKLOG })); + }); + it("rejects non-objects", () => { const result = parseKanbanInboundMessage(null); expect(result.isErr()).toBe(true); @@ -43,7 +72,7 @@ describe("parseKanbanInboundMessage", () => { }); it("rejects unknown type", () => { - const result = parseKanbanInboundMessage({ type: "explode" }); + const result = parseKanbanInboundMessage({ variant: "explode" }); expect(result.isErr()).toBe(true); if (result.isOk()) return; expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); @@ -51,8 +80,8 @@ describe("parseKanbanInboundMessage", () => { it("rejects moveTask without id", () => { const result = parseKanbanInboundMessage({ - type: "moveTask", - status: "done", + variant: "moveTask", + status: TaskStatus.DONE, }); expect(result.isErr()).toBe(true); if (result.isOk()) return; @@ -61,7 +90,7 @@ describe("parseKanbanInboundMessage", () => { it("rejects moveTask with invalid status", () => { const result = parseKanbanInboundMessage({ - type: "moveTask", + variant: "moveTask", id: "abc", status: "__not_a_status__", }); @@ -69,4 +98,14 @@ describe("parseKanbanInboundMessage", () => { if (result.isOk()) return; expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); }); + + it("rejects setScopeFilter with invalid scope", () => { + const result = parseKanbanInboundMessage({ + variant: "setScopeFilter", + scope: "__not_a_scope__", + }); + expect(result.isErr()).toBe(true); + if (result.isOk()) return; + expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE); + }); }); diff --git a/src/kanban/messages.ts b/src/kanban/messages.ts index 969f14b..0b656af 100644 --- a/src/kanban/messages.ts +++ b/src/kanban/messages.ts @@ -1,11 +1,24 @@ import { err, ok, Result } from "neverthrow"; import { appError, AppError, AppErrorVariant } from "@/error"; +import { isTaskScope, type TaskScope } from "@/model/taskLocation"; import { isTaskStatus, type TaskStatus } from "@/model/taskStatus"; +/** Filter which scope's tasks are shown on the board. */ +export type KanbanScopeFilter = "all" | TaskScope; + export type KanbanInboundMessage = - | { type: "ready" } - | { type: "refresh" } - | { type: "moveTask"; id: string; status: TaskStatus }; + | { variant: "ready" } + | { variant: "refresh" } + | { variant: "moveTask"; id: string; status: TaskStatus } + | { variant: "setScopeFilter"; scope: KanbanScopeFilter } + | { variant: "setShowHidden"; showHidden: boolean } + | { variant: "createTask"; status: TaskStatus }; + +export function isKanbanScopeFilter( + value: unknown, +): value is KanbanScopeFilter { + return value === "all" || isTaskScope(value); +} function invalid(detail?: string): Result { return err( @@ -26,31 +39,48 @@ export function parseKanbanInboundMessage( return invalid(); } - const type = raw.type; - if (typeof type !== "string") { + const variant = raw.variant; + if (typeof variant !== "string") { return invalid(); } - switch (type) { + switch (variant) { case "ready": case "refresh": - return ok({ type }); + return ok({ variant }); case "moveTask": { if (typeof raw.id !== "string" || raw.id.length === 0) { - return invalid(type); + return invalid(variant); } if (!isTaskStatus(raw.status)) { - return invalid(type); + return invalid(variant); } - return ok({ - type: "moveTask", - id: raw.id, - status: raw.status, - }); + return ok({ variant: "moveTask", id: raw.id, status: raw.status }); + } + + case "setScopeFilter": { + if (!isKanbanScopeFilter(raw.scope)) { + return invalid(variant); + } + return ok({ variant: "setScopeFilter", scope: raw.scope }); + } + + case "setShowHidden": { + if (typeof raw.showHidden !== "boolean") { + return invalid(variant); + } + return ok({ variant: "setShowHidden", showHidden: raw.showHidden }); + } + + case "createTask": { + if (!isTaskStatus(raw.status)) { + return invalid(variant); + } + return ok({ variant: "createTask", status: raw.status }); } default: - return invalid(type); + return invalid(variant); } } diff --git a/src/kanban/outboundMessages.ts b/src/kanban/outboundMessages.ts index c579d94..80dd6d3 100644 --- a/src/kanban/outboundMessages.ts +++ b/src/kanban/outboundMessages.ts @@ -1,6 +1,15 @@ import { AppError } from "@/error"; +import { TaskScope } from "@/model/taskLocation"; import { KanbanBoard } from "./buildKanbanBoard"; +import { KanbanScopeFilter } from "./messages"; /** Host → webview. */ export type KanbanOutboundMessage = - { type: "state"; board: KanbanBoard } | { type: "error"; error: AppError }; + | { + variant: "state"; + board: KanbanBoard; + scopeFilter: KanbanScopeFilter; + showHidden: boolean; + availableScopes: TaskScope[]; + } + | { variant: "error"; error: AppError }; diff --git a/src/model/taskLocation.ts b/src/model/taskLocation.ts index 9a53f35..7bb2740 100644 --- a/src/model/taskLocation.ts +++ b/src/model/taskLocation.ts @@ -1,6 +1,22 @@ import { Task } from "./task"; -export type TaskScope = "project" | "global"; +export const TaskScope = { + PROJECT: "project", + GLOBAL: "global", +} as const; + +export type TaskScope = (typeof TaskScope)[keyof typeof TaskScope]; + +export const TASK_SCOPE_ORDER: TaskScope[] = [ + TaskScope.PROJECT, + TaskScope.GLOBAL, +]; + +export function isTaskScope(value: unknown): value is TaskScope { + return ( + typeof value === "string" && (TASK_SCOPE_ORDER as string[]).includes(value) + ); +} /** Where a task lives on disk (runtime; not stored in frontmatter). */ export type TaskLocation = { diff --git a/src/ui/taskLabels.ts b/src/ui/taskLabels.ts index 4befb16..190a1a5 100644 --- a/src/ui/taskLabels.ts +++ b/src/ui/taskLabels.ts @@ -1,3 +1,4 @@ +import { TaskScope } from "@/model/taskLocation"; import { TASK_PRIORITY_META, TASK_PRIORITY_ORDER, @@ -8,7 +9,6 @@ import { TASK_STATUS_ORDER, type TaskStatus, } from "@/model/taskStatus"; -import { TaskScope } from "@/model/taskLocation"; export { TASK_STATUS_ORDER, TASK_STATUS_META } from "@/model/taskStatus"; export { TASK_PRIORITY_ORDER, TASK_PRIORITY_META } from "@/model/taskPriority"; @@ -27,6 +27,6 @@ export const TASK_PRIORITY_LABELS: Record = ) as Record; export const TASK_SCOPE_LABELS: Record = { - project: "Project", - global: "Global", + [TaskScope.PROJECT]: "Project", + [TaskScope.GLOBAL]: "Global", }; diff --git a/src/views/taskDashboardHtml.ts b/src/views/taskDashboardHtml.ts index 545b0ba..5c84566 100644 --- a/src/views/taskDashboardHtml.ts +++ b/src/views/taskDashboardHtml.ts @@ -300,7 +300,7 @@ export function getTaskDashboardHtml( } function postFilter() { - post({ type: "setFilter", filter: normalizeFilter(state.filter) }); + post({ variant: "setFilter", filter: normalizeFilter(state.filter) }); } function renderChipRow(container, options, selectedValues, key) { @@ -341,8 +341,7 @@ export function getTaskDashboardHtml( function saveDescription() { if (!state.selected) return; - post({ - type: "saveDescription", + post({ variant: "saveDescription", id: state.selected.id, description: el.description.value, }); @@ -354,8 +353,8 @@ export function getTaskDashboardHtml( el.query.value = ""; el.groupBy.value = GROUP_BY_NONE; renderFilterChips(); - post({ type: "setFilter", filter: {} }); - post({ type: "setGroupBy", groupBy: GROUP_BY_NONE }); + post({ variant: "setFilter", filter: {} }); + post({ variant: "setGroupBy", groupBy: GROUP_BY_NONE }); } function renderList() { @@ -387,7 +386,7 @@ export function getTaskDashboardHtml( if (!okLeave) return; state.dirty = false; } - post({ type: "selectTask", id: task.id }); + post({ variant: "selectTask", id: task.id }); }); el.list.appendChild(btn); } @@ -447,16 +446,16 @@ export function getTaskDashboardHtml( }); el.groupBy.addEventListener("change", () => { - post({ type: "setGroupBy", groupBy: el.groupBy.value }); + post({ variant: "setGroupBy", groupBy: el.groupBy.value }); }); el.refresh.addEventListener("click", () => { state.dirty = false; - post({ type: "refresh" }); + post({ variant: "refresh" }); }); el.createTask.addEventListener("click", () => { - post({ type: "createTask" }); + post({ variant: "createTask" }); }); el.resetFilters.addEventListener("click", () => { @@ -482,11 +481,11 @@ export function getTaskDashboardHtml( window.addEventListener("message", (event) => { const message = event.data; if (!message || typeof message !== "object") return; - if (message.type === "state") { + if (message.variant === "state") { applyState(message); - } else if (message.type === "error") { + } else if (message.variant === "error") { setStatus((message.error && message.error.message) || "Error", true); - } else if (message.type === "saved") { + } else if (message.variant === "saved") { state.dirty = false; state.selected = message.task; setStatus("Saved"); @@ -495,7 +494,7 @@ export function getTaskDashboardHtml( }); renderFilterChips(); - post({ type: "ready" }); + post({ variant: "ready" }); `; diff --git a/src/views/taskDashboardPanel.ts b/src/views/taskDashboardPanel.ts index 28777cd..f037a71 100644 --- a/src/views/taskDashboardPanel.ts +++ b/src/views/taskDashboardPanel.ts @@ -110,7 +110,7 @@ export class TaskDashboardPanel { const result = await listLocatedTasks(this.#deps.repo, locations); if (result.isErr()) { - this.#post({ type: "error", error: result.error }); + this.#post({ variant: "error", error: result.error }); if (result.error.variant === AppErrorVariant.NO_FOLDER) { presentError(result.error); } @@ -130,14 +130,14 @@ export class TaskDashboardPanel { async #onMessage(raw: unknown): Promise { const parsed = parseDashboardInboundMessage(raw); if (parsed.isErr()) { - this.#post({ type: "error", error: parsed.error }); + this.#post({ variant: "error", error: parsed.error }); return; } await this.#handleInbound(parsed.value); } async #handleInbound(message: DashboardInboundMessage): Promise { - switch (message.type) { + switch (message.variant) { case "ready": case "refresh": await this.reloadTasks(); @@ -172,8 +172,7 @@ export class TaskDashboardPanel { async #saveDescription(id: string, description: string): Promise { const located = this.#tasks.find((item) => item.task.id === id); if (!located) { - this.#post({ - type: "error", + this.#post({ variant: "error", error: appError(AppErrorVariant.NOT_FOUND, { id }), }); return; @@ -186,7 +185,7 @@ export class TaskDashboardPanel { }); if (result.isErr()) { - this.#post({ type: "error", error: result.error }); + this.#post({ variant: "error", error: result.error }); return; } @@ -197,7 +196,7 @@ export class TaskDashboardPanel { : item, ); this.#selectedId = saved.id; - this.#post({ type: "saved", task: saved }); + this.#post({ variant: "saved", task: saved }); this.#postState(); this.#deps.onTasksMutated?.(); } @@ -210,8 +209,7 @@ export class TaskDashboardPanel { selectedId: this.#selectedId, }); - this.#post({ - type: "state", + this.#post({ variant: "state", filter: this.#filter, groupBy: this.#groupBy, selectedId: this.#selectedId, diff --git a/src/views/taskKanbanHtml.ts b/src/views/taskKanbanHtml.ts index 6e32a65..9324fd0 100644 --- a/src/views/taskKanbanHtml.ts +++ b/src/views/taskKanbanHtml.ts @@ -1,4 +1,6 @@ import * as vscode from "vscode"; +import { TaskScope } from "@/model/taskLocation"; +import { TASK_SCOPE_LABELS } from "@/ui/taskLabels"; const NONCE_LENGTH = 32; @@ -13,6 +15,11 @@ export function getTaskKanbanHtml( `script-src 'nonce-${nonce}'`, ].join("; "); + const scopeProject = JSON.stringify(TaskScope.PROJECT); + const scopeGlobal = JSON.stringify(TaskScope.GLOBAL); + const labelProject = JSON.stringify(TASK_SCOPE_LABELS[TaskScope.PROJECT]); + const labelGlobal = JSON.stringify(TASK_SCOPE_LABELS[TaskScope.GLOBAL]); + return ` @@ -33,6 +40,7 @@ export function getTaskKanbanHtml( } header { display: flex; + flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 12px; @@ -42,21 +50,32 @@ export function getTaskKanbanHtml( margin: 0; font-size: 1em; font-weight: 600; - flex: 1; } - button { + header .spacer { flex: 1; min-width: 8px; } + select, button { font: inherit; cursor: pointer; padding: 4px 10px; + color: var(--vscode-input-foreground); + background: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 2px; + } + button { color: var(--vscode-button-foreground); background: var(--vscode-button-background); - border: none; - border-radius: 2px; + border-color: transparent; } button.secondary { background: var(--vscode-button-secondaryBackground, var(--vscode-input-background)); color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); } + button.column-add { + padding: 0 6px; + font-size: 1em; + line-height: 1.2; + min-width: 24px; + } #status-line { font-size: 0.85em; opacity: 0.85; @@ -65,10 +84,10 @@ export function getTaskKanbanHtml( #status-line.error { color: var(--vscode-errorForeground); } #board { display: grid; - grid-template-columns: repeat(4, minmax(160px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; padding: 10px; - height: calc(100% - 48px); + height: calc(100% - 52px); overflow: auto; align-items: stretch; } @@ -89,9 +108,11 @@ export function getTaskKanbanHtml( font-size: 0.9em; border-bottom: 1px solid var(--vscode-panel-border, var(--vscode-widget-border)); display: flex; + align-items: center; justify-content: space-between; gap: 8px; } + .column-header .title { flex: 1; } .column-header .count { opacity: 0.7; font-weight: 400; @@ -124,17 +145,31 @@ export function getTaskKanbanHtml(

Kanban

+ + + +
`; diff --git a/src/views/taskKanbanPanel.ts b/src/views/taskKanbanPanel.ts index 9be0389..409be35 100644 --- a/src/views/taskKanbanPanel.ts +++ b/src/views/taskKanbanPanel.ts @@ -1,18 +1,21 @@ import * as vscode from "vscode"; import { changeStatus } from "@/commands/changeStatus"; +import { createTask } from "@/commands/createTask"; import { listLocatedTasks } from "@/commands/listLocatedTasks"; import { resolveTaskLocations } from "@/commands/resolveTaskLocations"; import { buildKanbanBoard } from "@/kanban/buildKanbanBoard"; import { parseKanbanInboundMessage, type KanbanInboundMessage, + type KanbanScopeFilter, } from "@/kanban/messages"; import { KanbanOutboundMessage } from "@/kanban/outboundMessages"; import { appError, AppErrorVariant } from "@/error"; -import { LocatedTask } from "@/model/taskLocation"; -import { TaskStatus } from "@/model/task"; +import { LocatedTask, TaskLocation, TaskScope } from "@/model/taskLocation"; +import { TaskStatus } from "@/model/taskStatus"; import { IConfigProvider, ITaskRepository } from "@/ports"; import { presentError } from "@/ui/presentError"; +import { TASK_SCOPE_LABELS } from "@/ui/taskLabels"; import { createKanbanNonce, getTaskKanbanHtml } from "./taskKanbanHtml"; export type TaskKanbanDeps = { @@ -56,6 +59,8 @@ export class TaskKanbanPanel { readonly #deps: TaskKanbanDeps; #tasks: LocatedTask[] = []; + #scopeFilter: KanbanScopeFilter = "all"; + #showHidden = false; #disposed = false; constructor(panel: vscode.WebviewPanel, deps: TaskKanbanDeps) { @@ -84,7 +89,7 @@ export class TaskKanbanPanel { const result = await listLocatedTasks(this.#deps.repo, locations); if (result.isErr()) { - this.#post({ type: "error", error: result.error }); + this.#post({ variant: "error", error: result.error }); if (result.error.variant === AppErrorVariant.NO_FOLDER) { presentError(result.error); } @@ -98,14 +103,14 @@ export class TaskKanbanPanel { async #onMessage(raw: unknown): Promise { const parsed = parseKanbanInboundMessage(raw); if (parsed.isErr()) { - this.#post({ type: "error", error: parsed.error }); + this.#post({ variant: "error", error: parsed.error }); return; } await this.#handleInbound(parsed.value); } async #handleInbound(message: KanbanInboundMessage): Promise { - switch (message.type) { + switch (message.variant) { case "ready": case "refresh": await this.reloadTasks(); @@ -114,6 +119,20 @@ export class TaskKanbanPanel { case "moveTask": await this.#moveTask(message.id, message.status); return; + + case "setScopeFilter": + this.#scopeFilter = message.scope; + this.#postState(); + return; + + case "setShowHidden": + this.#showHidden = message.showHidden; + this.#postState(); + return; + + case "createTask": + await this.#createInColumn(message.status); + return; } } @@ -121,7 +140,7 @@ export class TaskKanbanPanel { const located = this.#tasks.find((item) => item.task.id === id); if (!located) { this.#post({ - type: "error", + variant: "error", error: appError(AppErrorVariant.NOT_FOUND, { id }), }); return; @@ -138,7 +157,7 @@ export class TaskKanbanPanel { }); if (result.isErr()) { - this.#post({ type: "error", error: result.error }); + this.#post({ variant: "error", error: result.error }); return; } @@ -152,9 +171,95 @@ export class TaskKanbanPanel { this.#deps.onTasksMutated?.(); } + async #createInColumn(status: TaskStatus): Promise { + const location = await this.#pickLocationForCreate(); + if (location === undefined) { + return; + } + + const title = await vscode.window.showInputBox({ + prompt: "Task title", + placeHolder: "Enter task title...", + }); + if (title === undefined) { + return; + } + + const result = await createTask(this.#deps.repo, { + folderPath: location.folderPath, + title, + status, + }); + + if (result.isErr()) { + presentError(result.error); + this.#post({ variant: "error", error: result.error }); + return; + } + + await this.reloadTasks(); + this.#deps.onTasksMutated?.(); + } + + async #pickLocationForCreate(): Promise { + const locations = resolveTaskLocations(this.#deps.config); + if (locations.length === 0) { + presentError(appError(AppErrorVariant.NO_FOLDER)); + return undefined; + } + + if (this.#scopeFilter !== "all") { + const match = locations.find((loc) => loc.scope === this.#scopeFilter); + if (match) { + return match; + } + } + + if (locations.length === 1) { + return locations[0]; + } + + const picked = await vscode.window.showQuickPick( + locations.map((location) => ({ + label: TASK_SCOPE_LABELS[location.scope], + description: location.folderPath, + location, + })), + { placeHolder: "Create task in…" }, + ); + return picked?.location; + } + + #filteredTasks(): LocatedTask[] { + if (this.#scopeFilter === "all") { + return this.#tasks; + } + return this.#tasks.filter( + (item) => item.location.scope === this.#scopeFilter, + ); + } + + #availableScopes(): TaskScope[] { + const scopes = new Set(this.#tasks.map((item) => item.location.scope)); + // Also expose configured locations even if empty (for filter + create). + for (const loc of resolveTaskLocations(this.#deps.config)) { + scopes.add(loc.scope); + } + return [...scopes]; + } + #postState(): void { - const board = buildKanbanBoard(this.#tasks.map((item) => item.task)); - this.#post({ type: "state", board }); + const board = buildKanbanBoard( + this.#filteredTasks().map((item) => item.task), + { showHiddenStatuses: this.#showHidden }, + ); + this.#post({ + variant: "state", + board, + scopeFilter: this.#scopeFilter, + showHidden: this.#showHidden, + availableScopes: this.#availableScopes(), + }); } #post(message: KanbanOutboundMessage): void { diff --git a/todo.md b/todo.md index d53898f..f55d986 100644 --- a/todo.md +++ b/todo.md @@ -57,13 +57,21 @@ headless/тестов можно оставить; editor-save — optional path - `+ Task` в toolbar → inbound `createTask` → host `executeCommand("xboctFlow.createTask")` → reload list -[ ] **Kanban** +[x] **Kanban** -- кнопка переключения global\project -- кнопка создания задачи на каждой колонке -- колонка cancelled - по умолчанию скрыта, добавить кнопку для её отображения +- кнопка переключения global\project (scope filter: All / Project / Global) +- кнопка создания задачи на каждой колонке (`createTask` + status колонки) +- колонки с `defaultHidden` (backlog, cancelled) скрыты по умолчанию; кнопка + Show hidden / Hide backlog/cancelled -[ ] **Cтатус backlog** +[x] **Cтатус backlog** -- добавить в фильтр -- добавить в канбан +- в модели (`TaskStatus.BACKLOG`), ORDER, chips dashboard из ORDER +- в kanban: колонка при Show hidden; defaultHidden в meta + +[ ] **Dashboard** + +- добавить фильтр по global\project +- в списке задач выводить её scope +- сортировка - по умолчанию старые задачи сверху, но можно выбрать обратную + сортировку