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
+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(", ")}`,
);
}