diff --git a/src/ui/editFrontmatterField.test.ts b/src/ui/editFrontmatterField.test.ts new file mode 100644 index 0000000..2f0767e --- /dev/null +++ b/src/ui/editFrontmatterField.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("vscode", () => ({})); + +import { getCurrentFieldValue } from "./editFrontmatterField"; + +const inlineSample = `--- +title: Test +status: in-progress +priority: high +tags: + - bug + - urgent +assignee: me +--- + +Body +`; + +const quotedSample = `--- +status: "done" +priority: 'low' +--- + +Body +`; + +describe("getCurrentFieldValue", () => { + it("returns undefined when field is missing", () => { + expect(getCurrentFieldValue(inlineSample, "nonexistent")).toBeUndefined(); + }); + + it("returns inline status value", () => { + expect(getCurrentFieldValue(inlineSample, "status")).toBe("in-progress"); + }); + + it("returns inline priority value", () => { + expect(getCurrentFieldValue(inlineSample, "priority")).toBe("high"); + }); + + it("returns assignee value", () => { + expect(getCurrentFieldValue(inlineSample, "assignee")).toBe("me"); + }); + + it("strips double quotes", () => { + expect(getCurrentFieldValue(quotedSample, "status")).toBe("done"); + }); + + it("strips single quotes", () => { + expect(getCurrentFieldValue(quotedSample, "priority")).toBe("low"); + }); + + it("returns undefined for empty frontmatter", () => { + expect(getCurrentFieldValue("---\n---\n\nBody", "status")).toBeUndefined(); + }); + + it("returns undefined for document without frontmatter", () => { + expect(getCurrentFieldValue("Just body text", "status")).toBeUndefined(); + }); +}); diff --git a/src/ui/editFrontmatterField.ts b/src/ui/editFrontmatterField.ts index 05fc54c..a5d41b0 100644 --- a/src/ui/editFrontmatterField.ts +++ b/src/ui/editFrontmatterField.ts @@ -8,7 +8,7 @@ import { setFrontmatterTags, } from "@/frontmatter/frontmatterFields"; import { parseTaskTags } from "@/frontmatter/parseTaskTags"; -import { normalizeTag } from "@/model/projectConfig"; +import { getProjectConfigPath, normalizeTag } from "@/model/projectConfig"; import { isTaskFileName, TASK_FILE_EXTENSION, @@ -19,6 +19,8 @@ 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, @@ -107,6 +109,20 @@ async function applyFieldValue( 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) { @@ -116,16 +132,49 @@ export async function runEditTaskStatus(uriArg?: string): Promise { return; } - const picked = await vscode.window.showQuickPick( - TASK_STATUS_ORDER.filter( - (status) => !TASK_STATUS_META[status].fallback, - ).map((status) => ({ - label: TASK_STATUS_LABELS[status], - description: status, - status, - })), - { placeHolder: "Status" }, - ); + 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); @@ -140,16 +189,49 @@ export async function runEditTaskPriority(uriArg?: string): Promise { return; } - const picked = await vscode.window.showQuickPick( - TASK_PRIORITY_ORDER.filter( - (priority) => !TASK_PRIORITY_META[priority].fallback, - ).map((priority) => ({ - label: TASK_PRIORITY_LABELS[priority], - description: priority, - priority, - })), - { placeHolder: "Priority" }, - ); + 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); @@ -180,6 +262,39 @@ export async function runSetAssigneeMe(uriArg?: string): Promise { ); } +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, diff --git a/src/ui/taskCodeLenses.ts b/src/ui/taskCodeLenses.ts index 5ff7a25..da9d09d 100644 --- a/src/ui/taskCodeLenses.ts +++ b/src/ui/taskCodeLenses.ts @@ -35,6 +35,12 @@ const FIELDS: FieldLensDef[] = [ label: "Edit tags", command: "xboctukFlow.editTaskTags", }, + { + field: EditableField.TAGS, + icon: "$(gear)", + label: "Configure project tags", + command: "xboctukFlow.openProjectConfig", + }, { field: EditableField.ASSIGNEE, icon: "$(account)", diff --git a/src/ui/taskCommands.ts b/src/ui/taskCommands.ts index f127296..b3e6c52 100644 --- a/src/ui/taskCommands.ts +++ b/src/ui/taskCommands.ts @@ -16,6 +16,7 @@ import { runEditTaskPriority, runEditTaskStatus, runEditTaskTags, + runOpenProjectConfig, runSetAssigneeMe, } from "./editFrontmatterField"; import { presentError } from "./presentError"; @@ -267,6 +268,11 @@ export function registerTaskCommands( (uri?: string) => runEditTaskTags({ fs: deps.fs, config: deps.config }, uri), ), + vscode.commands.registerCommand( + "xboctukFlow.openProjectConfig", + (uri?: string) => + runOpenProjectConfig({ fs: deps.fs, config: deps.config }, uri), + ), vscode.commands.registerCommand( "xboctukFlow.addTagToProject", (uri?: string, tag?: string) =>