refactor: separate config service

This commit is contained in:
2026-07-18 13:29:38 +05:00
parent 1fbf020c80
commit 70968c450e
8 changed files with 193 additions and 32 deletions
+1 -2
View File
@@ -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) => {
+3 -1
View File
@@ -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 = {
@@ -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]);
});
});
});
+43
View File
@@ -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<ProjectConfig> {
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<ProjectConfig>): Promise<void> {
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);
}
}
+5 -5
View File
@@ -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<string[], AppError> {
): Result<TaskStatus[], AppError> {
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(
@@ -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", () => {
+3 -2
View File
@@ -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();
+15 -20
View File
@@ -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<void> {
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<void> {
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<void> {