Files
xboct-flow/src/ui/editFrontmatterField.ts
T

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;
}
}
}