544 lines
13 KiB
TypeScript
544 lines
13 KiB
TypeScript
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 { getProjectConfigPath, normalizeTag } from "@/model/projectConfig";
|
|
import {
|
|
isTaskFileName,
|
|
TASK_FILE_EXTENSION,
|
|
TASK_LANGUAGE_ID,
|
|
} from "@/model/taskFile";
|
|
import { IConfigProvider, IFileSystem } from "@/ports";
|
|
import * as vscode from "vscode";
|
|
import { getGitUserName, yamlInlineScalar } from "./getGitUserName";
|
|
import { presentError } from "./presentError";
|
|
import { resolveTaskFolderForPath } from "./resolveTaskFolder";
|
|
import { type TaskPriority } from "@/model/taskPriority";
|
|
import { type TaskStatus } from "@/model/taskStatus";
|
|
import {
|
|
TASK_PRIORITY_LABELS,
|
|
TASK_PRIORITY_META,
|
|
TASK_PRIORITY_ORDER,
|
|
TASK_STATUS_LABELS,
|
|
TASK_STATUS_META,
|
|
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];
|
|
|
|
function isTaskDocument(document: vscode.TextDocument): boolean {
|
|
if (document.languageId === TASK_LANGUAGE_ID) {
|
|
return true;
|
|
}
|
|
return isTaskFileName(document.fileName);
|
|
}
|
|
|
|
async function resolveDocument(
|
|
uriArg?: string,
|
|
): Promise<vscode.TextDocument | undefined> {
|
|
if (typeof uriArg === "string" && uriArg.length > 0) {
|
|
const uri = vscode.Uri.parse(uriArg);
|
|
return vscode.workspace.openTextDocument(uri);
|
|
}
|
|
const active = vscode.window.activeTextEditor?.document;
|
|
if (active && isTaskDocument(active)) {
|
|
return active;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function workspaceCwdFor(document: vscode.TextDocument): string | undefined {
|
|
const folder = vscode.workspace.getWorkspaceFolder(document.uri);
|
|
return folder?.uri.fsPath;
|
|
}
|
|
|
|
async function applyFieldValue(
|
|
document: vscode.TextDocument,
|
|
key: EditableField,
|
|
value: string,
|
|
): Promise<boolean> {
|
|
const text = document.getText();
|
|
const result = replaceFrontmatterField(text, key, value);
|
|
if (!result.ok) {
|
|
void vscode.window.showErrorMessage(
|
|
result.reason === "no-field"
|
|
? `Field "${key}" not found in frontmatter`
|
|
: "No YAML frontmatter in this file",
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const valueRange = findFieldRange(text, key);
|
|
if (!valueRange) {
|
|
// Fallback: replace whole document
|
|
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);
|
|
}
|
|
|
|
export function getCurrentFieldValue(text: string, field: string): string | undefined {
|
|
const range = findFieldRange(text, field);
|
|
if (!range) return undefined;
|
|
const lines = text.split(/\r?\n/);
|
|
const raw = lines[range.startLine]!.slice(range.startCol, range.endCol).trim();
|
|
if (
|
|
(raw.startsWith('"') && raw.endsWith('"')) ||
|
|
(raw.startsWith("'") && raw.endsWith("'"))
|
|
) {
|
|
return raw.slice(1, raw.length - 1);
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
export async function runEditTaskStatus(uriArg?: string): Promise<void> {
|
|
const document = await resolveDocument(uriArg);
|
|
if (!document) {
|
|
void vscode.window.showErrorMessage(
|
|
`Open a ${TASK_FILE_EXTENSION} task file first`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
type StatusItem = vscode.QuickPickItem & { status: TaskStatus };
|
|
|
|
const text = document.getText();
|
|
const items: StatusItem[] = TASK_STATUS_ORDER.filter(
|
|
(status) => !TASK_STATUS_META[status].fallback,
|
|
).map((status) => ({
|
|
label: TASK_STATUS_LABELS[status],
|
|
description: status,
|
|
status,
|
|
}));
|
|
|
|
const currentValue = getCurrentFieldValue(text, EditableField.STATUS);
|
|
const activeItem = currentValue
|
|
? items.find((item) => item.status === currentValue)
|
|
: undefined;
|
|
|
|
const picked = await new Promise<StatusItem | undefined>((resolve) => {
|
|
const qp = vscode.window.createQuickPick<StatusItem>();
|
|
qp.items = items;
|
|
qp.placeholder = "Status";
|
|
if (activeItem) qp.activeItems = [activeItem];
|
|
|
|
let accepted = false;
|
|
|
|
qp.onDidAccept(() => {
|
|
accepted = true;
|
|
const picked = qp.activeItems[0];
|
|
if (!picked) return;
|
|
qp.hide();
|
|
qp.dispose();
|
|
resolve(picked);
|
|
});
|
|
|
|
qp.onDidHide(() => {
|
|
if (!accepted) {
|
|
qp.dispose();
|
|
resolve(undefined);
|
|
}
|
|
});
|
|
|
|
qp.show();
|
|
});
|
|
|
|
if (!picked) return;
|
|
|
|
await applyFieldValue(document, EditableField.STATUS, picked.status);
|
|
}
|
|
|
|
export async function runEditTaskPriority(uriArg?: string): Promise<void> {
|
|
const document = await resolveDocument(uriArg);
|
|
if (!document) {
|
|
void vscode.window.showErrorMessage(
|
|
`Open a ${TASK_FILE_EXTENSION} task file first`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
type PriorityItem = vscode.QuickPickItem & { priority: TaskPriority };
|
|
|
|
const text = document.getText();
|
|
const items: PriorityItem[] = TASK_PRIORITY_ORDER.filter(
|
|
(priority) => !TASK_PRIORITY_META[priority].fallback,
|
|
).map((priority) => ({
|
|
label: TASK_PRIORITY_LABELS[priority],
|
|
description: priority,
|
|
priority,
|
|
}));
|
|
|
|
const currentValue = getCurrentFieldValue(text, EditableField.PRIORITY);
|
|
const activeItem = currentValue
|
|
? items.find((item) => item.priority === currentValue)
|
|
: undefined;
|
|
|
|
const picked = await new Promise<PriorityItem | undefined>((resolve) => {
|
|
const qp = vscode.window.createQuickPick<PriorityItem>();
|
|
qp.items = items;
|
|
qp.placeholder = "Priority";
|
|
if (activeItem) qp.activeItems = [activeItem];
|
|
|
|
let accepted = false;
|
|
|
|
qp.onDidAccept(() => {
|
|
accepted = true;
|
|
const picked = qp.activeItems[0];
|
|
if (!picked) return;
|
|
qp.hide();
|
|
qp.dispose();
|
|
resolve(picked);
|
|
});
|
|
|
|
qp.onDidHide(() => {
|
|
if (!accepted) {
|
|
qp.dispose();
|
|
resolve(undefined);
|
|
}
|
|
});
|
|
|
|
qp.show();
|
|
});
|
|
|
|
if (!picked) return;
|
|
|
|
await applyFieldValue(document, EditableField.PRIORITY, picked.priority);
|
|
}
|
|
|
|
/** Set `assignee` from `git config user.name`. */
|
|
export async function runSetAssigneeMe(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 name = await getGitUserName(workspaceCwdFor(document));
|
|
if (!name) {
|
|
void vscode.window.showErrorMessage(
|
|
"Could not read git user.name (set git config user.name)",
|
|
);
|
|
return;
|
|
}
|
|
|
|
await applyFieldValue(
|
|
document,
|
|
EditableField.ASSIGNEE,
|
|
yamlInlineScalar(name),
|
|
);
|
|
}
|
|
|
|
export async function runOpenProjectConfig(
|
|
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 configPath = getProjectConfigPath(folder);
|
|
const uri = vscode.Uri.file(configPath);
|
|
|
|
try {
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
await vscode.window.showTextDocument(doc);
|
|
} catch {
|
|
void vscode.window.showErrorMessage(
|
|
`Could not open config file: ${configPath}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
await applyTags(document, selected);
|
|
}
|
|
|
|
/**
|
|
* Add tag(s) to task-folder config.yml.
|
|
* Prefer `tagArg` from Code Action on an unknown-tag warning.
|
|
* Without tag: multi-select remaining unknown tags from the task.
|
|
*/
|
|
export async function runAddTagToProject(
|
|
deps: FrontmatterEditDeps,
|
|
uriArg?: string,
|
|
tagArg?: 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 single = normalizeTag(tagArg ?? "");
|
|
if (single) {
|
|
const result = await addProjectTag(deps.fs, {
|
|
folderPath: folder,
|
|
tag: single,
|
|
});
|
|
if (result.isErr()) {
|
|
presentError(result.error);
|
|
}
|
|
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) {
|
|
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;
|
|
}
|
|
}
|
|
}
|