From 6eedfb2bc51c3f708b9cabd022c62192225fc5f1 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Thu, 16 Jul 2026 03:34:23 +0500 Subject: [PATCH] feat: add tags workflow --- README.md | 23 +- README_ru.md | 23 +- package.json | 8 + src/commands/addProjectTag.test.ts | 75 +++++ src/commands/addProjectTag.ts | 69 +++++ src/commands/loadProjectConfig.test.ts | 54 ++++ src/commands/loadProjectConfig.ts | 53 ++++ src/error.ts | 4 + src/extension.ts | 3 +- src/frontmatter/frontmatterFields.test.ts | 31 +- src/frontmatter/frontmatterFields.ts | 16 ++ src/frontmatter/parseTaskTags.test.ts | 31 ++ src/frontmatter/parseTaskTags.ts | 23 ++ src/frontmatter/validateFrontmatter.test.ts | 34 +++ src/frontmatter/validateFrontmatter.ts | 55 +++- src/model/projectConfig.test.ts | 30 ++ src/model/projectConfig.ts | 22 ++ src/projectConfig/parseProjectConfig.test.ts | 40 +++ src/projectConfig/parseProjectConfig.ts | 73 +++++ .../serializeProjectConfig.test.ts | 19 ++ src/projectConfig/serializeProjectConfig.ts | 15 + src/ui/editFrontmatterField.ts | 266 +++++++++++++++++- src/ui/getGitUserName.ts | 12 +- src/ui/resolveTaskFolder.test.ts | 38 +++ src/ui/resolveTaskFolder.ts | 29 ++ src/ui/taskCommands.ts | 13 + src/ui/taskDecorations.ts | 32 +++ src/ui/taskDiagnostics.ts | 73 ++++- src/utils/yamlScalar.ts | 11 + 29 files changed, 1153 insertions(+), 22 deletions(-) create mode 100644 src/commands/addProjectTag.test.ts create mode 100644 src/commands/addProjectTag.ts create mode 100644 src/commands/loadProjectConfig.test.ts create mode 100644 src/commands/loadProjectConfig.ts create mode 100644 src/frontmatter/parseTaskTags.test.ts create mode 100644 src/frontmatter/parseTaskTags.ts create mode 100644 src/model/projectConfig.test.ts create mode 100644 src/model/projectConfig.ts create mode 100644 src/projectConfig/parseProjectConfig.test.ts create mode 100644 src/projectConfig/parseProjectConfig.ts create mode 100644 src/projectConfig/serializeProjectConfig.test.ts create mode 100644 src/projectConfig/serializeProjectConfig.ts create mode 100644 src/ui/resolveTaskFolder.test.ts create mode 100644 src/ui/resolveTaskFolder.ts create mode 100644 src/utils/yamlScalar.ts diff --git a/README.md b/README.md index cbe9870..b4eb23b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ cloud, no server: git-friendly, editable in any editor. - **Dashboard** — task list + description (filters, scope, sort, group) - **Kanban** — status columns, drag-and-drop, create-in-column - **Commands** — create / open / change status / delete -- **In-editor** — frontmatter lint + status/priority Quick Pick (decorations) +- **In-editor** — frontmatter lint + status/priority/tags/assignee actions +- **Project tags** — catalog in task folder `config.yml` - **Two stores** — project folder (workspace) and global folder (shared on the machine) @@ -119,6 +120,26 @@ Task description in markdown… One file per task (`*.xflow.md`). File name is derived from the title (slug). `title` max length: 80 characters. +### Project config (`config.yml` in the task folder) + +Default layout: + +```text +.xflow/ + config.yml # project tags catalog + fix-login.xflow.md +``` + +```yaml +# .xflow/config.yml +tags: + - bug + - frontend +``` + +If `config.yml` exists, tags on a task that are **not** in the catalog show as +warnings. Hover `tags` → **Edit tags…** / **Add tag to project**. + ## Dashboard - Search title/body; status/priority chips; group none/status/priority diff --git a/README_ru.md b/README_ru.md index 05d36a6..056fa2b 100644 --- a/README_ru.md +++ b/README_ru.md @@ -15,7 +15,8 @@ extension. - **Dashboard** — список + описание задачи (фильтры, scope, sort, group) - **Kanban** — колонки по статусу, drag-and-drop, create в колонке - **Команды** — create / open / change status / delete -- **In-editor** — lint frontmatter + Quick Pick status/priority (decorations) +- **In-editor** — lint frontmatter + status/priority/tags/assignee +- **Теги проекта** — каталог в `config.yml` папки задач - **Два хранилища** — проектное (в workspace) и глобальное (общая папка на машине) @@ -119,6 +120,26 @@ updated: 2026-07-12T10:00:00.000Z Один файл — одна задача (`*.xflow.md`). Имя файла строится из title (slug). Макс. длина `title`: 80 символов. +### Конфиг проекта (`config.yml` в папке задач) + +По умолчанию: + +```text +.xflow/ + config.yml + fix-login.xflow.md +``` + +```yaml +# .xflow/config.yml +tags: + - bug + - frontend +``` + +Если `config.yml` есть, теги задачи вне каталога — warning. Hover на `tags` → +**Edit tags…** / **Add tag to project**. + ## Dashboard - Поиск по title/body, chips status/priority, group none/status/priority diff --git a/package.json b/package.json index 235098a..98b6050 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,14 @@ "command": "xboctukFlow.setAssigneeMe", "title": "XBOCTuK Flow: Set Assignee to Me" }, + { + "command": "xboctukFlow.editTaskTags", + "title": "XBOCTuK Flow: Edit Tags (in file)" + }, + { + "command": "xboctukFlow.addTagToProject", + "title": "XBOCTuK Flow: Add Tag to Project" + }, { "command": "xboctukFlow.openDashboard", "title": "XBOCTuK Flow: Open Dashboard", diff --git a/src/commands/addProjectTag.test.ts b/src/commands/addProjectTag.test.ts new file mode 100644 index 0000000..07aa1ca --- /dev/null +++ b/src/commands/addProjectTag.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { AppErrorVariant } from "@/error"; +import { getProjectConfigPath } from "@/model/projectConfig"; +import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem"; +import { addProjectTag } from "./addProjectTag"; +import { loadProjectConfig } from "./loadProjectConfig"; + +describe("addProjectTag", () => { + const folder = "/ws/.xflow"; + let fs: InMemoryFileSystem; + + beforeEach(() => { + fs = new InMemoryFileSystem(); + }); + + it("creates config.yml and adds the tag", async () => { + const result = await addProjectTag(fs, { + folderPath: folder, + tag: "bug", + }); + expect(result.isOk()).toBe(true); + if (result.isErr()) return; + expect(result.value.added).toBe(true); + expect(result.value.config.tags).toEqual(["bug"]); + + const loaded = await loadProjectConfig(fs, folder); + expect(loaded._unsafeUnwrap().exists).toBe(true); + expect(loaded._unsafeUnwrap().config.tags).toEqual(["bug"]); + expect(await fs.readFile(getProjectConfigPath(folder))).toContain("bug"); + }); + + it("is idempotent for an existing tag", async () => { + await addProjectTag(fs, { folderPath: folder, tag: "bug" }); + const result = await addProjectTag(fs, { + folderPath: folder, + tag: "bug", + }); + expect(result.isOk()).toBe(true); + if (result.isErr()) return; + expect(result.value.added).toBe(false); + expect(result.value.config.tags).toEqual(["bug"]); + }); + + it("appends a new tag", async () => { + await addProjectTag(fs, { folderPath: folder, tag: "bug" }); + const result = await addProjectTag(fs, { + folderPath: folder, + tag: "frontend", + }); + expect(result._unsafeUnwrap().config.tags).toEqual(["bug", "frontend"]); + }); + + it("trims tag and rejects empty", async () => { + const empty = await addProjectTag(fs, { + folderPath: folder, + tag: " ", + }); + expect(empty.isErr()).toBe(true); + if (empty.isOk()) return; + expect(empty.error.variant).toBe(AppErrorVariant.EMPTY_TAG); + + const trimmed = await addProjectTag(fs, { + folderPath: folder, + tag: " docs ", + }); + expect(trimmed._unsafeUnwrap().config.tags).toEqual(["docs"]); + }); + + it("rejects missing folder", async () => { + const result = await addProjectTag(fs, { tag: "bug" }); + expect(result.isErr()).toBe(true); + if (result.isOk()) return; + expect(result.error.variant).toBe(AppErrorVariant.NO_FOLDER); + }); +}); diff --git a/src/commands/addProjectTag.ts b/src/commands/addProjectTag.ts new file mode 100644 index 0000000..42fdfae --- /dev/null +++ b/src/commands/addProjectTag.ts @@ -0,0 +1,69 @@ +import { errAsync, okAsync, ResultAsync } from "neverthrow"; +import { appError, AppError, AppErrorVariant } from "@/error"; +import { + getProjectConfigPath, + normalizeTag, + type ProjectConfig, +} from "@/model/projectConfig"; +import { IFileSystem } from "@/ports"; +import { serializeProjectConfig } from "@/projectConfig/serializeProjectConfig"; +import { loadProjectConfig } from "./loadProjectConfig"; + +export type AddProjectTagInput = { + folderPath?: string; + tag?: string; +}; + +export type AddProjectTagValue = { + config: ProjectConfig; + /** True if the tag was newly added. */ + added: boolean; + path: string; +}; + +export function addProjectTag( + fs: IFileSystem, + input: AddProjectTagInput, +): ResultAsync { + if (input.folderPath === undefined || input.folderPath === "") { + return errAsync(appError(AppErrorVariant.NO_FOLDER)); + } + if (input.tag === undefined) { + return errAsync(appError(AppErrorVariant.EMPTY_TAG)); + } + + const tag = normalizeTag(input.tag); + if (!tag) { + return errAsync(appError(AppErrorVariant.EMPTY_TAG)); + } + + const folderPath = input.folderPath; + const path = getProjectConfigPath(folderPath); + + return loadProjectConfig(fs, folderPath).andThen((loaded) => { + if (loaded.config.tags.includes(tag)) { + return okAsync({ + config: loaded.config, + added: false, + path, + }); + } + + const config: ProjectConfig = { + tags: [...loaded.config.tags, tag], + }; + const content = serializeProjectConfig(config); + + return ResultAsync.fromPromise( + fs.mkdir(folderPath).then(() => fs.writeFile(path, content)), + (error) => + appError(AppErrorVariant.INVALID_PROJECT_CONFIG, { + detail: error instanceof Error ? error.message : "write failed", + }), + ).map(() => ({ + config, + added: true, + path, + })); + }); +} diff --git a/src/commands/loadProjectConfig.test.ts b/src/commands/loadProjectConfig.test.ts new file mode 100644 index 0000000..cc7d8e1 --- /dev/null +++ b/src/commands/loadProjectConfig.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { AppErrorVariant } from "@/error"; +import { getProjectConfigPath } from "@/model/projectConfig"; +import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem"; +import { loadProjectConfig } from "./loadProjectConfig"; + +describe("loadProjectConfig", () => { + const folder = "/ws/.xflow"; + let fs: InMemoryFileSystem; + + beforeEach(() => { + fs = new InMemoryFileSystem(); + }); + + it("returns exists:false and empty tags when config.yml is missing", async () => { + const result = await loadProjectConfig(fs, folder); + expect(result.isOk()).toBe(true); + if (result.isErr()) return; + expect(result.value.exists).toBe(false); + expect(result.value.config.tags).toEqual([]); + expect(result.value.path).toBe(getProjectConfigPath(folder)); + }); + + it("loads tags when config.yml exists", async () => { + await fs.mkdir(folder); + await fs.writeFile( + getProjectConfigPath(folder), + "tags:\n - bug\n - frontend\n", + ); + + const result = await loadProjectConfig(fs, folder); + expect(result.isOk()).toBe(true); + if (result.isErr()) return; + expect(result.value.exists).toBe(true); + expect(result.value.config.tags).toEqual(["bug", "frontend"]); + }); + + it("rejects missing folder path", async () => { + const result = await loadProjectConfig(fs, undefined); + expect(result.isErr()).toBe(true); + if (result.isOk()) return; + expect(result.error.variant).toBe(AppErrorVariant.NO_FOLDER); + }); + + it("rejects invalid config content", async () => { + await fs.mkdir(folder); + await fs.writeFile(getProjectConfigPath(folder), "tags: [\n"); + + const result = await loadProjectConfig(fs, folder); + expect(result.isErr()).toBe(true); + if (result.isOk()) return; + expect(result.error.variant).toBe(AppErrorVariant.INVALID_PROJECT_CONFIG); + }); +}); diff --git a/src/commands/loadProjectConfig.ts b/src/commands/loadProjectConfig.ts new file mode 100644 index 0000000..998498a --- /dev/null +++ b/src/commands/loadProjectConfig.ts @@ -0,0 +1,53 @@ +import { errAsync, okAsync, ResultAsync } from "neverthrow"; +import { appError, AppError, AppErrorVariant } from "@/error"; +import { + EMPTY_PROJECT_CONFIG, + getProjectConfigPath, + type ProjectConfig, +} from "@/model/projectConfig"; +import { IFileSystem } from "@/ports"; +import { parseProjectConfig } from "@/projectConfig/parseProjectConfig"; + +export type LoadProjectConfigValue = { + config: ProjectConfig; + /** False when config.yml is missing (catalog checks should be skipped). */ + exists: boolean; + path: string; +}; + +export function loadProjectConfig( + fs: IFileSystem, + folderPath: string | undefined, +): ResultAsync { + if (folderPath === undefined || folderPath === "") { + return errAsync(appError(AppErrorVariant.NO_FOLDER)); + } + + const path = getProjectConfigPath(folderPath); + + return ResultAsync.fromSafePromise( + fs.readFile(path).then( + (content) => ({ found: true as const, content }), + () => ({ found: false as const }), + ), + ).andThen((read) => { + if (!read.found) { + return okAsync({ + config: { ...EMPTY_PROJECT_CONFIG }, + exists: false, + path, + }); + } + + const parsed = parseProjectConfig(read.content); + if (parsed.isErr()) { + return errAsync(parsed.error); + } + + return okAsync({ + config: parsed.value, + exists: true, + path, + }); + }); +} diff --git a/src/error.ts b/src/error.ts index f982780..0e0947f 100644 --- a/src/error.ts +++ b/src/error.ts @@ -8,6 +8,8 @@ export const AppErrorVariant = { NOT_FOUND: "not-found", INVALID_MESSAGE: "invalid-message", INVALID_TASK_FILE: "invalid-task-file", + INVALID_PROJECT_CONFIG: "invalid-project-config", + EMPTY_TAG: "empty-tag", } as const; export type AppErrorVariant = @@ -31,6 +33,8 @@ const APP_ERROR_MESSAGES: Record = { [AppErrorVariant.NOT_FOUND]: "Task not found: {id}", [AppErrorVariant.INVALID_MESSAGE]: "Invalid message: {detail}", [AppErrorVariant.INVALID_TASK_FILE]: "Invalid task file: {detail}", + [AppErrorVariant.INVALID_PROJECT_CONFIG]: "Invalid project config: {detail}", + [AppErrorVariant.EMPTY_TAG]: "Tag is empty", }; export function formatMessage( diff --git a/src/extension.ts b/src/extension.ts index 7e8d6e6..52b5c3a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -48,13 +48,14 @@ export function activate(context: vscode.ExtensionContext) { statusBarItem.show(); context.subscriptions.push(statusBarItem); - registerTaskDiagnostics(context); + registerTaskDiagnostics(context, { fs: fileSystem, config }); registerTaskDecorations(context); registerTaskCommands(context, { repo, config, tree, + fs: fileSystem, onTasksMutated: () => { TaskDashboardPanel.refreshIfOpen(); TaskKanbanPanel.refreshIfOpen(); diff --git a/src/frontmatter/frontmatterFields.test.ts b/src/frontmatter/frontmatterFields.test.ts index 68ef71e..ac8087b 100644 --- a/src/frontmatter/frontmatterFields.test.ts +++ b/src/frontmatter/frontmatterFields.test.ts @@ -1,11 +1,17 @@ import { describe, expect, it } from "vitest"; -import { findFieldRange, replaceFrontmatterField } from "./frontmatterFields"; +import { + findFieldRange, + formatTagsYamlValue, + replaceFrontmatterField, + setFrontmatterTags, +} from "./frontmatterFields"; const sample = `--- id: abc title: Hello status: todo priority: medium +tags: [old] --- Body @@ -47,3 +53,26 @@ describe("findFieldRange", () => { expect(line.slice(range!.startCol, range!.endCol)).toBe("todo"); }); }); + +describe("formatTagsYamlValue", () => { + it("formats empty and simple lists", () => { + expect(formatTagsYamlValue([])).toBe("[]"); + expect(formatTagsYamlValue(["bug", "frontend"])).toBe( + "[bug, frontend]", + ); + }); + + it("quotes tags with spaces", () => { + expect(formatTagsYamlValue(["my tag"])).toBe('["my tag"]'); + }); +}); + +describe("setFrontmatterTags", () => { + it("replaces the tags line", () => { + const result = setFrontmatterTags(sample, ["bug", "docs"]); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.content).toContain("tags: [bug, docs]"); + expect(result.content).not.toContain("tags: [old]"); + }); +}); diff --git a/src/frontmatter/frontmatterFields.ts b/src/frontmatter/frontmatterFields.ts index 500fcc2..150d2fd 100644 --- a/src/frontmatter/frontmatterFields.ts +++ b/src/frontmatter/frontmatterFields.ts @@ -1,3 +1,4 @@ +import { yamlInlineScalar } from "@/utils/yamlScalar"; import { extractFrontmatterBlock, findFieldLineRange, @@ -12,6 +13,21 @@ export type ReplaceFrontmatterFieldResult = | { ok: true; content: string; range: TextRange } | { ok: false; reason: "no-frontmatter" | "no-field" }; +/** Inline YAML list for frontmatter `tags: […]`. */ +export function formatTagsYamlValue(tags: string[]): string { + if (tags.length === 0) { + return "[]"; + } + return `[${tags.map((t) => yamlInlineScalar(t)).join(", ")}]`; +} + +export function setFrontmatterTags( + content: string, + tags: string[], +): ReplaceFrontmatterFieldResult { + return replaceFrontmatterField(content, "tags", formatTagsYamlValue(tags)); +} + /** * Replace a scalar frontmatter field value on its line. * Does not re-serialize YAML; only rewrites the value after `:`. diff --git a/src/frontmatter/parseTaskTags.test.ts b/src/frontmatter/parseTaskTags.test.ts new file mode 100644 index 0000000..82f776d --- /dev/null +++ b/src/frontmatter/parseTaskTags.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { parseTaskTags } from "./parseTaskTags"; + +const sample = `--- +id: "a" +title: T +status: todo +priority: medium +tags: [bug, frontend] +assignee: "" +created: 2026-01-01T00:00:00.000Z +updated: 2026-01-01T00:00:00.000Z +--- + +body +`; + +describe("parseTaskTags", () => { + it("reads tags from frontmatter", () => { + expect(parseTaskTags(sample)).toEqual(["bug", "frontend"]); + }); + + it("returns empty when tags missing", () => { + const content = sample.replace("tags: [bug, frontend]\n", ""); + expect(parseTaskTags(content)).toEqual([]); + }); + + it("returns empty for invalid content", () => { + expect(parseTaskTags("not a task")).toEqual([]); + }); +}); diff --git a/src/frontmatter/parseTaskTags.ts b/src/frontmatter/parseTaskTags.ts new file mode 100644 index 0000000..eadc3ce --- /dev/null +++ b/src/frontmatter/parseTaskTags.ts @@ -0,0 +1,23 @@ +import * as yaml from "js-yaml"; +import { extractFrontmatterBlock } from "./frontmatterBlock"; + +/** Read current tags array from task file content (empty if missing/invalid). */ +export function parseTaskTags(content: string): string[] { + const block = extractFrontmatterBlock(content); + if (!block || block.closeLine < 0) { + return []; + } + try { + const data = yaml.load(block.yaml); + if (!data || typeof data !== "object" || Array.isArray(data)) { + return []; + } + const tags = (data as Record).tags; + if (!Array.isArray(tags)) { + return []; + } + return tags.filter((t): t is string => typeof t === "string"); + } catch { + return []; + } +} diff --git a/src/frontmatter/validateFrontmatter.test.ts b/src/frontmatter/validateFrontmatter.test.ts index 2d2ad8b..31d4e17 100644 --- a/src/frontmatter/validateFrontmatter.test.ts +++ b/src/frontmatter/validateFrontmatter.test.ts @@ -93,4 +93,38 @@ title: [unterminated const issues = validateFrontmatter(content); expect(issues.some((i) => /yaml/i.test(i.message))).toBe(true); }); + + it("does not warn about unknown tags when knownTags is omitted", () => { + const issues = validateFrontmatter(valid); + expect(issues.filter((i) => i.field === "tags")).toEqual([]); + }); + + it("warns when a tag is not in knownTags", () => { + const content = valid.replace("tags: [bug]", "tags: [bug, nope]"); + const issues = validateFrontmatter(content, { knownTags: ["bug"] }); + const unknown = issues.filter( + (i) => i.field === "tags" && i.severity === "warning", + ); + expect(unknown).toHaveLength(1); + expect(unknown[0].message).toMatch(/nope/i); + }); + + it("does not warn when all tags are known", () => { + const issues = validateFrontmatter(valid, { + knownTags: ["bug", "frontend"], + }); + expect(issues.filter((i) => i.severity === "warning")).toEqual([]); + }); + + it("warns for every tag when knownTags is empty array (config exists)", () => { + const issues = validateFrontmatter(valid, { knownTags: [] }); + expect( + issues.some( + (i) => + i.field === "tags" && + i.severity === "warning" && + /bug/i.test(i.message), + ), + ).toBe(true); + }); }); diff --git a/src/frontmatter/validateFrontmatter.ts b/src/frontmatter/validateFrontmatter.ts index 4b46da2..ca4ede9 100644 --- a/src/frontmatter/validateFrontmatter.ts +++ b/src/frontmatter/validateFrontmatter.ts @@ -47,11 +47,50 @@ function fieldRangeOrBlock( return findFieldRange(content, key) ?? blockRange; } +/** Best-effort range of a tag token inside the tags field value. */ +export function findTagTokenRange( + content: string, + tag: string, +): TextRange | undefined { + const fieldRange = findFieldRange(content, "tags"); + if (!fieldRange) return undefined; + + const lines = content.split(/\r?\n/); + const line = lines[fieldRange.startLine] ?? ""; + const value = line.slice(fieldRange.startCol); + const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp( + `(? { + it("uses config.yml as the filename inside the task folder", () => { + expect(TASK_FOLDER_CONFIG_FILENAME).toBe("config.yml"); + }); + + it("joins folder path with config.yml", () => { + expect(getProjectConfigPath("/ws/.xflow")).toBe("/ws/.xflow/config.yml"); + expect(getProjectConfigPath("C:\\proj\\.xflow")).toMatch( + /config\.yml$/, + ); + }); + + it("empty config has no tags", () => { + expect(EMPTY_PROJECT_CONFIG).toEqual({ tags: [] }); + }); + + it("normalizeTag trims and rejects empty", () => { + expect(normalizeTag(" bug ")).toBe("bug"); + expect(normalizeTag(" ")).toBeUndefined(); + expect(normalizeTag("")).toBeUndefined(); + }); +}); diff --git a/src/model/projectConfig.ts b/src/model/projectConfig.ts new file mode 100644 index 0000000..419893b --- /dev/null +++ b/src/model/projectConfig.ts @@ -0,0 +1,22 @@ +/** Config file name inside a task folder (project or global). */ +export const TASK_FOLDER_CONFIG_FILENAME = "config.yml"; + +export type ProjectConfig = { + tags: string[]; +}; + +export const EMPTY_PROJECT_CONFIG: ProjectConfig = { + tags: [], +}; + +/** Absolute/joined path to config.yml in a task folder. */ +export function getProjectConfigPath(folderPath: string): string { + const base = folderPath.replace(/\\/g, "/").replace(/\/$/, ""); + return `${base}/${TASK_FOLDER_CONFIG_FILENAME}`; +} + +/** Normalize a tag: trim. Empty after trim → undefined. */ +export function normalizeTag(value: string): string | undefined { + const tag = value.trim(); + return tag.length > 0 ? tag : undefined; +} diff --git a/src/projectConfig/parseProjectConfig.test.ts b/src/projectConfig/parseProjectConfig.test.ts new file mode 100644 index 0000000..c17397b --- /dev/null +++ b/src/projectConfig/parseProjectConfig.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { AppErrorVariant } from "@/error"; +import { parseProjectConfig } from "./parseProjectConfig"; + +describe("parseProjectConfig", () => { + it("parses tags list", () => { + const result = parseProjectConfig("tags:\n - bug\n - frontend\n"); + expect(result.isOk()).toBe(true); + if (result.isErr()) return; + expect(result.value.tags).toEqual(["bug", "frontend"]); + }); + + it("accepts empty / missing tags", () => { + expect(parseProjectConfig("")._unsafeUnwrap().tags).toEqual([]); + expect(parseProjectConfig("tags: []\n")._unsafeUnwrap().tags).toEqual( + [], + ); + }); + + it("dedupes and trims tags", () => { + const result = parseProjectConfig( + "tags:\n - bug\n - bug \n - frontend\n", + ); + expect(result._unsafeUnwrap().tags).toEqual(["bug", "frontend"]); + }); + + it("rejects invalid YAML", () => { + const result = parseProjectConfig("tags: [\n"); + expect(result.isErr()).toBe(true); + if (result.isOk()) return; + expect(result.error.variant).toBe(AppErrorVariant.INVALID_PROJECT_CONFIG); + }); + + it("rejects non-string tags", () => { + const result = parseProjectConfig("tags:\n - 1\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 new file mode 100644 index 0000000..4eeb722 --- /dev/null +++ b/src/projectConfig/parseProjectConfig.ts @@ -0,0 +1,73 @@ +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"; + +/** + * Parse task-folder config.yml content. + * Accepts missing/empty tags → []. + */ +export function parseProjectConfig( + content: string, +): Result { + if (content.trim() === "") { + return ok({ ...EMPTY_PROJECT_CONFIG }); + } + + let data: unknown; + try { + data = yaml.load(content); + } catch (error) { + const detail = error instanceof Error ? error.message : "invalid YAML"; + return err( + appError(AppErrorVariant.INVALID_PROJECT_CONFIG, { detail }), + ); + } + + if (data === null || data === undefined) { + return ok({ ...EMPTY_PROJECT_CONFIG }); + } + + if (typeof data !== "object" || Array.isArray(data)) { + return err( + appError(AppErrorVariant.INVALID_PROJECT_CONFIG, { + detail: "root must be a mapping", + }), + ); + } + + const record = data as Record; + 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") { + 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 }); +} diff --git a/src/projectConfig/serializeProjectConfig.test.ts b/src/projectConfig/serializeProjectConfig.test.ts new file mode 100644 index 0000000..9ebe1e9 --- /dev/null +++ b/src/projectConfig/serializeProjectConfig.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { parseProjectConfig } from "./parseProjectConfig"; +import { serializeProjectConfig } from "./serializeProjectConfig"; + +describe("serializeProjectConfig", () => { + it("round-trips tags", () => { + const yaml = serializeProjectConfig({ tags: ["bug", "docs"] }); + expect(yaml).toContain("bug"); + expect(yaml).toContain("docs"); + const parsed = parseProjectConfig(yaml); + expect(parsed._unsafeUnwrap().tags).toEqual(["bug", "docs"]); + }); + + it("serializes empty tags", () => { + const yaml = serializeProjectConfig({ tags: [] }); + const parsed = parseProjectConfig(yaml); + expect(parsed._unsafeUnwrap().tags).toEqual([]); + }); +}); diff --git a/src/projectConfig/serializeProjectConfig.ts b/src/projectConfig/serializeProjectConfig.ts new file mode 100644 index 0000000..e417f0e --- /dev/null +++ b/src/projectConfig/serializeProjectConfig.ts @@ -0,0 +1,15 @@ +import * as yaml from "js-yaml"; +import type { ProjectConfig } from "@/model/projectConfig"; + +export function serializeProjectConfig(config: ProjectConfig): string { + return yaml + .dump( + { tags: config.tags }, + { + lineWidth: -1, + forceQuotes: false, + }, + ) + .trimEnd() + .concat("\n"); +} diff --git a/src/ui/editFrontmatterField.ts b/src/ui/editFrontmatterField.ts index f496e0c..3181aea 100644 --- a/src/ui/editFrontmatterField.ts +++ b/src/ui/editFrontmatterField.ts @@ -1,14 +1,24 @@ import * as vscode from "vscode"; +import { addProjectTag } from "@/commands/addProjectTag"; +import { loadProjectConfig } from "@/commands/loadProjectConfig"; +import { resolveTaskLocations } from "@/commands/resolveTaskLocations"; import { findFieldRange, + formatTagsYamlValue, replaceFrontmatterField, + setFrontmatterTags, } from "@/frontmatter/frontmatterFields"; +import { parseTaskTags } from "@/frontmatter/parseTaskTags"; +import { normalizeTag } from "@/model/projectConfig"; import { isTaskFileName, TASK_FILE_EXTENSION, TASK_LANGUAGE_ID, } from "@/model/taskFile"; +import { IConfigProvider, IFileSystem } from "@/ports"; import { getGitUserName, yamlInlineScalar } from "./getGitUserName"; +import { presentError } from "./presentError"; +import { resolveTaskFolderForPath } from "./resolveTaskFolder"; import { TASK_PRIORITY_LABELS, TASK_PRIORITY_ORDER, @@ -16,10 +26,16 @@ import { TASK_STATUS_ORDER, } from "./taskLabels"; +export type FrontmatterEditDeps = { + fs: IFileSystem; + config: IConfigProvider; +}; + export const EditableField = { STATUS: "status", PRIORITY: "priority", ASSIGNEE: "assignee", + TAGS: "tags", } as const; export type EditableField = (typeof EditableField)[keyof typeof EditableField]; @@ -151,5 +167,253 @@ export async function runSetAssigneeMe(uriArg?: string): Promise { return; } - await applyFieldValue(document, EditableField.ASSIGNEE, yamlInlineScalar(name)); + await applyFieldValue( + document, + EditableField.ASSIGNEE, + yamlInlineScalar(name), + ); +} + +async function resolveTaskFolder( + document: vscode.TextDocument, + config: IConfigProvider, +): Promise { + const locations = resolveTaskLocations(config); + return resolveTaskFolderForPath(document.fileName, locations); +} + +async function applyTags( + document: vscode.TextDocument, + tags: string[], +): Promise { + const text = document.getText(); + const result = setFrontmatterTags(text, tags); + if (!result.ok) { + void vscode.window.showErrorMessage( + result.reason === "no-field" + ? 'Field "tags" not found in frontmatter' + : "No YAML frontmatter in this file", + ); + return false; + } + + const valueRange = findFieldRange(text, EditableField.TAGS); + const value = formatTagsYamlValue(tags); + if (!valueRange) { + const full = new vscode.Range( + document.positionAt(0), + document.positionAt(text.length), + ); + const edit = new vscode.WorkspaceEdit(); + edit.replace(document.uri, full, result.content); + return vscode.workspace.applyEdit(edit); + } + + const range = new vscode.Range( + valueRange.startLine, + valueRange.startCol, + valueRange.endLine, + valueRange.endCol, + ); + const edit = new vscode.WorkspaceEdit(); + edit.replace(document.uri, range, value); + return vscode.workspace.applyEdit(edit); +} + +/** + * Multi-select tags from project config. + * + * - Type a name + Enter → add tag (selected), stay on Quick Pick + * - Enter on empty field → apply selection to task + config + * - Escape → cancel + */ +export async function runEditTaskTags( + deps: FrontmatterEditDeps, + uriArg?: string, +): Promise { + const document = await resolveDocument(uriArg); + if (!document) { + void vscode.window.showErrorMessage( + `Open a ${TASK_FILE_EXTENSION} task file first`, + ); + return; + } + + const folder = await resolveTaskFolder(document, deps.config); + if (!folder) { + void vscode.window.showErrorMessage( + "Task file is outside a configured task folder", + ); + return; + } + + const loaded = await loadProjectConfig(deps.fs, folder); + if (loaded.isErr()) { + presentError(loaded.error); + return; + } + + const catalog = [...loaded.value.config.tags]; + const current = parseTaskTags(document.getText()); + + type TagItem = vscode.QuickPickItem & { tag: string }; + + const selected = await new Promise((resolve) => { + const qp = vscode.window.createQuickPick(); + qp.canSelectMany = true; + qp.ignoreFocusOut = true; + qp.placeholder = + "Select tags · type name + Enter to add · empty Enter to apply · Esc to cancel"; + qp.matchOnDescription = true; + + const extraTags: string[] = []; + let selectedTags = new Set(current); + let accepted = false; + let syncingSelection = false; + + const rebuild = (): void => { + const items: TagItem[] = []; + const seen = new Set(); + for (const tag of [...catalog, ...current, ...extraTags]) { + if (seen.has(tag)) continue; + seen.add(tag); + items.push({ + label: tag, + description: catalog.includes(tag) + ? undefined + : "will add to config", + tag, + }); + } + syncingSelection = true; + qp.items = items; + qp.selectedItems = items.filter((item) => selectedTags.has(item.tag)); + syncingSelection = false; + }; + + rebuild(); + + qp.onDidChangeSelection((items) => { + if (syncingSelection) return; + selectedTags = new Set(items.map((item) => item.tag)); + }); + + qp.onDidAccept(() => { + const typed = normalizeTag(qp.value); + if (typed) { + if (!extraTags.includes(typed) && !catalog.includes(typed)) { + extraTags.push(typed); + } + selectedTags.add(typed); + qp.value = ""; + rebuild(); + return; + } + + accepted = true; + const tags = [...selectedTags]; + qp.hide(); + qp.dispose(); + resolve(tags); + }); + + qp.onDidHide(() => { + if (!accepted) { + qp.dispose(); + resolve(undefined); + } + }); + + qp.show(); + }); + + if (selected === undefined) return; + + const catalogSet = new Set(catalog); + for (const tag of selected) { + if (catalogSet.has(tag)) continue; + const added = await addProjectTag(deps.fs, { + folderPath: folder, + tag, + }); + if (added.isErr()) { + presentError(added.error); + return; + } + catalogSet.add(tag); + } + + const ok = await applyTags(document, selected); + if (ok) { + void vscode.window.showInformationMessage( + selected.length ? `Tags: ${selected.join(", ")}` : "Tags cleared", + ); + } +} + +/** Add unknown task tags into the task-folder config.yml. */ +export async function runAddTagToProject( + deps: FrontmatterEditDeps, + uriArg?: string, +): Promise { + const document = await resolveDocument(uriArg); + if (!document) { + void vscode.window.showErrorMessage( + `Open a ${TASK_FILE_EXTENSION} task file first`, + ); + return; + } + + const folder = await resolveTaskFolder(document, deps.config); + if (!folder) { + void vscode.window.showErrorMessage( + "Task file is outside a configured task folder", + ); + return; + } + + const loaded = await loadProjectConfig(deps.fs, folder); + if (loaded.isErr()) { + presentError(loaded.error); + return; + } + + const known = new Set(loaded.value.config.tags); + const current = parseTaskTags(document.getText()); + const unknown = current.filter((tag) => !known.has(tag)); + + if (unknown.length === 0) { + void vscode.window.showInformationMessage( + "All task tags are already in project config", + ); + return; + } + + const picked = await vscode.window.showQuickPick( + unknown.map((tag) => ({ + label: tag, + tag, + picked: true, + })), + { + canPickMany: true, + placeHolder: "Add to project config", + }, + ); + if (!picked || picked.length === 0) return; + + for (const item of picked) { + const result = await addProjectTag(deps.fs, { + folderPath: folder, + tag: item.tag, + }); + if (result.isErr()) { + presentError(result.error); + return; + } + } + + void vscode.window.showInformationMessage( + `Added to project config: ${picked.map((p) => p.tag).join(", ")}`, + ); } diff --git a/src/ui/getGitUserName.ts b/src/ui/getGitUserName.ts index cbfa69e..84d380f 100644 --- a/src/ui/getGitUserName.ts +++ b/src/ui/getGitUserName.ts @@ -21,14 +21,4 @@ export async function getGitUserName( } } -/** Quote a YAML scalar for a single-line frontmatter value. */ -export function yamlInlineScalar(value: string): string { - if (value === "") { - return '""'; - } - // Safe unquoted token (no spaces / special YAML chars). - if (/^[A-Za-z0-9_./+-]+$/.test(value)) { - return value; - } - return JSON.stringify(value); -} +export { yamlInlineScalar } from "@/utils/yamlScalar"; diff --git a/src/ui/resolveTaskFolder.test.ts b/src/ui/resolveTaskFolder.test.ts new file mode 100644 index 0000000..9da0c22 --- /dev/null +++ b/src/ui/resolveTaskFolder.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { TaskScope } from "@/model/taskLocation"; +import { resolveTaskFolderForPath } from "./resolveTaskFolder"; + +describe("resolveTaskFolderForPath", () => { + const locations = [ + { scope: TaskScope.PROJECT, folderPath: "/ws/.xflow" }, + { scope: TaskScope.GLOBAL, folderPath: "/global/tasks" }, + ]; + + it("matches project task file", () => { + expect( + resolveTaskFolderForPath("/ws/.xflow/fix-login.xflow.md", locations), + ).toBe("/ws/.xflow"); + }); + + it("matches global task file", () => { + expect( + resolveTaskFolderForPath("/global/tasks/note.xflow.md", locations), + ).toBe("/global/tasks"); + }); + + it("returns undefined outside task folders", () => { + expect(resolveTaskFolderForPath("/ws/src/app.ts", locations)).toBe( + undefined, + ); + }); + + it("prefers longer matching prefix", () => { + const nested = [ + { scope: TaskScope.PROJECT, folderPath: "/ws" }, + { scope: TaskScope.GLOBAL, folderPath: "/ws/.xflow" }, + ]; + expect( + resolveTaskFolderForPath("/ws/.xflow/a.xflow.md", nested), + ).toBe("/ws/.xflow"); + }); +}); diff --git a/src/ui/resolveTaskFolder.ts b/src/ui/resolveTaskFolder.ts new file mode 100644 index 0000000..8e74136 --- /dev/null +++ b/src/ui/resolveTaskFolder.ts @@ -0,0 +1,29 @@ +import * as path from "path"; +import { TaskLocation } from "@/model/taskLocation"; + +/** + * Find the task folder (project/global path) that contains `filePath`. + * Longest matching prefix wins. + */ +export function resolveTaskFolderForPath( + filePath: string, + locations: TaskLocation[], +): string | undefined { + const normalized = path.resolve(filePath).replace(/\\/g, "/"); + let best: string | undefined; + let bestLen = -1; + + for (const location of locations) { + const folder = path.resolve(location.folderPath).replace(/\\/g, "/"); + const prefix = folder.endsWith("/") ? folder : `${folder}/`; + if ( + (normalized === folder || normalized.startsWith(prefix)) && + folder.length > bestLen + ) { + best = location.folderPath; + bestLen = folder.length; + } + } + + return best; +} diff --git a/src/ui/taskCommands.ts b/src/ui/taskCommands.ts index 196c46f..b3c4fb2 100644 --- a/src/ui/taskCommands.ts +++ b/src/ui/taskCommands.ts @@ -11,8 +11,10 @@ import { TaskDashboardPanel } from "@/views/taskDashboardPanel"; import { TaskKanbanPanel } from "@/views/taskKanbanPanel"; import { TaskTreeProvider } from "@/views/taskTreeProvider"; import { + runAddTagToProject, runEditTaskPriority, runEditTaskStatus, + runEditTaskTags, runSetAssigneeMe, } from "./editFrontmatterField"; import { presentError } from "./presentError"; @@ -27,6 +29,7 @@ export type TaskCommandDeps = { repo: ITaskRepository; config: IConfigProvider; tree: TaskTreeProvider; + fs: import("@/ports").IFileSystem; /** Refresh dashboard (and any other listeners) after task mutations. */ onTasksMutated?: () => void; }; @@ -257,6 +260,16 @@ export function registerTaskCommands( "xboctukFlow.setAssigneeMe", (uri?: string) => runSetAssigneeMe(uri), ), + vscode.commands.registerCommand( + "xboctukFlow.editTaskTags", + (uri?: string) => + runEditTaskTags({ fs: deps.fs, config: deps.config }, uri), + ), + vscode.commands.registerCommand( + "xboctukFlow.addTagToProject", + (uri?: string) => + runAddTagToProject({ fs: deps.fs, config: deps.config }, uri), + ), vscode.commands.registerCommand("xboctukFlow.openDashboard", () => runOpenDashboard(deps), ), diff --git a/src/ui/taskDecorations.ts b/src/ui/taskDecorations.ts index 42483c8..5aae5da 100644 --- a/src/ui/taskDecorations.ts +++ b/src/ui/taskDecorations.ts @@ -42,6 +42,19 @@ function hoverAssigneeMe(document: vscode.TextDocument): vscode.MarkdownString { return md; } +function hoverTags(document: vscode.TextDocument): vscode.MarkdownString { + const uri = document.uri.toString(); + const md = new vscode.MarkdownString( + [ + `[$(edit) Edit tags…](${commandUri("xboctukFlow.editTaskTags", [uri])})`, + `[$(repo-push) Add tag to project](${commandUri("xboctukFlow.addTagToProject", [uri])})`, + ].join(" · "), + ); + md.isTrusted = true; + md.supportThemeIcons = true; + return md; +} + export function registerTaskDecorations( context: vscode.ExtensionContext, ): void { @@ -106,6 +119,25 @@ export function registerTaskDecorations( }); } + const tagsRange = findFieldRange(text, EditableField.TAGS); + if (tagsRange) { + options.push({ + range: new vscode.Range( + tagsRange.startLine, + tagsRange.startCol, + tagsRange.endLine, + tagsRange.endCol, + ), + hoverMessage: hoverTags(editor.document), + renderOptions: { + after: { + contentText: " ✎ tags", + color: new vscode.ThemeColor("textLink.foreground"), + }, + }, + }); + } + editor.setDecorations(decorationType, options); }; diff --git a/src/ui/taskDiagnostics.ts b/src/ui/taskDiagnostics.ts index 1d11fe0..f42d51b 100644 --- a/src/ui/taskDiagnostics.ts +++ b/src/ui/taskDiagnostics.ts @@ -1,12 +1,22 @@ import * as vscode from "vscode"; +import { resolveTaskLocations } from "@/commands/resolveTaskLocations"; +import { loadProjectConfig } from "@/commands/loadProjectConfig"; import { validateFrontmatter, type FrontmatterIssue, } from "@/frontmatter/validateFrontmatter"; +import { TASK_FOLDER_CONFIG_FILENAME } from "@/model/projectConfig"; import { isTaskFileName, TASK_LANGUAGE_ID } from "@/model/taskFile"; +import { IConfigProvider, IFileSystem } from "@/ports"; +import { resolveTaskFolderForPath } from "./resolveTaskFolder"; const DEBOUNCE_MS = 200; +export type TaskDiagnosticsDeps = { + fs: IFileSystem; + config: IConfigProvider; +}; + function isTaskDocument(document: vscode.TextDocument): boolean { if (document.uri.scheme !== "file" && document.uri.scheme !== "untitled") { return false; @@ -45,19 +55,46 @@ function toDiagnostic(issue: FrontmatterIssue): vscode.Diagnostic { export function registerTaskDiagnostics( context: vscode.ExtensionContext, + deps: TaskDiagnosticsDeps, ): vscode.DiagnosticCollection { const collection = vscode.languages.createDiagnosticCollection("xboctuk-flow"); context.subscriptions.push(collection); const timers = new Map>(); + /** folderPath → knownTags when config exists; undefined key means skip catalog. */ + const knownTagsByFolder = new Map(); - const lint = (document: vscode.TextDocument): void => { + const invalidateFolder = (folderPath: string): void => { + knownTagsByFolder.delete(folderPath); + }; + + const getKnownTags = async ( + folderPath: string | undefined, + ): Promise => { + if (!folderPath) return undefined; + if (knownTagsByFolder.has(folderPath)) { + return knownTagsByFolder.get(folderPath); + } + const loaded = await loadProjectConfig(deps.fs, folderPath); + if (loaded.isErr() || !loaded.value.exists) { + knownTagsByFolder.set(folderPath, undefined); + return undefined; + } + knownTagsByFolder.set(folderPath, loaded.value.config.tags); + return loaded.value.config.tags; + }; + + const lint = async (document: vscode.TextDocument): Promise => { if (!isTaskDocument(document)) { collection.delete(document.uri); return; } - const issues = validateFrontmatter(document.getText()); + + const locations = resolveTaskLocations(deps.config); + const folder = resolveTaskFolderForPath(document.fileName, locations); + const knownTags = await getKnownTags(folder); + const issues = validateFrontmatter(document.getText(), { knownTags }); collection.set(document.uri, issues.map(toDiagnostic)); }; @@ -69,17 +106,43 @@ export function registerTaskDiagnostics( key, setTimeout(() => { timers.delete(key); - lint(document); + void lint(document); }, DEBOUNCE_MS), ); }; + const relintAllTasks = (): void => { + for (const document of vscode.workspace.textDocuments) { + if (isTaskDocument(document)) { + void lint(document); + } + } + }; + for (const document of vscode.workspace.textDocuments) { - lint(document); + void lint(document); + } + + for (const location of resolveTaskLocations(deps.config)) { + const configPattern = new vscode.RelativePattern( + location.folderPath, + TASK_FOLDER_CONFIG_FILENAME, + ); + const watcher = vscode.workspace.createFileSystemWatcher(configPattern); + const onConfigChange = (): void => { + invalidateFolder(location.folderPath); + relintAllTasks(); + }; + watcher.onDidCreate(onConfigChange); + watcher.onDidChange(onConfigChange); + watcher.onDidDelete(onConfigChange); + context.subscriptions.push(watcher); } context.subscriptions.push( - vscode.workspace.onDidOpenTextDocument(lint), + vscode.workspace.onDidOpenTextDocument((doc) => { + void lint(doc); + }), vscode.workspace.onDidChangeTextDocument((event) => { lintDebounced(event.document); }), diff --git a/src/utils/yamlScalar.ts b/src/utils/yamlScalar.ts new file mode 100644 index 0000000..566ce30 --- /dev/null +++ b/src/utils/yamlScalar.ts @@ -0,0 +1,11 @@ +/** Quote a YAML scalar for a single-line frontmatter value. */ +export function yamlInlineScalar(value: string): string { + if (value === "") { + return '""'; + } + // Safe unquoted token (no spaces / special YAML chars). + if (/^[A-Za-z0-9_./+-]+$/.test(value)) { + return value; + } + return JSON.stringify(value); +}