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 { 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 { 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 { 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((resolve) => { const qp = vscode.window.createQuickPick(); 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 { 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((resolve) => { const qp = vscode.window.createQuickPick(); 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 { 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 { 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 { const locations = resolveTaskLocations(config); return resolveTaskFolderForPath(document.fileName, locations); } async function applyTags( document: vscode.TextDocument, tags: string[], ): Promise { 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 { 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((resolve) => { const qp = vscode.window.createQuickPick(); 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(); 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 { 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; } } }