refactor: separate config service
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user