diff --git a/docs/PLAN.md b/docs/PLAN.md index d5603ba..be1b24d 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -46,27 +46,24 @@ src/ ├── ports.ts # IFileSystem, ITaskRepository, IConfigProvider ├── error.ts # AppErrorVariant + AppError { variant, message } ├── model/task.ts -├── storage/ -│ ├── FsTaskRepository.ts -│ ├── NodeFileSystem.ts -│ └── VscodeConfigProvider.ts -├── commands/ # use-cases без vscode UI -│ ├── createTask.ts -│ ├── openTask.ts -│ ├── deleteTask.ts -│ └── changeStatus.ts -├── dashboard/ # filter/group/view + inbound message parse +├── storage/ # infrastructure adapters +├── commands/ # use-cases (no vscode) +├── dashboard/ # pure view-model helpers +├── ui/ # thin vscode adapters (input/quickpick/errors) +│ ├── presentError.ts +│ ├── resolveTaskId.ts +│ ├── taskLabels.ts +│ └── taskCommands.ts # register + run* handlers ├── views/ -│ └── taskTreeProvider.ts # TreeView; WebView host — отдельно +│ ├── taskTreeItem.ts +│ └── taskTreeProvider.ts └── utils/ - ├── uuid.ts - └── markdown.ts # parse/serialize frontmatter tests/helpers/InMemoryFileSystem.ts ``` -UI (input box, quick pick, open editor) — тонкие адаптеры; бизнес-логика в -use-cases + `ITaskRepository`. Ошибки — `AppError` из `error.ts`. +Слои: use-cases + ports — без vscode; `ui/*` — только ввод/вывод host API; +`extension.ts` — сборка зависимостей и регистрация. ## План реализации @@ -81,14 +78,16 @@ use-cases + `ITaskRepository`. Ошибки — `AppError` из `error.ts`. | **7. Kanban** | drag-n-drop | | **8. Публикация** | vsce publish | -### Фаза 4 — команды (use-cases) +### Фаза 4 — команды (use-cases + UI wiring) -| Команда | Поведение | -| -------------- | -------------------------------------------------------------- | -| `createTask` | title + folder → defaults todo/medium; empty title / no folder | -| `deleteTask` | folder + id → delete; not found | -| `changeStatus` | folder + id + status → update; not found | -| `openTask` | folder + id → path + task для editor; not found | +| Команда | Use-case | UI adapter | +| -------------- | -------------------------------------------------------------- | ------------------------------------------- | +| `createTask` | title + folder → defaults todo/medium; empty title / no folder | InputBox → use-case → tree.refresh | +| `deleteTask` | folder + id → delete; not found | confirm + TreeItem/QuickPick → use-case | +| `changeStatus` | folder + id + status → update; not found | status QuickPick → use-case | +| `openTask` | folder + id → path + task | TreeItem click / command → showTextDocument | + +Ошибки use-case → `presentError` (`AppError.message`). Cancel UI ≠ `AppError`. ### Фаза 5 — Task Dashboard diff --git a/package.json b/package.json index fb003dc..41e6be4 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,23 @@ "commands": [ { "command": "projectTasks.createTask", - "title": "Project Tasks: Create Task" + "title": "Project Tasks: Create Task", + "icon": "$(add)" + }, + { + "command": "projectTasks.openTask", + "title": "Project Tasks: Open Task", + "icon": "$(go-to-file)" + }, + { + "command": "projectTasks.deleteTask", + "title": "Project Tasks: Delete Task", + "icon": "$(trash)" + }, + { + "command": "projectTasks.changeStatus", + "title": "Project Tasks: Change Status", + "icon": "$(sync)" }, { "command": "projectTasks.openDashboard", @@ -84,6 +100,23 @@ "when": "view == projectTasks.tasks", "group": "navigation" } + ], + "view/item/context": [ + { + "command": "projectTasks.openTask", + "when": "view == projectTasks.tasks && viewItem == task", + "group": "inline@1" + }, + { + "command": "projectTasks.changeStatus", + "when": "view == projectTasks.tasks && viewItem == task", + "group": "1_task@1" + }, + { + "command": "projectTasks.deleteTask", + "when": "view == projectTasks.tasks && viewItem == task", + "group": "1_task@2" + } ] } }, diff --git a/src/extension.ts b/src/extension.ts index 2224da3..f7ffe74 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,30 +3,27 @@ import * as vscode from "vscode"; import { FsTaskRepository } from "@/storage/FsTaskRepository"; import { NodeFileSystem } from "@/storage/NodeFileSystem"; import { VscodeConfigProvider } from "@/storage/VscodeConfigProvider"; +import { registerTaskCommands } from "@/ui/taskCommands"; import { TaskTreeProvider } from "@/views/taskTreeProvider"; export function activate(context: vscode.ExtensionContext) { const fileSystem = new NodeFileSystem(); - const configStore = new VscodeConfigProvider(); - const taskStore = new FsTaskRepository( - fileSystem, - configStore.getFileExtension(), - ); - const taskTreeProvider = new TaskTreeProvider(taskStore, configStore); + const config = new VscodeConfigProvider(); + const repo = new FsTaskRepository(fileSystem, config.getFileExtension()); + const tree = new TaskTreeProvider(repo, config); - vscode.window.registerTreeDataProvider( - "projectTasks.tasks", - taskTreeProvider, + context.subscriptions.push( + vscode.window.registerTreeDataProvider("projectTasks.tasks", tree), ); - const projectPath = configStore.getProjectTaskPath(); + const projectPath = config.getProjectTaskPath(); if (projectPath) { - const ext = configStore.getFileExtension(); + const ext = config.getFileExtension(); const taskPattern = path.join(projectPath, `*${ext}`).replace(/\\/g, "/"); const watcher = vscode.workspace.createFileSystemWatcher(taskPattern); - watcher.onDidCreate(() => taskTreeProvider.refresh()); - watcher.onDidChange(() => taskTreeProvider.refresh()); - watcher.onDidDelete(() => taskTreeProvider.refresh()); + watcher.onDidCreate(() => tree.refresh()); + watcher.onDidChange(() => tree.refresh()); + watcher.onDidDelete(() => tree.refresh()); context.subscriptions.push(watcher); } @@ -39,41 +36,13 @@ export function activate(context: vscode.ExtensionContext) { statusBarItem.show(); context.subscriptions.push(statusBarItem); - const createTaskCommand = vscode.commands.registerCommand( - "projectTasks.createTask", - async () => { - const title = await vscode.window.showInputBox({ - prompt: "Task title", - placeHolder: "Enter task title...", - }); - if (!title) return; + registerTaskCommands(context, { repo, config, tree }); - const projectPath = configStore.getProjectTaskPath(); - if (!projectPath) { - vscode.window.showErrorMessage("Open a workspace folder first"); - return; - } - - await taskStore.create(projectPath, { - title, - description: "", - status: "todo", - priority: "medium", - tags: [], - assignee: "", - }); - vscode.window.showInformationMessage(`Task created: ${title}`); - }, + context.subscriptions.push( + vscode.commands.registerCommand("projectTasks.openDashboard", () => { + void vscode.window.showInformationMessage("Dashboard coming soon!"); + }), ); - - const openDashboardCommand = vscode.commands.registerCommand( - "projectTasks.openDashboard", - () => { - vscode.window.showInformationMessage("Dashboard coming soon!"); - }, - ); - - context.subscriptions.push(createTaskCommand, openDashboardCommand); } export function deactivate() {} diff --git a/src/ui/presentError.ts b/src/ui/presentError.ts new file mode 100644 index 0000000..ae5f42b --- /dev/null +++ b/src/ui/presentError.ts @@ -0,0 +1,7 @@ +import * as vscode from "vscode"; +import { AppError } from "@/error"; + +/** Thin UI adapter: show application error to the user. */ +export function presentError(error: AppError): void { + void vscode.window.showErrorMessage(error.message); +} diff --git a/src/ui/resolveTaskId.ts b/src/ui/resolveTaskId.ts new file mode 100644 index 0000000..dfe7bbf --- /dev/null +++ b/src/ui/resolveTaskId.ts @@ -0,0 +1,40 @@ +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/taskCommands.ts b/src/ui/taskCommands.ts new file mode 100644 index 0000000..728038d --- /dev/null +++ b/src/ui/taskCommands.ts @@ -0,0 +1,169 @@ +import * as vscode from "vscode"; +import { changeStatus } from "@/commands/changeStatus"; +import { createTask } from "@/commands/createTask"; +import { deleteTask } from "@/commands/deleteTask"; +import { openTask } from "@/commands/openTask"; +import { appError, AppErrorVariant } from "@/error"; +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"; + +export type TaskCommandDeps = { + repo: ITaskRepository; + config: IConfigProvider; + tree: TaskTreeProvider; +}; + +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 result = await createTask(deps.repo, { + folderPath: deps.config.getProjectTaskPath(), + title, + }); + + if (result.isErr()) { + presentError(result.error); + return; + } + + deps.tree.refresh(); + void vscode.window.showInformationMessage( + `Task created: ${result.value.title}`, + ); +} + +export async function runOpenTask( + 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) { + return; + } + + const result = await openTask(deps.repo, { folderPath, id }); + if (result.isErr()) { + presentError(result.error); + return; + } + + const uri = vscode.Uri.file(result.value.path); + await vscode.window.showTextDocument(uri); +} + +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) { + return; + } + + const confirm = await vscode.window.showWarningMessage( + "Delete this task?", + { modal: true }, + "Delete", + ); + if (confirm !== "Delete") { + return; + } + + const result = await deleteTask(deps.repo, { folderPath, id }); + if (result.isErr()) { + presentError(result.error); + return; + } + + deps.tree.refresh(); + void vscode.window.showInformationMessage("Task deleted"); +} + +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) { + return; + } + + const picked = await vscode.window.showQuickPick( + TASK_STATUS_ORDER.map((value) => ({ + label: TASK_STATUS_LABELS[value], + status: value as TaskStatus, + })), + { placeHolder: "New status" }, + ); + if (!picked) { + return; + } + + const result = await changeStatus(deps.repo, { + folderPath, + id, + status: picked.status, + }); + if (result.isErr()) { + presentError(result.error); + return; + } + + deps.tree.refresh(); + void vscode.window.showInformationMessage( + `Status: ${TASK_STATUS_LABELS[result.value.status]}`, + ); +} + +/** Register all task command adapters on the extension host. */ +export function registerTaskCommands( + context: vscode.ExtensionContext, + deps: TaskCommandDeps, +): void { + context.subscriptions.push( + vscode.commands.registerCommand("projectTasks.createTask", () => + runCreateTask(deps), + ), + vscode.commands.registerCommand( + "projectTasks.openTask", + (item?: unknown) => runOpenTask(deps, item), + ), + vscode.commands.registerCommand( + "projectTasks.deleteTask", + (item?: unknown) => runDeleteTask(deps, item), + ), + vscode.commands.registerCommand( + "projectTasks.changeStatus", + (item?: unknown) => runChangeStatus(deps, item), + ), + ); +} diff --git a/src/ui/taskLabels.ts b/src/ui/taskLabels.ts new file mode 100644 index 0000000..581cbe1 --- /dev/null +++ b/src/ui/taskLabels.ts @@ -0,0 +1,15 @@ +import { TaskStatus } from "@/model/task"; + +export const TASK_STATUS_ORDER: TaskStatus[] = [ + "todo", + "in-progress", + "done", + "cancelled", +]; + +export const TASK_STATUS_LABELS: Record = { + todo: "To Do", + "in-progress": "In Progress", + done: "Done", + cancelled: "Cancelled", +}; diff --git a/src/views/taskTreeItem.ts b/src/views/taskTreeItem.ts new file mode 100644 index 0000000..3ba03ea --- /dev/null +++ b/src/views/taskTreeItem.ts @@ -0,0 +1,25 @@ +import * as vscode from "vscode"; +import { Task } from "@/model/task"; + +/** Tree item that carries the domain task (for context-menu commands). */ +export class TaskTreeItem extends vscode.TreeItem { + readonly task: Task; + + constructor(task: Task) { + super(task.title, vscode.TreeItemCollapsibleState.None); + this.task = task; + this.id = task.id; + this.description = task.priority; + this.contextValue = "task"; + this.tooltip = new vscode.MarkdownString( + `**${task.title}**\n\nPriority: ${task.priority}\nStatus: ${task.status}${ + task.assignee ? `\nAssignee: ${task.assignee}` : "" + }`, + ); + this.command = { + command: "projectTasks.openTask", + title: "Open Task", + arguments: [this], + }; + } +} diff --git a/src/views/taskTreeProvider.ts b/src/views/taskTreeProvider.ts index 36a9153..16145c4 100644 --- a/src/views/taskTreeProvider.ts +++ b/src/views/taskTreeProvider.ts @@ -1,8 +1,8 @@ +import * as vscode from "vscode"; import { Task, TaskStatus } from "@/model/task"; import { IConfigProvider, ITaskRepository } from "@/ports"; -import * as vscode from "vscode"; - -const STATUS_ORDER: TaskStatus[] = ["todo", "in-progress", "done", "cancelled"]; +import { TASK_STATUS_LABELS, TASK_STATUS_ORDER } from "@/ui/taskLabels"; +import { TaskTreeItem } from "./taskTreeItem"; const STATUS_ICONS: Record = { todo: new vscode.ThemeIcon("circle-outline"), @@ -14,11 +14,16 @@ const STATUS_ICONS: Record = { class TaskGroupTreeItem extends vscode.TreeItem { readonly tasks: Task[]; - constructor(label: string, tasks: Task[], icon: vscode.ThemeIcon) { - super(label, vscode.TreeItemCollapsibleState.Collapsed); - this.iconPath = icon; + constructor(status: TaskStatus, tasks: Task[]) { + super( + TASK_STATUS_LABELS[status], + vscode.TreeItemCollapsibleState.Collapsed, + ); + this.iconPath = STATUS_ICONS[status]; this.tasks = tasks; this.description = `(${tasks.length})`; + this.id = `group-${status}`; + this.contextValue = "taskGroup"; } } @@ -48,7 +53,10 @@ export class TaskTreeProvider implements vscode.TreeDataProvider new TaskTreeItem(task)); + } + return []; } private async getRootChildren(): Promise { @@ -59,37 +67,9 @@ export class TaskTreeProvider implements vscode.TreeDataProvider { + return TASK_STATUS_ORDER.map((status) => { const groupTasks = tasks.filter((t) => t.status === status); - const label = - status === "todo" - ? "To Do" - : status === "in-progress" - ? "In Progress" - : status === "done" - ? "Done" - : "Cancelled"; - const item = new TaskGroupTreeItem( - label, - groupTasks, - STATUS_ICONS[status], - ); - item.id = `group-${status}`; - item.contextValue = "taskGroup"; - return item; - }); - } - - private getTaskChildren(group: TaskGroupTreeItem): vscode.TreeItem[] { - return group.tasks.map((task) => { - const item = new vscode.TreeItem(task.title); - item.id = task.id; - item.description = task.priority; - item.tooltip = new vscode.MarkdownString( - `**${task.title}**\n\nPriority: ${task.priority}\nStatus: ${task.status}${task.assignee ? `\nAssignee: ${task.assignee}` : ""}`, - ); - item.contextValue = "task"; - return item; + return new TaskGroupTreeItem(status, groupTasks); }); } }