feat: add tags workflow
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 `:`.
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user