From 70968c450e05d7e4d9f95fff0dd9e3b9929dd0e4 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sat, 18 Jul 2026 13:29:38 +0500 Subject: [PATCH] refactor: separate config service --- src/kanban/buildKanbanBoard.ts | 3 +- src/model/projectConfig.ts | 4 +- .../ProjectConfigService.test.ts | 117 ++++++++++++++++++ src/projectConfig/ProjectConfigService.ts | 43 +++++++ src/projectConfig/parseProjectConfig.ts | 10 +- .../serializeProjectConfig.test.ts | 8 +- src/ui/taskCommands.ts | 5 +- src/views/taskKanbanPanel.ts | 35 +++--- 8 files changed, 193 insertions(+), 32 deletions(-) create mode 100644 src/projectConfig/ProjectConfigService.test.ts create mode 100644 src/projectConfig/ProjectConfigService.ts diff --git a/src/kanban/buildKanbanBoard.ts b/src/kanban/buildKanbanBoard.ts index 96f8b65..04cebb2 100644 --- a/src/kanban/buildKanbanBoard.ts +++ b/src/kanban/buildKanbanBoard.ts @@ -28,8 +28,7 @@ export function buildKanbanBoard( const extraVisible = options.shownHiddenColumns ?? []; const statuses = TASK_STATUS_ORDER.filter( (status) => - !TASK_STATUS_META[status].defaultHidden || - extraVisible.includes(status), + !TASK_STATUS_META[status].defaultHidden || extraVisible.includes(status), ); const columns: KanbanColumn[] = statuses.map((status) => { diff --git a/src/model/projectConfig.ts b/src/model/projectConfig.ts index e181121..7ca0594 100644 --- a/src/model/projectConfig.ts +++ b/src/model/projectConfig.ts @@ -1,9 +1,11 @@ /** Config file name inside a task folder (project or global). */ export const TASK_FOLDER_CONFIG_FILENAME = "config.yml"; +import { TaskStatus } from "./taskStatus"; + export type ProjectConfig = { tags: string[]; - shownHiddenColumns: string[]; + shownHiddenColumns: TaskStatus[]; }; export const EMPTY_PROJECT_CONFIG: ProjectConfig = { diff --git a/src/projectConfig/ProjectConfigService.test.ts b/src/projectConfig/ProjectConfigService.test.ts new file mode 100644 index 0000000..761ac24 --- /dev/null +++ b/src/projectConfig/ProjectConfigService.test.ts @@ -0,0 +1,117 @@ +import { + EMPTY_PROJECT_CONFIG, + getProjectConfigPath, +} from "@/model/projectConfig"; +import { TaskStatus } from "@/model/taskStatus"; +import { IConfigProvider } from "@/ports"; +import { beforeEach, describe, expect, it } from "vitest"; +import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem"; +import { ProjectConfigService } from "./ProjectConfigService"; + +const FOLDER = "/ws/.xflow"; + +function configProvider(partial: { + project?: string; + global?: string; +}): IConfigProvider { + return { + getProjectTaskPath: () => partial.project, + getGlobalTaskPath: () => partial.global, + }; +} + +describe("ProjectConfigService", () => { + let fs: InMemoryFileSystem; + + beforeEach(() => { + fs = new InMemoryFileSystem(); + }); + + describe("load", () => { + it("returns empty config when folder path is unset", async () => { + const svc = new ProjectConfigService(fs, configProvider({})); + await expect(svc.load()).resolves.toEqual(EMPTY_PROJECT_CONFIG); + }); + + it("returns empty config when config.yml missing", async () => { + const svc = new ProjectConfigService( + fs, + configProvider({ project: FOLDER }), + ); + await expect(svc.load()).resolves.toEqual(EMPTY_PROJECT_CONFIG); + }); + + it("returns parsed config when config.yml exists with tags", async () => { + await fs.writeFile( + getProjectConfigPath(FOLDER), + "tags:\n - bug\n - docs\n", + ); + const svc = new ProjectConfigService( + fs, + configProvider({ project: FOLDER }), + ); + const config = await svc.load(); + expect(config.tags).toEqual(["bug", "docs"]); + expect(config.shownHiddenColumns).toEqual([]); + }); + + it("returns config with shownHiddenColumns", async () => { + await fs.writeFile( + getProjectConfigPath(FOLDER), + "tags: []\nshownHiddenColumns:\n - backlog\n", + ); + const svc = new ProjectConfigService( + fs, + configProvider({ project: FOLDER }), + ); + const config = await svc.load(); + expect(config.shownHiddenColumns).toEqual([TaskStatus.BACKLOG]); + }); + + it("returns empty config when config.yml is invalid", async () => { + await fs.writeFile(getProjectConfigPath(FOLDER), "tags: [\n"); + const svc = new ProjectConfigService( + fs, + configProvider({ project: FOLDER }), + ); + await expect(svc.load()).resolves.toEqual(EMPTY_PROJECT_CONFIG); + }); + }); + + describe("merge", () => { + it("creates config.yml with merged fields", async () => { + const svc = new ProjectConfigService( + fs, + configProvider({ project: FOLDER }), + ); + await svc.merge({ shownHiddenColumns: [TaskStatus.BACKLOG] }); + const config = await svc.load(); + expect(config.shownHiddenColumns).toEqual([TaskStatus.BACKLOG]); + }); + + it("preserves existing tags when merging shownHiddenColumns", async () => { + await fs.writeFile(getProjectConfigPath(FOLDER), "tags:\n - bug\n"); + const svc = new ProjectConfigService( + fs, + configProvider({ project: FOLDER }), + ); + await svc.merge({ shownHiddenColumns: [TaskStatus.CANCELLED] }); + const config = await svc.load(); + expect(config.tags).toEqual(["bug"]); + expect(config.shownHiddenColumns).toEqual([TaskStatus.CANCELLED]); + }); + + it("load after merge returns merged config (idempotent)", async () => { + const svc = new ProjectConfigService( + fs, + configProvider({ project: FOLDER }), + ); + await svc.merge({ + shownHiddenColumns: [TaskStatus.BACKLOG, TaskStatus.UNKNOWN], + }); + await svc.merge({ shownHiddenColumns: [TaskStatus.BACKLOG] }); + const config = await svc.load(); + expect(config.shownHiddenColumns).toEqual([TaskStatus.BACKLOG]); + }); + }); +}); diff --git a/src/projectConfig/ProjectConfigService.ts b/src/projectConfig/ProjectConfigService.ts new file mode 100644 index 0000000..0ade6cf --- /dev/null +++ b/src/projectConfig/ProjectConfigService.ts @@ -0,0 +1,43 @@ +import { + EMPTY_PROJECT_CONFIG, + getProjectConfigPath, + type ProjectConfig, +} from "@/model/projectConfig"; +import { IConfigProvider, IFileSystem } from "@/ports"; +import { parseProjectConfig } from "./parseProjectConfig"; +import { serializeProjectConfig } from "./serializeProjectConfig"; + +export class ProjectConfigService { + #fs: IFileSystem; + #config: IConfigProvider; + + constructor(fs: IFileSystem, config: IConfigProvider) { + this.#fs = fs; + this.#config = config; + } + + async load(): Promise { + const folderPath = this.#config.getProjectTaskPath(); + if (!folderPath) return { ...EMPTY_PROJECT_CONFIG }; + + const path = getProjectConfigPath(folderPath); + try { + const content = await this.#fs.readFile(path); + const parsed = parseProjectConfig(content); + if (parsed.isErr()) return { ...EMPTY_PROJECT_CONFIG }; + return parsed.value; + } catch { + return { ...EMPTY_PROJECT_CONFIG }; + } + } + + async merge(partial: Partial): Promise { + const folderPath = this.#config.getProjectTaskPath(); + if (!folderPath) return; + + const current = await this.load(); + const merged: ProjectConfig = { ...current, ...partial }; + const content = serializeProjectConfig(merged); + await this.#fs.writeFile(getProjectConfigPath(folderPath), content); + } +} diff --git a/src/projectConfig/parseProjectConfig.ts b/src/projectConfig/parseProjectConfig.ts index 4fe9056..36e1b73 100644 --- a/src/projectConfig/parseProjectConfig.ts +++ b/src/projectConfig/parseProjectConfig.ts @@ -1,16 +1,16 @@ -import * as yaml from "js-yaml"; -import { err, ok, Result } from "neverthrow"; import { appError, AppError, AppErrorVariant } from "@/error"; import { EMPTY_PROJECT_CONFIG, normalizeTag, type ProjectConfig, } from "@/model/projectConfig"; -import { isTaskStatus } from "@/model/taskStatus"; +import { isTaskStatus, TaskStatus } from "@/model/taskStatus"; +import * as yaml from "js-yaml"; +import { err, ok, Result } from "neverthrow"; function parseShownHiddenColumns( value: unknown, -): Result { +): Result { if (value === undefined) return ok([]); if (!Array.isArray(value)) { return err( @@ -28,7 +28,7 @@ function parseShownHiddenColumns( ); } } - return ok(value as string[]); + return ok(value as TaskStatus[]); } export function parseProjectConfig( diff --git a/src/projectConfig/serializeProjectConfig.test.ts b/src/projectConfig/serializeProjectConfig.test.ts index f3da820..bd494a3 100644 --- a/src/projectConfig/serializeProjectConfig.test.ts +++ b/src/projectConfig/serializeProjectConfig.test.ts @@ -1,3 +1,4 @@ +import { TaskStatus } from "@/model/taskStatus"; import { describe, expect, it } from "vitest"; import { parseProjectConfig } from "./parseProjectConfig"; import { serializeProjectConfig } from "./serializeProjectConfig"; @@ -27,13 +28,16 @@ describe("serializeProjectConfig", () => { it("round-trips tags with shownHiddenColumns", () => { const yaml = serializeProjectConfig({ tags: ["bug"], - shownHiddenColumns: ["backlog", "cancelled"], + shownHiddenColumns: [TaskStatus.BACKLOG, TaskStatus.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"]); + expect(parsed.value.shownHiddenColumns).toEqual([ + TaskStatus.BACKLOG, + TaskStatus.CANCELLED, + ]); }); it("serializes empty shownHiddenColumns", () => { diff --git a/src/ui/taskCommands.ts b/src/ui/taskCommands.ts index 215905f..f127296 100644 --- a/src/ui/taskCommands.ts +++ b/src/ui/taskCommands.ts @@ -1,4 +1,3 @@ -import * as vscode from "vscode"; import { changeStatus } from "@/commands/changeStatus"; import { createTask } from "@/commands/createTask"; import { deleteTask } from "@/commands/deleteTask"; @@ -7,9 +6,11 @@ import { resolveTaskLocations } from "@/commands/resolveTaskLocations"; import { appError, AppErrorVariant } from "@/error"; import { TaskLocation } from "@/model/taskLocation"; import { IConfigProvider, ITaskRepository } from "@/ports"; +import { ProjectConfigService } from "@/projectConfig/ProjectConfigService"; import { TaskDashboardPanel } from "@/views/taskDashboardPanel"; import { TaskKanbanPanel } from "@/views/taskKanbanPanel"; import { TaskTreeProvider } from "@/views/taskTreeProvider"; +import * as vscode from "vscode"; import { runAddTagToProject, runEditTaskPriority, @@ -221,7 +222,7 @@ export function runOpenKanban(deps: TaskCommandDeps): void { TaskKanbanPanel.show({ repo: deps.repo, config: deps.config, - fs: deps.fs, + configService: new ProjectConfigService(deps.fs, deps.config), onTasksMutated: () => { deps.tree.refresh(); TaskDashboardPanel.refreshIfOpen(); diff --git a/src/views/taskKanbanPanel.ts b/src/views/taskKanbanPanel.ts index b98531d..e6814c8 100644 --- a/src/views/taskKanbanPanel.ts +++ b/src/views/taskKanbanPanel.ts @@ -1,8 +1,8 @@ -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 { appError, AppErrorVariant } from "@/error"; import { buildKanbanBoard } from "@/kanban/buildKanbanBoard"; import { parseKanbanInboundMessage, @@ -10,21 +10,23 @@ import { type KanbanScopeFilter, } 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, IFileSystem, ITaskRepository } from "@/ports"; -import { loadProjectConfig } from "@/commands/loadProjectConfig"; -import { serializeProjectConfig } from "@/projectConfig/serializeProjectConfig"; +import { + TASK_STATUS_META, + TASK_STATUS_ORDER, + TaskStatus, +} from "@/model/taskStatus"; +import { IConfigProvider, ITaskRepository } from "@/ports"; +import { ProjectConfigService } from "@/projectConfig/ProjectConfigService"; import { presentError } from "@/ui/presentError"; import { TASK_SCOPE_LABELS } from "@/ui/taskLabels"; +import * as vscode from "vscode"; import { createKanbanNonce, getTaskKanbanHtml } from "./taskKanbanHtml"; export type TaskKanbanDeps = { repo: ITaskRepository; config: IConfigProvider; - fs: IFileSystem; + configService: ProjectConfigService; onTasksMutated?: () => void; }; @@ -107,21 +109,14 @@ export class TaskKanbanPanel { } 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[]; + const config = await this.#deps.configService.load(); + this.#shownHiddenColumns = config.shownHiddenColumns; } 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); + await this.#deps.configService.merge({ + shownHiddenColumns: this.#shownHiddenColumns, + }); } async #onMessage(raw: unknown): Promise {