Files
archy/packages/app/vite-fs.ts
T
DorianandClaude Opus 4.6 00bdc055ba feat(app): add design system viewer, nostr feed, stop generation, and content refactor
- Design system browser with grid/detail views for tokens and components
- Nostr feed tab with note/article/zap filtering and relay status
- Stop generation button to abort AI streaming mid-response
- Paste & extract content without sending to AI
- Refactor useContentPanel into contentExtraction.ts and contentFiltering.ts
- Banner fallback composable for 3-stage image loading
- Wikipedia and Google Books as fallback image sources
- Loading skeletons with variant-specific shapes
- Mobile UX: auto-switch to content, back button, detail flow
- Project grid with breadcrumb nav and inline creation
- Filesystem Vite plugin for local project browsing
- Magazine text cleanup and song grid polish

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 13:08:32 +00:00

207 lines
7.1 KiB
TypeScript

import type { Plugin } from 'vite'
import type { Connect } from 'vite'
import { readdirSync, statSync, readFileSync, existsSync, mkdirSync } from 'fs'
import { join, resolve, relative } from 'path'
const PROJECTS_ROOT = '/Users/dorian/Projects'
const IGNORED = new Set([
'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.output',
'.cache', '.turbo', '.vercel', '.netlify', '__pycache__', 'target',
'.DS_Store', 'coverage', '.vite', '.angular',
])
const MAX_FILE_SIZE = 1_000_000 // 1MB
const MAX_TREE_ENTRIES = 500
const MAX_DEPTH = 4
/** Validate path is within PROJECTS_ROOT to prevent directory traversal */
function isPathSafe(p: string): boolean {
const resolved = resolve(p)
return resolved.startsWith(PROJECTS_ROOT)
}
function parseUrl(req: Connect.IncomingMessage): URL {
return new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`)
}
/** GET /api/fs/list — list projects */
function handleList(_req: Connect.IncomingMessage, res: any) {
try {
const entries = readdirSync(PROJECTS_ROOT, { withFileTypes: true })
const projects = entries
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
.map(e => {
const fullPath = join(PROJECTS_ROOT, e.name)
const isGit = existsSync(join(fullPath, '.git'))
let language = 'Unknown'
// Detect project language from config files
try {
const files = readdirSync(fullPath).map(f => f)
if (files.includes('package.json')) language = 'TypeScript/JavaScript'
else if (files.includes('Cargo.toml')) language = 'Rust'
else if (files.includes('go.mod')) language = 'Go'
else if (files.includes('requirements.txt') || files.includes('pyproject.toml')) language = 'Python'
else if (files.includes('pom.xml') || files.includes('build.gradle')) language = 'Java'
else if (files.includes('Package.swift')) language = 'Swift'
else if (files.includes('Gemfile')) language = 'Ruby'
else if (files.includes('composer.json')) language = 'PHP'
else if (files.some(f => f.endsWith('.csproj') || f.endsWith('.sln'))) language = 'C#'
} catch { /* ignore read errors */ }
return { name: e.name, path: fullPath, isGit, language }
})
.sort((a, b) => a.name.localeCompare(b.name))
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ projects }))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: String(err) }))
}
}
/** GET /api/fs/tree — recursive file tree */
function handleTree(req: Connect.IncomingMessage, res: any) {
const url = parseUrl(req)
const dirPath = url.searchParams.get('path')
if (!dirPath || !isPathSafe(dirPath)) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Invalid or missing path' }))
return
}
let entryCount = 0
function walk(dir: string, depth: number): any[] {
if (depth > MAX_DEPTH || entryCount >= MAX_TREE_ENTRIES) return []
try {
const entries = readdirSync(dir, { withFileTypes: true })
.filter(e => !IGNORED.has(e.name) && !e.name.startsWith('.'))
.sort((a, b) => {
// Directories first, then alphabetical
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1
return a.name.localeCompare(b.name)
})
const result: any[] = []
for (const entry of entries) {
if (entryCount >= MAX_TREE_ENTRIES) break
entryCount++
const fullPath = join(dir, entry.name)
const relPath = relative(dirPath, fullPath)
if (entry.isDirectory()) {
result.push({
name: entry.name,
path: relPath,
isDirectory: true,
children: walk(fullPath, depth + 1),
})
} else {
result.push({
name: entry.name,
path: relPath,
isDirectory: false,
})
}
}
return result
} catch {
return []
}
}
try {
const files = walk(dirPath, 0)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ files }))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: String(err) }))
}
}
/** GET /api/fs/read — read file content */
function handleRead(req: Connect.IncomingMessage, res: any) {
const url = parseUrl(req)
const filePath = url.searchParams.get('path')
if (!filePath || !isPathSafe(filePath)) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Invalid or missing path' }))
return
}
try {
const stat = statSync(filePath)
if (stat.isDirectory()) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Path is a directory' }))
return
}
if (stat.size > MAX_FILE_SIZE) {
res.writeHead(413, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'File too large', size: stat.size }))
return
}
const content = readFileSync(filePath, 'utf-8')
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ content, size: stat.size }))
} catch (err: any) {
const code = err?.code === 'ENOENT' ? 404 : 500
res.writeHead(code, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: String(err) }))
}
}
/** POST /api/fs/mkdir — create a directory */
function handleMkdir(req: Connect.IncomingMessage, res: any) {
let body = ''
req.on('data', (chunk: Buffer) => { body += chunk.toString() })
req.on('end', () => {
try {
const { path: dirPath } = JSON.parse(body)
if (!dirPath || !isPathSafe(dirPath)) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Invalid or missing path' }))
return
}
if (existsSync(dirPath)) {
res.writeHead(409, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Directory already exists' }))
return
}
mkdirSync(dirPath, { recursive: true })
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ ok: true, path: dirPath }))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: String(err) }))
}
})
}
export function fsPlugin(): Plugin {
return {
name: 'aiui-fs',
configureServer(server) {
server.middlewares.use('/api/fs/list', (req, res, next) => {
if (req.method !== 'GET') return next()
handleList(req, res)
})
server.middlewares.use('/api/fs/tree', (req, res, next) => {
if (req.method !== 'GET') return next()
handleTree(req, res)
})
server.middlewares.use('/api/fs/read', (req, res, next) => {
if (req.method !== 'GET') return next()
handleRead(req, res)
})
server.middlewares.use('/api/fs/mkdir', (req, res, next) => {
if (req.method !== 'POST') return next()
handleMkdir(req, res)
})
},
}
}