Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1732b7bd02 | ||
|
|
c15b960466 | ||
|
|
0bee801689 | ||
|
|
d2ad5b0b03 | ||
|
|
184f1c4d1e | ||
|
|
93b299e6ad | ||
|
|
01661dc60e | ||
|
|
2bf3140708 | ||
|
|
c818ed551e | ||
|
|
ad982eb1cc | ||
|
|
1382abc26d |
@@ -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"
|
||||
@@ -1,2 +1,4 @@
|
||||
node_modules/
|
||||
out/
|
||||
.env
|
||||
*.vsix
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
pre_bump_hooks = [
|
||||
"npm version --no-git-tag-version {{version}}"
|
||||
]
|
||||
|
||||
disable_changelog = true
|
||||
Generated
+3628
-4
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -2,8 +2,8 @@
|
||||
"name": "xboctuk-flow",
|
||||
"displayName": "XBOCTuK Flow",
|
||||
"description": "Local-first task management for solo developers and small teams",
|
||||
"version": "0.0.1",
|
||||
"publisher": "",
|
||||
"version": "0.1.0",
|
||||
"publisher": "Ku6epXBOCTuK",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"vscode": "^1.96.0"
|
||||
@@ -19,8 +19,7 @@
|
||||
"project-management"
|
||||
],
|
||||
"activationEvents": [
|
||||
"onStartupFinished",
|
||||
"onLanguage:xflow"
|
||||
"onStartupFinished"
|
||||
],
|
||||
"main": "./out/extension.js",
|
||||
"contributes": {
|
||||
@@ -200,6 +199,7 @@
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"watch": "vite build --watch",
|
||||
"package": "vsce package --allow-missing-repository --no-rewrite-relative-links",
|
||||
"vscode:prepublish": "npm run build",
|
||||
"lint": "eslint .",
|
||||
"check": "npm run check:app && npm run check:test",
|
||||
@@ -216,6 +216,7 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/vscode": "^1.96.0",
|
||||
"@vitest/eslint-plugin": "^1.5.0",
|
||||
"@vscode/vsce": "^3.9.2",
|
||||
"eslint": "^9.39.0",
|
||||
"eslint-config-prettier": "^10.1.0",
|
||||
"globals": "^16.4.0",
|
||||
|
||||
+5
-5
@@ -1,18 +1,18 @@
|
||||
import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
|
||||
import { TASK_FILE_EXTENSION } from "@/model/taskFile";
|
||||
import { IConfigProvider } from "@/ports";
|
||||
import { FsTaskRepository } from "@/storage/FsTaskRepository";
|
||||
import { NodeFileSystem } from "@/storage/NodeFileSystem";
|
||||
import { VscodeConfigProvider } from "@/storage/VscodeConfigProvider";
|
||||
import { IConfigProvider } from "@/ports";
|
||||
import { registerTaskCodeActions } from "@/ui/taskCodeActions";
|
||||
import { registerTaskCodeLenses } from "@/ui/taskCodeLenses";
|
||||
import { registerTaskCommands } from "@/ui/taskCommands";
|
||||
import { registerTaskDecorations } from "@/ui/taskDecorations";
|
||||
import { registerTaskDiagnostics } from "@/ui/taskDiagnostics";
|
||||
import { TaskDashboardPanel } from "@/views/taskDashboardPanel";
|
||||
import { TaskKanbanPanel } from "@/views/taskKanbanPanel";
|
||||
import { TaskTreeProvider } from "@/views/taskTreeProvider";
|
||||
import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
|
||||
function createTaskWatchers(
|
||||
config: IConfigProvider,
|
||||
@@ -74,7 +74,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
context.subscriptions.push(statusBarItem);
|
||||
|
||||
registerTaskDiagnostics(context, { fs: fileSystem, config });
|
||||
registerTaskDecorations(context);
|
||||
registerTaskCodeLenses(context);
|
||||
registerTaskCodeActions(context);
|
||||
|
||||
registerTaskCommands(context, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TaskPriority } from "@/model/taskPriority";
|
||||
import { TaskStatus } from "@/model/taskStatus";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractFrontmatterBlock,
|
||||
findFieldRange,
|
||||
@@ -20,6 +20,17 @@ tags: [old]
|
||||
Body
|
||||
`;
|
||||
|
||||
const blockTagsSample = `---
|
||||
priority: medium
|
||||
tags:
|
||||
- bug
|
||||
- issue
|
||||
status: todo
|
||||
---
|
||||
|
||||
Body
|
||||
`;
|
||||
|
||||
describe("replaceFrontmatterField", () => {
|
||||
it("replaces status value", () => {
|
||||
const result = replaceFrontmatterField(sample, "status", TaskStatus.DONE);
|
||||
@@ -41,6 +52,20 @@ describe("replaceFrontmatterField", () => {
|
||||
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", () => {
|
||||
const result = replaceFrontmatterField(sample, "assignee", "me");
|
||||
expect(result).toEqual({ ok: false, reason: "no-field" });
|
||||
@@ -59,27 +84,74 @@ describe("findFieldRange", () => {
|
||||
const line = sample.split("\n")[range!.startLine];
|
||||
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", () => {
|
||||
it("formats empty and simple lists", () => {
|
||||
it("formats empty list as inline []", () => {
|
||||
expect(formatTagsYamlValue([])).toBe("[]");
|
||||
expect(formatTagsYamlValue(["bug", "frontend"])).toBe("[bug, frontend]");
|
||||
});
|
||||
|
||||
it("quotes tags with spaces", () => {
|
||||
expect(formatTagsYamlValue(["my tag"])).toBe('["my tag"]');
|
||||
it("formats single tag as block-style list", () => {
|
||||
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", () => {
|
||||
it("replaces the tags line", () => {
|
||||
it("replaces the tags line with block-style list", () => {
|
||||
const result = setFrontmatterTags(sample, ["bug", "docs"]);
|
||||
expect(result.ok).toBe(true);
|
||||
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]");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -93,11 +93,25 @@ export function findFieldRange(
|
||||
}
|
||||
|
||||
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 {
|
||||
startLine: i,
|
||||
startCol: 0,
|
||||
endLine: i,
|
||||
endCol: line.length,
|
||||
startCol: valueCol,
|
||||
endLine,
|
||||
endCol,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,12 +157,12 @@ export type ReplaceFrontmatterFieldResult =
|
||||
| { ok: true; content: string; range: TextRange }
|
||||
| { 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 {
|
||||
if (tags.length === 0) {
|
||||
return "[]";
|
||||
}
|
||||
return `[${tags.map((t) => yamlInlineScalar(t)).join(", ")}]`;
|
||||
return "\n" + tags.map((t) => ` - ${yamlInlineScalar(t)}`).join("\n");
|
||||
}
|
||||
|
||||
export function setFrontmatterTags(
|
||||
@@ -161,6 +175,7 @@ export function setFrontmatterTags(
|
||||
/**
|
||||
* Replace a scalar frontmatter field value on its line.
|
||||
* Does not re-serialize YAML; only rewrites the value after `:`.
|
||||
* Handles multi-line block-style values (value starting with \n).
|
||||
*/
|
||||
export function replaceFrontmatterField(
|
||||
content: string,
|
||||
@@ -178,6 +193,7 @@ export function replaceFrontmatterField(
|
||||
}
|
||||
|
||||
const lines = content.split(/\r?\n/);
|
||||
const eol = content.includes("\r\n") ? "\r\n" : "\n";
|
||||
const line = lines[lineRange.startLine] ?? "";
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex < 0) {
|
||||
@@ -185,11 +201,38 @@ export function replaceFrontmatterField(
|
||||
}
|
||||
|
||||
const prefix = line.slice(0, colonIndex + 1);
|
||||
const spacing = line.slice(colonIndex + 1).match(/^\s*/)?.[0] ?? " ";
|
||||
const newLine = `${prefix}${spacing || " "}${value}`;
|
||||
lines[lineRange.startLine] = newLine;
|
||||
const keyIndent = line.match(/^\s*/)?.[0].length ?? 0;
|
||||
|
||||
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 valueRange = findFieldRange(next, key);
|
||||
@@ -200,7 +243,7 @@ export function replaceFrontmatterField(
|
||||
startLine: lineRange.startLine,
|
||||
startCol: 0,
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export type KanbanOutboundMessage =
|
||||
scopeFilter: KanbanScopeFilter;
|
||||
shownHiddenColumns: TaskStatus[];
|
||||
defaultHiddenStatuses: TaskStatus[];
|
||||
dangerStatuses: TaskStatus[];
|
||||
availableScopes: TaskScope[];
|
||||
}
|
||||
| { variant: "error"; error: AppError };
|
||||
|
||||
@@ -10,6 +10,11 @@ export type TaskPriority = (typeof TaskPriority)[keyof typeof TaskPriority];
|
||||
|
||||
export type TaskPriorityMeta = {
|
||||
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). */
|
||||
@@ -26,7 +31,7 @@ export const TASK_PRIORITY_META: Record<TaskPriority, TaskPriorityMeta> = {
|
||||
[TaskPriority.HIGH]: { label: "High" },
|
||||
[TaskPriority.MEDIUM]: { label: "Medium" },
|
||||
[TaskPriority.LOW]: { label: "Low" },
|
||||
[TaskPriority.UNKNOWN]: { label: "Unknown" },
|
||||
[TaskPriority.UNKNOWN]: { label: "Unknown", fallback: true },
|
||||
};
|
||||
|
||||
export function isTaskPriority(value: unknown): value is TaskPriority {
|
||||
|
||||
@@ -15,6 +15,11 @@ export type TaskStatusMeta = {
|
||||
defaultHidden: boolean;
|
||||
/** Codicon id for tree; adapters wrap in ThemeIcon. */
|
||||
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). */
|
||||
@@ -57,6 +62,7 @@ export const TASK_STATUS_META: Record<TaskStatus, TaskStatusMeta> = {
|
||||
label: "Unknown",
|
||||
defaultHidden: true,
|
||||
icon: "question",
|
||||
fallback: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,422 +1,8 @@
|
||||
import * as vscode from "vscode";
|
||||
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 { 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 { 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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
runEditTaskPriority,
|
||||
runEditTaskStatus,
|
||||
runEditTaskTags,
|
||||
runOpenProjectConfig,
|
||||
runSetAssigneeMe,
|
||||
} from "./editFrontmatterField";
|
||||
import { presentError } from "./presentError";
|
||||
@@ -267,6 +268,11 @@ export function registerTaskCommands(
|
||||
(uri?: string) =>
|
||||
runEditTaskTags({ fs: deps.fs, config: deps.config }, uri),
|
||||
),
|
||||
vscode.commands.registerCommand(
|
||||
"xboctukFlow.openProjectConfig",
|
||||
(uri?: string) =>
|
||||
runOpenProjectConfig({ fs: deps.fs, config: deps.config }, uri),
|
||||
),
|
||||
vscode.commands.registerCommand(
|
||||
"xboctukFlow.addTagToProject",
|
||||
(uri?: string, tag?: string) =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -116,6 +116,13 @@ export function getTaskKanbanHtml(
|
||||
color: var(--vscode-button-foreground);
|
||||
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 {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.85;
|
||||
@@ -210,6 +217,7 @@ export function getTaskKanbanHtml(
|
||||
let scopeFilter = "all";
|
||||
let shownHiddenColumns = [];
|
||||
let defaultHiddenStatuses = [];
|
||||
let dangerStatuses = [];
|
||||
let availableScopes = [];
|
||||
let dragTaskId = null;
|
||||
|
||||
@@ -269,6 +277,9 @@ export function getTaskKanbanHtml(
|
||||
if (shownHiddenColumns.indexOf(status) >= 0) {
|
||||
btn.classList.add("shown");
|
||||
}
|
||||
if (dangerStatuses.indexOf(status) >= 0) {
|
||||
btn.classList.add("danger");
|
||||
}
|
||||
btn.addEventListener("click", () => {
|
||||
post({ variant: "toggleHiddenColumn", status: status });
|
||||
});
|
||||
@@ -374,6 +385,7 @@ export function getTaskKanbanHtml(
|
||||
scopeFilter = message.scopeFilter || "all";
|
||||
shownHiddenColumns = message.shownHiddenColumns || [];
|
||||
defaultHiddenStatuses = message.defaultHiddenStatuses || [];
|
||||
dangerStatuses = message.dangerStatuses || [];
|
||||
availableScopes = message.availableScopes || [];
|
||||
setStatus("");
|
||||
render();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { listLocatedTasks } from "@/commands/listLocatedTasks";
|
||||
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
|
||||
import { appError, AppErrorVariant } from "@/error";
|
||||
import { buildKanbanBoard } from "@/kanban/buildKanbanBoard";
|
||||
import { filterDefaultHiddenStatuses } from "@/kanban/filterDefaultHiddenStatuses";
|
||||
import {
|
||||
parseKanbanInboundMessage,
|
||||
type KanbanInboundMessage,
|
||||
@@ -278,20 +279,24 @@ export class TaskKanbanPanel {
|
||||
}
|
||||
|
||||
#postState(): void {
|
||||
const board = buildKanbanBoard(
|
||||
this.#filteredTasks().map((item) => item.task),
|
||||
{ shownHiddenColumns: this.#shownHiddenColumns },
|
||||
);
|
||||
const filtered = this.#filteredTasks().map((item) => item.task);
|
||||
const board = buildKanbanBoard(filtered, {
|
||||
shownHiddenColumns: this.#shownHiddenColumns,
|
||||
});
|
||||
|
||||
const hasUnknown = filtered.some((t) => t.status === TaskStatus.UNKNOWN);
|
||||
|
||||
this.#post({
|
||||
variant: "state",
|
||||
board,
|
||||
scopeFilter: this.#scopeFilter,
|
||||
shownHiddenColumns: this.#shownHiddenColumns,
|
||||
defaultHiddenStatuses: TASK_STATUS_ORDER.filter(
|
||||
(s) => TASK_STATUS_META[s].defaultHidden,
|
||||
defaultHiddenStatuses: filterDefaultHiddenStatuses(
|
||||
hasUnknown,
|
||||
this.#shownHiddenColumns,
|
||||
),
|
||||
availableScopes: this.#availableScopes(),
|
||||
dangerStatuses: hasUnknown ? [TaskStatus.UNKNOWN] : [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import * as vscode from "vscode";
|
||||
import { listLocatedTasks } from "@/commands/listLocatedTasks";
|
||||
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
|
||||
import { Task } from "@/model/task";
|
||||
import { LocatedTask, TaskLocation } from "@/model/taskLocation";
|
||||
import {
|
||||
TaskStatus,
|
||||
TASK_STATUS_META,
|
||||
TASK_STATUS_ORDER,
|
||||
type TaskStatus,
|
||||
} from "@/model/taskStatus";
|
||||
import { IConfigProvider, ITaskRepository } from "@/ports";
|
||||
import { TASK_SCOPE_LABELS, TASK_STATUS_LABELS } from "@/ui/taskLabels";
|
||||
import * as vscode from "vscode";
|
||||
import { TaskTreeItem } from "./taskTreeItem";
|
||||
|
||||
class ScopeTreeItem extends vscode.TreeItem {
|
||||
@@ -47,7 +47,12 @@ class TaskGroupTreeItem extends vscode.TreeItem {
|
||||
this.description = `(${tasks.length})`;
|
||||
this.id = `group-${location.scope}-${status}`;
|
||||
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[] {
|
||||
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);
|
||||
return new TaskGroupTreeItem(status, scope.location, groupTasks);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user