feat: add config for kanban columns

This commit is contained in:
2026-07-18 09:33:51 +05:00
parent f382632b2b
commit 1fbf020c80
9 changed files with 155 additions and 36 deletions
+1
View File
@@ -51,6 +51,7 @@ export function addProjectTag(
const config: ProjectConfig = { const config: ProjectConfig = {
tags: [...loaded.config.tags, tag], tags: [...loaded.config.tags, tag],
shownHiddenColumns: loaded.config.shownHiddenColumns,
}; };
const content = serializeProjectConfig(config); const content = serializeProjectConfig(config);
+5 -2
View File
@@ -16,8 +16,11 @@ describe("projectConfig model", () => {
expect(getProjectConfigPath("C:\\proj\\.xflow")).toMatch(/config\.yml$/); expect(getProjectConfigPath("C:\\proj\\.xflow")).toMatch(/config\.yml$/);
}); });
it("empty config has no tags", () => { it("empty config has no tags and no shown hidden columns", () => {
expect(EMPTY_PROJECT_CONFIG).toEqual({ tags: [] }); expect(EMPTY_PROJECT_CONFIG).toEqual({
tags: [],
shownHiddenColumns: [],
});
}); });
it("normalizeTag trims and rejects empty", () => { it("normalizeTag trims and rejects empty", () => {
+2
View File
@@ -3,10 +3,12 @@ export const TASK_FOLDER_CONFIG_FILENAME = "config.yml";
export type ProjectConfig = { export type ProjectConfig = {
tags: string[]; tags: string[];
shownHiddenColumns: string[];
}; };
export const EMPTY_PROJECT_CONFIG: ProjectConfig = { export const EMPTY_PROJECT_CONFIG: ProjectConfig = {
tags: [], tags: [],
shownHiddenColumns: [],
}; };
/** Absolute/joined path to config.yml in a task folder. */ /** Absolute/joined path to config.yml in a task folder. */
@@ -1,4 +1,5 @@
import { AppErrorVariant } from "@/error"; import { AppErrorVariant } from "@/error";
import { TaskStatus } from "@/model/taskStatus";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { parseProjectConfig } from "./parseProjectConfig"; import { parseProjectConfig } from "./parseProjectConfig";
@@ -43,4 +44,32 @@ describe("parseProjectConfig", () => {
if (result.isOk()) return; if (result.isOk()) return;
expect(result.error.variant).toBe(AppErrorVariant.INVALID_PROJECT_CONFIG); 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);
});
}); });
+41 -10
View File
@@ -6,11 +6,31 @@ import {
normalizeTag, normalizeTag,
type ProjectConfig, type ProjectConfig,
} from "@/model/projectConfig"; } from "@/model/projectConfig";
import { isTaskStatus } from "@/model/taskStatus";
function parseShownHiddenColumns(
value: unknown,
): Result<string[], AppError> {
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( export function parseProjectConfig(
content: string, content: string,
): Result<ProjectConfig, AppError> { ): Result<ProjectConfig, AppError> {
@@ -39,10 +59,11 @@ export function parseProjectConfig(
} }
const record = data as Record<string, unknown>; const record = data as Record<string, unknown>;
if (record.tags === undefined) {
return ok({ tags: [] });
}
let tags: string[];
if (record.tags === undefined) {
tags = [];
} else {
if (!Array.isArray(record.tags)) { if (!Array.isArray(record.tags)) {
return err( return err(
appError(AppErrorVariant.INVALID_PROJECT_CONFIG, { appError(AppErrorVariant.INVALID_PROJECT_CONFIG, {
@@ -51,7 +72,7 @@ export function parseProjectConfig(
); );
} }
const tags: string[] = []; tags = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const item of record.tags) { for (const item of record.tags) {
if (typeof item !== "string") { if (typeof item !== "string") {
@@ -66,6 +87,16 @@ export function parseProjectConfig(
seen.add(tag); seen.add(tag);
tags.push(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,
});
} }
@@ -4,7 +4,10 @@ import { serializeProjectConfig } from "./serializeProjectConfig";
describe("serializeProjectConfig", () => { describe("serializeProjectConfig", () => {
it("round-trips tags", () => { it("round-trips tags", () => {
const yaml = serializeProjectConfig({ tags: ["bug", "docs"] }); const yaml = serializeProjectConfig({
tags: ["bug", "docs"],
shownHiddenColumns: [],
});
expect(yaml).toContain("bug"); expect(yaml).toContain("bug");
expect(yaml).toContain("docs"); expect(yaml).toContain("docs");
const parsed = parseProjectConfig(yaml); const parsed = parseProjectConfig(yaml);
@@ -14,10 +17,33 @@ describe("serializeProjectConfig", () => {
}); });
it("serializes empty tags", () => { it("serializes empty tags", () => {
const yaml = serializeProjectConfig({ tags: [] }); const yaml = serializeProjectConfig({ tags: [], shownHiddenColumns: [] });
const parsed = parseProjectConfig(yaml); const parsed = parseProjectConfig(yaml);
expect(parsed.isOk()).toBe(true); expect(parsed.isOk()).toBe(true);
if (parsed.isErr()) return; if (parsed.isErr()) return;
expect(parsed.value.tags).toEqual([]); 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([]);
});
}); });
+6 -5
View File
@@ -2,14 +2,15 @@ import * as yaml from "js-yaml";
import type { ProjectConfig } from "@/model/projectConfig"; import type { ProjectConfig } from "@/model/projectConfig";
export function serializeProjectConfig(config: ProjectConfig): string { export function serializeProjectConfig(config: ProjectConfig): string {
const data: Record<string, unknown> = { tags: config.tags };
if (config.shownHiddenColumns.length > 0) {
data.shownHiddenColumns = config.shownHiddenColumns;
}
return yaml return yaml
.dump( .dump(data, {
{ tags: config.tags },
{
lineWidth: -1, lineWidth: -1,
forceQuotes: false, forceQuotes: false,
}, })
)
.trimEnd() .trimEnd()
.concat("\n"); .concat("\n");
} }
+1
View File
@@ -221,6 +221,7 @@ export function runOpenKanban(deps: TaskCommandDeps): void {
TaskKanbanPanel.show({ TaskKanbanPanel.show({
repo: deps.repo, repo: deps.repo,
config: deps.config, config: deps.config,
fs: deps.fs,
onTasksMutated: () => { onTasksMutated: () => {
deps.tree.refresh(); deps.tree.refresh();
TaskDashboardPanel.refreshIfOpen(); TaskDashboardPanel.refreshIfOpen();
+26 -1
View File
@@ -11,9 +11,12 @@ import {
} from "@/kanban/messages"; } from "@/kanban/messages";
import { KanbanOutboundMessage } from "@/kanban/outboundMessages"; import { KanbanOutboundMessage } from "@/kanban/outboundMessages";
import { appError, AppErrorVariant } from "@/error"; import { appError, AppErrorVariant } from "@/error";
import { getProjectConfigPath } from "@/model/projectConfig";
import { LocatedTask, TaskLocation, TaskScope } from "@/model/taskLocation"; import { LocatedTask, TaskLocation, TaskScope } from "@/model/taskLocation";
import { TaskStatus, TASK_STATUS_META, TASK_STATUS_ORDER } from "@/model/taskStatus"; 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 { presentError } from "@/ui/presentError";
import { TASK_SCOPE_LABELS } from "@/ui/taskLabels"; import { TASK_SCOPE_LABELS } from "@/ui/taskLabels";
import { createKanbanNonce, getTaskKanbanHtml } from "./taskKanbanHtml"; import { createKanbanNonce, getTaskKanbanHtml } from "./taskKanbanHtml";
@@ -21,6 +24,7 @@ import { createKanbanNonce, getTaskKanbanHtml } from "./taskKanbanHtml";
export type TaskKanbanDeps = { export type TaskKanbanDeps = {
repo: ITaskRepository; repo: ITaskRepository;
config: IConfigProvider; config: IConfigProvider;
fs: IFileSystem;
onTasksMutated?: () => void; onTasksMutated?: () => void;
}; };
@@ -85,6 +89,8 @@ export class TaskKanbanPanel {
async reloadTasks(): Promise<void> { async reloadTasks(): Promise<void> {
if (this.#disposed) return; if (this.#disposed) return;
await this.#loadShownHiddenColumns();
const locations = resolveTaskLocations(this.#deps.config); const locations = resolveTaskLocations(this.#deps.config);
const result = await listLocatedTasks(this.#deps.repo, locations); const result = await listLocatedTasks(this.#deps.repo, locations);
@@ -100,6 +106,24 @@ export class TaskKanbanPanel {
this.#postState(); this.#postState();
} }
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[];
}
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);
}
async #onMessage(raw: unknown): Promise<void> { async #onMessage(raw: unknown): Promise<void> {
const parsed = parseKanbanInboundMessage(raw); const parsed = parseKanbanInboundMessage(raw);
if (parsed.isErr()) { if (parsed.isErr()) {
@@ -208,6 +232,7 @@ export class TaskKanbanPanel {
this.#shownHiddenColumns.push(status); this.#shownHiddenColumns.push(status);
} }
this.#postState(); this.#postState();
void this.#saveShownHiddenColumns();
} }
async #pickLocationForCreate(): Promise<TaskLocation | undefined> { async #pickLocationForCreate(): Promise<TaskLocation | undefined> {