diff --git a/README.md b/README.md index b4eb23b..98eefa3 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ tags: ``` If `config.yml` exists, tags on a task that are **not** in the catalog show as -warnings. Hover `tags` → **Edit tags…** / **Add tag to project**. +warnings (Quick Fix: **Add tag "…" to project**). Hover `tags` → **Edit tags…**. ## Dashboard diff --git a/README_ru.md b/README_ru.md index 056fa2b..9251c83 100644 --- a/README_ru.md +++ b/README_ru.md @@ -137,8 +137,8 @@ tags: - frontend ``` -Если `config.yml` есть, теги задачи вне каталога — warning. Hover на `tags` → -**Edit tags…** / **Add tag to project**. +Если `config.yml` есть, теги вне каталога — warning (Quick Fix: **Add tag "…" +to project**). Hover на `tags` → **Edit tags…**. ## Dashboard diff --git a/src/extension.ts b/src/extension.ts index 52b5c3a..029d60e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,6 +5,7 @@ import { TASK_FILE_EXTENSION } from "@/model/taskFile"; import { FsTaskRepository } from "@/storage/FsTaskRepository"; import { NodeFileSystem } from "@/storage/NodeFileSystem"; import { VscodeConfigProvider } from "@/storage/VscodeConfigProvider"; +import { registerTaskCodeActions } from "@/ui/taskCodeActions"; import { registerTaskCommands } from "@/ui/taskCommands"; import { registerTaskDecorations } from "@/ui/taskDecorations"; import { registerTaskDiagnostics } from "@/ui/taskDiagnostics"; @@ -50,6 +51,7 @@ export function activate(context: vscode.ExtensionContext) { registerTaskDiagnostics(context, { fs: fileSystem, config }); registerTaskDecorations(context); + registerTaskCodeActions(context); registerTaskCommands(context, { repo, diff --git a/src/frontmatter/validateFrontmatter.test.ts b/src/frontmatter/validateFrontmatter.test.ts index 31d4e17..a3bd93c 100644 --- a/src/frontmatter/validateFrontmatter.test.ts +++ b/src/frontmatter/validateFrontmatter.test.ts @@ -107,6 +107,8 @@ title: [unterminated ); expect(unknown).toHaveLength(1); expect(unknown[0].message).toMatch(/nope/i); + expect(unknown[0].code).toBe("unknown-tag"); + expect(unknown[0].tag).toBe("nope"); }); it("does not warn when all tags are known", () => { diff --git a/src/frontmatter/validateFrontmatter.ts b/src/frontmatter/validateFrontmatter.ts index ca4ede9..4a3b4d0 100644 --- a/src/frontmatter/validateFrontmatter.ts +++ b/src/frontmatter/validateFrontmatter.ts @@ -10,8 +10,15 @@ import { export type FrontmatterIssueSeverity = "error" | "warning"; +/** Diagnostic / Code Action id for a tag not present in project config. */ +export const UNKNOWN_TAG_ISSUE_CODE = "unknown-tag"; + export type FrontmatterIssue = { field?: string; + /** Stable issue code (e.g. unknown-tag). */ + code?: string; + /** For unknown-tag: the tag string. */ + tag?: string; message: string; severity: FrontmatterIssueSeverity; range: TextRange; @@ -254,7 +261,9 @@ export function validateFrontmatter( if (!known.has(tag)) { issues.push({ field: "tags", - message: `Unknown tag "${tag}"; add it to project config or pick from catalog`, + code: UNKNOWN_TAG_ISSUE_CODE, + tag, + message: `Unknown tag "${tag}"; add it to project config`, severity: "warning", range: findTagTokenRange(content, tag) ?? diff --git a/src/ui/editFrontmatterField.ts b/src/ui/editFrontmatterField.ts index 3181aea..197e93a 100644 --- a/src/ui/editFrontmatterField.ts +++ b/src/ui/editFrontmatterField.ts @@ -343,18 +343,18 @@ export async function runEditTaskTags( catalogSet.add(tag); } - const ok = await applyTags(document, selected); - if (ok) { - void vscode.window.showInformationMessage( - selected.length ? `Tags: ${selected.join(", ")}` : "Tags cleared", - ); - } + await applyTags(document, selected); } -/** Add unknown task tags into the task-folder config.yml. */ +/** + * 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) { @@ -372,6 +372,18 @@ export async function runAddTagToProject( 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); @@ -383,9 +395,6 @@ export async function runAddTagToProject( const unknown = current.filter((tag) => !known.has(tag)); if (unknown.length === 0) { - void vscode.window.showInformationMessage( - "All task tags are already in project config", - ); return; } @@ -412,8 +421,4 @@ export async function runAddTagToProject( return; } } - - void vscode.window.showInformationMessage( - `Added to project config: ${picked.map((p) => p.tag).join(", ")}`, - ); } diff --git a/src/ui/taskCodeActions.ts b/src/ui/taskCodeActions.ts new file mode 100644 index 0000000..d92ea94 --- /dev/null +++ b/src/ui/taskCodeActions.ts @@ -0,0 +1,66 @@ +import * as vscode from "vscode"; +import { isTaskFileName, TASK_LANGUAGE_ID } from "@/model/taskFile"; +import { parseUnknownTagFromDiagnosticCode } from "./taskDiagnostics"; + +/** + * Quick Fix on unknown-tag warnings: «Add tag "…" to project». + */ +export class TaskCodeActionProvider implements vscode.CodeActionProvider { + static readonly providedCodeActionKinds = [vscode.CodeActionKind.QuickFix]; + + provideCodeActions( + document: vscode.TextDocument, + _range: vscode.Range | vscode.Selection, + context: vscode.CodeActionContext, + ): vscode.CodeAction[] { + if ( + document.languageId !== TASK_LANGUAGE_ID && + !isTaskFileName(document.fileName) + ) { + return []; + } + + const actions: vscode.CodeAction[] = []; + const uri = document.uri.toString(); + + for (const diagnostic of context.diagnostics) { + if (diagnostic.source !== "xboctuk-flow") continue; + const tag = parseUnknownTagFromDiagnosticCode(diagnostic.code); + if (!tag) continue; + + const action = new vscode.CodeAction( + `Add tag "${tag}" to project`, + vscode.CodeActionKind.QuickFix, + ); + action.diagnostics = [diagnostic]; + action.isPreferred = true; + action.command = { + command: "xboctukFlow.addTagToProject", + title: `Add tag "${tag}" to project`, + arguments: [uri, tag], + }; + actions.push(action); + } + + return actions; + } +} + +export function registerTaskCodeActions( + context: vscode.ExtensionContext, +): void { + const selector: vscode.DocumentSelector = [ + { language: TASK_LANGUAGE_ID }, + { pattern: "**/*.xflow.md" }, + ]; + context.subscriptions.push( + vscode.languages.registerCodeActionsProvider( + selector, + new TaskCodeActionProvider(), + { + providedCodeActionKinds: + TaskCodeActionProvider.providedCodeActionKinds, + }, + ), + ); +} diff --git a/src/ui/taskCommands.ts b/src/ui/taskCommands.ts index b3c4fb2..98288cc 100644 --- a/src/ui/taskCommands.ts +++ b/src/ui/taskCommands.ts @@ -267,8 +267,12 @@ export function registerTaskCommands( ), vscode.commands.registerCommand( "xboctukFlow.addTagToProject", - (uri?: string) => - runAddTagToProject({ fs: deps.fs, config: deps.config }, uri), + (uri?: string, tag?: string) => + runAddTagToProject( + { fs: deps.fs, config: deps.config }, + uri, + tag, + ), ), vscode.commands.registerCommand("xboctukFlow.openDashboard", () => runOpenDashboard(deps), diff --git a/src/ui/taskDecorations.ts b/src/ui/taskDecorations.ts index 5aae5da..b5b4923 100644 --- a/src/ui/taskDecorations.ts +++ b/src/ui/taskDecorations.ts @@ -45,10 +45,7 @@ function hoverAssigneeMe(document: vscode.TextDocument): vscode.MarkdownString { function hoverTags(document: vscode.TextDocument): vscode.MarkdownString { const uri = document.uri.toString(); const md = new vscode.MarkdownString( - [ - `[$(edit) Edit tags…](${commandUri("xboctukFlow.editTaskTags", [uri])})`, - `[$(repo-push) Add tag to project](${commandUri("xboctukFlow.addTagToProject", [uri])})`, - ].join(" · "), + `[$(edit) Edit tags…](${commandUri("xboctukFlow.editTaskTags", [uri])})`, ); md.isTrusted = true; md.supportThemeIcons = true; diff --git a/src/ui/taskDiagnostics.ts b/src/ui/taskDiagnostics.ts index f42d51b..14531fe 100644 --- a/src/ui/taskDiagnostics.ts +++ b/src/ui/taskDiagnostics.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; import { resolveTaskLocations } from "@/commands/resolveTaskLocations"; import { loadProjectConfig } from "@/commands/loadProjectConfig"; import { + UNKNOWN_TAG_ISSUE_CODE, validateFrontmatter, type FrontmatterIssue, } from "@/frontmatter/validateFrontmatter"; @@ -12,6 +13,27 @@ import { resolveTaskFolderForPath } from "./resolveTaskFolder"; const DEBOUNCE_MS = 200; +/** Encode unknown-tag diagnostic code so Code Actions can recover the tag. */ +export function unknownTagDiagnosticCode(tag: string): string { + return `${UNKNOWN_TAG_ISSUE_CODE}:${tag}`; +} + +export function parseUnknownTagFromDiagnosticCode( + code: string | number | { value: string | number; target: vscode.Uri } | undefined, +): string | undefined { + const raw = + typeof code === "string" + ? code + : typeof code === "object" && code !== null && "value" in code + ? String(code.value) + : undefined; + if (!raw || !raw.startsWith(`${UNKNOWN_TAG_ISSUE_CODE}:`)) { + return undefined; + } + const tag = raw.slice(UNKNOWN_TAG_ISSUE_CODE.length + 1); + return tag.length > 0 ? tag : undefined; +} + export type TaskDiagnosticsDeps = { fs: IFileSystem; config: IConfigProvider; @@ -47,7 +69,9 @@ function toDiagnostic(issue: FrontmatterIssue): vscode.Diagnostic { severity, ); diagnostic.source = "xboctuk-flow"; - if (issue.field) { + if (issue.code === UNKNOWN_TAG_ISSUE_CODE && issue.tag) { + diagnostic.code = unknownTagDiagnosticCode(issue.tag); + } else if (issue.field) { diagnostic.code = issue.field; } return diagnostic;