From 1fbf020c809871577abb2644f1d82108fdb67706 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sat, 18 Jul 2026 09:33:51 +0500 Subject: [PATCH] feat: add config for kanban columns --- src/commands/addProjectTag.ts | 1 + src/model/projectConfig.test.ts | 7 +- src/model/projectConfig.ts | 2 + src/projectConfig/parseProjectConfig.test.ts | 29 +++++++ src/projectConfig/parseProjectConfig.ts | 79 +++++++++++++------ .../serializeProjectConfig.test.ts | 30 ++++++- src/projectConfig/serializeProjectConfig.ts | 15 ++-- src/ui/taskCommands.ts | 1 + src/views/taskKanbanPanel.ts | 27 ++++++- 9 files changed, 155 insertions(+), 36 deletions(-) diff --git a/src/commands/addProjectTag.ts b/src/commands/addProjectTag.ts index 42fdfae..dfcf505 100644 --- a/src/commands/addProjectTag.ts +++ b/src/commands/addProjectTag.ts @@ -51,6 +51,7 @@ export function addProjectTag( const config: ProjectConfig = { tags: [...loaded.config.tags, tag], + shownHiddenColumns: loaded.config.shownHiddenColumns, }; const content = serializeProjectConfig(config); diff --git a/src/model/projectConfig.test.ts b/src/model/projectConfig.test.ts index 62315e3..c90b214 100644 --- a/src/model/projectConfig.test.ts +++ b/src/model/projectConfig.test.ts @@ -16,8 +16,11 @@ describe("projectConfig model", () => { expect(getProjectConfigPath("C:\\proj\\.xflow")).toMatch(/config\.yml$/); }); - it("empty config has no tags", () => { - expect(EMPTY_PROJECT_CONFIG).toEqual({ tags: [] }); + it("empty config has no tags and no shown hidden columns", () => { + expect(EMPTY_PROJECT_CONFIG).toEqual({ + tags: [], + shownHiddenColumns: [], + }); }); it("normalizeTag trims and rejects empty", () => { diff --git a/src/model/projectConfig.ts b/src/model/projectConfig.ts index 419893b..e181121 100644 --- a/src/model/projectConfig.ts +++ b/src/model/projectConfig.ts @@ -3,10 +3,12 @@ export const TASK_FOLDER_CONFIG_FILENAME = "config.yml"; export type ProjectConfig = { tags: string[]; + shownHiddenColumns: string[]; }; export const EMPTY_PROJECT_CONFIG: ProjectConfig = { tags: [], + shownHiddenColumns: [], }; /** Absolute/joined path to config.yml in a task folder. */ diff --git a/src/projectConfig/parseProjectConfig.test.ts b/src/projectConfig/parseProjectConfig.test.ts index ea48da5..4e7b134 100644 --- a/src/projectConfig/parseProjectConfig.test.ts +++ b/src/projectConfig/parseProjectConfig.test.ts @@ -1,4 +1,5 @@ import { AppErrorVariant } from "@/error"; +import { TaskStatus } from "@/model/taskStatus"; import { describe, expect, it } from "vitest"; import { parseProjectConfig } from "./parseProjectConfig"; @@ -43,4 +44,32 @@ describe("parseProjectConfig", () => { if (result.isOk()) return; expect(result.error.variant).toBe(AppErrorVariant.INVALID_PROJECT_CONFIG); }); + + it("parses shownHiddenColumns", () => { + const result = parseProjectConfig( + "tags: []\nshownHiddenColumns:\n - backlog\n - cancelled\n", + ); + expect(result.isOk()).toBe(true); + if (result.isErr()) return; + expect(result.value.shownHiddenColumns).toEqual([ + TaskStatus.BACKLOG, + TaskStatus.CANCELLED, + ]); + }); + + it("shownHiddenColumns defaults to empty array when missing", () => { + const result = parseProjectConfig("tags: []\n"); + expect(result.isOk()).toBe(true); + if (result.isErr()) return; + expect(result.value.shownHiddenColumns).toEqual([]); + }); + + it("rejects shownHiddenColumns with invalid status values", () => { + const result = parseProjectConfig( + "tags: []\nshownHiddenColumns:\n - backlog\n - not_a_status\n", + ); + expect(result.isErr()).toBe(true); + if (result.isOk()) return; + expect(result.error.variant).toBe(AppErrorVariant.INVALID_PROJECT_CONFIG); + }); }); diff --git a/src/projectConfig/parseProjectConfig.ts b/src/projectConfig/parseProjectConfig.ts index b00f5cd..4fe9056 100644 --- a/src/projectConfig/parseProjectConfig.ts +++ b/src/projectConfig/parseProjectConfig.ts @@ -6,11 +6,31 @@ import { normalizeTag, type ProjectConfig, } from "@/model/projectConfig"; +import { isTaskStatus } from "@/model/taskStatus"; + +function parseShownHiddenColumns( + value: unknown, +): Result { + if (value === undefined) return ok([]); + if (!Array.isArray(value)) { + return err( + appError(AppErrorVariant.INVALID_PROJECT_CONFIG, { + detail: "shownHiddenColumns must be an array of strings", + }), + ); + } + for (const item of value) { + if (typeof item !== "string" || !isTaskStatus(item)) { + return err( + appError(AppErrorVariant.INVALID_PROJECT_CONFIG, { + detail: `invalid status in shownHiddenColumns: ${item}`, + }), + ); + } + } + return ok(value as string[]); +} -/** - * Parse task-folder config.yml content. - * Accepts missing/empty tags → []. - */ export function parseProjectConfig( content: string, ): Result { @@ -39,33 +59,44 @@ export function parseProjectConfig( } const record = data as Record; + + let tags: string[]; if (record.tags === undefined) { - return ok({ tags: [] }); - } - - if (!Array.isArray(record.tags)) { - return err( - appError(AppErrorVariant.INVALID_PROJECT_CONFIG, { - detail: "tags must be an array of strings", - }), - ); - } - - const tags: string[] = []; - const seen = new Set(); - for (const item of record.tags) { - if (typeof item !== "string") { + tags = []; + } else { + if (!Array.isArray(record.tags)) { return err( appError(AppErrorVariant.INVALID_PROJECT_CONFIG, { detail: "tags must be an array of strings", }), ); } - const tag = normalizeTag(item); - if (!tag || seen.has(tag)) continue; - seen.add(tag); - tags.push(tag); + + tags = []; + const seen = new Set(); + for (const item of record.tags) { + if (typeof item !== "string") { + return err( + appError(AppErrorVariant.INVALID_PROJECT_CONFIG, { + detail: "tags must be an array of strings", + }), + ); + } + const tag = normalizeTag(item); + if (!tag || seen.has(tag)) continue; + seen.add(tag); + tags.push(tag); + } } - return ok({ tags }); + const shownHiddenColumnsResult = parseShownHiddenColumns( + record.shownHiddenColumns, + ); + if (shownHiddenColumnsResult.isErr()) + return err(shownHiddenColumnsResult.error); + + return ok({ + tags, + shownHiddenColumns: shownHiddenColumnsResult.value, + }); } diff --git a/src/projectConfig/serializeProjectConfig.test.ts b/src/projectConfig/serializeProjectConfig.test.ts index 37cfc44..f3da820 100644 --- a/src/projectConfig/serializeProjectConfig.test.ts +++ b/src/projectConfig/serializeProjectConfig.test.ts @@ -4,7 +4,10 @@ import { serializeProjectConfig } from "./serializeProjectConfig"; describe("serializeProjectConfig", () => { it("round-trips tags", () => { - const yaml = serializeProjectConfig({ tags: ["bug", "docs"] }); + const yaml = serializeProjectConfig({ + tags: ["bug", "docs"], + shownHiddenColumns: [], + }); expect(yaml).toContain("bug"); expect(yaml).toContain("docs"); const parsed = parseProjectConfig(yaml); @@ -14,10 +17,33 @@ describe("serializeProjectConfig", () => { }); it("serializes empty tags", () => { - const yaml = serializeProjectConfig({ tags: [] }); + const yaml = serializeProjectConfig({ tags: [], shownHiddenColumns: [] }); const parsed = parseProjectConfig(yaml); expect(parsed.isOk()).toBe(true); if (parsed.isErr()) return; expect(parsed.value.tags).toEqual([]); }); + + it("round-trips tags with shownHiddenColumns", () => { + const yaml = serializeProjectConfig({ + tags: ["bug"], + shownHiddenColumns: ["backlog", "cancelled"], + }); + const parsed = parseProjectConfig(yaml); + expect(parsed.isOk()).toBe(true); + if (parsed.isErr()) return; + expect(parsed.value.tags).toEqual(["bug"]); + expect(parsed.value.shownHiddenColumns).toEqual(["backlog", "cancelled"]); + }); + + it("serializes empty shownHiddenColumns", () => { + const yaml = serializeProjectConfig({ + tags: [], + shownHiddenColumns: [], + }); + const parsed = parseProjectConfig(yaml); + expect(parsed.isOk()).toBe(true); + if (parsed.isErr()) return; + expect(parsed.value.shownHiddenColumns).toEqual([]); + }); }); diff --git a/src/projectConfig/serializeProjectConfig.ts b/src/projectConfig/serializeProjectConfig.ts index e417f0e..7862b70 100644 --- a/src/projectConfig/serializeProjectConfig.ts +++ b/src/projectConfig/serializeProjectConfig.ts @@ -2,14 +2,15 @@ import * as yaml from "js-yaml"; import type { ProjectConfig } from "@/model/projectConfig"; export function serializeProjectConfig(config: ProjectConfig): string { + const data: Record = { tags: config.tags }; + if (config.shownHiddenColumns.length > 0) { + data.shownHiddenColumns = config.shownHiddenColumns; + } return yaml - .dump( - { tags: config.tags }, - { - lineWidth: -1, - forceQuotes: false, - }, - ) + .dump(data, { + lineWidth: -1, + forceQuotes: false, + }) .trimEnd() .concat("\n"); } diff --git a/src/ui/taskCommands.ts b/src/ui/taskCommands.ts index 86f4d62..215905f 100644 --- a/src/ui/taskCommands.ts +++ b/src/ui/taskCommands.ts @@ -221,6 +221,7 @@ export function runOpenKanban(deps: TaskCommandDeps): void { TaskKanbanPanel.show({ repo: deps.repo, config: deps.config, + fs: deps.fs, onTasksMutated: () => { deps.tree.refresh(); TaskDashboardPanel.refreshIfOpen(); diff --git a/src/views/taskKanbanPanel.ts b/src/views/taskKanbanPanel.ts index 82d20e3..b98531d 100644 --- a/src/views/taskKanbanPanel.ts +++ b/src/views/taskKanbanPanel.ts @@ -11,9 +11,12 @@ import { } from "@/kanban/messages"; import { KanbanOutboundMessage } from "@/kanban/outboundMessages"; import { appError, AppErrorVariant } from "@/error"; +import { getProjectConfigPath } from "@/model/projectConfig"; import { LocatedTask, TaskLocation, TaskScope } from "@/model/taskLocation"; import { TaskStatus, TASK_STATUS_META, TASK_STATUS_ORDER } from "@/model/taskStatus"; -import { IConfigProvider, ITaskRepository } from "@/ports"; +import { IConfigProvider, IFileSystem, ITaskRepository } from "@/ports"; +import { loadProjectConfig } from "@/commands/loadProjectConfig"; +import { serializeProjectConfig } from "@/projectConfig/serializeProjectConfig"; import { presentError } from "@/ui/presentError"; import { TASK_SCOPE_LABELS } from "@/ui/taskLabels"; import { createKanbanNonce, getTaskKanbanHtml } from "./taskKanbanHtml"; @@ -21,6 +24,7 @@ import { createKanbanNonce, getTaskKanbanHtml } from "./taskKanbanHtml"; export type TaskKanbanDeps = { repo: ITaskRepository; config: IConfigProvider; + fs: IFileSystem; onTasksMutated?: () => void; }; @@ -85,6 +89,8 @@ export class TaskKanbanPanel { async reloadTasks(): Promise { if (this.#disposed) return; + await this.#loadShownHiddenColumns(); + const locations = resolveTaskLocations(this.#deps.config); const result = await listLocatedTasks(this.#deps.repo, locations); @@ -100,6 +106,24 @@ export class TaskKanbanPanel { this.#postState(); } + async #loadShownHiddenColumns(): Promise { + const folderPath = this.#deps.config.getProjectTaskPath(); + const loaded = await loadProjectConfig(this.#deps.fs, folderPath); + if (loaded.isErr()) return; + this.#shownHiddenColumns = loaded.value.config.shownHiddenColumns as TaskStatus[]; + } + + async #saveShownHiddenColumns(): Promise { + const folderPath = this.#deps.config.getProjectTaskPath(); + if (!folderPath) return; + const loaded = await loadProjectConfig(this.#deps.fs, folderPath); + if (loaded.isErr()) return; + const config = { ...loaded.value.config, shownHiddenColumns: this.#shownHiddenColumns }; + const content = serializeProjectConfig(config); + const path = getProjectConfigPath(folderPath); + await this.#deps.fs.writeFile(path, content); + } + async #onMessage(raw: unknown): Promise { const parsed = parseKanbanInboundMessage(raw); if (parsed.isErr()) { @@ -208,6 +232,7 @@ export class TaskKanbanPanel { this.#shownHiddenColumns.push(status); } this.#postState(); + void this.#saveShownHiddenColumns(); } async #pickLocationForCreate(): Promise {