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/
|
node_modules/
|
||||||
out/
|
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",
|
"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
@@ -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, {
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
scopeFilter: KanbanScopeFilter;
|
||||||
shownHiddenColumns: TaskStatus[];
|
shownHiddenColumns: TaskStatus[];
|
||||||
defaultHiddenStatuses: TaskStatus[];
|
defaultHiddenStatuses: TaskStatus[];
|
||||||
|
dangerStatuses: TaskStatus[];
|
||||||
availableScopes: TaskScope[];
|
availableScopes: TaskScope[];
|
||||||
}
|
}
|
||||||
| { variant: "error"; error: AppError };
|
| { variant: "error"; error: AppError };
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
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) =>
|
||||||
|
|||||||
@@ -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);
|
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();
|
||||||
|
|||||||
@@ -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] : [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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