|
#!/usr/bin/env bunx bun |
|
|
|
import { readdir, readFile } from "fs/promises"; |
|
import { join } from "path"; |
|
import { homedir } from "os"; |
|
import { parse as parseYaml } from "yaml"; |
|
|
|
const VIEWS_DIR = join(homedir(), ".config", "viewctl", "views"); |
|
|
|
// Parse command line arguments |
|
const args = process.argv.slice(2); |
|
let port = 8080; |
|
for (let i = 0; i < args.length; i++) { |
|
if (args[i] === "--port" && args[i + 1]) { |
|
port = parseInt(args[i + 1], 10); |
|
} |
|
} |
|
|
|
async function loadManifest(viewId) { |
|
try { |
|
const manifestPath = join(VIEWS_DIR, viewId, "_manifest.yaml"); |
|
const content = await readFile(manifestPath, "utf-8"); |
|
return parseYaml(content); |
|
} catch { |
|
return null; |
|
} |
|
} |
|
|
|
async function listViews() { |
|
try { |
|
const entries = await readdir(VIEWS_DIR, { withFileTypes: true }); |
|
const views = []; |
|
for (const entry of entries) { |
|
if (entry.isDirectory()) { |
|
const manifest = await loadManifest(entry.name); |
|
if (manifest) { |
|
views.push({ id: entry.name, ...manifest }); |
|
} |
|
} |
|
} |
|
// Sort by created date, newest first |
|
views.sort((a, b) => new Date(b.created) - new Date(a.created)); |
|
return views; |
|
} catch { |
|
return []; |
|
} |
|
} |
|
|
|
async function getViewWithContents(viewId) { |
|
const manifest = await loadManifest(viewId); |
|
if (!manifest) return null; |
|
|
|
const viewDir = join(VIEWS_DIR, viewId); |
|
const files = []; |
|
for (const fileInfo of manifest.files || []) { |
|
try { |
|
const content = await readFile(join(viewDir, fileInfo.name), "utf-8"); |
|
files.push({ |
|
name: fileInfo.name, |
|
display_path: fileInfo.display_path, |
|
language: fileInfo.language || null, |
|
content, |
|
}); |
|
} catch { |
|
// Skip files that can't be read |
|
} |
|
} |
|
|
|
return { |
|
id: viewId, |
|
created: manifest.created, |
|
files, |
|
}; |
|
} |
|
|
|
const HTML_TEMPLATE = `<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>viewctl</title> |
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/geist@1/dist/fonts/geist-mono/style.min.css"> |
|
<script src="https://cdn.tailwindcss.com"></script> |
|
<script type="importmap"> |
|
{ |
|
"imports": { |
|
"react": "https://esm.sh/react@18", |
|
"react-dom/client": "https://esm.sh/react-dom@18/client", |
|
"shiki": "https://esm.sh/shiki@1", |
|
"marked": "https://esm.sh/marked@12" |
|
} |
|
} |
|
</script> |
|
<style> |
|
:root { |
|
--background: oklch(0.1448 0 0); |
|
--foreground: oklch(0.9851 0 0); |
|
--card: oklch(0.2134 0 0); |
|
--secondary: oklch(0.2686 0 0); |
|
--muted-foreground: oklch(0.7090 0 0); |
|
--border: oklch(0.3407 0 0); |
|
--input: oklch(0.4386 0 0); |
|
--ring: oklch(0.5555 0 0); |
|
--link-color: oklch(0.7090 0 0); |
|
--scrollbar-thumb: oklch(0.4386 0 0); |
|
--scrollbar-track: transparent; |
|
--inline-code-bg: oklch(0.2686 0 0); |
|
} |
|
/* Styled scrollbars */ |
|
::-webkit-scrollbar { |
|
width: 8px; |
|
height: 8px; |
|
} |
|
::-webkit-scrollbar-track { |
|
background: var(--scrollbar-track); |
|
} |
|
::-webkit-scrollbar-thumb { |
|
background: var(--scrollbar-thumb); |
|
border-radius: 0; |
|
} |
|
::-webkit-scrollbar-thumb:hover { |
|
background: #6e7681; |
|
} |
|
::-webkit-scrollbar-corner { |
|
background: var(--scrollbar-track); |
|
} |
|
/* Firefox scrollbar */ |
|
* { |
|
scrollbar-width: thin; |
|
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); |
|
} |
|
body { |
|
background: var(--background); |
|
color: var(--foreground); |
|
font-family: 'Geist Mono', ui-monospace, monospace; |
|
} |
|
.file-block { |
|
border: 1px solid var(--border); |
|
border-radius: 0; |
|
overflow: hidden; |
|
margin-bottom: 16px; |
|
} |
|
.file-header { |
|
background: var(--card); |
|
padding: 8px 16px; |
|
border-bottom: 1px solid var(--border); |
|
font-size: 14px; |
|
font-family: 'Geist Mono', ui-monospace, monospace; |
|
color: var(--muted-foreground); |
|
} |
|
.file-content { |
|
background: var(--background); |
|
overflow-x: auto; |
|
} |
|
.file-content pre { |
|
margin: 0; |
|
padding: 16px; |
|
font-size: 13px; |
|
line-height: 1.45; |
|
} |
|
.shiki { |
|
background: transparent !important; |
|
} |
|
/* Markdown styles */ |
|
.markdown-body { |
|
padding: 16px; |
|
font-size: 14px; |
|
line-height: 1.6; |
|
} |
|
.markdown-body h1, .markdown-body h2, .markdown-body h3 { |
|
border-bottom: 1px solid var(--border); |
|
padding-bottom: 0.3em; |
|
margin-top: 24px; |
|
margin-bottom: 16px; |
|
font-weight: 600; |
|
} |
|
.markdown-body h1 { font-size: 2em; } |
|
.markdown-body h2 { font-size: 1.5em; } |
|
.markdown-body h3 { font-size: 1.25em; } |
|
.markdown-body p { margin-bottom: 16px; } |
|
.markdown-body code { |
|
background: var(--inline-code-bg); |
|
padding: 0.2em 0.4em; |
|
border-radius: 0; |
|
font-size: 85%; |
|
font-family: 'Geist Mono', ui-monospace, monospace; |
|
} |
|
.markdown-body pre { |
|
background: var(--card); |
|
padding: 16px; |
|
border-radius: 0; |
|
overflow-x: auto; |
|
margin-bottom: 16px; |
|
} |
|
.markdown-body pre code { |
|
background: transparent; |
|
padding: 0; |
|
font-size: 100%; |
|
} |
|
.markdown-body a { |
|
color: var(--link-color); |
|
text-decoration: none; |
|
} |
|
.markdown-body a:hover { |
|
text-decoration: underline; |
|
} |
|
.markdown-body ul, .markdown-body ol { |
|
padding-left: 2em; |
|
margin-bottom: 16px; |
|
} |
|
.markdown-body li { |
|
margin-bottom: 4px; |
|
} |
|
.markdown-body blockquote { |
|
border-left: 4px solid var(--border); |
|
padding-left: 16px; |
|
color: var(--muted-foreground); |
|
margin-bottom: 16px; |
|
} |
|
.view-list-item { |
|
border: 1px solid var(--border); |
|
border-radius: 0; |
|
padding: 16px; |
|
margin-bottom: 12px; |
|
background: var(--card); |
|
} |
|
.view-list-item:hover { |
|
border-color: var(--muted-foreground); |
|
} |
|
.view-list-item a { |
|
color: var(--link-color); |
|
text-decoration: none; |
|
font-weight: 600; |
|
} |
|
.view-list-item a:hover { |
|
text-decoration: underline; |
|
} |
|
.loading { |
|
display: flex; |
|
justify-content: center; |
|
align-items: center; |
|
min-height: 200px; |
|
color: var(--muted-foreground); |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<div id="root"></div> |
|
<script type="module"> |
|
import React, { useState, useEffect } from 'react'; |
|
import { createRoot } from 'react-dom/client'; |
|
import { createHighlighter } from 'shiki'; |
|
import { marked } from 'marked'; |
|
|
|
// Map detected language names to Shiki language IDs |
|
function mapDetectedToShiki(lang) { |
|
const map = { |
|
'shell': 'bash', |
|
'sh': 'bash', |
|
'c++': 'cpp', |
|
'c#': 'csharp', |
|
'objective-c': 'objc', |
|
'actionscript 3': 'actionscript-3', |
|
'just': 'makefile', // close enough |
|
}; |
|
return map[lang] || lang; |
|
} |
|
|
|
// Get file extension for syntax highlighting |
|
function getLanguage(filename, detectedLang) { |
|
// Use pre-detected language if available |
|
if (detectedLang) { |
|
return mapDetectedToShiki(detectedLang); |
|
} |
|
// Fall back to extension-based detection |
|
const ext = filename.split('.').pop()?.toLowerCase(); |
|
const langMap = { |
|
'js': 'javascript', |
|
'ts': 'typescript', |
|
'tsx': 'tsx', |
|
'jsx': 'jsx', |
|
'py': 'python', |
|
'rb': 'ruby', |
|
'rs': 'rust', |
|
'go': 'go', |
|
'java': 'java', |
|
'kt': 'kotlin', |
|
'swift': 'swift', |
|
'c': 'c', |
|
'cpp': 'cpp', |
|
'h': 'c', |
|
'hpp': 'cpp', |
|
'cs': 'csharp', |
|
'php': 'php', |
|
'sh': 'bash', |
|
'bash': 'bash', |
|
'zsh': 'bash', |
|
'fish': 'fish', |
|
'ps1': 'powershell', |
|
'sql': 'sql', |
|
'json': 'json', |
|
'yaml': 'yaml', |
|
'yml': 'yaml', |
|
'xml': 'xml', |
|
'html': 'html', |
|
'css': 'css', |
|
'scss': 'scss', |
|
'less': 'less', |
|
'md': 'markdown', |
|
'toml': 'toml', |
|
'ini': 'ini', |
|
'dockerfile': 'dockerfile', |
|
'makefile': 'makefile', |
|
'lua': 'lua', |
|
'vim': 'viml', |
|
'ex': 'elixir', |
|
'exs': 'elixir', |
|
'erl': 'erlang', |
|
'hs': 'haskell', |
|
'ml': 'ocaml', |
|
'clj': 'clojure', |
|
'lisp': 'lisp', |
|
'scala': 'scala', |
|
'r': 'r', |
|
'jl': 'julia', |
|
'dart': 'dart', |
|
'zig': 'zig', |
|
'nim': 'nim', |
|
'v': 'v', |
|
'vue': 'vue', |
|
'svelte': 'svelte', |
|
}; |
|
return langMap[ext] || 'text'; |
|
} |
|
|
|
function isMarkdown(filename) { |
|
const ext = filename.split('.').pop()?.toLowerCase(); |
|
return ext === 'md' || ext === 'markdown'; |
|
} |
|
|
|
// Highlighter singleton |
|
let highlighterPromise = null; |
|
async function getHighlighter() { |
|
if (!highlighterPromise) { |
|
highlighterPromise = createHighlighter({ |
|
themes: ['min-dark'], |
|
langs: ['javascript', 'typescript', 'python', 'bash', 'json', 'yaml', 'html', 'css', 'rust', 'go', 'java', 'ruby', 'php', 'c', 'cpp', 'markdown', 'sql', 'text'], |
|
}); |
|
} |
|
return highlighterPromise; |
|
} |
|
|
|
function FileBlock({ file }) { |
|
const [html, setHtml] = useState(null); |
|
|
|
useEffect(() => { |
|
async function highlight() { |
|
if (isMarkdown(file.name)) { |
|
const rendered = await marked(file.content); |
|
setHtml({ type: 'markdown', content: rendered }); |
|
} else { |
|
try { |
|
const highlighter = await getHighlighter(); |
|
const lang = getLanguage(file.name, file.language); |
|
// Load language if not already loaded |
|
const loadedLangs = highlighter.getLoadedLanguages(); |
|
if (!loadedLangs.includes(lang) && lang !== 'text') { |
|
try { |
|
await highlighter.loadLanguage(lang); |
|
} catch { |
|
// Fall back to text if language not available |
|
} |
|
} |
|
const highlighted = highlighter.codeToHtml(file.content, { |
|
lang: loadedLangs.includes(lang) ? lang : 'text', |
|
theme: 'min-dark', |
|
}); |
|
setHtml({ type: 'code', content: highlighted }); |
|
} catch (err) { |
|
setHtml({ type: 'code', content: '<pre>' + escapeHtml(file.content) + '</pre>' }); |
|
} |
|
} |
|
} |
|
highlight(); |
|
}, [file]); |
|
|
|
function escapeHtml(text) { |
|
return text |
|
.replace(/&/g, '&') |
|
.replace(/</g, '<') |
|
.replace(/>/g, '>'); |
|
} |
|
|
|
return React.createElement('div', { className: 'file-block' }, |
|
React.createElement('div', { className: 'file-header' }, file.display_path), |
|
React.createElement('div', { |
|
className: html?.type === 'markdown' ? 'markdown-body' : 'file-content', |
|
dangerouslySetInnerHTML: html ? { __html: html.content } : undefined |
|
}, html ? null : 'Loading...') |
|
); |
|
} |
|
|
|
function ViewDisplay({ viewId }) { |
|
const [view, setView] = useState(null); |
|
const [loading, setLoading] = useState(true); |
|
const [error, setError] = useState(null); |
|
|
|
useEffect(() => { |
|
fetch('/api/view/' + viewId) |
|
.then(res => res.json()) |
|
.then(data => { |
|
if (data.error) { |
|
setError(data.error); |
|
} else { |
|
setView(data); |
|
} |
|
setLoading(false); |
|
}) |
|
.catch(err => { |
|
setError('Failed to load view'); |
|
setLoading(false); |
|
}); |
|
}, [viewId]); |
|
|
|
if (loading) { |
|
return React.createElement('div', { className: 'loading' }, 'Loading...'); |
|
} |
|
|
|
if (error) { |
|
return React.createElement('div', { className: 'max-w-4xl mx-auto p-4' }, |
|
React.createElement('div', { className: 'text-gray-400' }, error) |
|
); |
|
} |
|
|
|
return React.createElement('div', { className: 'max-w-4xl mx-auto p-4' }, |
|
React.createElement('div', { className: 'mb-4 flex items-center justify-between' }, |
|
React.createElement('a', { href: '/', className: 'text-gray-400 hover:underline' }, '← All views'), |
|
React.createElement('span', { className: 'text-gray-500 text-sm' }, |
|
'Created: ' + new Date(view.created).toLocaleString() |
|
) |
|
), |
|
view.files.map((file, i) => |
|
React.createElement(FileBlock, { key: i, file }) |
|
) |
|
); |
|
} |
|
|
|
function ViewList() { |
|
const [views, setViews] = useState([]); |
|
const [loading, setLoading] = useState(true); |
|
|
|
useEffect(() => { |
|
fetch('/api/views') |
|
.then(res => res.json()) |
|
.then(data => { |
|
setViews(data); |
|
setLoading(false); |
|
}) |
|
.catch(() => setLoading(false)); |
|
}, []); |
|
|
|
if (loading) { |
|
return React.createElement('div', { className: 'loading' }, 'Loading...'); |
|
} |
|
|
|
if (views.length === 0) { |
|
return React.createElement('div', { className: 'max-w-4xl mx-auto p-4' }, |
|
React.createElement('h1', { className: 'text-2xl font-bold mb-4' }, 'viewctl'), |
|
React.createElement('p', { className: 'text-gray-500' }, |
|
'No views yet. Create one with: viewctl create-view <files...>' |
|
) |
|
); |
|
} |
|
|
|
return React.createElement('div', { className: 'max-w-4xl mx-auto p-4' }, |
|
React.createElement('h1', { className: 'text-2xl font-bold mb-4' }, 'viewctl'), |
|
views.map(view => |
|
React.createElement('div', { key: view.id, className: 'view-list-item' }, |
|
React.createElement('a', { href: '/view/' + view.id }, view.id), |
|
React.createElement('div', { className: 'text-sm text-gray-500 mt-1' }, |
|
view.files?.length + ' file(s) • ' + new Date(view.created).toLocaleString() |
|
), |
|
React.createElement('div', { className: 'text-sm text-gray-400 mt-1' }, |
|
view.files?.map(f => f.display_path).join(', ') |
|
) |
|
) |
|
) |
|
); |
|
} |
|
|
|
function App() { |
|
const path = window.location.pathname; |
|
const viewMatch = path.match(/^\\/view\\/([a-z0-9]+)$/); |
|
|
|
if (viewMatch) { |
|
return React.createElement(ViewDisplay, { viewId: viewMatch[1] }); |
|
} |
|
|
|
return React.createElement(ViewList); |
|
} |
|
|
|
const root = createRoot(document.getElementById('root')); |
|
root.render(React.createElement(App)); |
|
</script> |
|
</body> |
|
</html>`; |
|
|
|
const server = Bun.serve({ |
|
port, |
|
async fetch(req) { |
|
const url = new URL(req.url); |
|
const path = url.pathname; |
|
|
|
// API: List views |
|
if (path === "/api/views") { |
|
const views = await listViews(); |
|
return Response.json(views); |
|
} |
|
|
|
// API: Get single view with contents |
|
const viewMatch = path.match(/^\/api\/view\/([a-z0-9]+)$/); |
|
if (viewMatch) { |
|
const view = await getViewWithContents(viewMatch[1]); |
|
if (!view) { |
|
return Response.json({ error: "View not found" }, { status: 404 }); |
|
} |
|
return Response.json(view); |
|
} |
|
|
|
// Serve HTML for all other routes (SPA) |
|
return new Response(HTML_TEMPLATE, { |
|
headers: { "Content-Type": "text/html" }, |
|
}); |
|
}, |
|
}); |
|
|
|
console.log(`viewctl server running at http://localhost:${server.port}`); |