feat: add global tasks folder

This commit is contained in:
2026-07-15 00:32:25 +05:00
parent dc37cd8553
commit beeb9dd7f9
17 changed files with 473 additions and 156 deletions
+73
View File
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it } from "vitest";
import { err } from "neverthrow";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { appError, AppErrorVariant } from "@/error";
import { FsTaskRepository } from "@/storage/FsTaskRepository";
import { listLocatedTasks } from "./listLocatedTasks";
describe("listLocatedTasks", () => {
const project = "/project/tasks";
const global = "/global/tasks";
let repo: FsTaskRepository;
beforeEach(() => {
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
});
it("merges tasks from project and global with location", async () => {
const p = await repo.create(project, {
title: "Project task",
description: "",
status: "todo",
priority: "medium",
tags: [],
assignee: "",
});
const g = await repo.create(global, {
title: "Global task",
description: "",
status: "todo",
priority: "high",
tags: [],
assignee: "",
});
const result = await listLocatedTasks(repo, [
{ scope: "project", folderPath: project },
{ scope: "global", folderPath: global },
]);
expect(result.isOk()).toBe(true);
if (result.isErr()) return;
expect(result.value).toHaveLength(2);
const byId = Object.fromEntries(
result.value.map((item) => [item.task.id, item]),
);
expect(byId[p.id].task.title).toBe("Project task");
expect(byId[p.id].location).toEqual({
scope: "project",
folderPath: project,
});
expect(byId[g.id].task.title).toBe("Global task");
expect(byId[g.id].location).toEqual({
scope: "global",
folderPath: global,
});
});
it("returns empty array when folders have no tasks", async () => {
const result = await listLocatedTasks(repo, [
{ scope: "project", folderPath: project },
]);
expect(result.isOk()).toBe(true);
if (result.isErr()) return;
expect(result.value).toEqual([]);
});
it("rejects empty locations", async () => {
const result = await listLocatedTasks(repo, []);
expect(result).toEqual(err(appError(AppErrorVariant.NO_FOLDER)));
});
});
+32
View File
@@ -0,0 +1,32 @@
import { errAsync, ResultAsync } from "neverthrow";
import { appError, AppError, AppErrorVariant } from "@/error";
import { LocatedTask, TaskLocation } from "@/model/taskLocation";
import { ITaskRepository } from "@/ports";
export function listLocatedTasks(
repo: ITaskRepository,
locations: TaskLocation[],
): ResultAsync<LocatedTask[], AppError> {
if (locations.length === 0) {
return errAsync(appError(AppErrorVariant.NO_FOLDER));
}
return ResultAsync.fromSafePromise(
(async () => {
const located: LocatedTask[] = [];
for (const location of locations) {
const tasks = await repo.list(location.folderPath);
for (const task of tasks) {
located.push({ task, location });
}
}
return located.sort(
(a, b) =>
new Date(b.task.updated).getTime() -
new Date(a.task.updated).getTime(),
);
})(),
);
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { IConfigProvider } from "@/ports";
import { resolveTaskLocations } from "./resolveTaskLocations";
function config(partial: {
project?: string;
global?: string;
}): IConfigProvider {
return {
getProjectTaskPath: () => partial.project,
getGlobalTaskPath: () => partial.global,
getFileExtension: () => ".task.md",
};
}
describe("resolveTaskLocations", () => {
it("returns only project when global is unset", () => {
expect(resolveTaskLocations(config({ project: "/proj/tasks" }))).toEqual([
{ scope: "project", folderPath: "/proj/tasks" },
]);
});
it("returns only global when project is unset", () => {
expect(resolveTaskLocations(config({ global: "/global/tasks" }))).toEqual([
{ scope: "global", folderPath: "/global/tasks" },
]);
});
it("returns project then global when both set", () => {
expect(
resolveTaskLocations(
config({ project: "/proj/tasks", global: "/global/tasks" }),
),
).toEqual([
{ scope: "project", folderPath: "/proj/tasks" },
{ scope: "global", folderPath: "/global/tasks" },
]);
});
it("returns empty when neither is set", () => {
expect(resolveTaskLocations(config({}))).toEqual([]);
});
});
+19
View File
@@ -0,0 +1,19 @@
import { TaskLocation } from "@/model/taskLocation";
import { IConfigProvider } from "@/ports";
/** Active task folders from config (project first, then global). */
export function resolveTaskLocations(config: IConfigProvider): TaskLocation[] {
const locations: TaskLocation[] = [];
const projectPath = config.getProjectTaskPath();
if (projectPath) {
locations.push({ scope: "project", folderPath: projectPath });
}
const globalPath = config.getGlobalTaskPath();
if (globalPath) {
locations.push({ scope: "global", folderPath: globalPath });
}
return locations;
}
+10 -8
View File
@@ -1,5 +1,6 @@
import * as path from "path";
import * as vscode from "vscode";
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
import { FsTaskRepository } from "@/storage/FsTaskRepository";
import { NodeFileSystem } from "@/storage/NodeFileSystem";
import { VscodeConfigProvider } from "@/storage/VscodeConfigProvider";
@@ -19,13 +20,14 @@ export function activate(context: vscode.ExtensionContext) {
};
context.subscriptions.push(
vscode.window.registerTreeDataProvider("projectTasks.tasks", tree),
vscode.window.registerTreeDataProvider("xboctFlow.tasks", tree),
);
const projectPath = config.getProjectTaskPath();
if (projectPath) {
const ext = config.getFileExtension();
const taskPattern = path.join(projectPath, `*${ext}`).replace(/\\/g, "/");
const ext = config.getFileExtension();
for (const location of resolveTaskLocations(config)) {
const taskPattern = path
.join(location.folderPath, `*${ext}`)
.replace(/\\/g, "/");
const watcher = vscode.workspace.createFileSystemWatcher(taskPattern);
watcher.onDidCreate(onTasksMutated);
watcher.onDidChange(onTasksMutated);
@@ -37,8 +39,8 @@ export function activate(context: vscode.ExtensionContext) {
vscode.StatusBarAlignment.Left,
);
statusBarItem.text = "$(checklist) Tasks";
statusBarItem.command = "projectTasks.openDashboard";
statusBarItem.tooltip = "Project Tasks — Open Dashboard";
statusBarItem.command = "xboctFlow.openDashboard";
statusBarItem.tooltip = "XBOCT flow — Open Dashboard";
statusBarItem.show();
context.subscriptions.push(statusBarItem);
@@ -50,7 +52,7 @@ export function activate(context: vscode.ExtensionContext) {
});
context.subscriptions.push(
vscode.commands.registerCommand("projectTasks.openDashboard", () => {
vscode.commands.registerCommand("xboctFlow.openDashboard", () => {
TaskDashboardPanel.show({
repo,
config,
+14
View File
@@ -0,0 +1,14 @@
import { Task } from "./task";
export type TaskScope = "project" | "global";
/** Where a task lives on disk (runtime; not stored in frontmatter). */
export type TaskLocation = {
scope: TaskScope;
folderPath: string;
};
export type LocatedTask = {
task: Task;
location: TaskLocation;
};
+3 -3
View File
@@ -7,18 +7,18 @@ export class VscodeConfigProvider implements IConfigProvider {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) return undefined;
const config = vscode.workspace.getConfiguration("projectTasks");
const config = vscode.workspace.getConfiguration("xboctFlow");
const relativePath = config.get<string>("projectPath") ?? ".vscode/tasks";
return path.join(workspaceRoot, relativePath);
}
getGlobalTaskPath(): string | undefined {
const config = vscode.workspace.getConfiguration("projectTasks");
const config = vscode.workspace.getConfiguration("xboctFlow");
return config.get<string>("globalPath") || undefined;
}
getFileExtension(): string {
const config = vscode.workspace.getConfiguration("projectTasks");
const config = vscode.workspace.getConfiguration("xboctFlow");
return config.get<string>("fileExtension") ?? ".task.md";
}
}
-40
View File
@@ -1,40 +0,0 @@
import * as vscode from "vscode";
import { ITaskRepository } from "@/ports";
import { TaskTreeItem } from "@/views/taskTreeItem";
import { TASK_STATUS_LABELS } from "./taskLabels";
/**
* Resolve task id from a TreeView item, or ask the user via QuickPick
* when the command is invoked from the Command Palette.
*/
export async function resolveTaskId(
repo: ITaskRepository,
folderPath: string | undefined,
item?: unknown,
): Promise<string | undefined> {
if (item instanceof TaskTreeItem) {
return item.task.id;
}
if (folderPath === undefined) {
return undefined;
}
const tasks = await repo.list(folderPath);
if (tasks.length === 0) {
void vscode.window.showInformationMessage("No tasks found");
return undefined;
}
const picked = await vscode.window.showQuickPick(
tasks.map((task) => ({
label: task.title,
description: TASK_STATUS_LABELS[task.status],
detail: task.priority,
taskId: task.id,
})),
{ placeHolder: "Select a task" },
);
return picked?.taskId;
}
+52
View File
@@ -0,0 +1,52 @@
import * as vscode from "vscode";
import { listLocatedTasks } from "@/commands/listLocatedTasks";
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
import { TaskLocation } from "@/model/taskLocation";
import { IConfigProvider, ITaskRepository } from "@/ports";
import { TaskTreeItem } from "@/views/taskTreeItem";
import { TASK_SCOPE_LABELS, TASK_STATUS_LABELS } from "./taskLabels";
export type TaskRef = {
id: string;
location: TaskLocation;
};
/**
* Resolve task id + location from a TreeView item, or QuickPick when
* the command is invoked from the Command Palette.
*/
export async function resolveTaskRef(
repo: ITaskRepository,
config: IConfigProvider,
item?: unknown,
): Promise<TaskRef | undefined> {
if (item instanceof TaskTreeItem) {
return { id: item.task.id, location: item.location };
}
const locations = resolveTaskLocations(config);
if (locations.length === 0) {
return undefined;
}
const result = await listLocatedTasks(repo, locations);
if (result.isErr() || result.value.length === 0) {
void vscode.window.showInformationMessage("No tasks found");
return undefined;
}
const picked = await vscode.window.showQuickPick(
result.value.map((located) => ({
label: located.task.title,
description: `${TASK_SCOPE_LABELS[located.location.scope]} · ${TASK_STATUS_LABELS[located.task.status]}`,
detail: located.task.priority,
ref: {
id: located.task.id,
location: located.location,
} satisfies TaskRef,
})),
{ placeHolder: "Select a task" },
);
return picked?.ref;
}
+63 -37
View File
@@ -3,13 +3,15 @@ import { changeStatus } from "@/commands/changeStatus";
import { createTask } from "@/commands/createTask";
import { deleteTask } from "@/commands/deleteTask";
import { openTask } from "@/commands/openTask";
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
import { appError, AppErrorVariant } from "@/error";
import { TaskLocation } from "@/model/taskLocation";
import { TaskStatus } from "@/model/task";
import { IConfigProvider, ITaskRepository } from "@/ports";
import { TaskTreeProvider } from "@/views/taskTreeProvider";
import { presentError } from "./presentError";
import { resolveTaskId } from "./resolveTaskId";
import { TASK_STATUS_LABELS, TASK_STATUS_ORDER } from "./taskLabels";
import { resolveTaskRef } from "./resolveTaskRef";
import { TASK_SCOPE_LABELS, TASK_STATUS_LABELS, TASK_STATUS_ORDER } from "./taskLabels";
export type TaskCommandDeps = {
repo: ITaskRepository;
@@ -24,18 +26,45 @@ function notifyMutated(deps: TaskCommandDeps): void {
deps.onTasksMutated?.();
}
async function pickCreateLocation(
config: IConfigProvider,
): Promise<TaskLocation | undefined> {
const locations = resolveTaskLocations(config);
if (locations.length === 0) {
presentError(appError(AppErrorVariant.NO_FOLDER));
return undefined;
}
if (locations.length === 1) {
return locations[0];
}
const picked = await vscode.window.showQuickPick(
locations.map((location) => ({
label: TASK_SCOPE_LABELS[location.scope],
description: location.folderPath,
location,
})),
{ placeHolder: "Create task in…" },
);
return picked?.location;
}
export async function runCreateTask(deps: TaskCommandDeps): Promise<void> {
const title = await vscode.window.showInputBox({
prompt: "Task title",
placeHolder: "Enter task title...",
});
// InputBox cancel is not an application error.
if (title === undefined) {
return;
}
const location = await pickCreateLocation(deps.config);
if (location === undefined) {
return;
}
const result = await createTask(deps.repo, {
folderPath: deps.config.getProjectTaskPath(),
folderPath: location.folderPath,
title,
});
@@ -46,7 +75,7 @@ export async function runCreateTask(deps: TaskCommandDeps): Promise<void> {
notifyMutated(deps);
void vscode.window.showInformationMessage(
`Task created: ${result.value.title}`,
`Task created (${TASK_SCOPE_LABELS[location.scope]}): ${result.value.title}`,
);
}
@@ -54,18 +83,18 @@ export async function runOpenTask(
deps: TaskCommandDeps,
item?: unknown,
): Promise<void> {
const folderPath = deps.config.getProjectTaskPath();
if (folderPath === undefined) {
presentError(appError(AppErrorVariant.NO_FOLDER));
const ref = await resolveTaskRef(deps.repo, deps.config, item);
if (ref === undefined) {
if (resolveTaskLocations(deps.config).length === 0) {
presentError(appError(AppErrorVariant.NO_FOLDER));
}
return;
}
const id = await resolveTaskId(deps.repo, folderPath, item);
if (id === undefined) {
return;
}
const result = await openTask(deps.repo, { folderPath, id });
const result = await openTask(deps.repo, {
folderPath: ref.location.folderPath,
id: ref.id,
});
if (result.isErr()) {
presentError(result.error);
return;
@@ -79,14 +108,11 @@ export async function runDeleteTask(
deps: TaskCommandDeps,
item?: unknown,
): Promise<void> {
const folderPath = deps.config.getProjectTaskPath();
if (folderPath === undefined) {
presentError(appError(AppErrorVariant.NO_FOLDER));
return;
}
const id = await resolveTaskId(deps.repo, folderPath, item);
if (id === undefined) {
const ref = await resolveTaskRef(deps.repo, deps.config, item);
if (ref === undefined) {
if (resolveTaskLocations(deps.config).length === 0) {
presentError(appError(AppErrorVariant.NO_FOLDER));
}
return;
}
@@ -99,7 +125,10 @@ export async function runDeleteTask(
return;
}
const result = await deleteTask(deps.repo, { folderPath, id });
const result = await deleteTask(deps.repo, {
folderPath: ref.location.folderPath,
id: ref.id,
});
if (result.isErr()) {
presentError(result.error);
return;
@@ -113,14 +142,11 @@ export async function runChangeStatus(
deps: TaskCommandDeps,
item?: unknown,
): Promise<void> {
const folderPath = deps.config.getProjectTaskPath();
if (folderPath === undefined) {
presentError(appError(AppErrorVariant.NO_FOLDER));
return;
}
const id = await resolveTaskId(deps.repo, folderPath, item);
if (id === undefined) {
const ref = await resolveTaskRef(deps.repo, deps.config, item);
if (ref === undefined) {
if (resolveTaskLocations(deps.config).length === 0) {
presentError(appError(AppErrorVariant.NO_FOLDER));
}
return;
}
@@ -136,8 +162,8 @@ export async function runChangeStatus(
}
const result = await changeStatus(deps.repo, {
folderPath,
id,
folderPath: ref.location.folderPath,
id: ref.id,
status: picked.status,
});
if (result.isErr()) {
@@ -157,19 +183,19 @@ export function registerTaskCommands(
deps: TaskCommandDeps,
): void {
context.subscriptions.push(
vscode.commands.registerCommand("projectTasks.createTask", () =>
vscode.commands.registerCommand("xboctFlow.createTask", () =>
runCreateTask(deps),
),
vscode.commands.registerCommand(
"projectTasks.openTask",
"xboctFlow.openTask",
(item?: unknown) => runOpenTask(deps, item),
),
vscode.commands.registerCommand(
"projectTasks.deleteTask",
"xboctFlow.deleteTask",
(item?: unknown) => runDeleteTask(deps, item),
),
vscode.commands.registerCommand(
"projectTasks.changeStatus",
"xboctFlow.changeStatus",
(item?: unknown) => runChangeStatus(deps, item),
),
);
+7
View File
@@ -1,4 +1,5 @@
import { TaskStatus } from "@/model/task";
import { TaskScope } from "@/model/taskLocation";
export const TASK_STATUS_ORDER: TaskStatus[] = [
"todo",
@@ -13,3 +14,9 @@ export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
done: "Done",
cancelled: "Cancelled",
};
export const TASK_SCOPE_LABELS: Record<TaskScope, string> = {
project: "Project",
global: "Global",
};
+26 -14
View File
@@ -1,5 +1,6 @@
import * as vscode from "vscode";
import { loadDashboard } from "@/commands/loadDashboard";
import { listLocatedTasks } from "@/commands/listLocatedTasks";
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
import { saveTaskDescription } from "@/commands/saveTaskDescription";
import { buildDashboardView } from "@/dashboard/buildDashboardView";
import { TaskFilter } from "@/dashboard/filterTasks";
@@ -9,8 +10,8 @@ import {
type DashboardInboundMessage,
} from "@/dashboard/messages";
import { DashboardOutboundMessage } from "@/dashboard/outboundMessages";
import { AppErrorVariant } from "@/error";
import { Task } from "@/model/task";
import { appError, AppErrorVariant } from "@/error";
import { LocatedTask } from "@/model/taskLocation";
import { IConfigProvider, ITaskRepository } from "@/ports";
import { presentError } from "@/ui/presentError";
import { createNonce, getTaskDashboardHtml } from "./taskDashboardHtml";
@@ -37,7 +38,7 @@ export class TaskDashboardPanel {
}
const panel = vscode.window.createWebviewPanel(
"projectTasks.dashboard",
"xboctFlow.dashboard",
"Task Dashboard",
vscode.ViewColumn.One,
{
@@ -56,7 +57,7 @@ export class TaskDashboardPanel {
readonly #panel: vscode.WebviewPanel;
readonly #deps: TaskDashboardDeps;
#tasks: Task[] = [];
#tasks: LocatedTask[] = [];
#filter: TaskFilter = {};
#groupBy: GroupBy = "none";
#selectedId: string | undefined;
@@ -84,8 +85,8 @@ export class TaskDashboardPanel {
async reloadTasks(): Promise<void> {
if (this.#disposed) return;
const folderPath = this.#deps.config.getProjectTaskPath();
const result = await loadDashboard(this.#deps.repo, { folderPath });
const locations = resolveTaskLocations(this.#deps.config);
const result = await listLocatedTasks(this.#deps.repo, locations);
if (result.isErr()) {
this.#post({ type: "error", error: result.error });
@@ -95,10 +96,10 @@ export class TaskDashboardPanel {
return;
}
this.#tasks = result.value.tasks;
this.#tasks = result.value;
if (
this.#selectedId &&
!this.#tasks.some((task) => task.id === this.#selectedId)
!this.#tasks.some((item) => item.task.id === this.#selectedId)
) {
this.#selectedId = undefined;
}
@@ -143,9 +144,17 @@ export class TaskDashboardPanel {
}
async #saveDescription(id: string, description: string): Promise<void> {
const folderPath = this.#deps.config.getProjectTaskPath();
const located = this.#tasks.find((item) => item.task.id === id);
if (!located) {
this.#post({
type: "error",
error: appError(AppErrorVariant.NOT_FOUND, { id }),
});
return;
}
const result = await saveTaskDescription(this.#deps.repo, {
folderPath,
folderPath: located.location.folderPath,
id,
description,
});
@@ -156,8 +165,10 @@ export class TaskDashboardPanel {
}
const saved = result.value;
this.#tasks = this.#tasks.map((task) =>
task.id === saved.id ? saved : task,
this.#tasks = this.#tasks.map((item) =>
item.task.id === saved.id
? { task: saved, location: item.location }
: item,
);
this.#selectedId = saved.id;
this.#post({ type: "saved", task: saved });
@@ -166,7 +177,8 @@ export class TaskDashboardPanel {
}
#postState(): void {
const view = buildDashboardView(this.#tasks, {
const plainTasks = this.#tasks.map((item) => item.task);
const view = buildDashboardView(plainTasks, {
filter: this.#filter,
groupBy: this.#groupBy,
selectedId: this.#selectedId,
+10 -6
View File
@@ -1,23 +1,27 @@
import * as vscode from "vscode";
import { Task } from "@/model/task";
import { TaskLocation } from "@/model/taskLocation";
import { TASK_SCOPE_LABELS } from "@/ui/taskLabels";
/** Tree item that carries the domain task (for context-menu commands). */
/** Tree item that carries the domain task + where it lives. */
export class TaskTreeItem extends vscode.TreeItem {
readonly task: Task;
readonly location: TaskLocation;
constructor(task: Task) {
constructor(task: Task, location: TaskLocation) {
super(task.title, vscode.TreeItemCollapsibleState.None);
this.task = task;
this.id = task.id;
this.description = task.priority;
this.location = location;
this.id = `${location.scope}:${task.id}`;
this.description = `${task.priority} · ${TASK_SCOPE_LABELS[location.scope]}`;
this.contextValue = "task";
this.tooltip = new vscode.MarkdownString(
`**${task.title}**\n\nPriority: ${task.priority}\nStatus: ${task.status}${
`**${task.title}**\n\nScope: ${TASK_SCOPE_LABELS[location.scope]}\nPriority: ${task.priority}\nStatus: ${task.status}${
task.assignee ? `\nAssignee: ${task.assignee}` : ""
}`,
);
this.command = {
command: "projectTasks.openTask",
command: "xboctFlow.openTask",
title: "Open Task",
arguments: [this],
};
+68 -10
View File
@@ -1,7 +1,14 @@
import * as vscode from "vscode";
import { listLocatedTasks } from "@/commands/listLocatedTasks";
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
import { Task, TaskStatus } from "@/model/task";
import { LocatedTask, TaskLocation } from "@/model/taskLocation";
import { IConfigProvider, ITaskRepository } from "@/ports";
import { TASK_STATUS_LABELS, TASK_STATUS_ORDER } from "@/ui/taskLabels";
import {
TASK_SCOPE_LABELS,
TASK_STATUS_LABELS,
TASK_STATUS_ORDER,
} from "@/ui/taskLabels";
import { TaskTreeItem } from "./taskTreeItem";
const STATUS_ICONS: Record<TaskStatus, vscode.ThemeIcon> = {
@@ -11,19 +18,42 @@ const STATUS_ICONS: Record<TaskStatus, vscode.ThemeIcon> = {
cancelled: new vscode.ThemeIcon("close"),
};
class ScopeTreeItem extends vscode.TreeItem {
readonly location: TaskLocation;
readonly locatedTasks: LocatedTask[];
constructor(location: TaskLocation, locatedTasks: LocatedTask[]) {
super(
TASK_SCOPE_LABELS[location.scope],
vscode.TreeItemCollapsibleState.Expanded,
);
this.location = location;
this.locatedTasks = locatedTasks;
this.id = `scope-${location.scope}`;
this.contextValue = "taskScope";
this.description = `(${locatedTasks.length})`;
this.iconPath =
location.scope === "project"
? new vscode.ThemeIcon("root-folder")
: new vscode.ThemeIcon("globe");
}
}
class TaskGroupTreeItem extends vscode.TreeItem {
readonly location: TaskLocation;
readonly tasks: Task[];
constructor(status: TaskStatus, tasks: Task[]) {
constructor(status: TaskStatus, location: TaskLocation, tasks: Task[]) {
super(
TASK_STATUS_LABELS[status],
vscode.TreeItemCollapsibleState.Collapsed,
);
this.iconPath = STATUS_ICONS[status];
this.location = location;
this.tasks = tasks;
this.description = `(${tasks.length})`;
this.id = `group-${status}`;
this.id = `group-${location.scope}-${status}`;
this.contextValue = "taskGroup";
this.iconPath = STATUS_ICONS[status];
}
}
@@ -53,23 +83,51 @@ export class TaskTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem
if (!element) {
return this.getRootChildren();
}
if (element instanceof ScopeTreeItem) {
return this.getStatusGroups(element);
}
if (element instanceof TaskGroupTreeItem) {
return element.tasks.map((task) => new TaskTreeItem(task));
return element.tasks.map(
(task) => new TaskTreeItem(task, element.location),
);
}
return [];
}
private async getRootChildren(): Promise<vscode.TreeItem[]> {
const projectPath = this.#configStore.getProjectTaskPath();
if (!projectPath) {
return [new vscode.TreeItem("Open a workspace folder to start")];
const locations = resolveTaskLocations(this.#configStore);
if (locations.length === 0) {
return [
new vscode.TreeItem(
"Configure project or global task path in settings",
),
];
}
const tasks = await this.#taskStore.list(projectPath);
const result = await listLocatedTasks(this.#taskStore, locations);
if (result.isErr()) {
return [new vscode.TreeItem(result.error.message)];
}
const byScope = new Map<string, LocatedTask[]>();
for (const location of locations) {
byScope.set(location.scope, []);
}
for (const located of result.value) {
byScope.get(located.location.scope)?.push(located);
}
return locations.map((location) => {
const tasks = byScope.get(location.scope) ?? [];
return new ScopeTreeItem(location, tasks);
});
}
private getStatusGroups(scope: ScopeTreeItem): vscode.TreeItem[] {
const tasks = scope.locatedTasks.map((item) => item.task);
return TASK_STATUS_ORDER.map((status) => {
const groupTasks = tasks.filter((t) => t.status === status);
return new TaskGroupTreeItem(status, groupTasks);
return new TaskGroupTreeItem(status, scope.location, groupTasks);
});
}
}