import type { Plugin, ViteDevServer } from "vite" import { generate, GenerateInput, GenerateResult, GenerateType } from '@hono-di/generate'; import fs from "node:fs/promises" import path from "node:path" /* ------------------------ User Provided Types ------------------------ */ /* ------------------------ Utils ------------------------ */ const toPosix = (p: string) => p.split(path.sep).join("/") const isIgnored = (p: string) => p.startsWith("node_modules/") || p.startsWith(".git/") || p.startsWith("dist/") || p.startsWith(".vite/") || p.includes("/.DS_Store") || p.includes("/.idea/") || p.includes("/.vscode/") function resolveSafe(root: string, rel: string) { const abs = path.resolve(root, rel) const relCheck = path.relative(root, abs) // Fix: Check if path goes outside root (starts with ..) or is absolute (different drive on win) // This prevents partial matching vulnerabilities (e.g., /root vs /root_sibling) if (relCheck.startsWith('..') || path.isAbsolute(relCheck)) { throw new Error("Invalid path: Access denied") } return abs } function debounce any>(fn: T, ms: number) { let timer: NodeJS.Timeout return (...args: Parameters) => { clearTimeout(timer) timer = setTimeout(() => fn(...args), ms) } } /* ------------------------ Generator Logic (Server Side) ------------------------ */ // Helper to convert "my-user" to "MyUser" const toPascalCase = (str: string) => str.replace(/(^\w|-\w)/g, (c) => c.replace('-', '').toUpperCase()); // Helper to convert "MyUser" to "my-user" const toKebabCase = (str: string) => str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); // Map aliases to full types for internal logic const ALIAS_MAP: Record = { mo: 'module', co: 'controller', s: 'service', pr: 'provider', cl: 'class', itf: 'interface', pi: 'pipe', gu: 'guard', f: 'filter', itc: 'interceptor', d: 'decorator' }; type GenerateInputBody = Omit & { type: GenerateType[] }; /* ------------------------ Tree Builder ------------------------ */ interface TreeNode { name: string; path: string; type: "file" | "dir"; children?: TreeNode[]; } async function buildTree(root: string, dir: string): Promise { let entries try { entries = await fs.readdir(dir, { withFileTypes: true }) } catch (e) { return [] } const out: TreeNode[] = [] for (const e of entries) { const abs = path.join(dir, e.name) const rel = toPosix(path.relative(root, abs)) if (!rel || isIgnored(rel + (e.isDirectory() ? "/" : ""))) continue if (e.isDirectory()) { out.push({ name: e.name, path: rel, type: "dir", children: await buildTree(root, abs) }) } else { out.push({ name: e.name, path: rel, type: "file" }) } } out.sort((a, b) => a.type !== b.type ? (a.type === "dir" ? -1 : 1) : a.name.localeCompare(b.name)) return out } async function getTree(server: ViteDevServer) { const root = server.config.root return { rootAbs: root, tree: { name: path.basename(root), path: "", type: "dir", children: await buildTree(root, root) } as TreeNode, } } /* ------------------------ Plugin ------------------------ */ export default function fileTreeVisualizer(): Plugin { let serverRef: ViteDevServer let cached: any const rebuild = debounce(async () => { if (!serverRef) return try { cached = await getTree(serverRef) serverRef.ws.send({ type: "custom", event: "filetree:update", data: cached }) } catch (e) { serverRef.config.logger.error(`[filetree] Error: ${e}`) } }, 100) return { name: "vite-plugin-filetree-visualizer", apply: "serve", configureServer(server) { serverRef = server server.httpServer?.once("listening", () => { const base = server.resolvedUrls?.local?.[0] ?? "http://localhost:5173" setTimeout(() => server.config.logger.info(` ➜ File Tree: \x1b[36m${base}__filetree/\x1b[0m\n`), 100) }) /* ---- Middleware ---- */ server.middlewares.use("/__filetree/", async (req, res, next) => { if (req.originalUrl !== '/__filetree/' && !req.originalUrl?.startsWith('/__filetree/api')) return next(); if (req.originalUrl?.startsWith('/__filetree/api')) return next(); try { const html = await server.transformIndexHtml(req.url ?? '/', UI_HTML) res.setHeader("Content-Type", "text/html; charset=utf-8") res.end(html) } catch (e) { next(e) } }) const parseBody = (req: any): Promise => { return new Promise((resolve, reject) => { let body = "" req.on("data", (c: any) => (body += c)) req.on("end", () => { try { resolve(JSON.parse(body)) } catch (e) { reject(e) } }) req.on("error", reject) }) } /* ---- API Handlers ---- */ server.middlewares.use("/__filetree/api/tree", async (_, res) => { cached ??= await getTree(server) res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(cached)) }) // Generate API server.middlewares.use("/__filetree/api/generate", async (req, res) => { try { const input: GenerateInputBody = await parseBody(req); server.config.logger.info(`[filetree] GENERATE ${JSON.stringify(input.type)} ${input.name}`, { timestamp: true }); const result = await Promise.all(input.type.map(async (t) => { return new Promise(async (resolve, reject) => { const tmpRes = generate({...input, type: t}) if (!tmpRes.success) { return reject(tmpRes.errors?.join(", ") || "Generation failed"); } resolve(tmpRes.operations); }); })).then((ops) => ops.flat()).then(ops => ({ success: true, operations: ops })); // input.type.forEach((t, i) => { // results.operations.push(...generate({...input, type: t}).operations); // }) // 1. Calculate Operations // 2. Execute Operations (if not dryRun) if (result.success && !input.dryRun) { for (const op of result.operations) { const absPath = resolveSafe(server.config.root, op.path); if (op.action === 'create' || op.action === 'overwrite') { server.config.logger.info(` - Creating: ${op.path}`, { timestamp: true }); await fs.mkdir(path.dirname(absPath), { recursive: true }); await fs.writeFile(absPath, op.content || ''); } } } res.end(JSON.stringify(result)); } catch (e: any) { server.config.logger.error(`[filetree] Generate Error: ${e.message}`, { timestamp: true }); res.statusCode = 500; res.end(JSON.stringify({ error: e.message })) } }) // Standard File Ops server.middlewares.use("/__filetree/api/file/create", async (req, res) => { try { const { path: rel, content = "" } = await parseBody(req) server.config.logger.info(`[filetree] CREATE FILE ${rel}`, { timestamp: true }); const abs = resolveSafe(server.config.root, rel) await fs.mkdir(path.dirname(abs), { recursive: true }) await fs.writeFile(abs, content) res.end(JSON.stringify({ ok: true })) } catch (e: any) { server.config.logger.error(`[filetree] Create File Error: ${e.message}`, { timestamp: true }); res.statusCode = 500; res.end(JSON.stringify({ error: e.message })) } }) server.middlewares.use("/__filetree/api/dir/create", async (req, res) => { try { const { path: rel } = await parseBody(req); server.config.logger.info(`[filetree] CREATE DIR ${rel}`, { timestamp: true }); await fs.mkdir(resolveSafe(server.config.root, rel), { recursive: true }); res.end(JSON.stringify({ ok: true })) } catch (e: any) { server.config.logger.error(`[filetree] Create Dir Error: ${e.message}`, { timestamp: true }); res.statusCode = 500; res.end(JSON.stringify({ error: e.message })) } }) server.middlewares.use("/__filetree/api/delete", async (req, res) => { try { const { path: rel } = await parseBody(req); server.config.logger.info(`[filetree] DELETE ${rel}`, { timestamp: true }); await fs.rm(resolveSafe(server.config.root, rel), { recursive: true, force: true }); res.end(JSON.stringify({ ok: true })) } catch (e: any) { server.config.logger.error(`[filetree] Delete Error: ${e.message}`, { timestamp: true }); res.statusCode = 500; res.end(JSON.stringify({ error: e.message })) } }) server.middlewares.use("/__filetree/api/move", async (req, res) => { try { const { from, to } = await parseBody(req); server.config.logger.info(`[filetree] MOVE ${from} -> ${to}`, { timestamp: true }); const a = resolveSafe(server.config.root, from); const b = resolveSafe(server.config.root, to); await fs.mkdir(path.dirname(b), { recursive: true }); await fs.rename(a, b); res.end(JSON.stringify({ ok: true })) } catch (e: any) { server.config.logger.error(`[filetree] Move Error: ${e.message}`, { timestamp: true }); res.statusCode = 500; res.end(JSON.stringify({ error: e.message })) } }) server.watcher.on("all", (event, file) => { if(!isIgnored(path.relative(server.config.root, file))) rebuild() }) }, } } /* ------------------------ UI ------------------------ */ const UI_HTML = ` Project Explorer
EXPLORER
`