diff --git a/web/src/client/app-new-entry.ts b/web/src/client/app-new-entry.ts new file mode 100644 index 00000000..8d59a506 --- /dev/null +++ b/web/src/client/app-new-entry.ts @@ -0,0 +1,2 @@ +// Entry point for the new app +import './app-new.js'; \ No newline at end of file diff --git a/web/src/client/app-new.ts b/web/src/client/app-new.ts new file mode 100644 index 00000000..2ec857b0 --- /dev/null +++ b/web/src/client/app-new.ts @@ -0,0 +1,144 @@ +import { LitElement, html } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; + +// Import components +import './components/app-header.js'; +import './components/session-create-form.js'; +import './components/session-list.js'; + +import type { Session } from './components/session-list.js'; + +@customElement('vibetunnel-app-new') +export class VibeTunnelAppNew extends LitElement { + // Disable shadow DOM to use Tailwind + createRenderRoot() { + return this; + } + + @state() private errorMessage = ''; + @state() private sessions: Session[] = []; + @state() private loading = false; + + private hotReloadWs: WebSocket | null = null; + + connectedCallback() { + super.connectedCallback(); + this.setupHotReload(); + this.loadSessions(); + this.startAutoRefresh(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.hotReloadWs) { + this.hotReloadWs.close(); + } + } + + private showError(message: string) { + this.errorMessage = message; + // Clear error after 5 seconds + setTimeout(() => { + this.errorMessage = ''; + }, 5000); + } + + private clearError() { + this.errorMessage = ''; + } + + private async loadSessions() { + this.loading = true; + try { + const response = await fetch('/api/sessions'); + if (response.ok) { + const sessionsData = await response.json(); + this.sessions = sessionsData.map((session: any) => ({ + id: session.id, + command: session.command, + workingDir: session.workingDir, + status: session.status, + exitCode: session.exitCode, + startedAt: session.startedAt, + lastModified: session.lastModified, + pid: session.pid + })); + this.clearError(); + } else { + this.showError('Failed to load sessions'); + } + } catch (error) { + console.error('Error loading sessions:', error); + this.showError('Failed to load sessions'); + } finally { + this.loading = false; + } + } + + private startAutoRefresh() { + // Refresh sessions every 3 seconds + setInterval(() => { + this.loadSessions(); + }, 3000); + } + + private handleSessionCreated(e: CustomEvent) { + console.log('Session created:', e.detail); + this.showError('Session created successfully!'); + this.loadSessions(); // Refresh the list + } + + private handleSessionSelect(e: CustomEvent) { + const session = e.detail as Session; + console.log('Session selected:', session); + this.showError(`Terminal view not implemented yet for session: ${session.id}`); + } + + private handleSessionKilled(e: CustomEvent) { + console.log('Session killed:', e.detail); + this.loadSessions(); // Refresh the list + } + + private handleRefresh() { + this.loadSessions(); + } + + private handleError(e: CustomEvent) { + this.showError(e.detail); + } + + private setupHotReload(): void { + if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}?hotReload=true`; + + this.hotReloadWs = new WebSocket(wsUrl); + this.hotReloadWs.onmessage = (event) => { + const message = JSON.parse(event.data); + if (message.type === 'reload') { + window.location.reload(); + } + }; + } + } + + render() { + return html` +
+ + + +
+ `; + } +} \ No newline at end of file diff --git a/web/src/client/components/app-header.ts b/web/src/client/components/app-header.ts new file mode 100644 index 00000000..93b362a8 --- /dev/null +++ b/web/src/client/components/app-header.ts @@ -0,0 +1,17 @@ +import { LitElement, html } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +@customElement('app-header') +export class AppHeader extends LitElement { + createRenderRoot() { + return this; + } + + render() { + return html` +
+

VibeTunnel

