feat: add wirings - view and ui

This commit is contained in:
2026-07-14 10:05:00 +05:00
parent 3490781ad5
commit cbb97a99ce
9 changed files with 344 additions and 107 deletions
+21 -22
View File
@@ -46,27 +46,24 @@ src/
├── ports.ts # IFileSystem, ITaskRepository, IConfigProvider ├── ports.ts # IFileSystem, ITaskRepository, IConfigProvider
├── error.ts # AppErrorVariant + AppError { variant, message } ├── error.ts # AppErrorVariant + AppError { variant, message }
├── model/task.ts ├── model/task.ts
├── storage/ ├── storage/ # infrastructure adapters
│ ├── FsTaskRepository.ts ├── commands/ # use-cases (no vscode)
│ ├── NodeFileSystem.ts ├── dashboard/ # pure view-model helpers
│ └── VscodeConfigProvider.ts ├── ui/ # thin vscode adapters (input/quickpick/errors)
├── commands/ # use-cases без vscode UI │ ├── presentError.ts
│ ├── createTask.ts │ ├── resolveTaskId.ts
│ ├── openTask.ts │ ├── taskLabels.ts
── deleteTask.ts ── taskCommands.ts # register + run* handlers
│ └── changeStatus.ts
├── dashboard/ # filter/group/view + inbound message parse
├── views/ ├── views/
── taskTreeProvider.ts # TreeView; WebView host — отдельно ── taskTreeItem.ts
│ └── taskTreeProvider.ts
└── utils/ └── utils/
├── uuid.ts
└── markdown.ts # parse/serialize frontmatter
tests/helpers/InMemoryFileSystem.ts tests/helpers/InMemoryFileSystem.ts
``` ```
UI (input box, quick pick, open editor) — тонкие адаптеры; бизнес-логика в Слои: use-cases + ports — без vscode; `ui/*` — только ввод/вывод host API;
use-cases + `ITaskRepository`. Ошибки — `AppError` из `error.ts`. `extension.ts` — сборка зависимостей и регистрация.
## План реализации ## План реализации
@@ -81,14 +78,16 @@ use-cases + `ITaskRepository`. Ошибки — `AppError` из `error.ts`.
| **7. Kanban** | drag-n-drop | | **7. Kanban** | drag-n-drop |
| **8. Публикация** | vsce publish | | **8. Публикация** | vsce publish |
### Фаза 4 — команды (use-cases) ### Фаза 4 — команды (use-cases + UI wiring)
| Команда | Поведение | | Команда | Use-case | UI adapter |
| -------------- | -------------------------------------------------------------- | | -------------- | -------------------------------------------------------------- | ------------------------------------------- |
| `createTask` | title + folder → defaults todo/medium; empty title / no folder | | `createTask` | title + folder → defaults todo/medium; empty title / no folder | InputBox → use-case → tree.refresh |
| `deleteTask` | folder + id → delete; not found | | `deleteTask` | folder + id → delete; not found | confirm + TreeItem/QuickPick → use-case |
| `changeStatus` | folder + id + status → update; not found | | `changeStatus` | folder + id + status → update; not found | status QuickPick → use-case |
| `openTask` | folder + id → path + task для editor; not found | | `openTask` | folder + id → path + task | TreeItem click / command → showTextDocument |
Ошибки use-case → `presentError` (`AppError.message`). Cancel UI ≠ `AppError`.
### Фаза 5 — Task Dashboard ### Фаза 5 — Task Dashboard
+34 -1
View File
@@ -26,7 +26,23 @@
"commands": [ "commands": [
{ {
"command": "projectTasks.createTask", "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", "command": "projectTasks.openDashboard",
@@ -84,6 +100,23 @@
"when": "view == projectTasks.tasks", "when": "view == projectTasks.tasks",
"group": "navigation" "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"
}
] ]
} }
}, },
+16 -47
View File
@@ -3,30 +3,27 @@ import * as vscode from "vscode";
import { FsTaskRepository } from "@/storage/FsTaskRepository"; import { FsTaskRepository } from "@/storage/FsTaskRepository";
import { NodeFileSystem } from "@/storage/NodeFileSystem"; import { NodeFileSystem } from "@/storage/NodeFileSystem";
import { VscodeConfigProvider } from "@/storage/VscodeConfigProvider"; import { VscodeConfigProvider } from "@/storage/VscodeConfigProvider";
import { registerTaskCommands } from "@/ui/taskCommands";
import { TaskTreeProvider } from "@/views/taskTreeProvider"; import { TaskTreeProvider } from "@/views/taskTreeProvider";
export function activate(context: vscode.ExtensionContext) { export function activate(context: vscode.ExtensionContext) {
const fileSystem = new NodeFileSystem(); const fileSystem = new NodeFileSystem();
const configStore = new VscodeConfigProvider(); const config = new VscodeConfigProvider();
const taskStore = new FsTaskRepository( const repo = new FsTaskRepository(fileSystem, config.getFileExtension());
fileSystem, const tree = new TaskTreeProvider(repo, config);
configStore.getFileExtension(),
);
const taskTreeProvider = new TaskTreeProvider(taskStore, configStore);
vscode.window.registerTreeDataProvider( context.subscriptions.push(
"projectTasks.tasks", vscode.window.registerTreeDataProvider("projectTasks.tasks", tree),
taskTreeProvider,
); );
const projectPath = configStore.getProjectTaskPath(); const projectPath = config.getProjectTaskPath();
if (projectPath) { if (projectPath) {
const ext = configStore.getFileExtension(); const ext = config.getFileExtension();
const taskPattern = path.join(projectPath, `*${ext}`).replace(/\\/g, "/"); const taskPattern = path.join(projectPath, `*${ext}`).replace(/\\/g, "/");
const watcher = vscode.workspace.createFileSystemWatcher(taskPattern); const watcher = vscode.workspace.createFileSystemWatcher(taskPattern);
watcher.onDidCreate(() => taskTreeProvider.refresh()); watcher.onDidCreate(() => tree.refresh());
watcher.onDidChange(() => taskTreeProvider.refresh()); watcher.onDidChange(() => tree.refresh());
watcher.onDidDelete(() => taskTreeProvider.refresh()); watcher.onDidDelete(() => tree.refresh());
context.subscriptions.push(watcher); context.subscriptions.push(watcher);
} }
@@ -39,41 +36,13 @@ export function activate(context: vscode.ExtensionContext) {
statusBarItem.show(); statusBarItem.show();
context.subscriptions.push(statusBarItem); context.subscriptions.push(statusBarItem);
const createTaskCommand = vscode.commands.registerCommand( registerTaskCommands(context, { repo, config, tree });
"projectTasks.createTask",
async () => {
const title = await vscode.window.showInputBox({
prompt: "Task title",
placeHolder: "Enter task title...",
});
if (!title) return;
const projectPath = configStore.getProjectTaskPath(); context.subscriptions.push(
if (!projectPath) { vscode.commands.registerCommand("projectTasks.openDashboard", () => {
vscode.window.showErrorMessage("Open a workspace folder first"); void vscode.window.showInformationMessage("Dashboard coming soon!");
return; }),
}
await taskStore.create(projectPath, {
title,
description: "",
status: "todo",
priority: "medium",
tags: [],
assignee: "",
});
vscode.window.showInformationMessage(`Task created: ${title}`);
},
); );
const openDashboardCommand = vscode.commands.registerCommand(
"projectTasks.openDashboard",
() => {
vscode.window.showInformationMessage("Dashboard coming soon!");
},
);
context.subscriptions.push(createTaskCommand, openDashboardCommand);
} }
export function deactivate() {} export function deactivate() {}
+7
View File
@@ -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);
}
+40
View File
@@ -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<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;
}
+169
View File
@@ -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<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 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<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) {
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<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) {
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<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) {
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),
),
);
}
+15
View File
@@ -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<TaskStatus, string> = {
todo: "To Do",
"in-progress": "In Progress",
done: "Done",
cancelled: "Cancelled",
};
+25
View File
@@ -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],
};
}
}
+17 -37
View File
@@ -1,8 +1,8 @@
import * as vscode from "vscode";
import { Task, TaskStatus } from "@/model/task"; import { Task, TaskStatus } from "@/model/task";
import { IConfigProvider, ITaskRepository } from "@/ports"; import { IConfigProvider, ITaskRepository } from "@/ports";
import * as vscode from "vscode"; import { TASK_STATUS_LABELS, TASK_STATUS_ORDER } from "@/ui/taskLabels";
import { TaskTreeItem } from "./taskTreeItem";
const STATUS_ORDER: TaskStatus[] = ["todo", "in-progress", "done", "cancelled"];
const STATUS_ICONS: Record<TaskStatus, vscode.ThemeIcon> = { const STATUS_ICONS: Record<TaskStatus, vscode.ThemeIcon> = {
todo: new vscode.ThemeIcon("circle-outline"), todo: new vscode.ThemeIcon("circle-outline"),
@@ -14,11 +14,16 @@ const STATUS_ICONS: Record<TaskStatus, vscode.ThemeIcon> = {
class TaskGroupTreeItem extends vscode.TreeItem { class TaskGroupTreeItem extends vscode.TreeItem {
readonly tasks: Task[]; readonly tasks: Task[];
constructor(label: string, tasks: Task[], icon: vscode.ThemeIcon) { constructor(status: TaskStatus, tasks: Task[]) {
super(label, vscode.TreeItemCollapsibleState.Collapsed); super(
this.iconPath = icon; TASK_STATUS_LABELS[status],
vscode.TreeItemCollapsibleState.Collapsed,
);
this.iconPath = STATUS_ICONS[status];
this.tasks = tasks; this.tasks = tasks;
this.description = `(${tasks.length})`; this.description = `(${tasks.length})`;
this.id = `group-${status}`;
this.contextValue = "taskGroup";
} }
} }
@@ -48,7 +53,10 @@ export class TaskTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem
if (!element) { if (!element) {
return this.getRootChildren(); return this.getRootChildren();
} }
return this.getTaskChildren(element as TaskGroupTreeItem); if (element instanceof TaskGroupTreeItem) {
return element.tasks.map((task) => new TaskTreeItem(task));
}
return [];
} }
private async getRootChildren(): Promise<vscode.TreeItem[]> { private async getRootChildren(): Promise<vscode.TreeItem[]> {
@@ -59,37 +67,9 @@ export class TaskTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem
const tasks = await this.#taskStore.list(projectPath); const tasks = await this.#taskStore.list(projectPath);
return STATUS_ORDER.map((status) => { return TASK_STATUS_ORDER.map((status) => {
const groupTasks = tasks.filter((t) => t.status === status); const groupTasks = tasks.filter((t) => t.status === status);
const label = return new TaskGroupTreeItem(status, groupTasks);
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;
}); });
} }
} }