diff --git a/src/ui/editFrontmatterBase.ts b/src/ui/editFrontmatterBase.ts new file mode 100644 index 0000000..fe80573 --- /dev/null +++ b/src/ui/editFrontmatterBase.ts @@ -0,0 +1,118 @@ +import { resolveTaskLocations } from "@/commands/resolveTaskLocations"; +import { + findFieldRange, + replaceFrontmatterField, +} from "@/frontmatter/frontmatterFields"; +import { + isTaskFileName, + TASK_LANGUAGE_ID, +} from "@/model/taskFile"; +import { IConfigProvider, IFileSystem } from "@/ports"; +import * as vscode from "vscode"; +import { resolveTaskFolderForPath } from "./resolveTaskFolder"; + +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); +} + +export 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; +} + +export function workspaceCwdFor( + document: vscode.TextDocument, +): string | undefined { + const folder = vscode.workspace.getWorkspaceFolder(document.uri); + return folder?.uri.fsPath; +} + +export 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) { + 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 resolveTaskFolder( + document: vscode.TextDocument, + config: IConfigProvider, +): Promise { + const locations = resolveTaskLocations(config); + return resolveTaskFolderForPath(document.fileName, locations); +} diff --git a/src/ui/editFrontmatterField.ts b/src/ui/editFrontmatterField.ts index a5d41b0..64d1058 100644 --- a/src/ui/editFrontmatterField.ts +++ b/src/ui/editFrontmatterField.ts @@ -1,543 +1,8 @@ -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 { EditableField, getCurrentFieldValue } from "./editFrontmatterBase"; +export type { FrontmatterEditDeps } from "./editFrontmatterBase"; +export { runEditTaskStatus } from "./editTaskStatus"; +export { runEditTaskPriority } from "./editTaskPriority"; +export { runSetAssigneeMe } from "./editTaskAssignee"; +export { runAddTagToProject, runEditTaskTags } from "./editTaskTags"; +export { runOpenProjectConfig } from "./openProjectConfig"; -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; - } - } -} diff --git a/src/ui/editTaskAssignee.ts b/src/ui/editTaskAssignee.ts new file mode 100644 index 0000000..04ece30 --- /dev/null +++ b/src/ui/editTaskAssignee.ts @@ -0,0 +1,34 @@ +import { TASK_FILE_EXTENSION } from "@/model/taskFile"; +import * as vscode from "vscode"; +import { + EditableField, + applyFieldValue, + resolveDocument, + workspaceCwdFor, +} from "./editFrontmatterBase"; +import { getGitUserName, yamlInlineScalar } from "./getGitUserName"; + +/** 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), + ); +} diff --git a/src/ui/editTaskPriority.ts b/src/ui/editTaskPriority.ts new file mode 100644 index 0000000..7c9422d --- /dev/null +++ b/src/ui/editTaskPriority.ts @@ -0,0 +1,71 @@ +import { type TaskPriority } from "@/model/taskPriority"; +import { TASK_FILE_EXTENSION } from "@/model/taskFile"; +import * as vscode from "vscode"; +import { + EditableField, + applyFieldValue, + getCurrentFieldValue, + resolveDocument, +} from "./editFrontmatterBase"; +import { + TASK_PRIORITY_LABELS, + TASK_PRIORITY_META, + TASK_PRIORITY_ORDER, +} from "./taskLabels"; + +type PriorityItem = vscode.QuickPickItem & { priority: TaskPriority }; + +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; + } + + 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); +} diff --git a/src/ui/editTaskStatus.ts b/src/ui/editTaskStatus.ts new file mode 100644 index 0000000..a639e2d --- /dev/null +++ b/src/ui/editTaskStatus.ts @@ -0,0 +1,71 @@ +import { type TaskStatus } from "@/model/taskStatus"; +import { TASK_FILE_EXTENSION } from "@/model/taskFile"; +import * as vscode from "vscode"; +import { + EditableField, + applyFieldValue, + getCurrentFieldValue, + resolveDocument, +} from "./editFrontmatterBase"; +import { + TASK_STATUS_LABELS, + TASK_STATUS_META, + TASK_STATUS_ORDER, +} from "./taskLabels"; + +type StatusItem = vscode.QuickPickItem & { status: TaskStatus }; + +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; + } + + 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); +} diff --git a/src/ui/editTaskTags.ts b/src/ui/editTaskTags.ts new file mode 100644 index 0000000..fbf85c7 --- /dev/null +++ b/src/ui/editTaskTags.ts @@ -0,0 +1,257 @@ +import { addProjectTag } from "@/commands/addProjectTag"; +import { loadProjectConfig } from "@/commands/loadProjectConfig"; +import { + findFieldRange, + formatTagsYamlValue, + setFrontmatterTags, +} from "@/frontmatter/frontmatterFields"; +import { parseTaskTags } from "@/frontmatter/parseTaskTags"; +import { normalizeTag } from "@/model/projectConfig"; +import { TASK_FILE_EXTENSION } from "@/model/taskFile"; +import * as vscode from "vscode"; +import { + EditableField, + type FrontmatterEditDeps, + resolveDocument, + resolveTaskFolder, +} from "./editFrontmatterBase"; +import { presentError } from "./presentError"; + +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; + } + } +} diff --git a/src/ui/openProjectConfig.ts b/src/ui/openProjectConfig.ts new file mode 100644 index 0000000..6d9f2be --- /dev/null +++ b/src/ui/openProjectConfig.ts @@ -0,0 +1,41 @@ +import { getProjectConfigPath } from "@/model/projectConfig"; +import { TASK_FILE_EXTENSION } from "@/model/taskFile"; +import * as vscode from "vscode"; +import { + type FrontmatterEditDeps, + resolveDocument, + resolveTaskFolder, +} from "./editFrontmatterBase"; + +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}`, + ); + } +}