+
+ `; + } +} \ No newline at end of file diff --git a/web/src/client/components/session-create-form.ts b/web/src/client/components/session-create-form.ts new file mode 100644 index 00000000..51cbfda1 --- /dev/null +++ b/web/src/client/components/session-create-form.ts @@ -0,0 +1,183 @@ +import { LitElement, html } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import './file-browser.js'; + +export interface SessionCreateData { + command: string[]; + workingDir: string; +} + +@customElement('session-create-form') +export class SessionCreateForm extends LitElement { + // Disable shadow DOM to use Tailwind + createRenderRoot() { + return this; + } + + @property({ type: String }) workingDir = '~/'; + @property({ type: String }) command = ''; + @property({ type: Boolean }) disabled = false; + + @state() private isCreating = false; + @state() private showFileBrowser = false; + + private handleWorkingDirChange(e: Event) { + const input = e.target as HTMLInputElement; + this.workingDir = input.value; + this.dispatchEvent(new CustomEvent('working-dir-change', { + detail: this.workingDir + })); + } + + private handleCommandChange(e: Event) { + const input = e.target as HTMLInputElement; + this.command = input.value; + } + + private handleBrowse() { + this.showFileBrowser = true; + } + + private handleDirectorySelected(e: CustomEvent) { + this.workingDir = e.detail; + this.showFileBrowser = false; + } + + private handleBrowserCancel() { + this.showFileBrowser = false; + } + + private async handleCreate() { + if (!this.workingDir.trim() || !this.command.trim()) { + this.dispatchEvent(new CustomEvent('error', { + detail: 'Please fill in both working directory and command' + })); + return; + } + + this.isCreating = true; + + const sessionData: SessionCreateData = { + command: this.parseCommand(this.command.trim()), + workingDir: this.workingDir.trim() + }; + + try { + const response = await fetch('/api/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(sessionData) + }); + + if (response.ok) { + const result = await response.json(); + this.command = ''; // Clear command on success + this.dispatchEvent(new CustomEvent('session-created', { + detail: result + })); + } else { + const error = await response.json(); + this.dispatchEvent(new CustomEvent('error', { + detail: `Failed to create session: ${error.error}` + })); + } + } catch (error) { + console.error('Error creating session:', error); + this.dispatchEvent(new CustomEvent('error', { + detail: 'Failed to create session' + })); + } finally { + this.isCreating = false; + } + } + + private parseCommand(commandStr: string): string[] { + // Simple command parsing - split by spaces but respect quotes + const args: string[] = []; + let current = ''; + let inQuotes = false; + let quoteChar = ''; + + for (let i = 0; i < commandStr.length; i++) { + const char = commandStr[i]; + + if ((char === '"' || char === "'") && !inQuotes) { + inQuotes = true; + quoteChar = char; + } else if (char === quoteChar && inQuotes) { + inQuotes = false; + quoteChar = ''; + } else if (char === ' ' && !inQuotes) { + if (current) { + args.push(current); + current = ''; + } + } else { + current += char; + } + } + + if (current) { + args.push(current); + } + + return args; + } + + render() { + return html` +
+
Create New Session
+ +
+
Working Directory:
+
+ + +
+
+ +
+
Command:
+ e.key === 'Enter' && this.handleCreate()} + placeholder="zsh" + ?disabled=${this.disabled || this.isCreating} + /> +
+ + +
+ + + `; + } +} \ No newline at end of file diff --git a/web/src/server-new.ts b/web/src/server-new.ts new file mode 100644 index 00000000..9cf72ad9 --- /dev/null +++ b/web/src/server-new.ts @@ -0,0 +1,555 @@ +import express from 'express'; +import { createServer } from 'http'; +import { WebSocketServer } from 'ws'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import { spawn } from 'child_process'; + +const app = express(); +const server = createServer(app); +const wss = new WebSocketServer({ server }); + +const PORT = process.env.PORT || 3000; + +// tty-fwd binary path - check multiple possible locations +const possibleTtyFwdPaths = [ + path.resolve(__dirname, '..', '..', 'tty-fwd', 'target', 'release', 'tty-fwd'), + path.resolve(__dirname, '..', '..', '..', 'tty-fwd', 'target', 'release', 'tty-fwd'), + 'tty-fwd' // System PATH +]; + +let TTY_FWD_PATH = ''; +for (const pathToCheck of possibleTtyFwdPaths) { + if (fs.existsSync(pathToCheck)) { + TTY_FWD_PATH = pathToCheck; + break; + } +} + +if (!TTY_FWD_PATH) { + console.error('tty-fwd binary not found. Please ensure it is built and available.'); + process.exit(1); +} + +const TTY_FWD_CONTROL_DIR = process.env.TTY_FWD_CONTROL_DIR || path.join(os.homedir(), '.vibetunnel'); + +// Ensure control directory exists +if (!fs.existsSync(TTY_FWD_CONTROL_DIR)) { + fs.mkdirSync(TTY_FWD_CONTROL_DIR, { recursive: true }); + console.log(`Created control directory: ${TTY_FWD_CONTROL_DIR}`); +} + +console.log(`Using tty-fwd at: ${TTY_FWD_PATH}`); +console.log(`Control directory: ${TTY_FWD_CONTROL_DIR}`); + +// Types for tty-fwd responses +interface TtyFwdSession { + cmdline: string[]; + cwd: string; + exit_code: number | null; + name: string; + pid: number; + started_at: string; + status: "running" | "exited"; + stdin: string; + "stream-out": string; +} + +interface TtyFwdListResponse { + [sessionId: string]: TtyFwdSession; +} + +// Helper function to execute tty-fwd commands +async function executeTtyFwd(args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(TTY_FWD_PATH, args); + let output = ''; + + child.stdout.on('data', (data) => { + output += data.toString(); + }); + + child.on('close', (code) => { + if (code === 0) { + resolve(output); + } else { + reject(new Error(`tty-fwd failed with code ${code}`)); + } + }); + + child.on('error', (error) => { + reject(error); + }); + }); +} + +// Helper function to resolve paths with ~ expansion +function resolvePath(inputPath: string, fallback?: string): string { + if (!inputPath) { + return fallback || process.cwd(); + } + + if (inputPath.startsWith('~')) { + return path.join(os.homedir(), inputPath.slice(1)); + } + + return path.resolve(inputPath); +} + +// Middleware +app.use(express.json()); +app.use(express.static(path.join(__dirname, '..', 'public'))); + +// Hot reload functionality for development +const hotReloadClients = new Set(); + +// === SESSION MANAGEMENT === + +// List all sessions +app.get('/api/sessions', async (req, res) => { + try { + const output = await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']); + const sessions: TtyFwdListResponse = JSON.parse(output || '{}'); + + const sessionData = Object.entries(sessions).map(([sessionId, sessionInfo]) => { + // Get actual last modified time from stream-out file + let lastModified = sessionInfo.started_at; + try { + if (fs.existsSync(sessionInfo["stream-out"])) { + const stats = fs.statSync(sessionInfo["stream-out"]); + lastModified = stats.mtime.toISOString(); + } + } catch (e) { + // Use started_at as fallback + } + + return { + id: sessionId, + command: sessionInfo.cmdline.join(' '), + workingDir: sessionInfo.cwd, + status: sessionInfo.status, + exitCode: sessionInfo.exit_code, + startedAt: sessionInfo.started_at, + lastModified: lastModified, + pid: sessionInfo.pid + }; + }); + + // Sort by lastModified, most recent first + sessionData.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()); + res.json(sessionData); + } catch (error) { + console.error('Failed to list sessions:', error); + res.status(500).json({ error: 'Failed to list sessions' }); + } +}); + +// Create new session +app.post('/api/sessions', async (req, res) => { + try { + const { command, workingDir } = req.body; + + if (!command || !Array.isArray(command) || command.length === 0) { + return res.status(400).json({ error: 'Command array is required and cannot be empty' }); + } + + const sessionName = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const cwd = resolvePath(workingDir, process.cwd()); + + const args = [ + '--control-path', TTY_FWD_CONTROL_DIR, + '--session-name', sessionName, + '--' + ].concat(command); + + console.log(`Creating session: ${TTY_FWD_PATH} ${args.join(' ')}`); + + const child = spawn(TTY_FWD_PATH, args, { + cwd: cwd, + detached: false, + stdio: 'pipe' + }); + + // Log output for debugging + child.stdout.on('data', (data) => { + console.log(`Session ${sessionName} stdout:`, data.toString()); + }); + + child.stderr.on('data', (data) => { + console.log(`Session ${sessionName} stderr:`, data.toString()); + }); + + child.on('close', (code) => { + console.log(`Session ${sessionName} exited with code: ${code}`); + }); + + // Respond immediately - don't wait for completion + res.json({ sessionId: sessionName }); + + } catch (error) { + console.error('Error creating session:', error); + res.status(500).json({ error: 'Failed to create session' }); + } +}); + +// Kill session (just kill the process) +app.delete('/api/sessions/:sessionId', async (req, res) => { + const sessionId = req.params.sessionId; + + try { + const output = await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']); + const sessions: TtyFwdListResponse = JSON.parse(output || '{}'); + const session = sessions[sessionId]; + + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + if (session.pid) { + try { + process.kill(session.pid, 'SIGTERM'); + setTimeout(() => { + try { + process.kill(session.pid, 0); // Check if still alive + process.kill(session.pid, 'SIGKILL'); // Force kill + } catch (e) { + // Process already dead + } + }, 1000); + } catch (error) { + // Process already dead + } + } + + res.json({ success: true, message: 'Session killed' }); + + } catch (error) { + console.error('Error killing session:', error); + res.status(500).json({ error: 'Failed to kill session' }); + } +}); + +// Cleanup session files +app.delete('/api/sessions/:sessionId/cleanup', async (req, res) => { + const sessionId = req.params.sessionId; + + try { + await executeTtyFwd([ + '--control-path', TTY_FWD_CONTROL_DIR, + '--session', sessionId, + '--cleanup' + ]); + + res.json({ success: true, message: 'Session cleaned up' }); + + } catch (error) { + // If tty-fwd cleanup fails, force remove directory + console.log('tty-fwd cleanup failed, force removing directory'); + const sessionDir = path.join(TTY_FWD_CONTROL_DIR, sessionId); + try { + if (fs.existsSync(sessionDir)) { + fs.rmSync(sessionDir, { recursive: true, force: true }); + } + res.json({ success: true, message: 'Session force cleaned up' }); + } catch (fsError) { + console.error('Error force removing session directory:', fsError); + res.status(500).json({ error: 'Failed to cleanup session' }); + } + } +}); + +// === TERMINAL I/O === + +// Server-sent events for terminal output streaming +app.get('/api/sessions/:sessionId/stream', (req, res) => { + const sessionId = req.params.sessionId; + const streamOutPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out'); + + if (!fs.existsSync(streamOutPath)) { + return res.status(404).json({ error: 'Session not found' }); + } + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Cache-Control' + }); + + const startTime = Date.now() / 1000; + let headerSent = false; + + // Send existing content first + // NOTE: Small race condition possible between reading file and starting tail + try { + const content = fs.readFileSync(streamOutPath, 'utf8'); + const lines = content.trim().split('\n'); + + for (const line of lines) { + if (line.trim()) { + try { + const parsed = JSON.parse(line); + if (parsed.version && parsed.width && parsed.height) { + res.write(`data: ${line}\n\n`); + headerSent = true; + } else if (Array.isArray(parsed) && parsed.length >= 3) { + const instantEvent = [0, parsed[1], parsed[2]]; + res.write(`data: ${JSON.stringify(instantEvent)}\n\n`); + } + } catch (e) { + // Skip invalid lines + } + } + } + } catch (error) { + console.error('Error reading existing content:', error); + } + + // Send default header if none found + if (!headerSent) { + const defaultHeader = { + version: 2, + width: 80, + height: 24, + timestamp: Math.floor(startTime), + env: { TERM: "xterm-256color" } + }; + res.write(`data: ${JSON.stringify(defaultHeader)}\n\n`); + } + + // Stream new content + const tailProcess = spawn('tail', ['-f', streamOutPath]); + let buffer = ''; + + tailProcess.stdout.on('data', (chunk) => { + buffer += chunk.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + try { + const parsed = JSON.parse(line); + if (parsed.version && parsed.width && parsed.height) { + return; // Skip duplicate headers + } + if (Array.isArray(parsed) && parsed.length >= 3) { + const currentTime = Date.now() / 1000; + const realTimeEvent = [currentTime - startTime, parsed[1], parsed[2]]; + res.write(`data: ${JSON.stringify(realTimeEvent)}\n\n`); + } + } catch (e) { + // Handle non-JSON as raw output + const currentTime = Date.now() / 1000; + const castEvent = [currentTime - startTime, "o", line]; + res.write(`data: ${JSON.stringify(castEvent)}\n\n`); + } + } + } + }); + + // Cleanup on disconnect + req.on('close', () => tailProcess.kill('SIGTERM')); + req.on('aborted', () => tailProcess.kill('SIGTERM')); +}); + +// Get session snapshot (asciinema cast with adjusted timestamps for immediate playback) +app.get('/api/sessions/:sessionId/snapshot', (req, res) => { + const sessionId = req.params.sessionId; + const streamOutPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out'); + + if (!fs.existsSync(streamOutPath)) { + return res.status(404).json({ error: 'Session not found' }); + } + + try { + const content = fs.readFileSync(streamOutPath, 'utf8'); + const lines = content.trim().split('\n'); + + let header = null; + const events = []; + let startTime = null; + + for (const line of lines) { + if (line.trim()) { + try { + const parsed = JSON.parse(line); + + // Header line + if (parsed.version && parsed.width && parsed.height) { + header = parsed; + } + // Event line [timestamp, type, data] + else if (Array.isArray(parsed) && parsed.length >= 3) { + if (startTime === null) { + startTime = parsed[0]; + } + // Adjust timestamp to start from 0 and compress time + const adjustedTime = (parsed[0] - startTime) * 0.1; // 10x speed + events.push([adjustedTime, parsed[1], parsed[2]]); + } + } catch (e) { + // Skip invalid lines + } + } + } + + // Build the complete asciinema cast + const cast = []; + + // Add header if found, otherwise use default + if (header) { + cast.push(JSON.stringify(header)); + } else { + cast.push(JSON.stringify({ + version: 2, + width: 80, + height: 24, + timestamp: Math.floor(Date.now() / 1000), + env: { TERM: "xterm-256color" } + })); + } + + // Add all events + events.forEach(event => { + cast.push(JSON.stringify(event)); + }); + + res.setHeader('Content-Type', 'text/plain'); + res.send(cast.join('\n')); + + } catch (error) { + console.error('Error reading session snapshot:', error); + res.status(500).json({ error: 'Failed to read session snapshot' }); + } +}); + +// Send input to session +app.post('/api/sessions/:sessionId/input', async (req, res) => { + const sessionId = req.params.sessionId; + const { text } = req.body; + + if (text === undefined || text === null) { + return res.status(400).json({ error: 'Text is required' }); + } + + console.log(`Sending input to session ${sessionId}:`, JSON.stringify(text)); + + try { + // Check if this is a special key that should use --send-key + const specialKeys = ['arrow_up', 'arrow_down', 'arrow_left', 'arrow_right', 'escape', 'enter']; + const isSpecialKey = specialKeys.includes(text); + + if (isSpecialKey) { + await executeTtyFwd([ + '--control-path', TTY_FWD_CONTROL_DIR, + '--session', sessionId, + '--send-key', text + ]); + console.log(`Successfully sent key: ${text}`); + } else { + await executeTtyFwd([ + '--control-path', TTY_FWD_CONTROL_DIR, + '--session', sessionId, + '--send-text', text + ]); + console.log(`Successfully sent text: ${text}`); + } + + res.json({ success: true }); + + } catch (error) { + console.error('Error sending input via tty-fwd:', error); + res.status(500).json({ error: 'Failed to send input' }); + } +}); + +// === FILE SYSTEM === + +// Directory listing for file browser +app.get('/api/fs/browse', (req, res) => { + const dirPath = req.query.path as string || '~'; + + try { + const expandedPath = resolvePath(dirPath, '~'); + + if (!fs.existsSync(expandedPath)) { + return res.status(404).json({ error: 'Directory not found' }); + } + + const stats = fs.statSync(expandedPath); + if (!stats.isDirectory()) { + return res.status(400).json({ error: 'Path is not a directory' }); + } + + const files = fs.readdirSync(expandedPath).map(name => { + const filePath = path.join(expandedPath, name); + const fileStats = fs.statSync(filePath); + + return { + name, + created: fileStats.birthtime.toISOString(), + lastModified: fileStats.mtime.toISOString(), + size: fileStats.size, + isDir: fileStats.isDirectory() + }; + }); + + res.json({ + absolutePath: expandedPath, + files: files.sort((a, b) => { + // Directories first, then files + if (a.isDir && !b.isDir) return -1; + if (!a.isDir && b.isDir) return 1; + return a.name.localeCompare(b.name); + }) + }); + + } catch (error) { + console.error('Error listing directory:', error); + res.status(500).json({ error: 'Failed to list directory' }); + } +}); + +// === WEBSOCKETS === + +// WebSocket for hot reload +wss.on('connection', (ws, req) => { + const url = new URL(req.url!, `http://${req.headers.host}`); + const isHotReload = url.searchParams.get('hotReload') === 'true'; + + if (isHotReload) { + hotReloadClients.add(ws); + ws.on('close', () => { + hotReloadClients.delete(ws); + }); + return; + } + + ws.close(1008, 'Only hot reload connections supported'); +}); + +// Hot reload file watching in development +if (process.env.NODE_ENV !== 'production') { + const chokidar = require('chokidar'); + const watcher = chokidar.watch(['public/**/*', 'src/**/*'], { + ignored: /node_modules/, + persistent: true + }); + + watcher.on('change', (path: string) => { + console.log(`File changed: ${path}`); + hotReloadClients.forEach((ws: any) => { + if (ws.readyState === ws.OPEN) { + ws.send(JSON.stringify({ type: 'reload' })); + } + }); + }); +} + +server.listen(PORT, () => { + console.log(`VibeTunnel New Server running on http://localhost:${PORT}`); + console.log(`Using tty-fwd: ${TTY_FWD_PATH}`); +}); \ No newline at end of file