feat: add tags workflow

This commit is contained in:
2026-07-16 03:34:23 +05:00
parent 7c0584707a
commit 6eedfb2bc5
29 changed files with 1153 additions and 22 deletions
+22 -1
View File
@@ -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
+22 -1
View File
@@ -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
+8
View File
@@ -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",
+75
View File
@@ -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);
});
});
+69
View File
@@ -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<AddProjectTagValue, AppError> {
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,
}));
});
}
+54
View File
@@ -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);
});
});
+53
View File
@@ -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<LoadProjectConfigValue, AppError> {
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,
});
});
}
+4
View File
@@ -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, string> = {
[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(
+2 -1
View File
@@ -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();
+30 -1
View File
@@ -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]");
});
});
+16
View File
@@ -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 `:`.
+31
View File
@@ -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([]);
});
});
+23
View File
@@ -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<string, unknown>).tags;
if (!Array.isArray(tags)) {
return [];
}
return tags.filter((t): t is string => typeof t === "string");
} catch {
return [];
}
}
@@ -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);
});
});
+54 -1
View File
@@ -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(
`(?<![\\w-])(?:"${escaped}"|'${escaped}'|${escaped})(?![\\w-])`,
);
const match = re.exec(value);
if (!match || match.index === undefined) {
return fieldRange;
}
const startCol = fieldRange.startCol + match.index;
return {
startLine: fieldRange.startLine,
startCol,
endLine: fieldRange.startLine,
endCol: startCol + match[0].length,
};
}
export type ValidateFrontmatterOptions = {
/**
* When set (including empty array), tags not in this list produce warnings.
* When undefined, catalog checks are skipped (no project config file).
*/
knownTags?: string[];
};
/**
* Validate task file frontmatter. Pure — no vscode.
* Ranges are 0-based line/column.
*/
export function validateFrontmatter(content: string): FrontmatterIssue[] {
export function validateFrontmatter(
content: string,
options: ValidateFrontmatterOptions = {},
): FrontmatterIssue[] {
const issues: FrontmatterIssue[] = [];
const block = extractFrontmatterBlock(content);
@@ -209,6 +248,20 @@ export function validateFrontmatter(content: string): FrontmatterIssue[] {
severity: "error",
range: fieldRangeOrBlock(content, "tags", blockRange),
});
} else if (options.knownTags !== undefined) {
const known = new Set(options.knownTags);
for (const tag of record.tags as string[]) {
if (!known.has(tag)) {
issues.push({
field: "tags",
message: `Unknown tag "${tag}"; add it to project config or pick from catalog`,
severity: "warning",
range:
findTagTokenRange(content, tag) ??
fieldRangeOrBlock(content, "tags", blockRange),
});
}
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import {
EMPTY_PROJECT_CONFIG,
getProjectConfigPath,
normalizeTag,
TASK_FOLDER_CONFIG_FILENAME,
} from "./projectConfig";
describe("projectConfig model", () => {
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();
});
});
+22
View File
@@ -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;
}
@@ -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);
});
});
+73
View File
@@ -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<ProjectConfig, AppError> {
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<string, unknown>;
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<string>();
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 });
}
@@ -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([]);
});
});
@@ -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");
}
+265 -1
View File
@@ -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<void> {
return;
}
await applyFieldValue(document, EditableField.ASSIGNEE, yamlInlineScalar(name));
await applyFieldValue(
document,
EditableField.ASSIGNEE,
yamlInlineScalar(name),
);
}
async function resolveTaskFolder(
document: vscode.TextDocument,
config: IConfigProvider,
): Promise<string | undefined> {
const locations = resolveTaskLocations(config);
return resolveTaskFolderForPath(document.fileName, locations);
}
async function applyTags(
document: vscode.TextDocument,
tags: string[],
): Promise<boolean> {
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<void> {
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<string[] | undefined>((resolve) => {
const qp = vscode.window.createQuickPick<TagItem>();
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<string>();
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<void> {
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(", ")}`,
);
}
+1 -11
View File
@@ -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";
+38
View File
@@ -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");
});
});
+29
View File
@@ -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;
}
+13
View File
@@ -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),
),
+32
View File
@@ -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);
};
+68 -5
View File
@@ -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<string, ReturnType<typeof setTimeout>>();
/** folderPath → knownTags when config exists; undefined key means skip catalog. */
const knownTagsByFolder = new Map<string, string[] | undefined>();
const lint = (document: vscode.TextDocument): void => {
const invalidateFolder = (folderPath: string): void => {
knownTagsByFolder.delete(folderPath);
};
const getKnownTags = async (
folderPath: string | undefined,
): Promise<string[] | undefined> => {
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<void> => {
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) {
lint(document);
if (isTaskDocument(document)) {
void lint(document);
}
}
};
for (const document of vscode.workspace.textDocuments) {
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);
}),
+11
View File
@@ -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);
}