feat: add vitest, vite, move to tdd
This commit is contained in:
+14
-8
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, "/");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user