feat: add vitest, vite, move to tdd

This commit is contained in:
2026-07-13 07:07:44 +05:00
parent 4704a1d5a0
commit 62862beb06
18 changed files with 2043 additions and 346 deletions
+14 -8
View File
@@ -1,13 +1,18 @@
import * as path from "path";
import * as vscode from "vscode";
import { ConfigStore } from "./storage/configStore";
import { TaskStore } from "./storage/taskStore";
import { TaskTreeProvider } from "./views/taskTreeProvider";
import { FsTaskRepository } from "@/storage/FsTaskRepository";
import { NodeFileSystem } from "@/storage/NodeFileSystem";
import { VscodeConfigProvider } from "@/storage/VscodeConfigProvider";
import { TaskTreeProvider } from "@/views/taskTreeProvider";
export function activate(context: vscode.ExtensionContext) {
const taskStore = new TaskStore();
const configStore = new ConfigStore();
const taskTreeProvider = new TaskTreeProvider();
const fileSystem = new NodeFileSystem();
const configStore = new VscodeConfigProvider();
const taskStore = new FsTaskRepository(
fileSystem,
configStore.getFileExtension(),
);
const taskTreeProvider = new TaskTreeProvider(taskStore, configStore);
vscode.window.registerTreeDataProvider(
"projectTasks.tasks",
@@ -16,7 +21,8 @@ export function activate(context: vscode.ExtensionContext) {
const projectPath = configStore.getProjectTaskPath();
if (projectPath) {
const taskPattern = path.join(projectPath, "*.task.md").replace(/\\/g, "/");
const ext = configStore.getFileExtension();
const taskPattern = path.join(projectPath, `*${ext}`).replace(/\\/g, "/");
const watcher = vscode.workspace.createFileSystemWatcher(taskPattern);
watcher.onDidCreate(() => taskTreeProvider.refresh());
watcher.onDidChange(() => taskTreeProvider.refresh());
@@ -48,7 +54,7 @@ export function activate(context: vscode.ExtensionContext) {
return;
}
await taskStore.createTask(projectPath, {
await taskStore.create(projectPath, {
title,
description: "",
status: "todo",
+26
View File
@@ -0,0 +1,26 @@
import { Task } from "@/model/task";
export interface IFileSystem {
readFile(path: string): Promise<string>;
writeFile(path: string, content: string): Promise<void>;
deleteFile(path: string): Promise<void>;
readdir(path: string): Promise<string[]>;
mkdir(path: string): Promise<void>;
}
export interface ITaskRepository {
list(folderPath: string): Promise<Task[]>;
getById(folderPath: string, id: string): Promise<Task | undefined>;
create(
folderPath: string,
task: Omit<Task, "id" | "created" | "updated">,
): Promise<Task>;
update(folderPath: string, task: Task): Promise<void>;
delete(folderPath: string, id: string): Promise<void>;
}
export interface IConfigProvider {
getProjectTaskPath(): string | undefined;
getGlobalTaskPath(): string | undefined;
getFileExtension(): string;
}
+104
View File
@@ -0,0 +1,104 @@
import { Task } from "@/model/task";
import { IFileSystem, ITaskRepository } from "@/ports";
import { parseTaskFile, serializeTask } from "@/utils/markdown";
import { generateId } from "@/utils/uuid";
export class FsTaskRepository implements ITaskRepository {
constructor(
private fs: IFileSystem,
private fileExtension: string,
) {}
async list(folderPath: string): Promise<Task[]> {
let entries: string[];
try {
entries = await this.fs.readdir(folderPath);
} catch {
return [];
}
const files = entries.filter((name) => name.endsWith(this.fileExtension));
const tasks: Task[] = [];
for (const file of files) {
try {
const fullPath = this.joinPath(folderPath, file);
const content = await this.fs.readFile(fullPath);
const task = parseTaskFile(content);
if (!task.id) continue;
tasks.push(task);
} catch {
// skip unparseable files
}
}
return tasks.sort(
(a, b) => new Date(b.updated).getTime() - new Date(a.updated).getTime(),
);
}
async getById(folderPath: string, id: string): Promise<Task | undefined> {
const tasks = await this.list(folderPath);
return tasks.find((t) => t.id === id);
}
async create(
folderPath: string,
task: Omit<Task, "id" | "created" | "updated">,
): Promise<Task> {
const now = new Date().toISOString();
const newTask: Task = {
...task,
id: generateId(),
created: now,
updated: now,
};
await this.fs.mkdir(folderPath);
const filePath = this.taskFilePath(folderPath, newTask);
await this.fs.writeFile(filePath, serializeTask(newTask));
return newTask;
}
async update(folderPath: string, task: Task): Promise<void> {
const existing = await this.getById(folderPath, task.id);
const updatedTask = { ...task, updated: new Date().toISOString() };
const newPath = this.taskFilePath(folderPath, updatedTask);
if (existing) {
const oldPath = this.taskFilePath(folderPath, existing);
if (oldPath !== newPath) {
await this.fs.deleteFile(oldPath).catch(() => {});
}
}
await this.fs.writeFile(newPath, serializeTask(updatedTask));
}
async delete(folderPath: string, id: string): Promise<void> {
const task = await this.getById(folderPath, id);
if (!task) return;
const filePath = this.taskFilePath(folderPath, task);
try {
await this.fs.deleteFile(filePath);
} catch {
// already gone
}
}
private taskFilePath(folderPath: string, task: Task): string {
const safeName = task.title
.toLowerCase()
.replace(/[^a-z0-9\u0400-\u04FF]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 80);
return this.joinPath(folderPath, `${safeName}${this.fileExtension}`);
}
private joinPath(...segments: string[]): string {
return segments.join("/").replace(/\\/g, "/");
}
}
+25
View File
@@ -0,0 +1,25 @@
import * as fs from "fs";
import { IFileSystem } from "@/ports";
export class NodeFileSystem implements IFileSystem {
async readFile(path: string): Promise<string> {
return fs.promises.readFile(path, "utf-8");
}
async writeFile(path: string, content: string): Promise<void> {
await fs.promises.writeFile(path, content, "utf-8");
}
async deleteFile(path: string): Promise<void> {
await fs.promises.unlink(path);
}
async readdir(path: string): Promise<string[]> {
const entries = await fs.promises.readdir(path, { withFileTypes: true });
return entries.filter((e) => e.isFile()).map((e) => e.name);
}
async mkdir(path: string): Promise<void> {
await fs.promises.mkdir(path, { recursive: true });
}
}
@@ -1,7 +1,8 @@
import * as path from "path";
import * as vscode from "vscode";
import { IConfigProvider } from "@/ports";
export class ConfigStore {
export class VscodeConfigProvider implements IConfigProvider {
getProjectTaskPath(): string | undefined {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) return undefined;
@@ -13,8 +14,7 @@ export class ConfigStore {
getGlobalTaskPath(): string | undefined {
const config = vscode.workspace.getConfiguration("projectTasks");
const globalPath = config.get<string>("globalPath");
return globalPath || undefined;
return config.get<string>("globalPath") || undefined;
}
getFileExtension(): string {
-114
View File
@@ -1,114 +0,0 @@
import * as vscode from "vscode";
import * as path from "path";
import * as fs from "fs";
import { Task } from "../model/task";
import { parseTaskFile, serializeTask } from "../utils/markdown";
import { generateId } from "../utils/uuid";
export class TaskStore {
private fileExtension: string;
constructor() {
const config = vscode.workspace.getConfiguration("projectTasks");
this.fileExtension = config.get<string>("fileExtension") ?? ".task.md";
}
getProjectTaskPath(): string | undefined {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) return undefined;
const config = vscode.workspace.getConfiguration("projectTasks");
const relativePath = config.get<string>("projectPath") ?? ".vscode/tasks";
return path.join(workspaceRoot, relativePath);
}
getGlobalTaskPath(): string | undefined {
const config = vscode.workspace.getConfiguration("projectTasks");
const globalPath = config.get<string>("globalPath");
return globalPath || undefined;
}
async listTasks(folderPath: string): Promise<Task[]> {
try {
await fs.promises.mkdir(folderPath, { recursive: true });
} catch {
return [];
}
const entries = await fs.promises.readdir(folderPath, {
withFileTypes: true,
});
const files = entries.filter(
(e) => e.isFile() && e.name.endsWith(this.fileExtension),
);
const tasks: Task[] = [];
for (const file of files) {
try {
const content = await fs.promises.readFile(
path.join(folderPath, file.name),
"utf-8",
);
tasks.push(parseTaskFile(content));
} catch (err) {
console.error(`Failed to parse task file ${file.name}:`, err);
}
}
return tasks.sort(
(a, b) => new Date(b.updated).getTime() - new Date(a.updated).getTime(),
);
}
async getTask(folderPath: string, taskId: string): Promise<Task | undefined> {
const tasks = await this.listTasks(folderPath);
return tasks.find((t) => t.id === taskId);
}
async createTask(
folderPath: string,
task: Omit<Task, "id" | "created" | "updated">,
): Promise<Task> {
const now = new Date().toISOString();
const newTask: Task = {
...task,
id: generateId(),
created: now,
updated: now,
};
await fs.promises.mkdir(folderPath, { recursive: true });
const filePath = this.taskFilePath(folderPath, newTask);
await fs.promises.writeFile(filePath, serializeTask(newTask), "utf-8");
return newTask;
}
async updateTask(folderPath: string, task: Task): Promise<void> {
const updatedTask = { ...task, updated: new Date().toISOString() };
const filePath = this.taskFilePath(folderPath, updatedTask);
await fs.promises.writeFile(filePath, serializeTask(updatedTask), "utf-8");
}
async deleteTask(folderPath: string, taskId: string): Promise<void> {
const task = await this.getTask(folderPath, taskId);
if (!task) return;
const filePath = this.taskFilePath(folderPath, task);
try {
await fs.promises.unlink(filePath);
} catch (err) {
console.error(`Failed to delete task file:`, err);
}
}
private taskFilePath(folderPath: string, task: Task): string {
const safeName = task.title
.toLowerCase()
.replace(/[^a-z0-9\u0400-\u04FF]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 80);
return path.join(folderPath, `${safeName}${this.fileExtension}`);
}
}
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { parseTaskFile, serializeTask } from "./markdown";
import { Task } from "@/model/task";
const fullTask: Task = {
id: "550e8400-e29b-41d4-a716-446655440000",
title: "Пофиксить баг логина",
description: "Ошибка валидации токена",
status: "in-progress",
priority: "high",
tags: ["bug", "frontend"],
assignee: "ivanov",
created: "2026-07-12T10:00:00.000Z",
updated: "2026-07-12T10:00:00.000Z",
};
describe("markdown", () => {
it("parses a .task.md file with full frontmatter", () => {
const content = [
"---",
'id: "550e8400-e29b-41d4-a716-446655440000"',
'title: "Пофиксить баг логина"',
"status: in-progress",
"priority: high",
"tags: [bug, frontend]",
'assignee: "ivanov"',
"created: 2026-07-12T10:00:00.000Z",
"updated: 2026-07-12T10:00:00.000Z",
"---",
"Ошибка валидации токена",
].join("\n");
const task = parseTaskFile(content);
expect(task).toEqual(fullTask);
});
it("fills defaults for missing fields", () => {
const content = [
"---",
'id: "abc"',
'title: "Minimal"',
"---",
"",
].join("\n");
const task = parseTaskFile(content);
expect(task.id).toBe("abc");
expect(task.title).toBe("Minimal");
expect(task.description).toBe("");
expect(task.status).toBe("todo");
expect(task.priority).toBe("medium");
expect(task.tags).toEqual([]);
expect(task.assignee).toBe("");
expect(task.created).toBeTruthy();
expect(task.updated).toBeTruthy();
});
it("serializeTask produces valid frontmatter", () => {
const result = serializeTask(fullTask);
expect(result).toContain("---");
expect(result).toContain("title: Пофиксить баг логина");
expect(result).toContain("status: in-progress");
expect(result).toContain("Ошибка валидации токена");
});
it("round-trip: serialize → parse produces the same task", () => {
const serialized = serializeTask(fullTask);
const parsed = parseTaskFile(serialized);
expect(parsed).toEqual(fullTask);
});
it("description with utf-8 survives round-trip", () => {
const task: Task = {
...fullTask,
description: "Тестовая задача с кириллицей\nи переносом строки",
};
const serialized = serializeTask(task);
const parsed = parseTaskFile(serialized);
expect(parsed.description).toBe(task.description);
});
});
+9 -3
View File
@@ -1,5 +1,11 @@
import matter from "gray-matter";
import { Task } from "../model/task";
import { Task } from "@/model/task";
function toISO(value: unknown): string {
if (value instanceof Date) return value.toISOString();
if (typeof value === "string") return value;
return new Date().toISOString();
}
export function parseTaskFile(content: string): Task {
const parsed = matter(content);
@@ -11,8 +17,8 @@ export function parseTaskFile(content: string): Task {
priority: parsed.data.priority ?? "medium",
tags: parsed.data.tags ?? [],
assignee: parsed.data.assignee ?? "",
created: parsed.data.created ?? new Date().toISOString(),
updated: parsed.data.updated ?? new Date().toISOString(),
created: toISO(parsed.data.created),
updated: toISO(parsed.data.updated),
};
}
+7 -6
View File
@@ -1,7 +1,6 @@
import * as vscode from "vscode";
import { Task, TaskStatus } from "../model/task";
import { TaskStore } from "../storage/taskStore";
import { ConfigStore } from "../storage/configStore";
import { Task, TaskStatus } from "@/model/task";
import { IConfigProvider, ITaskRepository } from "@/ports";
const STATUS_ORDER: TaskStatus[] = ["todo", "in-progress", "done", "cancelled"];
@@ -30,8 +29,10 @@ export class TaskTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem
>();
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
private taskStore = new TaskStore();
private configStore = new ConfigStore();
constructor(
private taskStore: ITaskRepository,
private configStore: IConfigProvider,
) {}
refresh(): void {
this._onDidChangeTreeData.fire();
@@ -54,7 +55,7 @@ export class TaskTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem
return [new vscode.TreeItem("Open a workspace folder to start")];
}
const tasks = await this.taskStore.listTasks(projectPath);
const tasks = await this.taskStore.list(projectPath);
return STATUS_ORDER.map((status) => {
const groupTasks = tasks.filter((t) => t.status === status);