feat: add global tasks folder
This commit is contained in:
+15
-3
@@ -1,4 +1,4 @@
|
||||
# project-tasks — VS Code Extension
|
||||
# xboct-flow — VS Code Extension
|
||||
|
||||
## Хранение
|
||||
|
||||
@@ -22,7 +22,7 @@ updated: 2026-07-12T10:00:00Z
|
||||
**Структура папок:**
|
||||
|
||||
- `.vscode/tasks/` — проектные задачи
|
||||
- глобальная папка — настраивается (`projectTasks.globalPath`)
|
||||
- глобальная папка — настраивается (`xboctFlow.globalPath`)
|
||||
|
||||
Оба пути настраиваются через VS Code settings.
|
||||
|
||||
@@ -30,7 +30,7 @@ updated: 2026-07-12T10:00:00Z
|
||||
|
||||
Через **Custom Editor** + **WebView**:
|
||||
|
||||
1. **Activity Bar** — вкладка Project Tasks
|
||||
1. **Activity Bar** — вкладка XBOCT flow
|
||||
2. **Task Dashboard** — WebView в editor area со split layout:
|
||||
- Левая панель — список задач с фильтрацией/группировкой
|
||||
- Правая панель — содержимое выбранной задачи (редактируемый markdown)
|
||||
@@ -112,6 +112,18 @@ Split layout: список (filter/group) + detail (editable markdown body).
|
||||
|
||||
**По-прежнему только smoke руками:** визуал, CSP, postMessage round-trip
|
||||
|
||||
### Фаза 6 — globalPath
|
||||
|
||||
| Модуль | Поведение |
|
||||
| ------------------------------ | ---------------------------------------------------------- |
|
||||
| `TaskLocation` / `LocatedTask` | runtime scope + folderPath (не в frontmatter) |
|
||||
| `resolveTaskLocations` | project и/или global из config |
|
||||
| `listLocatedTasks` | list всех locations → `LocatedTask[]`; empty → `NO_FOLDER` |
|
||||
| Tree | Project / Global → status → task (location на item) |
|
||||
| Create | QuickPick scope, если оба path заданы |
|
||||
| Dashboard | multi-folder list; save по location из host cache |
|
||||
| Watchers | на каждый active folder |
|
||||
|
||||
## Зависимости
|
||||
|
||||
- `front-matter` — разбор YAML frontmatter (body + attributes)
|
||||
|
||||
+26
-26
@@ -25,44 +25,44 @@
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "projectTasks.createTask",
|
||||
"title": "Project Tasks: Create Task",
|
||||
"command": "xboctFlow.createTask",
|
||||
"title": "XBOCT flow: Create Task",
|
||||
"icon": "$(add)"
|
||||
},
|
||||
{
|
||||
"command": "projectTasks.openTask",
|
||||
"title": "Project Tasks: Open Task",
|
||||
"command": "xboctFlow.openTask",
|
||||
"title": "XBOCT flow: Open Task",
|
||||
"icon": "$(go-to-file)"
|
||||
},
|
||||
{
|
||||
"command": "projectTasks.deleteTask",
|
||||
"title": "Project Tasks: Delete Task",
|
||||
"command": "xboctFlow.deleteTask",
|
||||
"title": "XBOCT flow: Delete Task",
|
||||
"icon": "$(trash)"
|
||||
},
|
||||
{
|
||||
"command": "projectTasks.changeStatus",
|
||||
"title": "Project Tasks: Change Status",
|
||||
"command": "xboctFlow.changeStatus",
|
||||
"title": "XBOCT flow: Change Status",
|
||||
"icon": "$(sync)"
|
||||
},
|
||||
{
|
||||
"command": "projectTasks.openDashboard",
|
||||
"title": "Project Tasks: Open Dashboard"
|
||||
"command": "xboctFlow.openDashboard",
|
||||
"title": "XBOCT flow: Open Dashboard"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
"title": "Project Tasks",
|
||||
"title": "XBOCT flow",
|
||||
"properties": {
|
||||
"projectTasks.projectPath": {
|
||||
"xboctFlow.projectPath": {
|
||||
"type": "string",
|
||||
"default": ".vscode/tasks",
|
||||
"description": "Path to project-level tasks folder (relative to workspace root)"
|
||||
},
|
||||
"projectTasks.globalPath": {
|
||||
"xboctFlow.globalPath": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Path to global tasks folder (absolute path, shared across projects)"
|
||||
},
|
||||
"projectTasks.fileExtension": {
|
||||
"xboctFlow.fileExtension": {
|
||||
"type": "string",
|
||||
"default": ".task.md",
|
||||
"enum": [
|
||||
@@ -77,17 +77,17 @@
|
||||
"viewsContainers": {
|
||||
"activitybar": [
|
||||
{
|
||||
"id": "projectTasks",
|
||||
"title": "Project Tasks",
|
||||
"id": "xboctFlow",
|
||||
"title": "XBOCT flow",
|
||||
"icon": "media/icon.svg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"views": {
|
||||
"projectTasks": [
|
||||
"xboctFlow": [
|
||||
{
|
||||
"type": "tree",
|
||||
"id": "projectTasks.tasks",
|
||||
"id": "xboctFlow.tasks",
|
||||
"name": "Tasks",
|
||||
"icon": "media/icon.svg"
|
||||
}
|
||||
@@ -96,25 +96,25 @@
|
||||
"menus": {
|
||||
"view/title": [
|
||||
{
|
||||
"command": "projectTasks.createTask",
|
||||
"when": "view == projectTasks.tasks",
|
||||
"command": "xboctFlow.createTask",
|
||||
"when": "view == xboctFlow.tasks",
|
||||
"group": "navigation"
|
||||
}
|
||||
],
|
||||
"view/item/context": [
|
||||
{
|
||||
"command": "projectTasks.openTask",
|
||||
"when": "view == projectTasks.tasks && viewItem == task",
|
||||
"command": "xboctFlow.openTask",
|
||||
"when": "view == xboctFlow.tasks && viewItem == task",
|
||||
"group": "inline@1"
|
||||
},
|
||||
{
|
||||
"command": "projectTasks.changeStatus",
|
||||
"when": "view == projectTasks.tasks && viewItem == task",
|
||||
"command": "xboctFlow.changeStatus",
|
||||
"when": "view == xboctFlow.tasks && viewItem == task",
|
||||
"group": "1_task@1"
|
||||
},
|
||||
{
|
||||
"command": "projectTasks.deleteTask",
|
||||
"when": "view == projectTasks.tasks && viewItem == task",
|
||||
"command": "xboctFlow.deleteTask",
|
||||
"when": "view == xboctFlow.tasks && viewItem == task",
|
||||
"group": "1_task@2"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
);
|
||||
})(),
|
||||
);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
+9
-7
@@ -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, "/");
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+58
-32
@@ -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) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,18 +16,17 @@
|
||||
записи на диск. Обычный Ctrl+S в `.md` / `.task.md` идёт через editor → format
|
||||
применяется. Наша кнопка — другой путь.
|
||||
|
||||
**Не делать:** вручную вызывать prettier/`formatDocument` «всегда» — это не
|
||||
«как в VS Code», а свой форс, игнорит user settings (off / другой formatter).
|
||||
**Не делать:** вручную вызывать prettier/`formatDocument` «всегда» — это не «как
|
||||
в VS Code», а свой форс, игнорит user settings (off / другой formatter).
|
||||
|
||||
**Как сделать «как в VS Code» (если захотим паритет):** UI-адаптер save
|
||||
открывает/берёт `TextDocument` файла задачи, правит body через
|
||||
`WorkspaceEdit`, затем `document.save()` — тогда сработают format-on-save и
|
||||
остальные will-save хуки, **только если** они включены у пользователя.
|
||||
Use-case/repo для headless/тестов можно оставить; editor-save — optional path
|
||||
из host.
|
||||
открывает/берёт `TextDocument` файла задачи, правит body через `WorkspaceEdit`,
|
||||
затем `document.save()` — тогда сработают format-on-save и остальные will-save
|
||||
хуки, **только если** они включены у пользователя. Use-case/repo для
|
||||
headless/тестов можно оставить; editor-save — optional path из host.
|
||||
|
||||
**Пока:** не блокер; format при правке файла в editor уже ок. Ctrl+S в
|
||||
Dashboard без editor-path format не даст.
|
||||
**Пока:** не блокер; format при правке файла в editor уже ок. Ctrl+S в Dashboard
|
||||
без editor-path format не даст.
|
||||
|
||||
[x] **Кнопка сброса фильтров**
|
||||
|
||||
@@ -50,3 +49,7 @@ Dashboard без editor-path format не даст.
|
||||
|
||||
- кнопка открытия дешборда
|
||||
- кнопки для - удаления, редактирования через дешборд
|
||||
|
||||
[ ] **Dashboard**
|
||||
|
||||
- кнопка создания задачи
|
||||
|
||||
Reference in New Issue
Block a user