Compare commits

..
28 changed files with 4735 additions and 622 deletions
+62
View File
@@ -0,0 +1,62 @@
name: Release VSIX
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
release:
runs-on: ubuntu-latest
permissions:
releases: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm run build
- run: npm run package
- name: Upload VSIX to release
if:
startsWith(github.ref, 'refs/tags/') || github.event_name ==
'workflow_dispatch'
env:
GITEA_TOKEN: ${{ github.token }}
run: |
set -e
TAG="${GITHUB_REF#refs/tags/}"
VSIX=$(ls *.vsix | head -1)
API="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
echo "Creating release for tag: $TAG"
BODY=$(jq -n --arg tag "$TAG" '{tag_name: $tag, name: $tag}')
RESP=$(curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$BODY" \
"${API}/releases") || true
RELEASE_ID=$(echo "$RESP" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ]; then
echo "Release may already exist, fetching..."
RELEASE_ID=$(curl -s \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/releases/tags/${TAG}" | jq -r '.id')
fi
echo "Uploading $VSIX to release $RELEASE_ID"
curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${VSIX}" \
"${API}/releases/${RELEASE_ID}/assets"
echo "Done"
+2
View File
@@ -1,2 +1,4 @@
node_modules/ node_modules/
out/ out/
.env
*.vsix
+5
View File
@@ -0,0 +1,5 @@
pre_bump_hooks = [
"npm version --no-git-tag-version {{version}}"
]
disable_changelog = true
+3628 -4
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -2,8 +2,8 @@
"name": "xboctuk-flow", "name": "xboctuk-flow",
"displayName": "XBOCTuK Flow", "displayName": "XBOCTuK Flow",
"description": "Local-first task management for solo developers and small teams", "description": "Local-first task management for solo developers and small teams",
"version": "0.0.1", "version": "0.1.0",
"publisher": "", "publisher": "Ku6epXBOCTuK",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"vscode": "^1.96.0" "vscode": "^1.96.0"
@@ -19,8 +19,7 @@
"project-management" "project-management"
], ],
"activationEvents": [ "activationEvents": [
"onStartupFinished", "onStartupFinished"
"onLanguage:xflow"
], ],
"main": "./out/extension.js", "main": "./out/extension.js",
"contributes": { "contributes": {
@@ -200,6 +199,7 @@
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"watch": "vite build --watch", "watch": "vite build --watch",
"package": "vsce package --allow-missing-repository --no-rewrite-relative-links",
"vscode:prepublish": "npm run build", "vscode:prepublish": "npm run build",
"lint": "eslint .", "lint": "eslint .",
"check": "npm run check:app && npm run check:test", "check": "npm run check:app && npm run check:test",
@@ -216,6 +216,7 @@
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@types/vscode": "^1.96.0", "@types/vscode": "^1.96.0",
"@vitest/eslint-plugin": "^1.5.0", "@vitest/eslint-plugin": "^1.5.0",
"@vscode/vsce": "^3.9.2",
"eslint": "^9.39.0", "eslint": "^9.39.0",
"eslint-config-prettier": "^10.1.0", "eslint-config-prettier": "^10.1.0",
"globals": "^16.4.0", "globals": "^16.4.0",
+5 -5
View File
@@ -1,18 +1,18 @@
import * as path from "path";
import * as vscode from "vscode";
import { resolveTaskLocations } from "@/commands/resolveTaskLocations"; import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
import { TASK_FILE_EXTENSION } from "@/model/taskFile"; import { TASK_FILE_EXTENSION } from "@/model/taskFile";
import { IConfigProvider } from "@/ports";
import { FsTaskRepository } from "@/storage/FsTaskRepository"; import { FsTaskRepository } from "@/storage/FsTaskRepository";
import { NodeFileSystem } from "@/storage/NodeFileSystem"; import { NodeFileSystem } from "@/storage/NodeFileSystem";
import { VscodeConfigProvider } from "@/storage/VscodeConfigProvider"; import { VscodeConfigProvider } from "@/storage/VscodeConfigProvider";
import { IConfigProvider } from "@/ports";
import { registerTaskCodeActions } from "@/ui/taskCodeActions"; import { registerTaskCodeActions } from "@/ui/taskCodeActions";
import { registerTaskCodeLenses } from "@/ui/taskCodeLenses";
import { registerTaskCommands } from "@/ui/taskCommands"; import { registerTaskCommands } from "@/ui/taskCommands";
import { registerTaskDecorations } from "@/ui/taskDecorations";
import { registerTaskDiagnostics } from "@/ui/taskDiagnostics"; import { registerTaskDiagnostics } from "@/ui/taskDiagnostics";
import { TaskDashboardPanel } from "@/views/taskDashboardPanel"; import { TaskDashboardPanel } from "@/views/taskDashboardPanel";
import { TaskKanbanPanel } from "@/views/taskKanbanPanel"; import { TaskKanbanPanel } from "@/views/taskKanbanPanel";
import { TaskTreeProvider } from "@/views/taskTreeProvider"; import { TaskTreeProvider } from "@/views/taskTreeProvider";
import * as path from "path";
import * as vscode from "vscode";
function createTaskWatchers( function createTaskWatchers(
config: IConfigProvider, config: IConfigProvider,
@@ -74,7 +74,7 @@ export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(statusBarItem); context.subscriptions.push(statusBarItem);
registerTaskDiagnostics(context, { fs: fileSystem, config }); registerTaskDiagnostics(context, { fs: fileSystem, config });
registerTaskDecorations(context); registerTaskCodeLenses(context);
registerTaskCodeActions(context); registerTaskCodeActions(context);
registerTaskCommands(context, { registerTaskCommands(context, {
+79 -7
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { TaskPriority } from "@/model/taskPriority"; import { TaskPriority } from "@/model/taskPriority";
import { TaskStatus } from "@/model/taskStatus"; import { TaskStatus } from "@/model/taskStatus";
import { describe, expect, it } from "vitest";
import { import {
extractFrontmatterBlock, extractFrontmatterBlock,
findFieldRange, findFieldRange,
@@ -20,6 +20,17 @@ tags: [old]
Body Body
`; `;
const blockTagsSample = `---
priority: medium
tags:
- bug
- issue
status: todo
---
Body
`;
describe("replaceFrontmatterField", () => { describe("replaceFrontmatterField", () => {
it("replaces status value", () => { it("replaces status value", () => {
const result = replaceFrontmatterField(sample, "status", TaskStatus.DONE); const result = replaceFrontmatterField(sample, "status", TaskStatus.DONE);
@@ -41,6 +52,20 @@ describe("replaceFrontmatterField", () => {
expect(result.content).toContain("priority: critical"); expect(result.content).toContain("priority: critical");
}); });
it("replaces block-style tags without leaving leftover list items", () => {
const result = replaceFrontmatterField(
blockTagsSample,
"tags",
"\n - bug\n - issue\n - new_issue",
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.content).toContain(
"tags:\n - bug\n - issue\n - new_issue",
);
expect(result.content).not.toContain(" - bug\n - bug");
});
it("fails when field is missing", () => { it("fails when field is missing", () => {
const result = replaceFrontmatterField(sample, "assignee", "me"); const result = replaceFrontmatterField(sample, "assignee", "me");
expect(result).toEqual({ ok: false, reason: "no-field" }); expect(result).toEqual({ ok: false, reason: "no-field" });
@@ -59,27 +84,74 @@ describe("findFieldRange", () => {
const line = sample.split("\n")[range!.startLine]; const line = sample.split("\n")[range!.startLine];
expect(line.slice(range!.startCol, range!.endCol)).toBe("todo"); expect(line.slice(range!.startCol, range!.endCol)).toBe("todo");
}); });
it("returns range covering multi-line block-style tags value", () => {
const range = findFieldRange(blockTagsSample, "tags");
expect(range).toBeDefined();
expect(range!.startLine).toBe(2);
expect(range!.startCol).toBe(5);
expect(range!.endLine).toBe(4);
expect(range!.endCol).toBe(9);
});
it("range from findFieldRange replaces entire block value", () => {
const range = findFieldRange(blockTagsSample, "tags");
expect(range).toBeDefined();
const lines = blockTagsSample.split("\n");
// Replace the range with new block-style value
const before = lines.slice(0, range!.startLine);
const after = lines.slice(range!.endLine + 1);
const replaced = [...before, "tags:\n - bug\n - issue", ...after].join(
"\n",
);
expect(replaced).not.toMatch(/^- /m);
});
}); });
describe("formatTagsYamlValue", () => { describe("formatTagsYamlValue", () => {
it("formats empty and simple lists", () => { it("formats empty list as inline []", () => {
expect(formatTagsYamlValue([])).toBe("[]"); expect(formatTagsYamlValue([])).toBe("[]");
expect(formatTagsYamlValue(["bug", "frontend"])).toBe("[bug, frontend]");
}); });
it("quotes tags with spaces", () => { it("formats single tag as block-style list", () => {
expect(formatTagsYamlValue(["my tag"])).toBe('["my tag"]'); expect(formatTagsYamlValue(["bug"])).toBe("\n - bug");
});
it("formats multiple tags as block-style list", () => {
expect(formatTagsYamlValue(["bug", "frontend"])).toBe(
"\n - bug\n - frontend",
);
});
it("quotes tags with spaces in block-style", () => {
expect(formatTagsYamlValue(["my tag", "plain"])).toBe(
'\n - "my tag"\n - plain',
);
}); });
}); });
describe("setFrontmatterTags", () => { describe("setFrontmatterTags", () => {
it("replaces the tags line", () => { it("replaces the tags line with block-style list", () => {
const result = setFrontmatterTags(sample, ["bug", "docs"]); const result = setFrontmatterTags(sample, ["bug", "docs"]);
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
if (!result.ok) return; if (!result.ok) return;
expect(result.content).toContain("tags: [bug, docs]"); expect(result.content).toContain("tags:\n - bug\n - docs");
expect(result.content).not.toContain("tags: [old]"); expect(result.content).not.toContain("tags: [old]");
}); });
it("replaces block-style tags without leaving orphaned list items", () => {
const result = setFrontmatterTags(blockTagsSample, [
"bug",
"issue",
"new_issue",
]);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.content).toContain(
"tags:\n - bug\n - issue\n - new_issue",
);
expect(result.content).not.toContain(" - bug\n - bug");
});
}); });
describe("extractFrontmatterBlock", () => { describe("extractFrontmatterBlock", () => {
+53 -10
View File
@@ -93,11 +93,25 @@ export function findFieldRange(
} }
if (valueText.length === 0) { if (valueText.length === 0) {
// Block-style value — extend to continuation lines
const keyIndent = indent.length;
let endLine = i;
let endCol = line.length;
for (let j = i + 1; j < block.closeLine; j += 1) {
const nextLine = lines[j] ?? "";
const nextIndent = nextLine.match(/^\s*/)?.[0].length ?? 0;
if (nextIndent > keyIndent) {
endLine = j;
endCol = nextLine.length;
} else {
break;
}
}
return { return {
startLine: i, startLine: i,
startCol: 0, startCol: valueCol,
endLine: i, endLine,
endCol: line.length, endCol,
}; };
} }
@@ -143,12 +157,12 @@ export type ReplaceFrontmatterFieldResult =
| { ok: true; content: string; range: TextRange } | { ok: true; content: string; range: TextRange }
| { ok: false; reason: "no-frontmatter" | "no-field" }; | { ok: false; reason: "no-frontmatter" | "no-field" };
/** Inline YAML list for frontmatter `tags: […]`. */ /** Block-style YAML list for frontmatter `tags:\n - item`. */
export function formatTagsYamlValue(tags: string[]): string { export function formatTagsYamlValue(tags: string[]): string {
if (tags.length === 0) { if (tags.length === 0) {
return "[]"; return "[]";
} }
return `[${tags.map((t) => yamlInlineScalar(t)).join(", ")}]`; return "\n" + tags.map((t) => ` - ${yamlInlineScalar(t)}`).join("\n");
} }
export function setFrontmatterTags( export function setFrontmatterTags(
@@ -161,6 +175,7 @@ export function setFrontmatterTags(
/** /**
* Replace a scalar frontmatter field value on its line. * Replace a scalar frontmatter field value on its line.
* Does not re-serialize YAML; only rewrites the value after `:`. * Does not re-serialize YAML; only rewrites the value after `:`.
* Handles multi-line block-style values (value starting with \n).
*/ */
export function replaceFrontmatterField( export function replaceFrontmatterField(
content: string, content: string,
@@ -178,6 +193,7 @@ export function replaceFrontmatterField(
} }
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
const eol = content.includes("\r\n") ? "\r\n" : "\n";
const line = lines[lineRange.startLine] ?? ""; const line = lines[lineRange.startLine] ?? "";
const colonIndex = line.indexOf(":"); const colonIndex = line.indexOf(":");
if (colonIndex < 0) { if (colonIndex < 0) {
@@ -185,11 +201,38 @@ export function replaceFrontmatterField(
} }
const prefix = line.slice(0, colonIndex + 1); const prefix = line.slice(0, colonIndex + 1);
const spacing = line.slice(colonIndex + 1).match(/^\s*/)?.[0] ?? " "; const keyIndent = line.match(/^\s*/)?.[0].length ?? 0;
const newLine = `${prefix}${spacing || " "}${value}`;
lines[lineRange.startLine] = newLine; if (value.startsWith("\n")) {
// Block-style value: replace key line and remove old continuation lines
lines[lineRange.startLine] = prefix;
// Find and remove old continuation lines
let continuationEnd = lineRange.startLine;
for (let j = lineRange.startLine + 1; j < block.closeLine; j += 1) {
const nextLine = lines[j] ?? "";
const nextIndent = nextLine.match(/^\s*/)?.[0].length ?? 0;
if (nextIndent > keyIndent) {
continuationEnd = j;
} else {
break;
}
}
const removeCount = continuationEnd - lineRange.startLine;
if (removeCount > 0) {
lines.splice(lineRange.startLine + 1, removeCount);
}
// Append the value lines (skip leading \n)
const valueLines = value.slice(1).split("\n");
for (let k = valueLines.length - 1; k >= 0; k -= 1) {
lines.splice(lineRange.startLine + 1, 0, valueLines[k]);
}
} else {
const spacing = line.slice(colonIndex + 1).match(/^\s*/)?.[0] ?? " ";
lines[lineRange.startLine] = `${prefix}${spacing || " "}${value}`;
}
const eol = content.includes("\r\n") ? "\r\n" : "\n";
const next = lines.join(eol); const next = lines.join(eol);
const valueRange = findFieldRange(next, key); const valueRange = findFieldRange(next, key);
@@ -200,7 +243,7 @@ export function replaceFrontmatterField(
startLine: lineRange.startLine, startLine: lineRange.startLine,
startCol: 0, startCol: 0,
endLine: lineRange.startLine, endLine: lineRange.startLine,
endCol: newLine.length, endCol: lines[lineRange.startLine].length,
}, },
}; };
} }
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { TaskStatus } from "@/model/taskStatus";
import { filterDefaultHiddenStatuses } from "./filterDefaultHiddenStatuses";
describe("filterDefaultHiddenStatuses", () => {
it("excludes unknown when no unknown tasks and column not shown", () => {
const result = filterDefaultHiddenStatuses(false, []);
expect(result).not.toContain(TaskStatus.UNKNOWN);
});
it("includes unknown when unknown tasks exist", () => {
const result = filterDefaultHiddenStatuses(true, []);
expect(result).toContain(TaskStatus.UNKNOWN);
});
it("includes unknown when column is shown even without unknown tasks", () => {
const result = filterDefaultHiddenStatuses(false, [TaskStatus.UNKNOWN]);
expect(result).toContain(TaskStatus.UNKNOWN);
});
});
+16
View File
@@ -0,0 +1,16 @@
import {
TaskStatus,
TASK_STATUS_META,
TASK_STATUS_ORDER,
} from "@/model/taskStatus";
export function filterDefaultHiddenStatuses(
hasUnknown: boolean,
shownHiddenColumns: TaskStatus[],
): TaskStatus[] {
return TASK_STATUS_ORDER.filter(
(s) =>
TASK_STATUS_META[s].defaultHidden &&
(s !== TaskStatus.UNKNOWN || hasUnknown || shownHiddenColumns.includes(TaskStatus.UNKNOWN)),
);
}
+1
View File
@@ -12,6 +12,7 @@ export type KanbanOutboundMessage =
scopeFilter: KanbanScopeFilter; scopeFilter: KanbanScopeFilter;
shownHiddenColumns: TaskStatus[]; shownHiddenColumns: TaskStatus[];
defaultHiddenStatuses: TaskStatus[]; defaultHiddenStatuses: TaskStatus[];
dangerStatuses: TaskStatus[];
availableScopes: TaskScope[]; availableScopes: TaskScope[];
} }
| { variant: "error"; error: AppError }; | { variant: "error"; error: AppError };
+6 -1
View File
@@ -10,6 +10,11 @@ export type TaskPriority = (typeof TaskPriority)[keyof typeof TaskPriority];
export type TaskPriorityMeta = { export type TaskPriorityMeta = {
label: string; label: string;
/**
* Fallback value assigned when the parsed priority is invalid/missing.
* Excluded from editing choices.
*/
fallback?: boolean;
}; };
/** Explicit order for filters / grouping (critical → low → unknown). */ /** Explicit order for filters / grouping (critical → low → unknown). */
@@ -26,7 +31,7 @@ export const TASK_PRIORITY_META: Record<TaskPriority, TaskPriorityMeta> = {
[TaskPriority.HIGH]: { label: "High" }, [TaskPriority.HIGH]: { label: "High" },
[TaskPriority.MEDIUM]: { label: "Medium" }, [TaskPriority.MEDIUM]: { label: "Medium" },
[TaskPriority.LOW]: { label: "Low" }, [TaskPriority.LOW]: { label: "Low" },
[TaskPriority.UNKNOWN]: { label: "Unknown" }, [TaskPriority.UNKNOWN]: { label: "Unknown", fallback: true },
}; };
export function isTaskPriority(value: unknown): value is TaskPriority { export function isTaskPriority(value: unknown): value is TaskPriority {
+6
View File
@@ -15,6 +15,11 @@ export type TaskStatusMeta = {
defaultHidden: boolean; defaultHidden: boolean;
/** Codicon id for tree; adapters wrap in ThemeIcon. */ /** Codicon id for tree; adapters wrap in ThemeIcon. */
icon: string; icon: string;
/**
* Fallback value assigned when the parsed status is invalid/missing.
* Excluded from editing choices; hidden in tree when empty.
*/
fallback?: boolean;
}; };
/** Explicit display / column order (not Object.values). */ /** Explicit display / column order (not Object.values). */
@@ -57,6 +62,7 @@ export const TASK_STATUS_META: Record<TaskStatus, TaskStatusMeta> = {
label: "Unknown", label: "Unknown",
defaultHidden: true, defaultHidden: true,
icon: "question", icon: "question",
fallback: true,
}, },
}; };
+118
View File
@@ -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<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;
}
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<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) {
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<string | undefined> {
const locations = resolveTaskLocations(config);
return resolveTaskFolderForPath(document.fileName, locations);
}
+60
View File
@@ -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();
});
});
+7 -421
View File
@@ -1,422 +1,8 @@
import * as vscode from "vscode"; export { EditableField, getCurrentFieldValue } from "./editFrontmatterBase";
import { addProjectTag } from "@/commands/addProjectTag"; export type { FrontmatterEditDeps } from "./editFrontmatterBase";
import { loadProjectConfig } from "@/commands/loadProjectConfig"; export { runEditTaskStatus } from "./editTaskStatus";
import { resolveTaskLocations } from "@/commands/resolveTaskLocations"; export { runEditTaskPriority } from "./editTaskPriority";
import { export { runSetAssigneeMe } from "./editTaskAssignee";
findFieldRange, export { runAddTagToProject, runEditTaskTags } from "./editTaskTags";
formatTagsYamlValue, export { runOpenProjectConfig } from "./openProjectConfig";
replaceFrontmatterField,
setFrontmatterTags,
} from "@/frontmatter/frontmatterFields";
import { parseTaskTags } from "@/frontmatter/parseTaskTags";
import { normalizeTag } from "@/model/projectConfig";
import {
isTaskFileName,
TASK_FILE_EXTENSION,
TASK_LANGUAGE_ID,
} from "@/model/taskFile";
import { IConfigProvider, IFileSystem } from "@/ports";
import { getGitUserName, yamlInlineScalar } from "./getGitUserName";
import { presentError } from "./presentError";
import { resolveTaskFolderForPath } from "./resolveTaskFolder";
import {
TASK_PRIORITY_LABELS,
TASK_PRIORITY_ORDER,
TASK_STATUS_LABELS,
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 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;
}
const picked = await vscode.window.showQuickPick(
TASK_STATUS_ORDER.map((status) => ({
label: TASK_STATUS_LABELS[status],
description: status,
status,
})),
{ placeHolder: "Status" },
);
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;
}
const picked = await vscode.window.showQuickPick(
TASK_PRIORITY_ORDER.map((priority) => ({
label: TASK_PRIORITY_LABELS[priority],
description: priority,
priority,
})),
{ placeHolder: "Priority" },
);
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),
);
}
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;
}
}
}
+34
View File
@@ -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<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),
);
}
+71
View File
@@ -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<void> {
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<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);
}
+71
View File
@@ -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<void> {
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<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);
}
+257
View File
@@ -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<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;
}
}
}
+41
View File
@@ -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<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}`,
);
}
}
+96
View File
@@ -0,0 +1,96 @@
import { findFieldRange } from "@/frontmatter/frontmatterFields";
import {
isTaskFileName,
TASK_FILE_EXTENSION,
TASK_LANGUAGE_ID,
} from "@/model/taskFile";
import * as vscode from "vscode";
import { EditableField } from "./editFrontmatterField";
const NBSP = "\u00A0";
type FieldLensDef = {
field: EditableField;
icon: string;
label: string;
command: string;
};
const FIELDS: FieldLensDef[] = [
{
field: EditableField.STATUS,
icon: "$(symbol-misc)",
label: "Change status",
command: "xboctukFlow.editTaskStatus",
},
{
field: EditableField.PRIORITY,
icon: "$(arrow-up)",
label: "Change priority",
command: "xboctukFlow.editTaskPriority",
},
{
field: EditableField.TAGS,
icon: "$(tag)",
label: "Edit tags",
command: "xboctukFlow.editTaskTags",
},
{
field: EditableField.TAGS,
icon: "$(gear)",
label: "Configure project tags",
command: "xboctukFlow.openProjectConfig",
},
{
field: EditableField.ASSIGNEE,
icon: "$(account)",
label: "Assign me",
command: "xboctukFlow.setAssigneeMe",
},
];
function isTaskDocument(document: vscode.TextDocument): boolean {
if (document.languageId === TASK_LANGUAGE_ID) return true;
return isTaskFileName(document.fileName);
}
class TaskFieldCodeLensProvider implements vscode.CodeLensProvider {
provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] {
if (!isTaskDocument(document)) return [];
const lenses: vscode.CodeLens[] = [];
const text = document.getText();
const uri = document.uri.toString();
for (const { field, icon, label, command } of FIELDS) {
const range = findFieldRange(text, field);
if (!range) continue;
lenses.push(
new vscode.CodeLens(
new vscode.Range(range.startLine, 0, range.startLine, 0),
{
title: `${icon}${NBSP}${label}`,
command,
arguments: [uri],
},
),
);
}
return lenses;
}
}
export function registerTaskCodeLenses(context: vscode.ExtensionContext): void {
const provider = new TaskFieldCodeLensProvider();
context.subscriptions.push(
vscode.languages.registerCodeLensProvider(
[
{ language: TASK_LANGUAGE_ID },
{ pattern: `**/*${TASK_FILE_EXTENSION}` },
],
provider,
),
);
}
+6
View File
@@ -16,6 +16,7 @@ import {
runEditTaskPriority, runEditTaskPriority,
runEditTaskStatus, runEditTaskStatus,
runEditTaskTags, runEditTaskTags,
runOpenProjectConfig,
runSetAssigneeMe, runSetAssigneeMe,
} from "./editFrontmatterField"; } from "./editFrontmatterField";
import { presentError } from "./presentError"; import { presentError } from "./presentError";
@@ -267,6 +268,11 @@ export function registerTaskCommands(
(uri?: string) => (uri?: string) =>
runEditTaskTags({ fs: deps.fs, config: deps.config }, uri), 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( vscode.commands.registerCommand(
"xboctukFlow.addTagToProject", "xboctukFlow.addTagToProject",
(uri?: string, tag?: string) => (uri?: string, tag?: string) =>
-160
View File
@@ -1,160 +0,0 @@
import * as vscode from "vscode";
import { findFieldRange } from "@/frontmatter/frontmatterFields";
import { isTaskFileName, TASK_LANGUAGE_ID } from "@/model/taskFile";
import { EditableField } from "./editFrontmatterField";
function isTaskEditor(editor: vscode.TextEditor): boolean {
const { document } = editor;
if (document.languageId === TASK_LANGUAGE_ID) {
return true;
}
return isTaskFileName(document.fileName);
}
function commandUri(command: string, args: unknown[]): string {
return `command:${command}?${encodeURIComponent(JSON.stringify(args))}`;
}
function hoverEditField(
document: vscode.TextDocument,
field: typeof EditableField.STATUS | typeof EditableField.PRIORITY,
): vscode.MarkdownString {
const command =
field === EditableField.STATUS
? "xboctukFlow.editTaskStatus"
: "xboctukFlow.editTaskPriority";
const label =
field === EditableField.STATUS ? "Change status…" : "Change priority…";
const md = new vscode.MarkdownString(
`[$(edit) ${label}](${commandUri(command, [document.uri.toString()])})`,
);
md.isTrusted = true;
md.supportThemeIcons = true;
return md;
}
function hoverAssigneeMe(document: vscode.TextDocument): vscode.MarkdownString {
const md = new vscode.MarkdownString(
`[$(account) me](${commandUri("xboctukFlow.setAssigneeMe", [document.uri.toString()])}) — set assignee from \`git config user.name\``,
);
md.isTrusted = true;
md.supportThemeIcons = true;
return md;
}
function hoverTags(document: vscode.TextDocument): vscode.MarkdownString {
const uri = document.uri.toString();
const md = new vscode.MarkdownString(
`[$(edit) Edit tags…](${commandUri("xboctukFlow.editTaskTags", [uri])})`,
);
md.isTrusted = true;
md.supportThemeIcons = true;
return md;
}
export function registerTaskDecorations(
context: vscode.ExtensionContext,
): void {
const decorationType = vscode.window.createTextEditorDecorationType({
after: {
margin: "0 0 0 1.5em",
color: new vscode.ThemeColor("editorCodeLens.foreground"),
},
});
context.subscriptions.push(decorationType);
const refresh = (editor: vscode.TextEditor | undefined): void => {
if (!editor || !isTaskEditor(editor)) {
if (editor) {
editor.setDecorations(decorationType, []);
}
return;
}
const text = editor.document.getText();
const options: vscode.DecorationOptions[] = [];
for (const field of [
EditableField.STATUS,
EditableField.PRIORITY,
] as const) {
const range = findFieldRange(text, field);
if (!range) continue;
options.push({
range: new vscode.Range(
range.startLine,
range.startCol,
range.endLine,
range.endCol,
),
hoverMessage: hoverEditField(editor.document, field),
renderOptions: {
after: {
contentText: " ✎",
},
},
});
}
const assigneeRange = findFieldRange(text, EditableField.ASSIGNEE);
if (assigneeRange) {
options.push({
range: new vscode.Range(
assigneeRange.startLine,
assigneeRange.startCol,
assigneeRange.endLine,
assigneeRange.endCol,
),
hoverMessage: hoverAssigneeMe(editor.document),
renderOptions: {
after: {
contentText: " me",
color: new vscode.ThemeColor("textLink.foreground"),
},
},
});
}
const tagsRange = findFieldRange(text, EditableField.TAGS);
if (tagsRange) {
options.push({
range: new vscode.Range(
tagsRange.startLine,
tagsRange.startCol,
tagsRange.endLine,
tagsRange.endCol,
),
hoverMessage: hoverTags(editor.document),
renderOptions: {
after: {
contentText: " ✎ tags",
color: new vscode.ThemeColor("textLink.foreground"),
},
},
});
}
editor.setDecorations(decorationType, options);
};
const refreshVisible = (): void => {
for (const editor of vscode.window.visibleTextEditors) {
refresh(editor);
}
};
refreshVisible();
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(refresh),
vscode.window.onDidChangeVisibleTextEditors(() => refreshVisible()),
vscode.workspace.onDidChangeTextDocument((event) => {
for (const editor of vscode.window.visibleTextEditors) {
if (editor.document === event.document) {
refresh(editor);
}
}
}),
);
}
+12
View File
@@ -116,6 +116,13 @@ export function getTaskKanbanHtml(
color: var(--vscode-button-foreground); color: var(--vscode-button-foreground);
border-color: transparent; border-color: transparent;
} }
#visibility-bar button.danger {
border-color: var(--vscode-errorForeground);
border-width: 2px;
}
#visibility-bar button.danger.shown {
background: var(--vscode-errorForeground);
}
#status-line { #status-line {
font-size: 0.85em; font-size: 0.85em;
opacity: 0.85; opacity: 0.85;
@@ -210,6 +217,7 @@ export function getTaskKanbanHtml(
let scopeFilter = "all"; let scopeFilter = "all";
let shownHiddenColumns = []; let shownHiddenColumns = [];
let defaultHiddenStatuses = []; let defaultHiddenStatuses = [];
let dangerStatuses = [];
let availableScopes = []; let availableScopes = [];
let dragTaskId = null; let dragTaskId = null;
@@ -269,6 +277,9 @@ export function getTaskKanbanHtml(
if (shownHiddenColumns.indexOf(status) >= 0) { if (shownHiddenColumns.indexOf(status) >= 0) {
btn.classList.add("shown"); btn.classList.add("shown");
} }
if (dangerStatuses.indexOf(status) >= 0) {
btn.classList.add("danger");
}
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
post({ variant: "toggleHiddenColumn", status: status }); post({ variant: "toggleHiddenColumn", status: status });
}); });
@@ -374,6 +385,7 @@ export function getTaskKanbanHtml(
scopeFilter = message.scopeFilter || "all"; scopeFilter = message.scopeFilter || "all";
shownHiddenColumns = message.shownHiddenColumns || []; shownHiddenColumns = message.shownHiddenColumns || [];
defaultHiddenStatuses = message.defaultHiddenStatuses || []; defaultHiddenStatuses = message.defaultHiddenStatuses || [];
dangerStatuses = message.dangerStatuses || [];
availableScopes = message.availableScopes || []; availableScopes = message.availableScopes || [];
setStatus(""); setStatus("");
render(); render();
+11 -6
View File
@@ -4,6 +4,7 @@ import { listLocatedTasks } from "@/commands/listLocatedTasks";
import { resolveTaskLocations } from "@/commands/resolveTaskLocations"; import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
import { appError, AppErrorVariant } from "@/error"; import { appError, AppErrorVariant } from "@/error";
import { buildKanbanBoard } from "@/kanban/buildKanbanBoard"; import { buildKanbanBoard } from "@/kanban/buildKanbanBoard";
import { filterDefaultHiddenStatuses } from "@/kanban/filterDefaultHiddenStatuses";
import { import {
parseKanbanInboundMessage, parseKanbanInboundMessage,
type KanbanInboundMessage, type KanbanInboundMessage,
@@ -278,20 +279,24 @@ export class TaskKanbanPanel {
} }
#postState(): void { #postState(): void {
const board = buildKanbanBoard( const filtered = this.#filteredTasks().map((item) => item.task);
this.#filteredTasks().map((item) => item.task), const board = buildKanbanBoard(filtered, {
{ shownHiddenColumns: this.#shownHiddenColumns }, shownHiddenColumns: this.#shownHiddenColumns,
); });
const hasUnknown = filtered.some((t) => t.status === TaskStatus.UNKNOWN);
this.#post({ this.#post({
variant: "state", variant: "state",
board, board,
scopeFilter: this.#scopeFilter, scopeFilter: this.#scopeFilter,
shownHiddenColumns: this.#shownHiddenColumns, shownHiddenColumns: this.#shownHiddenColumns,
defaultHiddenStatuses: TASK_STATUS_ORDER.filter( defaultHiddenStatuses: filterDefaultHiddenStatuses(
(s) => TASK_STATUS_META[s].defaultHidden, hasUnknown,
this.#shownHiddenColumns,
), ),
availableScopes: this.#availableScopes(), availableScopes: this.#availableScopes(),
dangerStatuses: hasUnknown ? [TaskStatus.UNKNOWN] : [],
}); });
} }
+12 -4
View File
@@ -1,15 +1,15 @@
import * as vscode from "vscode";
import { listLocatedTasks } from "@/commands/listLocatedTasks"; import { listLocatedTasks } from "@/commands/listLocatedTasks";
import { resolveTaskLocations } from "@/commands/resolveTaskLocations"; import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
import { Task } from "@/model/task"; import { Task } from "@/model/task";
import { LocatedTask, TaskLocation } from "@/model/taskLocation"; import { LocatedTask, TaskLocation } from "@/model/taskLocation";
import { import {
TaskStatus,
TASK_STATUS_META, TASK_STATUS_META,
TASK_STATUS_ORDER, TASK_STATUS_ORDER,
type TaskStatus,
} from "@/model/taskStatus"; } from "@/model/taskStatus";
import { IConfigProvider, ITaskRepository } from "@/ports"; import { IConfigProvider, ITaskRepository } from "@/ports";
import { TASK_SCOPE_LABELS, TASK_STATUS_LABELS } from "@/ui/taskLabels"; import { TASK_SCOPE_LABELS, TASK_STATUS_LABELS } from "@/ui/taskLabels";
import * as vscode from "vscode";
import { TaskTreeItem } from "./taskTreeItem"; import { TaskTreeItem } from "./taskTreeItem";
class ScopeTreeItem extends vscode.TreeItem { class ScopeTreeItem extends vscode.TreeItem {
@@ -47,7 +47,12 @@ class TaskGroupTreeItem extends vscode.TreeItem {
this.description = `(${tasks.length})`; this.description = `(${tasks.length})`;
this.id = `group-${location.scope}-${status}`; this.id = `group-${location.scope}-${status}`;
this.contextValue = "taskGroup"; this.contextValue = "taskGroup";
this.iconPath = new vscode.ThemeIcon(TASK_STATUS_META[status].icon); this.iconPath = new vscode.ThemeIcon(
TASK_STATUS_META[status].icon,
status === TaskStatus.UNKNOWN
? new vscode.ThemeColor("charts.red")
: undefined,
);
} }
} }
@@ -119,7 +124,10 @@ export class TaskTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem
private getStatusGroups(scope: ScopeTreeItem): vscode.TreeItem[] { private getStatusGroups(scope: ScopeTreeItem): vscode.TreeItem[] {
const tasks = scope.locatedTasks.map((item) => item.task); const tasks = scope.locatedTasks.map((item) => item.task);
return TASK_STATUS_ORDER.map((status) => { return TASK_STATUS_ORDER.filter((status) => {
const groupTasks = tasks.filter((t) => t.status === status);
return !(groupTasks.length === 0 && TASK_STATUS_META[status].fallback);
}).map((status) => {
const groupTasks = tasks.filter((t) => t.status === status); const groupTasks = tasks.filter((t) => t.status === status);
return new TaskGroupTreeItem(status, scope.location, groupTasks); return new TaskGroupTreeItem(status, scope.location, groupTasks);
}); });
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { TaskStatus } from "@/model/taskStatus";
import { InMemoryFileSystem } from "../helpers/InMemoryFileSystem";
import { parseTaskFile } from "@/utils/markdown";
const FS = new InMemoryFileSystem();
const FILE = "/tasks/test.xflow.md";
describe("task status integration", () => {
it("parses valid status from file", async () => {
await FS.writeFile(
FILE,
[
"---",
'id: "abc"',
'title: "Test"',
"status: open",
"---",
"",
"Body",
].join("\n"),
);
const raw = await FS.readFile(FILE);
const task = parseTaskFile(raw);
expect(task.status).toBe(TaskStatus.OPEN);
});
it("returns UNKNOWN after manually changing status to invalid value in file", async () => {
await FS.writeFile(
FILE,
[
"---",
'id: "abc"',
'title: "Test"',
"status: open",
"---",
"",
"Body",
].join("\n"),
);
const raw = await FS.readFile(FILE);
const modified = raw.replace("status: open", "status: __not_a_status__");
await FS.writeFile(FILE, modified);
const final = await FS.readFile(FILE);
const task = parseTaskFile(final);
expect(task.status).toBe(TaskStatus.UNKNOWN);
});
});