From beeb9dd7f9df2b652be54cacb4fa2eac5fea62ca Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Wed, 15 Jul 2026 00:32:25 +0500 Subject: [PATCH] feat: add global tasks folder --- docs/PLAN.md | 18 +++- package.json | 52 +++++------ src/commands/listLocatedTasks.test.ts | 73 ++++++++++++++++ src/commands/listLocatedTasks.ts | 32 +++++++ src/commands/resolveTaskLocations.test.ts | 43 ++++++++++ src/commands/resolveTaskLocations.ts | 19 ++++ src/extension.ts | 18 ++-- src/model/taskLocation.ts | 14 +++ src/storage/VscodeConfigProvider.ts | 6 +- src/ui/resolveTaskId.ts | 40 --------- src/ui/resolveTaskRef.ts | 52 +++++++++++ src/ui/taskCommands.ts | 100 ++++++++++++++-------- src/ui/taskLabels.ts | 7 ++ src/views/taskDashboardPanel.ts | 40 ++++++--- src/views/taskTreeItem.ts | 16 ++-- src/views/taskTreeProvider.ts | 78 ++++++++++++++--- todo.md | 21 +++-- 17 files changed, 473 insertions(+), 156 deletions(-) create mode 100644 src/commands/listLocatedTasks.test.ts create mode 100644 src/commands/listLocatedTasks.ts create mode 100644 src/commands/resolveTaskLocations.test.ts create mode 100644 src/commands/resolveTaskLocations.ts create mode 100644 src/model/taskLocation.ts delete mode 100644 src/ui/resolveTaskId.ts create mode 100644 src/ui/resolveTaskRef.ts diff --git a/docs/PLAN.md b/docs/PLAN.md index 8c96ee9..49c7361 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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) diff --git a/package.json b/package.json index 41e6be4..d21cf72 100644 --- a/package.json +++ b/package.json @@ -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" } ] diff --git a/src/commands/listLocatedTasks.test.ts b/src/commands/listLocatedTasks.test.ts new file mode 100644 index 0000000..06c5858 --- /dev/null +++ b/src/commands/listLocatedTasks.test.ts @@ -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))); + }); +}); diff --git a/src/commands/listLocatedTasks.ts b/src/commands/listLocatedTasks.ts new file mode 100644 index 0000000..0ff509e --- /dev/null +++ b/src/commands/listLocatedTasks.ts @@ -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 { + 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(), + ); + })(), + ); +} diff --git a/src/commands/resolveTaskLocations.test.ts b/src/commands/resolveTaskLocations.test.ts new file mode 100644 index 0000000..377207d --- /dev/null +++ b/src/commands/resolveTaskLocations.test.ts @@ -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([]); + }); +}); diff --git a/src/commands/resolveTaskLocations.ts b/src/commands/resolveTaskLocations.ts new file mode 100644 index 0000000..ba82087 --- /dev/null +++ b/src/commands/resolveTaskLocations.ts @@ -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; +} diff --git a/src/extension.ts b/src/extension.ts index 8b2dfbc..aae72e0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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, diff --git a/src/model/taskLocation.ts b/src/model/taskLocation.ts new file mode 100644 index 0000000..9a53f35 --- /dev/null +++ b/src/model/taskLocation.ts @@ -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; +}; diff --git a/src/storage/VscodeConfigProvider.ts b/src/storage/VscodeConfigProvider.ts index a8a21e8..d40d40a 100644 --- a/src/storage/VscodeConfigProvider.ts +++ b/src/storage/VscodeConfigProvider.ts @@ -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("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("globalPath") || undefined; } getFileExtension(): string { - const config = vscode.workspace.getConfiguration("projectTasks"); + const config = vscode.workspace.getConfiguration("xboctFlow"); return config.get("fileExtension") ?? ".task.md"; } } diff --git a/src/ui/resolveTaskId.ts b/src/ui/resolveTaskId.ts deleted file mode 100644 index dfe7bbf..0000000 --- a/src/ui/resolveTaskId.ts +++ /dev/null @@ -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 { - 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; -} diff --git a/src/ui/resolveTaskRef.ts b/src/ui/resolveTaskRef.ts new file mode 100644 index 0000000..49b2795 --- /dev/null +++ b/src/ui/resolveTaskRef.ts @@ -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 { + 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; +} diff --git a/src/ui/taskCommands.ts b/src/ui/taskCommands.ts index a037dad..a79cdb6 100644 --- a/src/ui/taskCommands.ts +++ b/src/ui/taskCommands.ts @@ -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 { + 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 { 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 { 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 { - 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 { - 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 { - 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), ), ); diff --git a/src/ui/taskLabels.ts b/src/ui/taskLabels.ts index 581cbe1..f58640a 100644 --- a/src/ui/taskLabels.ts +++ b/src/ui/taskLabels.ts @@ -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 = { done: "Done", cancelled: "Cancelled", }; + +export const TASK_SCOPE_LABELS: Record = { + project: "Project", + global: "Global", +}; + diff --git a/src/views/taskDashboardPanel.ts b/src/views/taskDashboardPanel.ts index 83c5dcd..386c788 100644 --- a/src/views/taskDashboardPanel.ts +++ b/src/views/taskDashboardPanel.ts @@ -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 { 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 { - 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, diff --git a/src/views/taskTreeItem.ts b/src/views/taskTreeItem.ts index 3ba03ea..2592097 100644 --- a/src/views/taskTreeItem.ts +++ b/src/views/taskTreeItem.ts @@ -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], }; diff --git a/src/views/taskTreeProvider.ts b/src/views/taskTreeProvider.ts index 16145c4..0543375 100644 --- a/src/views/taskTreeProvider.ts +++ b/src/views/taskTreeProvider.ts @@ -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 = { @@ -11,19 +18,42 @@ const STATUS_ICONS: Record = { 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 new TaskTreeItem(task)); + return element.tasks.map( + (task) => new TaskTreeItem(task, element.location), + ); } return []; } private async getRootChildren(): Promise { - 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(); + 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); }); } } diff --git a/todo.md b/todo.md index bbb480e..ff41ab7 100644 --- a/todo.md +++ b/todo.md @@ -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** + +- кнопка создания задачи