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