Clean up build system and implement XTerm.js renderer
- Replace TypeScript compilation with esbuild bundling - Organize all generated files in public/bundle/ - Remove PWA features and simplify index.html - Add XTerm.js renderer with same API as custom renderer - Create comprehensive test suite in public/tests/ - Update .gitignore to only track source files 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
23
web/.gitignore
vendored
|
|
@ -4,22 +4,8 @@ npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# Generated shit
|
# Generated files
|
||||||
public/components
|
public/bundle/
|
||||||
public/app-new*
|
|
||||||
|
|
||||||
# Build outputs
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
public/app.js
|
|
||||||
public/app.js.map
|
|
||||||
public/app-new.js
|
|
||||||
public/app-new.js.map
|
|
||||||
public/app-new-entry.js
|
|
||||||
public/app-new-entry.js.map
|
|
||||||
public/components/
|
|
||||||
public/components.js
|
|
||||||
public/output.css
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
@ -117,7 +103,4 @@ tmp/
|
||||||
temp/
|
temp/
|
||||||
|
|
||||||
# Terminal session data (don't commit user sessions)
|
# Terminal session data (don't commit user sessions)
|
||||||
~/.vibetunnel/
|
~/.vibetunnel/
|
||||||
public/*.js
|
|
||||||
public/*.js.map
|
|
||||||
public/*components*
|
|
||||||
28
web/package-lock.json
generated
|
|
@ -9,6 +9,9 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
"asciinema-player": "^3.7.0",
|
"asciinema-player": "^3.7.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"lit": "^3.1.0",
|
"lit": "^3.1.0",
|
||||||
|
|
@ -24,6 +27,7 @@
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"chokidar": "^3.5.3",
|
"chokidar": "^3.5.3",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
|
"esbuild": "^0.25.5",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"postcss": "^8.5.5",
|
"postcss": "^8.5.5",
|
||||||
"puppeteer": "^21.0.0",
|
"puppeteer": "^21.0.0",
|
||||||
|
|
@ -1959,6 +1963,30 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-fit": {
|
||||||
|
"version": "0.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||||
|
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@xterm/xterm": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/addon-web-links": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@xterm/xterm": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/xterm": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,24 @@
|
||||||
"description": "Web frontend for terminal multiplexer",
|
"description": "Web frontend for terminal multiplexer",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run build:css && concurrently --kill-others-on-fail \"npm run watch:css\" \"npm run watch:client\" \"npm run watch:server\"",
|
"dev": "npm run build:css && concurrently --kill-others-on-fail \"npm run watch:css\" \"npm run bundle:watch\" \"npm run watch:server\"",
|
||||||
"watch:server": "tsx watch src/server.ts",
|
"watch:server": "tsx watch src/server.ts",
|
||||||
"watch:client": "tsc -p tsconfig.client.json --watch --preserveWatchOutput",
|
"watch:client": "tsc -p tsconfig.client.json --watch --preserveWatchOutput",
|
||||||
"watch:css": "npx tailwindcss -i ./src/input.css -o ./public/output.css --watch",
|
"watch:css": "npx tailwindcss -i ./src/input.css -o ./public/bundle/output.css --watch",
|
||||||
"build": "npm run build:css && npm run build:client && npm run build:server",
|
"build": "npm run build:css && npm run build:client && npm run build:server",
|
||||||
"build:server": "tsc",
|
"build:server": "tsc",
|
||||||
"build:client": "tsc -p tsconfig.client.json",
|
"build:client": "tsc -p tsconfig.client.json",
|
||||||
"build:css": "npx tailwindcss -i ./src/input.css -o ./public/output.css --minify",
|
"build:css": "npx tailwindcss -i ./src/input.css -o ./public/bundle/output.css --minify",
|
||||||
|
"bundle": "esbuild src/client/app-entry.ts --bundle --outfile=public/bundle/client-bundle.js --format=esm --sourcemap --external:@xterm/xterm/css/xterm.css",
|
||||||
|
"bundle:watch": "esbuild src/client/app-entry.ts --bundle --outfile=public/bundle/client-bundle.js --format=esm --sourcemap --external:@xterm/xterm/css/xterm.css --watch",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch"
|
"test:watch": "jest --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
"asciinema-player": "^3.7.0",
|
"asciinema-player": "^3.7.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"lit": "^3.1.0",
|
"lit": "^3.1.0",
|
||||||
|
|
@ -32,6 +37,7 @@
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"chokidar": "^3.5.3",
|
"chokidar": "^3.5.3",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
|
"esbuild": "^0.25.5",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"postcss": "^8.5.5",
|
"postcss": "^8.5.5",
|
||||||
"puppeteer": "^21.0.0",
|
"puppeteer": "^21.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
// Entry point for the app
|
|
||||||
import './app.js';
|
|
||||||
//# sourceMappingURL=app-entry.js.map
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"app-entry.js","sourceRoot":"","sources":["../src/client/app-entry.ts"],"names":[],"mappings":"AAAA,0BAA0B;AAC1B,OAAO,UAAU,CAAC","sourcesContent":["// Entry point for the app\nimport './app.js';"]}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
// Entry point for the new app
|
|
||||||
import './app-new.js';
|
|
||||||
//# sourceMappingURL=app-new-entry.js.map
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"app-new-entry.js","sourceRoot":"","sources":["../src/client/app-new-entry.ts"],"names":[],"mappings":"AAAA,8BAA8B;AAC9B,OAAO,cAAc,CAAC","sourcesContent":["// Entry point for the new app\nimport './app-new.js';"]}
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
||||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
||||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
||||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
||||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
||||||
};
|
|
||||||
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';
|
|
||||||
let VibeTunnelAppNew = class VibeTunnelAppNew extends LitElement {
|
|
||||||
constructor() {
|
|
||||||
super(...arguments);
|
|
||||||
this.errorMessage = '';
|
|
||||||
this.sessions = [];
|
|
||||||
this.loading = false;
|
|
||||||
this.hotReloadWs = null;
|
|
||||||
}
|
|
||||||
// Disable shadow DOM to use Tailwind
|
|
||||||
createRenderRoot() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
connectedCallback() {
|
|
||||||
super.connectedCallback();
|
|
||||||
this.setupHotReload();
|
|
||||||
this.loadSessions();
|
|
||||||
this.startAutoRefresh();
|
|
||||||
}
|
|
||||||
disconnectedCallback() {
|
|
||||||
super.disconnectedCallback();
|
|
||||||
if (this.hotReloadWs) {
|
|
||||||
this.hotReloadWs.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showError(message) {
|
|
||||||
this.errorMessage = message;
|
|
||||||
// Clear error after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
this.errorMessage = '';
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
clearError() {
|
|
||||||
this.errorMessage = '';
|
|
||||||
}
|
|
||||||
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) => ({
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
startAutoRefresh() {
|
|
||||||
// Refresh sessions every 3 seconds
|
|
||||||
setInterval(() => {
|
|
||||||
this.loadSessions();
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
handleSessionCreated(e) {
|
|
||||||
console.log('Session created:', e.detail);
|
|
||||||
this.showError('Session created successfully!');
|
|
||||||
this.loadSessions(); // Refresh the list
|
|
||||||
}
|
|
||||||
handleSessionSelect(e) {
|
|
||||||
const session = e.detail;
|
|
||||||
console.log('Session selected:', session);
|
|
||||||
this.showError(`Terminal view not implemented yet for session: ${session.id}`);
|
|
||||||
}
|
|
||||||
handleSessionKilled(e) {
|
|
||||||
console.log('Session killed:', e.detail);
|
|
||||||
this.loadSessions(); // Refresh the list
|
|
||||||
}
|
|
||||||
handleRefresh() {
|
|
||||||
this.loadSessions();
|
|
||||||
}
|
|
||||||
handleError(e) {
|
|
||||||
this.showError(e.detail);
|
|
||||||
}
|
|
||||||
setupHotReload() {
|
|
||||||
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 `
|
|
||||||
<div class="max-w-4xl mx-auto">
|
|
||||||
<app-header></app-header>
|
|
||||||
<session-list
|
|
||||||
.sessions=${this.sessions}
|
|
||||||
.loading=${this.loading}
|
|
||||||
@session-select=${this.handleSessionSelect}
|
|
||||||
@session-killed=${this.handleSessionKilled}
|
|
||||||
@session-created=${this.handleSessionCreated}
|
|
||||||
@refresh=${this.handleRefresh}
|
|
||||||
@error=${this.handleError}
|
|
||||||
></session-list>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
__decorate([
|
|
||||||
state()
|
|
||||||
], VibeTunnelAppNew.prototype, "errorMessage", void 0);
|
|
||||||
__decorate([
|
|
||||||
state()
|
|
||||||
], VibeTunnelAppNew.prototype, "sessions", void 0);
|
|
||||||
__decorate([
|
|
||||||
state()
|
|
||||||
], VibeTunnelAppNew.prototype, "loading", void 0);
|
|
||||||
VibeTunnelAppNew = __decorate([
|
|
||||||
customElement('vibetunnel-app-new')
|
|
||||||
], VibeTunnelAppNew);
|
|
||||||
export { VibeTunnelAppNew };
|
|
||||||
//# sourceMappingURL=app-new.js.map
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<browserconfig>
|
|
||||||
<msapplication>
|
|
||||||
<tile>
|
|
||||||
<square150x150logo src="/icons/icon-144x144.png"/>
|
|
||||||
<TileColor>#1e1e1e</TileColor>
|
|
||||||
</tile>
|
|
||||||
</msapplication>
|
|
||||||
</browserconfig>
|
|
||||||
|
|
@ -1,538 +0,0 @@
|
||||||
import { LitElement, html, css } from 'https://unpkg.com/lit@latest/index.js?module';
|
|
||||||
|
|
||||||
// Header component with ASCII art
|
|
||||||
class VibeHeader extends LitElement {
|
|
||||||
static styles = css`
|
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
color: var(--terminal-green);
|
|
||||||
margin: 1em 0;
|
|
||||||
}
|
|
||||||
.ascii-art {
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1em;
|
|
||||||
white-space: pre;
|
|
||||||
margin: 1em 0;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-size: 24px;
|
|
||||||
margin: 1em 0;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return html`
|
|
||||||
<div class="ascii-art">
|
|
||||||
██╗ ██╗██╗██████╗ ███████╗ ████████╗██╗ ██╗███╗ ███╗███╗ ██╗███████╗██╗
|
|
||||||
██║ ██║██║██╔══██╗██╔════╝ ╚══██╔══╝██║ ██║████╗ ████║████╗ ██║██╔════╝██║
|
|
||||||
██║ ██║██║██████╔╝█████╗ ██║ ██║ ██║██╔████╔██║██╔██╗ ██║█████╗ ██║
|
|
||||||
╚██╗ ██╔╝██║██╔══██╗██╔══╝ ██║ ██║ ██║██║╚██╔╝██║██║╚██╗██║██╔══╝ ██║
|
|
||||||
╚████╔╝ ██║██████╔╝███████╗ ██║ ╚██████╔╝██║ ╚═╝ ██║██║ ╚████║███████╗███████╗
|
|
||||||
╚═══╝ ╚═╝╚═════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝
|
|
||||||
</div>
|
|
||||||
<p>Terminal Multiplexer Web Interface</p>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session card component
|
|
||||||
class SessionCard extends LitElement {
|
|
||||||
static properties = {
|
|
||||||
session: { type: Object }
|
|
||||||
};
|
|
||||||
|
|
||||||
static styles = css`
|
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
border: 1px solid var(--terminal-gray);
|
|
||||||
background: var(--terminal-bg);
|
|
||||||
padding: 1em;
|
|
||||||
cursor: pointer;
|
|
||||||
height: 20em;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.card:hover {
|
|
||||||
border-color: var(--terminal-green);
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
color: var(--terminal-cyan);
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
.status {
|
|
||||||
color: var(--terminal-yellow);
|
|
||||||
}
|
|
||||||
.status.running {
|
|
||||||
color: var(--terminal-green);
|
|
||||||
}
|
|
||||||
.preview {
|
|
||||||
flex: 1;
|
|
||||||
border: 1px solid var(--terminal-gray);
|
|
||||||
min-height: 12em;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.preview-content {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
firstUpdated() {
|
|
||||||
this.renderPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
updated(changedProperties) {
|
|
||||||
if (changedProperties.has('session')) {
|
|
||||||
this.renderPreview();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderPreview() {
|
|
||||||
const previewEl = this.shadowRoot.querySelector('.preview-content');
|
|
||||||
if (!previewEl || !this.session?.lastOutput) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const lines = this.session.lastOutput.trim().split('\n');
|
|
||||||
if (lines.length > 1) {
|
|
||||||
// Parse asciinema format
|
|
||||||
const castData = [];
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
|
||||||
if (lines[i].trim()) {
|
|
||||||
try {
|
|
||||||
castData.push(JSON.parse(lines[i]));
|
|
||||||
} catch (e) {
|
|
||||||
// Skip invalid lines
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (castData.length > 0) {
|
|
||||||
const cast = {
|
|
||||||
version: 2,
|
|
||||||
width: 80,
|
|
||||||
height: 24,
|
|
||||||
timestamp: Math.floor(Date.now() / 1000)
|
|
||||||
};
|
|
||||||
|
|
||||||
AsciinemaPlayer.create({
|
|
||||||
data: castData,
|
|
||||||
...cast
|
|
||||||
}, previewEl, {
|
|
||||||
theme: 'asciinema',
|
|
||||||
loop: false,
|
|
||||||
autoPlay: false,
|
|
||||||
controls: false,
|
|
||||||
fit: 'width'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
previewEl.innerHTML = '<p style="color: var(--terminal-gray); padding: 1em;">No preview available</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClick() {
|
|
||||||
this.dispatchEvent(new CustomEvent('session-select', {
|
|
||||||
detail: { sessionId: this.session.id },
|
|
||||||
bubbles: true
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (!this.session) return html``;
|
|
||||||
|
|
||||||
const command = this.session.metadata?.cmdline?.join(' ') || 'Unknown';
|
|
||||||
const status = this.session.status || 'unknown';
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="card" @click="${this.handleClick}">
|
|
||||||
<div class="header">
|
|
||||||
${command}
|
|
||||||
</div>
|
|
||||||
<div class="status ${status}">
|
|
||||||
Status: ${status}
|
|
||||||
</div>
|
|
||||||
<div>ID: ${this.session.id.substring(0, 8)}...</div>
|
|
||||||
<div class="preview">
|
|
||||||
<div class="preview-content"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session overview component
|
|
||||||
class SessionOverview extends LitElement {
|
|
||||||
static properties = {
|
|
||||||
sessions: { type: Array }
|
|
||||||
};
|
|
||||||
|
|
||||||
static styles = css`
|
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
}
|
|
||||||
.controls {
|
|
||||||
margin: 1em 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 1em;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.form {
|
|
||||||
border: 1px solid var(--terminal-gray);
|
|
||||||
padding: 1em;
|
|
||||||
margin: 1em 0;
|
|
||||||
}
|
|
||||||
.form-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 1em;
|
|
||||||
margin: 1em 0;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(40ch, 1fr));
|
|
||||||
gap: 1em;
|
|
||||||
margin: 1em 0;
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
background: var(--terminal-bg);
|
|
||||||
color: var(--terminal-fg);
|
|
||||||
border: 1px solid var(--terminal-gray);
|
|
||||||
padding: 0 1ch;
|
|
||||||
height: 2em;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
background: var(--terminal-gray);
|
|
||||||
color: var(--terminal-bg);
|
|
||||||
border: 1px solid var(--terminal-gray);
|
|
||||||
padding: 0 1ch;
|
|
||||||
height: 2em;
|
|
||||||
cursor: pointer;
|
|
||||||
min-width: 8ch;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
background: var(--terminal-fg);
|
|
||||||
}
|
|
||||||
button.primary {
|
|
||||||
background: var(--terminal-green);
|
|
||||||
border-color: var(--terminal-green);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.sessions = [];
|
|
||||||
this.loadSessions();
|
|
||||||
this.refreshInterval = setInterval(() => this.loadSessions(), 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
super.disconnectedCallback();
|
|
||||||
if (this.refreshInterval) {
|
|
||||||
clearInterval(this.refreshInterval);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadSessions() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/sessions');
|
|
||||||
const data = await response.json();
|
|
||||||
this.sessions = data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load sessions:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async createSession(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const formData = new FormData(event.target);
|
|
||||||
const command = formData.get('command').trim().split(' ');
|
|
||||||
const workingDir = formData.get('workingDir').trim();
|
|
||||||
|
|
||||||
if (!command[0]) {
|
|
||||||
alert('Command is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/sessions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
command,
|
|
||||||
workingDir: workingDir || undefined
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
event.target.reset();
|
|
||||||
this.shadowRoot.querySelector('input[name="workingDir"]').value = '~/';
|
|
||||||
setTimeout(() => this.loadSessions(), 1000);
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
alert(`Failed to create session: ${error.error}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating session:', error);
|
|
||||||
alert('Failed to create session');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return html`
|
|
||||||
<h2>Session Overview</h2>
|
|
||||||
|
|
||||||
<div class="form">
|
|
||||||
<h3>Create New Session</h3>
|
|
||||||
<form @submit="${this.createSession}">
|
|
||||||
<div class="form-row">
|
|
||||||
<label>Working Directory:</label>
|
|
||||||
<input name="workingDir" type="text" value="~/" placeholder="~/projects/my-app" style="flex: 1;">
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label>Command:</label>
|
|
||||||
<input name="command" type="text" placeholder="bash" required style="flex: 1;">
|
|
||||||
<button type="submit" class="primary">Create</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<h3>Active Sessions (${this.sessions.length})</h3>
|
|
||||||
<button @click="${this.loadSessions}">Refresh</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
${this.sessions.map(session => html`
|
|
||||||
<session-card .session="${session}"></session-card>
|
|
||||||
`)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${this.sessions.length === 0 ? html`
|
|
||||||
<p style="color: var(--terminal-gray); text-align: center; margin: 2em;">
|
|
||||||
No active sessions. Create one above to get started.
|
|
||||||
</p>
|
|
||||||
` : ''}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session detail component
|
|
||||||
class SessionDetail extends LitElement {
|
|
||||||
static properties = {
|
|
||||||
sessionId: { type: String },
|
|
||||||
session: { type: Object }
|
|
||||||
};
|
|
||||||
|
|
||||||
static styles = css`
|
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin: 1em 0;
|
|
||||||
}
|
|
||||||
.terminal {
|
|
||||||
border: 1px solid var(--terminal-gray);
|
|
||||||
min-height: 30em;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.input-area {
|
|
||||||
display: flex;
|
|
||||||
gap: 1em;
|
|
||||||
margin: 1em 0;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
background: var(--terminal-bg);
|
|
||||||
color: var(--terminal-fg);
|
|
||||||
border: 1px solid var(--terminal-gray);
|
|
||||||
padding: 0 1ch;
|
|
||||||
height: 2em;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
background: var(--terminal-gray);
|
|
||||||
color: var(--terminal-bg);
|
|
||||||
border: 1px solid var(--terminal-gray);
|
|
||||||
padding: 0 1ch;
|
|
||||||
height: 2em;
|
|
||||||
cursor: pointer;
|
|
||||||
min-width: 8ch;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
background: var(--terminal-fg);
|
|
||||||
}
|
|
||||||
button.primary {
|
|
||||||
background: var(--terminal-green);
|
|
||||||
border-color: var(--terminal-green);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.sessionId = null;
|
|
||||||
this.session = null;
|
|
||||||
this.websocket = null;
|
|
||||||
this.player = null;
|
|
||||||
this.castData = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
updated(changedProperties) {
|
|
||||||
if (changedProperties.has('sessionId') && this.sessionId) {
|
|
||||||
this.loadSession();
|
|
||||||
this.connectWebSocket();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
super.disconnectedCallback();
|
|
||||||
this.disconnectWebSocket();
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadSession() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/sessions');
|
|
||||||
const sessions = await response.json();
|
|
||||||
this.session = sessions.find(s => s.id === this.sessionId);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load session:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
connectWebSocket() {
|
|
||||||
this.disconnectWebSocket();
|
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}?session=${this.sessionId}`;
|
|
||||||
|
|
||||||
this.websocket = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
this.websocket.onopen = () => {
|
|
||||||
console.log(`Connected to session ${this.sessionId}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.websocket.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const castEvent = JSON.parse(event.data);
|
|
||||||
this.castData.push(castEvent);
|
|
||||||
this.updatePlayer();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing WebSocket message:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.websocket.onclose = () => {
|
|
||||||
console.log(`Disconnected from session ${this.sessionId}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.websocket.onerror = (error) => {
|
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectWebSocket() {
|
|
||||||
if (this.websocket) {
|
|
||||||
this.websocket.close();
|
|
||||||
this.websocket = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePlayer() {
|
|
||||||
const terminalEl = this.shadowRoot.querySelector('.terminal');
|
|
||||||
if (!terminalEl || this.castData.length === 0) return;
|
|
||||||
|
|
||||||
terminalEl.innerHTML = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cast = {
|
|
||||||
version: 2,
|
|
||||||
width: 80,
|
|
||||||
height: 24,
|
|
||||||
timestamp: Math.floor(Date.now() / 1000)
|
|
||||||
};
|
|
||||||
|
|
||||||
this.player = AsciinemaPlayer.create({
|
|
||||||
data: this.castData,
|
|
||||||
...cast
|
|
||||||
}, terminalEl, {
|
|
||||||
theme: 'asciinema',
|
|
||||||
loop: false,
|
|
||||||
autoPlay: true,
|
|
||||||
controls: false,
|
|
||||||
fit: 'width'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating player:', error);
|
|
||||||
terminalEl.innerHTML = '<p style="color: var(--terminal-red); padding: 1em;">Error loading terminal</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendInput(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const input = this.shadowRoot.querySelector('input[name="input"]');
|
|
||||||
const value = input.value.trim();
|
|
||||||
|
|
||||||
if (!value || !this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.websocket.send(JSON.stringify({
|
|
||||||
type: 'input',
|
|
||||||
data: value
|
|
||||||
}));
|
|
||||||
|
|
||||||
input.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
goBack() {
|
|
||||||
this.dispatchEvent(new CustomEvent('go-back', { bubbles: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (!this.session) {
|
|
||||||
return html`<p>Loading session...</p>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const command = this.session.metadata?.cmdline?.join(' ') || 'Unknown';
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="header">
|
|
||||||
<button @click="${this.goBack}">← Back to Sessions</button>
|
|
||||||
<h2>${command}</h2>
|
|
||||||
<span style="color: var(--terminal-gray);">ID: ${this.sessionId.substring(0, 8)}...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="terminal"></div>
|
|
||||||
|
|
||||||
<form @submit="${this.sendInput}" class="input-area">
|
|
||||||
<span style="color: var(--terminal-green);">$</span>
|
|
||||||
<input name="input" type="text" placeholder="Enter command..." autofocus>
|
|
||||||
<button type="submit" class="primary">Send</button>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register components
|
|
||||||
customElements.define('vibe-header', VibeHeader);
|
|
||||||
customElements.define('session-card', SessionCard);
|
|
||||||
customElements.define('session-overview', SessionOverview);
|
|
||||||
customElements.define('session-detail', SessionDetail);
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
||||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
||||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
||||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
||||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
||||||
};
|
|
||||||
import { LitElement, html } from 'lit';
|
|
||||||
import { customElement } from 'lit/decorators.js';
|
|
||||||
let AppHeader = class AppHeader extends LitElement {
|
|
||||||
createRenderRoot() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
handleCreateSession() {
|
|
||||||
this.dispatchEvent(new CustomEvent('create-session'));
|
|
||||||
}
|
|
||||||
render() {
|
|
||||||
return html `
|
|
||||||
<div class="p-4 border-b border-vs-border">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="text-vs-user font-mono text-sm">VibeTunnel</div>
|
|
||||||
<button
|
|
||||||
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none rounded transition-colors text-sm"
|
|
||||||
@click=${this.handleCreateSession}
|
|
||||||
>
|
|
||||||
CREATE SESSION
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
AppHeader = __decorate([
|
|
||||||
customElement('app-header')
|
|
||||||
], AppHeader);
|
|
||||||
export { AppHeader };
|
|
||||||
//# sourceMappingURL=app-header.js.map
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"app-header.js","sourceRoot":"","sources":["../../src/client/components/app-header.ts"],"names":[],"mappings":";;;;;;AAAA,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,KAAK,CAAC;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAG3C,IAAM,SAAS,GAAf,MAAM,SAAU,SAAQ,UAAU;IACvC,gBAAgB;QACd,OAAO,IAAI,CAAC;IACd,CAAC;IAEO,mBAAmB;QACzB,IAAI,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,gBAAgB,CAAC,CAAC,CAAC;IACxD,CAAC;IAED,MAAM;QACJ,OAAO,IAAI,CAAA;;;;;;qBAMM,IAAI,CAAC,mBAAmB;;;;;;KAMxC,CAAC;IACJ,CAAC;CACF,CAAA;AAxBY,SAAS;IADrB,aAAa,CAAC,YAAY,CAAC;GACf,SAAS,CAwBrB","sourcesContent":["import { LitElement, html } from 'lit';\nimport { customElement } from 'lit/decorators.js';\n\n@customElement('app-header')\nexport class AppHeader extends LitElement {\n createRenderRoot() {\n return this;\n }\n\n private handleCreateSession() {\n this.dispatchEvent(new CustomEvent('create-session'));\n }\n\n render() {\n return html`\n <div class=\"p-4 border-b border-vs-border\">\n <div class=\"flex items-center justify-between\">\n <div class=\"text-vs-user font-mono text-sm\">VibeTunnel</div>\n <button\n class=\"bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none rounded transition-colors text-sm\"\n @click=${this.handleCreateSession}\n >\n CREATE SESSION\n </button>\n </div>\n </div>\n `;\n }\n}"]}
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
||||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
||||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
||||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
||||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
||||||
};
|
|
||||||
import { LitElement, html } from 'lit';
|
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
|
||||||
let FileBrowser = class FileBrowser extends LitElement {
|
|
||||||
constructor() {
|
|
||||||
super(...arguments);
|
|
||||||
this.currentPath = '~';
|
|
||||||
this.visible = false;
|
|
||||||
this.files = [];
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
// Disable shadow DOM to use Tailwind
|
|
||||||
createRenderRoot() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
async connectedCallback() {
|
|
||||||
super.connectedCallback();
|
|
||||||
if (this.visible) {
|
|
||||||
await this.loadDirectory(this.currentPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async updated(changedProperties) {
|
|
||||||
if (changedProperties.has('visible') && this.visible) {
|
|
||||||
await this.loadDirectory(this.currentPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async loadDirectory(dirPath) {
|
|
||||||
this.loading = true;
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/fs/browse?path=${encodeURIComponent(dirPath)}`);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
this.currentPath = data.absolutePath;
|
|
||||||
this.files = data.files;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
console.error('Failed to load directory');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Error loading directory:', error);
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handleDirectoryClick(dirName) {
|
|
||||||
const newPath = this.currentPath + '/' + dirName;
|
|
||||||
this.loadDirectory(newPath);
|
|
||||||
}
|
|
||||||
handleParentClick() {
|
|
||||||
const parentPath = this.currentPath.split('/').slice(0, -1).join('/') || '/';
|
|
||||||
this.loadDirectory(parentPath);
|
|
||||||
}
|
|
||||||
handleSelect() {
|
|
||||||
this.dispatchEvent(new CustomEvent('directory-selected', {
|
|
||||||
detail: this.currentPath
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
handleCancel() {
|
|
||||||
this.dispatchEvent(new CustomEvent('browser-cancel'));
|
|
||||||
}
|
|
||||||
render() {
|
|
||||||
if (!this.visible) {
|
|
||||||
return html ``;
|
|
||||||
}
|
|
||||||
return html `
|
|
||||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style="z-index: 9999;">
|
|
||||||
<div class="bg-vs-bg-secondary border border-vs-border font-mono text-sm w-96 h-96 flex flex-col">
|
|
||||||
<div class="p-4 border-b border-vs-border flex-shrink-0">
|
|
||||||
<div class="text-vs-assistant text-sm mb-2">Select Directory</div>
|
|
||||||
<div class="text-vs-muted text-sm break-all">${this.currentPath}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 flex-1 overflow-y-auto">
|
|
||||||
${this.loading ? html `
|
|
||||||
<div class="text-vs-muted">Loading...</div>
|
|
||||||
` : html `
|
|
||||||
${this.currentPath !== '/' ? html `
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 p-2 hover:bg-vs-nav-hover cursor-pointer text-vs-accent"
|
|
||||||
@click=${this.handleParentClick}
|
|
||||||
>
|
|
||||||
<span>📁</span>
|
|
||||||
<span>.. (parent directory)</span>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
${this.files.filter(f => f.isDir).map(file => html `
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 p-2 hover:bg-vs-nav-hover cursor-pointer text-vs-accent"
|
|
||||||
@click=${() => this.handleDirectoryClick(file.name)}
|
|
||||||
>
|
|
||||||
<span>📁</span>
|
|
||||||
<span>${file.name}</span>
|
|
||||||
</div>
|
|
||||||
`)}
|
|
||||||
|
|
||||||
${this.files.filter(f => !f.isDir).map(file => html `
|
|
||||||
<div class="flex items-center gap-2 p-2 text-vs-muted">
|
|
||||||
<span>📄</span>
|
|
||||||
<span>${file.name}</span>
|
|
||||||
</div>
|
|
||||||
`)}
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 border-t border-vs-border flex gap-4 justify-end flex-shrink-0">
|
|
||||||
<button
|
|
||||||
class="bg-vs-muted text-vs-bg hover:bg-vs-text font-mono px-4 py-2 border-none"
|
|
||||||
@click=${this.handleCancel}
|
|
||||||
>
|
|
||||||
cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="bg-vs-user text-vs-bg hover:bg-vs-accent font-mono px-4 py-2 border-none"
|
|
||||||
@click=${this.handleSelect}
|
|
||||||
>
|
|
||||||
select
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
__decorate([
|
|
||||||
property({ type: String })
|
|
||||||
], FileBrowser.prototype, "currentPath", void 0);
|
|
||||||
__decorate([
|
|
||||||
property({ type: Boolean })
|
|
||||||
], FileBrowser.prototype, "visible", void 0);
|
|
||||||
__decorate([
|
|
||||||
state()
|
|
||||||
], FileBrowser.prototype, "files", void 0);
|
|
||||||
__decorate([
|
|
||||||
state()
|
|
||||||
], FileBrowser.prototype, "loading", void 0);
|
|
||||||
FileBrowser = __decorate([
|
|
||||||
customElement('file-browser')
|
|
||||||
], FileBrowser);
|
|
||||||
export { FileBrowser };
|
|
||||||
//# sourceMappingURL=file-browser.js.map
|
|
||||||
|
|
@ -1,223 +0,0 @@
|
||||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
||||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
||||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
||||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
||||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
||||||
};
|
|
||||||
import { LitElement, html } from 'lit';
|
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
|
||||||
import './file-browser.js';
|
|
||||||
let SessionCreateForm = class SessionCreateForm extends LitElement {
|
|
||||||
constructor() {
|
|
||||||
super(...arguments);
|
|
||||||
this.workingDir = '~/';
|
|
||||||
this.command = 'zsh';
|
|
||||||
this.disabled = false;
|
|
||||||
this.visible = false;
|
|
||||||
this.isCreating = false;
|
|
||||||
this.showFileBrowser = false;
|
|
||||||
}
|
|
||||||
// Disable shadow DOM to use Tailwind
|
|
||||||
createRenderRoot() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
handleWorkingDirChange(e) {
|
|
||||||
const input = e.target;
|
|
||||||
this.workingDir = input.value;
|
|
||||||
this.dispatchEvent(new CustomEvent('working-dir-change', {
|
|
||||||
detail: this.workingDir
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
handleCommandChange(e) {
|
|
||||||
const input = e.target;
|
|
||||||
this.command = input.value;
|
|
||||||
}
|
|
||||||
handleBrowse() {
|
|
||||||
this.showFileBrowser = true;
|
|
||||||
}
|
|
||||||
handleDirectorySelected(e) {
|
|
||||||
this.workingDir = e.detail;
|
|
||||||
this.showFileBrowser = false;
|
|
||||||
}
|
|
||||||
handleBrowserCancel() {
|
|
||||||
this.showFileBrowser = false;
|
|
||||||
}
|
|
||||||
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 = {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parseCommand(commandStr) {
|
|
||||||
// Simple command parsing - split by spaces but respect quotes
|
|
||||||
const args = [];
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
handleCancel() {
|
|
||||||
this.dispatchEvent(new CustomEvent('cancel'));
|
|
||||||
}
|
|
||||||
render() {
|
|
||||||
if (!this.visible) {
|
|
||||||
return html ``;
|
|
||||||
}
|
|
||||||
return html `
|
|
||||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style="z-index: 9999;">
|
|
||||||
<div class="bg-vs-bg-secondary border border-vs-border font-mono text-sm w-96 max-w-full mx-4">
|
|
||||||
<div class="p-4 border-b border-vs-border flex justify-between items-center">
|
|
||||||
<div class="text-vs-assistant text-sm">Create New Session</div>
|
|
||||||
<button
|
|
||||||
class="text-vs-muted hover:text-vs-text text-lg leading-none border-none bg-transparent cursor-pointer"
|
|
||||||
@click=${this.handleCancel}
|
|
||||||
>×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4">
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="text-vs-text mb-2">Working Directory:</div>
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="flex-1 bg-vs-bg text-vs-text border border-vs-border outline-none font-mono px-4 py-2"
|
|
||||||
.value=${this.workingDir}
|
|
||||||
@input=${this.handleWorkingDirChange}
|
|
||||||
placeholder="~/"
|
|
||||||
?disabled=${this.disabled || this.isCreating}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-4 py-2 border-none"
|
|
||||||
@click=${this.handleBrowse}
|
|
||||||
?disabled=${this.disabled || this.isCreating}
|
|
||||||
>
|
|
||||||
browse
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="text-vs-text mb-2">Command:</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="w-full bg-vs-bg text-vs-text border border-vs-border outline-none font-mono px-4 py-2"
|
|
||||||
.value=${this.command}
|
|
||||||
@input=${this.handleCommandChange}
|
|
||||||
@keydown=${(e) => e.key === 'Enter' && this.handleCreate()}
|
|
||||||
placeholder="zsh"
|
|
||||||
?disabled=${this.disabled || this.isCreating}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-4 justify-end">
|
|
||||||
<button
|
|
||||||
class="bg-vs-muted text-vs-bg hover:bg-vs-text font-mono px-4 py-2 border-none"
|
|
||||||
@click=${this.handleCancel}
|
|
||||||
?disabled=${this.isCreating}
|
|
||||||
>
|
|
||||||
cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-vs-user"
|
|
||||||
@click=${this.handleCreate}
|
|
||||||
?disabled=${this.disabled || this.isCreating || !this.workingDir.trim() || !this.command.trim()}
|
|
||||||
>
|
|
||||||
${this.isCreating ? 'creating...' : 'create'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<file-browser
|
|
||||||
.visible=${this.showFileBrowser}
|
|
||||||
.currentPath=${this.workingDir}
|
|
||||||
@directory-selected=${this.handleDirectorySelected}
|
|
||||||
@browser-cancel=${this.handleBrowserCancel}
|
|
||||||
></file-browser>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
__decorate([
|
|
||||||
property({ type: String })
|
|
||||||
], SessionCreateForm.prototype, "workingDir", void 0);
|
|
||||||
__decorate([
|
|
||||||
property({ type: String })
|
|
||||||
], SessionCreateForm.prototype, "command", void 0);
|
|
||||||
__decorate([
|
|
||||||
property({ type: Boolean })
|
|
||||||
], SessionCreateForm.prototype, "disabled", void 0);
|
|
||||||
__decorate([
|
|
||||||
property({ type: Boolean })
|
|
||||||
], SessionCreateForm.prototype, "visible", void 0);
|
|
||||||
__decorate([
|
|
||||||
state()
|
|
||||||
], SessionCreateForm.prototype, "isCreating", void 0);
|
|
||||||
__decorate([
|
|
||||||
state()
|
|
||||||
], SessionCreateForm.prototype, "showFileBrowser", void 0);
|
|
||||||
SessionCreateForm = __decorate([
|
|
||||||
customElement('session-create-form')
|
|
||||||
], SessionCreateForm);
|
|
||||||
export { SessionCreateForm };
|
|
||||||
//# sourceMappingURL=session-create-form.js.map
|
|
||||||
|
|
@ -1,422 +0,0 @@
|
||||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
||||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
||||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
||||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
||||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
||||||
};
|
|
||||||
import { LitElement, html } from 'lit';
|
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
|
||||||
import './session-create-form.js';
|
|
||||||
let SessionList = class SessionList extends LitElement {
|
|
||||||
constructor() {
|
|
||||||
super(...arguments);
|
|
||||||
this.sessions = [];
|
|
||||||
this.loading = false;
|
|
||||||
this.hideExited = true;
|
|
||||||
this.showCreateModal = false;
|
|
||||||
this.killingSessionIds = new Set();
|
|
||||||
this.loadedSnapshots = new Map();
|
|
||||||
this.loadingSnapshots = new Set();
|
|
||||||
this.cleaningExited = false;
|
|
||||||
this.newSessionIds = new Set();
|
|
||||||
}
|
|
||||||
// Disable shadow DOM to use Tailwind
|
|
||||||
createRenderRoot() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
handleRefresh() {
|
|
||||||
this.dispatchEvent(new CustomEvent('refresh'));
|
|
||||||
}
|
|
||||||
async loadSnapshot(sessionId) {
|
|
||||||
if (this.loadedSnapshots.has(sessionId) || this.loadingSnapshots.has(sessionId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.loadingSnapshots.add(sessionId);
|
|
||||||
this.requestUpdate();
|
|
||||||
try {
|
|
||||||
// Just mark as loaded and create the player with the endpoint URL
|
|
||||||
this.loadedSnapshots.set(sessionId, sessionId);
|
|
||||||
this.requestUpdate();
|
|
||||||
// Create asciinema player after the element is rendered
|
|
||||||
setTimeout(() => this.createPlayer(sessionId), 10);
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Error loading snapshot:', error);
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
this.loadingSnapshots.delete(sessionId);
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadAllSnapshots() {
|
|
||||||
this.sessions.forEach(session => {
|
|
||||||
this.loadSnapshot(session.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
updated(changedProperties) {
|
|
||||||
super.updated(changedProperties);
|
|
||||||
if (changedProperties.has('sessions')) {
|
|
||||||
// Auto-load snapshots for existing sessions immediately, but delay for new ones
|
|
||||||
const prevSessions = changedProperties.get('sessions') || [];
|
|
||||||
const newSessionIdsList = this.sessions
|
|
||||||
.filter(session => !prevSessions.find((prev) => prev.id === session.id))
|
|
||||||
.map(session => session.id);
|
|
||||||
// Track new sessions
|
|
||||||
newSessionIdsList.forEach(id => this.newSessionIds.add(id));
|
|
||||||
// Load existing sessions immediately
|
|
||||||
const existingSessions = this.sessions.filter(session => !newSessionIdsList.includes(session.id));
|
|
||||||
existingSessions.forEach(session => this.loadSnapshot(session.id));
|
|
||||||
// Load new sessions after a delay to let them generate some output
|
|
||||||
if (newSessionIdsList.length > 0) {
|
|
||||||
setTimeout(() => {
|
|
||||||
newSessionIdsList.forEach(sessionId => {
|
|
||||||
this.newSessionIds.delete(sessionId); // Remove from new sessions set
|
|
||||||
this.loadSnapshot(sessionId);
|
|
||||||
});
|
|
||||||
this.requestUpdate(); // Update UI to show the players
|
|
||||||
}, 500); // Wait 500ms for new sessions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If hideExited changed, recreate players for newly visible sessions
|
|
||||||
if (changedProperties.has('hideExited')) {
|
|
||||||
// Use a slight delay to avoid blocking the checkbox click
|
|
||||||
setTimeout(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
this.filteredSessions.forEach(session => {
|
|
||||||
const playerElement = this.querySelector(`#player-${session.id}`);
|
|
||||||
if (playerElement && this.loadedSnapshots.has(session.id)) {
|
|
||||||
// Player element exists but might not have a player instance
|
|
||||||
// Check if it's empty and recreate if needed
|
|
||||||
if (!playerElement.hasChildNodes() || playerElement.children.length === 0) {
|
|
||||||
this.createPlayer(session.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
createPlayer(sessionId) {
|
|
||||||
const playerElement = this.querySelector(`#player-${sessionId}`);
|
|
||||||
if (!playerElement) {
|
|
||||||
// Element not ready yet, retry on next frame
|
|
||||||
requestAnimationFrame(() => this.createPlayer(sessionId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (window.AsciinemaPlayer) {
|
|
||||||
try {
|
|
||||||
// Find the session to check its status
|
|
||||||
const session = this.sessions.find(s => s.id === sessionId);
|
|
||||||
// For ended sessions, use snapshot instead of stream to avoid reloading
|
|
||||||
const url = session?.status === 'exited'
|
|
||||||
? `/api/sessions/${sessionId}/snapshot`
|
|
||||||
: `/api/sessions/${sessionId}/stream`;
|
|
||||||
const config = session?.status === 'exited'
|
|
||||||
? { url } // Static snapshot
|
|
||||||
: { driver: "eventsource", url }; // Live stream
|
|
||||||
window.AsciinemaPlayer.create(config, playerElement, {
|
|
||||||
autoPlay: true,
|
|
||||||
loop: false,
|
|
||||||
controls: false,
|
|
||||||
fit: 'width',
|
|
||||||
terminalFontSize: '8px',
|
|
||||||
idleTimeLimit: 0.5,
|
|
||||||
preload: true,
|
|
||||||
poster: 'npt:999999'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Error creating asciinema player:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handleSessionClick(session) {
|
|
||||||
this.dispatchEvent(new CustomEvent('session-select', {
|
|
||||||
detail: session
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
async handleKillSession(e, sessionId) {
|
|
||||||
e.stopPropagation(); // Prevent session selection
|
|
||||||
if (!confirm('Are you sure you want to kill this session?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.killingSessionIds.add(sessionId);
|
|
||||||
this.requestUpdate();
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/sessions/${sessionId}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
this.dispatchEvent(new CustomEvent('session-killed', {
|
|
||||||
detail: { sessionId }
|
|
||||||
}));
|
|
||||||
// Refresh the list after a short delay
|
|
||||||
setTimeout(() => {
|
|
||||||
this.handleRefresh();
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const error = await response.json();
|
|
||||||
this.dispatchEvent(new CustomEvent('error', {
|
|
||||||
detail: `Failed to kill session: ${error.error}`
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Error killing session:', error);
|
|
||||||
this.dispatchEvent(new CustomEvent('error', {
|
|
||||||
detail: 'Failed to kill session'
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
this.killingSessionIds.delete(sessionId);
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async handleCleanSession(e, sessionId) {
|
|
||||||
e.stopPropagation(); // Prevent session selection
|
|
||||||
if (!confirm('Are you sure you want to clean up this session?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.killingSessionIds.add(sessionId);
|
|
||||||
this.requestUpdate();
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/sessions/${sessionId}/cleanup`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
this.dispatchEvent(new CustomEvent('session-killed', {
|
|
||||||
detail: { sessionId }
|
|
||||||
}));
|
|
||||||
// Refresh the list after a short delay
|
|
||||||
setTimeout(() => {
|
|
||||||
this.handleRefresh();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const error = await response.json();
|
|
||||||
this.dispatchEvent(new CustomEvent('error', {
|
|
||||||
detail: `Failed to clean session: ${error.error}`
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Error cleaning session:', error);
|
|
||||||
this.dispatchEvent(new CustomEvent('error', {
|
|
||||||
detail: 'Failed to clean session'
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
this.killingSessionIds.delete(sessionId);
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
formatTime(timestamp) {
|
|
||||||
try {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
return date.toLocaleTimeString();
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return 'Unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
truncateId(id) {
|
|
||||||
return id.length > 8 ? `${id.substring(0, 8)}...` : id;
|
|
||||||
}
|
|
||||||
handleSessionCreated(e) {
|
|
||||||
this.dispatchEvent(new CustomEvent('session-created', {
|
|
||||||
detail: e.detail
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
handleCreateError(e) {
|
|
||||||
this.dispatchEvent(new CustomEvent('error', {
|
|
||||||
detail: e.detail
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
handleCreateModalClose() {
|
|
||||||
this.dispatchEvent(new CustomEvent('create-modal-close'));
|
|
||||||
}
|
|
||||||
async handleCleanExited() {
|
|
||||||
const exitedSessions = this.sessions.filter(session => session.status === 'exited');
|
|
||||||
if (exitedSessions.length === 0) {
|
|
||||||
this.dispatchEvent(new CustomEvent('error', {
|
|
||||||
detail: 'No exited sessions to clean'
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!confirm(`Are you sure you want to delete ${exitedSessions.length} exited session${exitedSessions.length > 1 ? 's' : ''}?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.cleaningExited = true;
|
|
||||||
this.requestUpdate();
|
|
||||||
try {
|
|
||||||
// Use the bulk cleanup API endpoint
|
|
||||||
const response = await fetch('/api/cleanup-exited', {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to cleanup exited sessions');
|
|
||||||
}
|
|
||||||
this.dispatchEvent(new CustomEvent('error', {
|
|
||||||
detail: `Successfully cleaned ${exitedSessions.length} exited session${exitedSessions.length > 1 ? 's' : ''}`
|
|
||||||
}));
|
|
||||||
// Refresh the list after cleanup
|
|
||||||
setTimeout(() => {
|
|
||||||
this.handleRefresh();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Error cleaning exited sessions:', error);
|
|
||||||
this.dispatchEvent(new CustomEvent('error', {
|
|
||||||
detail: 'Failed to clean exited sessions'
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
this.cleaningExited = false;
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handleHideExitedChange(e) {
|
|
||||||
const checked = e.target.checked;
|
|
||||||
this.dispatchEvent(new CustomEvent('hide-exited-change', {
|
|
||||||
detail: checked
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
get filteredSessions() {
|
|
||||||
return this.hideExited
|
|
||||||
? this.sessions.filter(session => session.status === 'running')
|
|
||||||
: this.sessions;
|
|
||||||
}
|
|
||||||
render() {
|
|
||||||
const sessionsToShow = this.filteredSessions;
|
|
||||||
return html `
|
|
||||||
<div class="font-mono text-sm p-4">
|
|
||||||
<!-- Controls -->
|
|
||||||
<div class="mb-4 flex items-center justify-between">
|
|
||||||
${!this.hideExited ? html `
|
|
||||||
<button
|
|
||||||
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-4 py-2 border-none rounded transition-colors disabled:opacity-50"
|
|
||||||
@click=${this.handleCleanExited}
|
|
||||||
?disabled=${this.cleaningExited || this.sessions.filter(s => s.status === 'exited').length === 0}
|
|
||||||
>
|
|
||||||
${this.cleaningExited ? '[~] CLEANING...' : 'CLEAN EXITED'}
|
|
||||||
</button>
|
|
||||||
` : html `<div></div>`}
|
|
||||||
|
|
||||||
<label class="flex items-center gap-2 text-vs-text text-sm cursor-pointer hover:text-vs-accent transition-colors">
|
|
||||||
<div class="relative">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="sr-only"
|
|
||||||
.checked=${this.hideExited}
|
|
||||||
@change=${this.handleHideExitedChange}
|
|
||||||
>
|
|
||||||
<div class="w-4 h-4 border border-vs-border rounded bg-vs-bg-secondary flex items-center justify-center transition-all ${this.hideExited ? 'bg-vs-user border-vs-user' : 'hover:border-vs-accent'}">
|
|
||||||
${this.hideExited ? html `
|
|
||||||
<svg class="w-3 h-3 text-vs-bg" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
hide exited
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${sessionsToShow.length === 0 ? html `
|
|
||||||
<div class="text-vs-muted text-center py-8">
|
|
||||||
${this.loading ? 'Loading sessions...' : (this.hideExited && this.sessions.length > 0 ? 'No running sessions' : 'No sessions found')}
|
|
||||||
</div>
|
|
||||||
` : html `
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
${sessionsToShow.map(session => html `
|
|
||||||
<div
|
|
||||||
class="bg-vs-bg border border-vs-border rounded shadow cursor-pointer overflow-hidden"
|
|
||||||
@click=${() => this.handleSessionClick(session)}
|
|
||||||
>
|
|
||||||
<!-- Compact Header -->
|
|
||||||
<div class="flex justify-between items-center px-3 py-2 border-b border-vs-border">
|
|
||||||
<div class="text-vs-text text-xs font-mono truncate pr-2 flex-1">${session.command}</div>
|
|
||||||
${session.status === 'running' || !this.hideExited ? html `
|
|
||||||
<button
|
|
||||||
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-2 py-0.5 border-none text-xs disabled:opacity-50 flex-shrink-0 rounded"
|
|
||||||
@click=${(e) => session.status === 'running' ? this.handleKillSession(e, session.id) : this.handleCleanSession(e, session.id)}
|
|
||||||
?disabled=${this.killingSessionIds.has(session.id)}
|
|
||||||
>
|
|
||||||
${this.killingSessionIds.has(session.id)
|
|
||||||
? (session.status === 'running' ? '[~] killing...' : '[~] cleaning...')
|
|
||||||
: (session.status === 'running' ? 'kill' : 'clean')}
|
|
||||||
</button>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Asciinema player (main content) -->
|
|
||||||
<div class="session-preview bg-black flex items-center justify-center overflow-hidden" style="aspect-ratio: 640/480;">
|
|
||||||
${this.loadedSnapshots.has(session.id) ? html `
|
|
||||||
<div id="player-${session.id}" class="w-full h-full overflow-hidden"></div>
|
|
||||||
` : html `
|
|
||||||
<div class="text-vs-muted text-xs">
|
|
||||||
${this.newSessionIds.has(session.id)
|
|
||||||
? '[~] init_session...'
|
|
||||||
: (this.loadingSnapshots.has(session.id) ? '[~] loading...' : '[~] loading...')}
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Compact Footer -->
|
|
||||||
<div class="px-3 py-2 text-vs-muted text-xs border-t border-vs-border">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span class="${session.status === 'running' ? 'text-vs-user' : 'text-vs-warning'} text-xs">
|
|
||||||
${session.status}
|
|
||||||
</span>
|
|
||||||
<span class="truncate">${this.truncateId(session.id)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="truncate text-xs opacity-75" title="${session.workingDir}">${session.workingDir}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`)}
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
|
|
||||||
<session-create-form
|
|
||||||
.visible=${this.showCreateModal}
|
|
||||||
@session-created=${this.handleSessionCreated}
|
|
||||||
@cancel=${this.handleCreateModalClose}
|
|
||||||
@error=${this.handleCreateError}
|
|
||||||
></session-create-form>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
__decorate([
|
|
||||||
property({ type: Array })
|
|
||||||
], SessionList.prototype, "sessions", void 0);
|
|
||||||
__decorate([
|
|
||||||
property({ type: Boolean })
|
|
||||||
], SessionList.prototype, "loading", void 0);
|
|
||||||
__decorate([
|
|
||||||
property({ type: Boolean })
|
|
||||||
], SessionList.prototype, "hideExited", void 0);
|
|
||||||
__decorate([
|
|
||||||
property({ type: Boolean })
|
|
||||||
], SessionList.prototype, "showCreateModal", void 0);
|
|
||||||
__decorate([
|
|
||||||
state()
|
|
||||||
], SessionList.prototype, "killingSessionIds", void 0);
|
|
||||||
__decorate([
|
|
||||||
state()
|
|
||||||
], SessionList.prototype, "loadedSnapshots", void 0);
|
|
||||||
__decorate([
|
|
||||||
state()
|
|
||||||
], SessionList.prototype, "loadingSnapshots", void 0);
|
|
||||||
__decorate([
|
|
||||||
state()
|
|
||||||
], SessionList.prototype, "cleaningExited", void 0);
|
|
||||||
__decorate([
|
|
||||||
state()
|
|
||||||
], SessionList.prototype, "newSessionIds", void 0);
|
|
||||||
SessionList = __decorate([
|
|
||||||
customElement('session-list')
|
|
||||||
], SessionList);
|
|
||||||
export { SessionList };
|
|
||||||
//# sourceMappingURL=session-list.js.map
|
|
||||||
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
|
@ -1,42 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" style="stop-color:#007acc;stop-opacity:1" />
|
|
||||||
<stop offset="100%" style="stop-color:#005a9e;stop-opacity:1" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- Background -->
|
|
||||||
<rect width="512" height="512" rx="64" ry="64" fill="#1e1e1e"/>
|
|
||||||
|
|
||||||
<!-- Terminal window -->
|
|
||||||
<rect x="64" y="96" width="384" height="288" rx="16" ry="16" fill="#2d2d30" stroke="#007acc" stroke-width="2"/>
|
|
||||||
|
|
||||||
<!-- Terminal header -->
|
|
||||||
<rect x="64" y="96" width="384" height="48" rx="16" ry="16" fill="url(#grad1)"/>
|
|
||||||
<rect x="64" y="128" width="384" height="16" fill="url(#grad1)"/>
|
|
||||||
|
|
||||||
<!-- Window controls -->
|
|
||||||
<circle cx="96" cy="120" r="6" fill="#ff5f57"/>
|
|
||||||
<circle cx="120" cy="120" r="6" fill="#ffbd2e"/>
|
|
||||||
<circle cx="144" cy="120" r="6" fill="#28ca42"/>
|
|
||||||
|
|
||||||
<!-- Terminal content lines -->
|
|
||||||
<rect x="96" y="168" width="320" height="8" rx="4" fill="#007acc"/>
|
|
||||||
<rect x="96" y="188" width="280" height="8" rx="4" fill="#4fc1ff"/>
|
|
||||||
<rect x="96" y="208" width="300" height="8" rx="4" fill="#007acc"/>
|
|
||||||
<rect x="96" y="228" width="200" height="8" rx="4" fill="#4fc1ff"/>
|
|
||||||
|
|
||||||
<!-- Cursor -->
|
|
||||||
<rect x="224" y="248" width="12" height="16" fill="#00ff00" opacity="0.8"/>
|
|
||||||
|
|
||||||
<!-- Connection lines (representing tunnel/multiplexing) -->
|
|
||||||
<path d="M 256 400 Q 180 420 120 450" stroke="#007acc" stroke-width="4" fill="none" opacity="0.6"/>
|
|
||||||
<path d="M 256 400 Q 332 420 392 450" stroke="#007acc" stroke-width="4" fill="none" opacity="0.6"/>
|
|
||||||
<path d="M 256 400 Q 256 430 256 460" stroke="#007acc" stroke-width="4" fill="none" opacity="0.6"/>
|
|
||||||
|
|
||||||
<!-- Connection endpoints -->
|
|
||||||
<circle cx="120" cy="450" r="8" fill="#007acc"/>
|
|
||||||
<circle cx="256" cy="460" r="8" fill="#007acc"/>
|
|
||||||
<circle cx="392" cy="450" r="8" fill="#007acc"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.8 KiB |
|
|
@ -6,37 +6,12 @@
|
||||||
<title>VibeTunnel - Terminal Multiplexer</title>
|
<title>VibeTunnel - Terminal Multiplexer</title>
|
||||||
<meta name="description" content="Interactive terminal sessions in your browser with real-time streaming and mobile support">
|
<meta name="description" content="Interactive terminal sessions in your browser with real-time streaming and mobile support">
|
||||||
|
|
||||||
<!-- PWA Manifest -->
|
|
||||||
<link rel="manifest" href="/manifest.json">
|
|
||||||
|
|
||||||
<!-- Theme colors -->
|
|
||||||
<meta name="theme-color" content="#007acc">
|
|
||||||
<meta name="msapplication-TileColor" content="#1e1e1e">
|
|
||||||
<meta name="msapplication-config" content="/browserconfig.xml">
|
|
||||||
|
|
||||||
<!-- Apple Touch Icons -->
|
|
||||||
<link rel="apple-touch-icon" sizes="152x152" href="/icons/icon-152x152.png">
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180x180.png">
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
||||||
<meta name="apple-mobile-web-app-title" content="VibeTunnel">
|
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32x32.png">
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/icon-16x16.png">
|
|
||||||
<link rel="shortcut icon" href="/favicon.ico">
|
<link rel="shortcut icon" href="/favicon.ico">
|
||||||
|
|
||||||
<!-- Open Graph / Social Media -->
|
|
||||||
<meta property="og:type" content="website">
|
|
||||||
<meta property="og:title" content="VibeTunnel - Terminal Multiplexer">
|
|
||||||
<meta property="og:description" content="Interactive terminal sessions in your browser with real-time streaming and mobile support">
|
|
||||||
<meta property="og:image" content="/icons/icon-512x512.png">
|
|
||||||
<meta property="og:url" content="/">
|
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
|
||||||
|
|
||||||
<!-- Styles -->
|
<!-- Styles -->
|
||||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/asciinema-player@3.7.0/dist/bundle/asciinema-player.css" />
|
<link rel="stylesheet" type="text/css" href="https://unpkg.com/asciinema-player@3.7.0/dist/bundle/asciinema-player.css" />
|
||||||
<link href="output.css" rel="stylesheet">
|
<link href="bundle/output.css" rel="stylesheet">
|
||||||
|
|
||||||
<!-- Import Maps -->
|
<!-- Import Maps -->
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
|
|
@ -51,77 +26,7 @@
|
||||||
<body class="bg-vs-bg m-0 p-0">
|
<body class="bg-vs-bg m-0 p-0">
|
||||||
<vibetunnel-app></vibetunnel-app>
|
<vibetunnel-app></vibetunnel-app>
|
||||||
<script src="https://unpkg.com/asciinema-player@3.7.0/dist/bundle/asciinema-player.min.js"></script>
|
<script src="https://unpkg.com/asciinema-player@3.7.0/dist/bundle/asciinema-player.min.js"></script>
|
||||||
<script type="module" src="app-entry.js"></script>
|
<script type="module" src="bundle/client-bundle.js"></script>
|
||||||
|
|
||||||
<!-- Service Worker Registration -->
|
|
||||||
<script>
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
navigator.serviceWorker.register('/sw.js')
|
|
||||||
.then((registration) => {
|
|
||||||
console.log('PWA: Service Worker registered successfully:', registration.scope);
|
|
||||||
|
|
||||||
// Check for updates
|
|
||||||
registration.addEventListener('updatefound', () => {
|
|
||||||
const newWorker = registration.installing;
|
|
||||||
if (newWorker) {
|
|
||||||
newWorker.addEventListener('statechange', () => {
|
|
||||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
|
||||||
// New update available
|
|
||||||
console.log('PWA: New version available');
|
|
||||||
// You could show a notification here to reload
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('PWA: Service Worker registration failed:', error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for messages from service worker
|
|
||||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
|
||||||
if (event.data && event.data.type === 'CACHE_UPDATED') {
|
|
||||||
console.log('PWA: Cache updated');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle install prompt
|
|
||||||
let deferredPrompt;
|
|
||||||
window.addEventListener('beforeinstallprompt', (e) => {
|
|
||||||
console.log('PWA: Install prompt available - you can install VibeTunnel!');
|
|
||||||
// Prevent Chrome 67 and earlier from automatically showing the prompt
|
|
||||||
e.preventDefault();
|
|
||||||
// Stash the event so it can be triggered later
|
|
||||||
deferredPrompt = e;
|
|
||||||
|
|
||||||
// Show a temporary notification that install is available
|
|
||||||
console.log('PWA: App can be installed - look for the install icon in your browser!');
|
|
||||||
|
|
||||||
// You can trigger the install prompt manually like this:
|
|
||||||
// deferredPrompt.prompt();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('appinstalled', (evt) => {
|
|
||||||
console.log('PWA: App was installed successfully');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Debug PWA readiness
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
// Check PWA criteria
|
|
||||||
console.log('PWA Debug Info:');
|
|
||||||
console.log('- HTTPS:', window.location.protocol === 'https:');
|
|
||||||
console.log('- Service Worker supported:', 'serviceWorker' in navigator);
|
|
||||||
console.log('- Manifest link present:', !!document.querySelector('link[rel="manifest"]'));
|
|
||||||
console.log('- Current URL:', window.location.href);
|
|
||||||
|
|
||||||
// Check if already installed
|
|
||||||
if (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) {
|
|
||||||
console.log('PWA: App is running in standalone mode (already installed)');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
{
|
|
||||||
"name": "VibeTunnel",
|
|
||||||
"short_name": "VibeTunnel",
|
|
||||||
"description": "Interactive terminal sessions in your browser",
|
|
||||||
"start_url": "/",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#1e1e1e",
|
|
||||||
"theme_color": "#007acc",
|
|
||||||
"orientation": "any",
|
|
||||||
"scope": "/",
|
|
||||||
"categories": ["developer", "utilities"],
|
|
||||||
"lang": "en-US",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-72x72.png",
|
|
||||||
"sizes": "72x72",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-96x96.png",
|
|
||||||
"sizes": "96x96",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-128x128.png",
|
|
||||||
"sizes": "128x128",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-144x144.png",
|
|
||||||
"sizes": "144x144",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-152x152.png",
|
|
||||||
"sizes": "152x152",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-384x384.png",
|
|
||||||
"sizes": "384x384",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-512x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"shortcuts": [
|
|
||||||
{
|
|
||||||
"name": "New Session",
|
|
||||||
"short_name": "New",
|
|
||||||
"description": "Create a new terminal session",
|
|
||||||
"url": "/?action=create",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-96x96.png",
|
|
||||||
"sizes": "96x96"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"screenshots": [
|
|
||||||
{
|
|
||||||
"src": "/screenshots/desktop.png",
|
|
||||||
"sizes": "1280x720",
|
|
||||||
"type": "image/png",
|
|
||||||
"form_factor": "wide",
|
|
||||||
"label": "VibeTunnel desktop view"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/screenshots/mobile.png",
|
|
||||||
"sizes": "390x844",
|
|
||||||
"type": "image/png",
|
|
||||||
"form_factor": "narrow",
|
|
||||||
"label": "VibeTunnel mobile view"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 18 KiB |
|
|
@ -1,5 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
|
||||||
<!-- Desktop screenshot placeholder -->
|
|
||||||
<rect width="1280" height="720" fill="#1e1e1e"/>
|
|
||||||
<text x="640" y="360" text-anchor="middle" fill="#007acc" font-family="monospace" font-size="32">VibeTunnel Desktop View</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 293 B |
|
Before Width: | Height: | Size: 7.6 KiB |
|
|
@ -1,5 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 390 844">
|
|
||||||
<!-- Mobile screenshot placeholder -->
|
|
||||||
<rect width="390" height="844" fill="#1e1e1e"/>
|
|
||||||
<text x="195" y="422" text-anchor="middle" fill="#007acc" font-family="monospace" font-size="16">VibeTunnel Mobile View</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 289 B |
125
web/public/tests/debug-xterm.html
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Debug XTerm Test</title>
|
||||||
|
|
||||||
|
<!-- XTerm.js CSS -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal {
|
||||||
|
border: 2px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #000;
|
||||||
|
height: 400px;
|
||||||
|
width: 800px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #555;
|
||||||
|
padding: 8px 16px;
|
||||||
|
margin: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log {
|
||||||
|
background: #333;
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Debug XTerm Loading</h1>
|
||||||
|
|
||||||
|
<button onclick="testXTerm()">Test XTerm</button>
|
||||||
|
<button onclick="testRenderer()">Test XTerm Renderer</button>
|
||||||
|
|
||||||
|
<div class="terminal" id="terminal"></div>
|
||||||
|
|
||||||
|
<div id="log"></div>
|
||||||
|
|
||||||
|
<!-- XTerm.js Scripts -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function log(message) {
|
||||||
|
const logDiv = document.getElementById('log');
|
||||||
|
logDiv.textContent += new Date().toLocaleTimeString() + ': ' + message + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check what's available globally
|
||||||
|
log('Checking global objects...');
|
||||||
|
log('Terminal available: ' + (typeof Terminal !== 'undefined'));
|
||||||
|
log('FitAddon available: ' + (typeof FitAddon !== 'undefined'));
|
||||||
|
log('WebLinksAddon available: ' + (typeof WebLinksAddon !== 'undefined'));
|
||||||
|
|
||||||
|
function testXTerm() {
|
||||||
|
try {
|
||||||
|
log('Creating XTerm directly...');
|
||||||
|
const terminal = new Terminal();
|
||||||
|
const container = document.getElementById('terminal');
|
||||||
|
container.innerHTML = '';
|
||||||
|
terminal.open(container);
|
||||||
|
terminal.write('Hello from XTerm.js!\r\n');
|
||||||
|
terminal.write('\x1b[31mRed text\x1b[0m\r\n');
|
||||||
|
log('XTerm direct test successful!');
|
||||||
|
} catch (error) {
|
||||||
|
log('XTerm direct test failed: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function testRenderer() {
|
||||||
|
try {
|
||||||
|
log('Testing XTerm Renderer import...');
|
||||||
|
|
||||||
|
// Test if we can access the renderer
|
||||||
|
import('./xterm-renderer.js')
|
||||||
|
.then(module => {
|
||||||
|
log('XTerm Renderer module loaded successfully');
|
||||||
|
log('XTermRenderer class: ' + (module.XTermRenderer ? 'available' : 'not available'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const container = document.getElementById('terminal');
|
||||||
|
const renderer = new module.XTermRenderer(container);
|
||||||
|
renderer.processOutput('Hello from XTerm Renderer!\n');
|
||||||
|
renderer.processOutput('\x1b[32mGreen text from renderer\x1b[0m\n');
|
||||||
|
log('XTerm Renderer test successful!');
|
||||||
|
} catch (error) {
|
||||||
|
log('XTerm Renderer creation failed: ' + error.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
log('XTerm Renderer import failed: ' + error.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
log('XTerm Renderer test failed: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run initial checks
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
log('Page loaded, running initial checks...');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
131
web/public/tests/index.html
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>VibeTunnel Tests</title>
|
||||||
|
<link href="../bundle/output.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #569cd6;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.test-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
}
|
||||||
|
.test-card {
|
||||||
|
background: #2d2d30;
|
||||||
|
border: 1px solid #3e3e42;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.test-card:hover {
|
||||||
|
border-color: #569cd6;
|
||||||
|
}
|
||||||
|
.test-title {
|
||||||
|
color: #dcdcaa;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.test-description {
|
||||||
|
color: #8c8c8c;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.test-link {
|
||||||
|
display: inline-block;
|
||||||
|
background: #6a9955;
|
||||||
|
color: #1e1e1e;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
.test-link:hover {
|
||||||
|
background: #7db161;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
color: #569cd6;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.back-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<a href="../" class="back-link">← Back to VibeTunnel</a>
|
||||||
|
|
||||||
|
<h1>VibeTunnel Test Suite</h1>
|
||||||
|
|
||||||
|
<div class="test-grid">
|
||||||
|
<div class="test-card">
|
||||||
|
<div class="test-title">Custom Renderer Test</div>
|
||||||
|
<div class="test-description">
|
||||||
|
Test the original custom ANSI renderer implementation with various escape sequences and color codes.
|
||||||
|
</div>
|
||||||
|
<a href="test-renderer.html" class="test-link">Run Test</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-card">
|
||||||
|
<div class="test-title">XTerm Renderer Test</div>
|
||||||
|
<div class="test-description">
|
||||||
|
Test the new XTerm.js-based renderer with comprehensive terminal features and cast file playback.
|
||||||
|
</div>
|
||||||
|
<a href="test-xterm-renderer.html" class="test-link">Run Test</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-card">
|
||||||
|
<div class="test-title">Side-by-Side Comparison</div>
|
||||||
|
<div class="test-description">
|
||||||
|
Compare custom renderer vs XTerm.js renderer side-by-side with the same input data.
|
||||||
|
</div>
|
||||||
|
<a href="simple-xterm-test.html" class="test-link">Run Test</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-card">
|
||||||
|
<div class="test-title">Simple Renderer Test</div>
|
||||||
|
<div class="test-description">
|
||||||
|
Basic test of the custom renderer with simple ANSI sequences and text output.
|
||||||
|
</div>
|
||||||
|
<a href="simple-test.html" class="test-link">Run Test</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-card">
|
||||||
|
<div class="test-title">XTerm Debug Test</div>
|
||||||
|
<div class="test-description">
|
||||||
|
Debug version of XTerm renderer with additional logging and development features.
|
||||||
|
</div>
|
||||||
|
<a href="debug-xterm.html" class="test-link">Run Test</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-card">
|
||||||
|
<div class="test-title">Minimal Test</div>
|
||||||
|
<div class="test-description">
|
||||||
|
Minimal test setup for quick debugging and isolated testing of specific features.
|
||||||
|
</div>
|
||||||
|
<a href="minimal-test.html" class="test-link">Run Test</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
39
web/public/tests/minimal-test.html
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Minimal Test</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="terminal"></div>
|
||||||
|
<script>
|
||||||
|
console.log('Script starting...');
|
||||||
|
|
||||||
|
function testBasic() {
|
||||||
|
console.log('testBasic called');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make it global so onclick can find it
|
||||||
|
window.testBasic = testBasic;
|
||||||
|
|
||||||
|
console.log('Basic function defined');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button onclick="testBasic()">Test Basic</button>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
console.log('Module script starting...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('About to import TerminalRenderer...');
|
||||||
|
const { TerminalRenderer } = await import('./renderer.js');
|
||||||
|
console.log('TerminalRenderer imported successfully');
|
||||||
|
|
||||||
|
const terminal = new TerminalRenderer(document.getElementById('terminal'));
|
||||||
|
console.log('TerminalRenderer created successfully');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing/creating TerminalRenderer:', error);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
135
web/public/tests/simple-test.html
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Simple Terminal Renderer Test</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal {
|
||||||
|
border: 2px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #000;
|
||||||
|
height: 400px;
|
||||||
|
width: 800px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #555;
|
||||||
|
padding: 8px 16px;
|
||||||
|
margin-right: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Simple Terminal Renderer Debug</h1>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button onclick="testBasic()">Test Basic Colors</button>
|
||||||
|
<button onclick="testScrollback()">Test Scrollback</button>
|
||||||
|
<button onclick="clearTerminal()">Clear</button>
|
||||||
|
<button onclick="debugOutput()">Debug Output</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="terminal" id="terminal"></div>
|
||||||
|
|
||||||
|
<div id="debug" style="margin-top: 20px; background: #333; padding: 10px; border-radius: 4px;">
|
||||||
|
<h3>Debug Output:</h3>
|
||||||
|
<pre id="debugText"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { TerminalRenderer } from './renderer.js';
|
||||||
|
|
||||||
|
const terminal = new TerminalRenderer(document.getElementById('terminal'), 80, 20);
|
||||||
|
|
||||||
|
window.testBasic = function() {
|
||||||
|
console.log('Testing basic colors...');
|
||||||
|
terminal.clear();
|
||||||
|
|
||||||
|
// Test individual ANSI sequences
|
||||||
|
const tests = [
|
||||||
|
'Hello World (no color)\n',
|
||||||
|
'\x1b[31mRed Text\x1b[0m\n',
|
||||||
|
'\x1b[32mGreen Text\x1b[0m\n',
|
||||||
|
'\x1b[1;33mBold Yellow\x1b[0m\n',
|
||||||
|
'\x1b[4;34mUnderline Blue\x1b[0m\n',
|
||||||
|
'\x1b[1;31m[001]\x1b[0m Test line 1\n',
|
||||||
|
'\x1b[1;32m[002]\x1b[0m Test line 2\n'
|
||||||
|
];
|
||||||
|
|
||||||
|
tests.forEach((test, i) => {
|
||||||
|
console.log(`Processing test ${i}:`, JSON.stringify(test));
|
||||||
|
terminal.processOutput(test);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateDebug();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.testScrollback = function() {
|
||||||
|
console.log('Testing scrollback...');
|
||||||
|
terminal.clear();
|
||||||
|
|
||||||
|
for (let i = 1; i <= 30; i++) {
|
||||||
|
const color = 31 + (i % 6);
|
||||||
|
const line = `\x1b[1;${color}m[${i.toString().padStart(3, '0')}]\x1b[0m Line ${i}: Scrollback test content\n`;
|
||||||
|
console.log(`Line ${i}:`, JSON.stringify(line));
|
||||||
|
terminal.processOutput(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDebug();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.clearTerminal = function() {
|
||||||
|
terminal.clear();
|
||||||
|
updateDebug();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.debugOutput = function() {
|
||||||
|
updateDebug();
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateDebug() {
|
||||||
|
const terminalEl = document.getElementById('terminal');
|
||||||
|
const debugEl = document.getElementById('debugText');
|
||||||
|
|
||||||
|
const info = {
|
||||||
|
innerHTML: terminalEl.innerHTML.substring(0, 500) + (terminalEl.innerHTML.length > 500 ? '...' : ''),
|
||||||
|
childCount: terminalEl.children.length,
|
||||||
|
scrollHeight: terminalEl.scrollHeight,
|
||||||
|
clientHeight: terminalEl.clientHeight
|
||||||
|
};
|
||||||
|
|
||||||
|
debugEl.textContent = JSON.stringify(info, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial test
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('Running initial test...');
|
||||||
|
testBasic();
|
||||||
|
}, 100);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
220
web/public/tests/simple-xterm-test.html
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Simple XTerm Renderer Test</title>
|
||||||
|
|
||||||
|
<!-- XTerm.js CSS -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal {
|
||||||
|
border: 2px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #000;
|
||||||
|
height: 400px;
|
||||||
|
width: 800px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #555;
|
||||||
|
padding: 8px 16px;
|
||||||
|
margin-right: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison .terminal {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #4CAF50;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Simple XTerm Renderer Test</h1>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button onclick="testBasic()">Test Basic Colors</button>
|
||||||
|
<button onclick="testAdvanced()">Test Advanced Features</button>
|
||||||
|
<button onclick="testScrollback()">Test Scrollback</button>
|
||||||
|
<button onclick="testPerformance()">Test Performance</button>
|
||||||
|
<button onclick="clearTerminals()">Clear All</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comparison">
|
||||||
|
<div>
|
||||||
|
<h2>Custom Renderer</h2>
|
||||||
|
<div class="terminal" id="custom-terminal"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>XTerm.js Renderer</h2>
|
||||||
|
<div class="terminal" id="xterm-terminal"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="debug" style="margin-top: 20px; background: #333; padding: 10px; border-radius: 4px;">
|
||||||
|
<h3>Performance Comparison:</h3>
|
||||||
|
<pre id="debugText">Run performance test to see timing comparison</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- XTerm.js Scripts -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js"></script>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { TerminalRenderer } from './renderer.js';
|
||||||
|
import { XTermRenderer } from './xterm-renderer.js';
|
||||||
|
|
||||||
|
const customTerminal = new TerminalRenderer(document.getElementById('custom-terminal'), 80, 20);
|
||||||
|
const xtermTerminal = new XTermRenderer(document.getElementById('xterm-terminal'), 80, 20);
|
||||||
|
|
||||||
|
function runOnBoth(callback) {
|
||||||
|
callback(customTerminal, 'Custom');
|
||||||
|
callback(xtermTerminal, 'XTerm');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.testBasic = function() {
|
||||||
|
console.log('Testing basic colors...');
|
||||||
|
|
||||||
|
runOnBoth((terminal, name) => {
|
||||||
|
terminal.clear();
|
||||||
|
|
||||||
|
const tests = [
|
||||||
|
`${name} Renderer Test\n`,
|
||||||
|
'\x1b[31mRed Text\x1b[0m\n',
|
||||||
|
'\x1b[32mGreen Text\x1b[0m\n',
|
||||||
|
'\x1b[1;33mBold Yellow\x1b[0m\n',
|
||||||
|
'\x1b[4;34mUnderline Blue\x1b[0m\n',
|
||||||
|
'\x1b[1;31m[001]\x1b[0m Test line 1\n',
|
||||||
|
'\x1b[1;32m[002]\x1b[0m Test line 2\n'
|
||||||
|
];
|
||||||
|
|
||||||
|
tests.forEach(test => terminal.processOutput(test));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.testAdvanced = function() {
|
||||||
|
console.log('Testing advanced features...');
|
||||||
|
|
||||||
|
runOnBoth((terminal, name) => {
|
||||||
|
terminal.clear();
|
||||||
|
|
||||||
|
const tests = [
|
||||||
|
`${name} Advanced Features\n`,
|
||||||
|
'\x1b[38;2;255;100;50mRGB Orange\x1b[0m\n',
|
||||||
|
'\x1b[38;2;100;255;100mRGB Lime\x1b[0m\n',
|
||||||
|
'\x1b[38;5;196m256-color Red\x1b[0m\n',
|
||||||
|
'\x1b[38;5;46m256-color Green\x1b[0m\n',
|
||||||
|
'\x1b[1;3;4mBold Italic Underline\x1b[0m\n',
|
||||||
|
'\x1b[7mInverse Video\x1b[0m\n',
|
||||||
|
'\x1b[9mStrikethrough\x1b[0m\n',
|
||||||
|
'Unicode: 🚀 ✨ 🎉 ♦ ♠ ♥ ♣\n',
|
||||||
|
'Box: ┌─┬─┐ │ │ │ ├─┼─┤\n'
|
||||||
|
];
|
||||||
|
|
||||||
|
tests.forEach(test => terminal.processOutput(test));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.testScrollback = function() {
|
||||||
|
console.log('Testing scrollback...');
|
||||||
|
|
||||||
|
runOnBoth((terminal, name) => {
|
||||||
|
terminal.clear();
|
||||||
|
|
||||||
|
for (let i = 1; i <= 30; i++) {
|
||||||
|
const color = 31 + (i % 6);
|
||||||
|
const line = `\x1b[1;${color}m[${i.toString().padStart(3, '0')}]\x1b[0m ${name} Line ${i}\n`;
|
||||||
|
terminal.processOutput(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.testPerformance = function() {
|
||||||
|
console.log('Testing performance...');
|
||||||
|
|
||||||
|
const results = {};
|
||||||
|
|
||||||
|
// Test custom renderer
|
||||||
|
const customStart = performance.now();
|
||||||
|
customTerminal.clear();
|
||||||
|
for (let i = 1; i <= 1000; i++) {
|
||||||
|
const color = 31 + (i % 6);
|
||||||
|
const line = `\x1b[1;${color}m[${i.toString().padStart(4, '0')}]\x1b[0m Performance test line ${i} with \x1b[4munderline\x1b[0m and \x1b[1mbold\x1b[0m\n`;
|
||||||
|
customTerminal.processOutput(line);
|
||||||
|
}
|
||||||
|
const customEnd = performance.now();
|
||||||
|
results.custom = customEnd - customStart;
|
||||||
|
|
||||||
|
// Test XTerm renderer
|
||||||
|
const xtermStart = performance.now();
|
||||||
|
xtermTerminal.clear();
|
||||||
|
for (let i = 1; i <= 1000; i++) {
|
||||||
|
const color = 31 + (i % 6);
|
||||||
|
const line = `\x1b[1;${color}m[${i.toString().padStart(4, '0')}]\x1b[0m Performance test line ${i} with \x1b[4munderline\x1b[0m and \x1b[1mbold\x1b[0m\n`;
|
||||||
|
xtermTerminal.processOutput(line);
|
||||||
|
}
|
||||||
|
const xtermEnd = performance.now();
|
||||||
|
results.xterm = xtermEnd - xtermStart;
|
||||||
|
|
||||||
|
// Display results
|
||||||
|
const debugEl = document.getElementById('debugText');
|
||||||
|
const comparison = {
|
||||||
|
'Custom Renderer': `${results.custom.toFixed(2)}ms`,
|
||||||
|
'XTerm.js Renderer': `${results.xterm.toFixed(2)}ms`,
|
||||||
|
'Performance Ratio': `${(results.custom / results.xterm).toFixed(2)}x`,
|
||||||
|
'Winner': results.xterm < results.custom ? 'XTerm.js' : 'Custom',
|
||||||
|
'Lines Processed': '1000 lines each',
|
||||||
|
'Test Date': new Date().toLocaleString()
|
||||||
|
};
|
||||||
|
|
||||||
|
debugEl.textContent = JSON.stringify(comparison, null, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.clearTerminals = function() {
|
||||||
|
customTerminal.clear();
|
||||||
|
xtermTerminal.clear();
|
||||||
|
|
||||||
|
const debugEl = document.getElementById('debugText');
|
||||||
|
debugEl.textContent = 'Terminals cleared. Run tests to see comparison.';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial test
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('Running initial comparison...');
|
||||||
|
testBasic();
|
||||||
|
}, 100);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
378
web/public/tests/test-renderer.html
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Terminal Renderer Test</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #555;
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls input {
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #555;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container {
|
||||||
|
border: 2px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #000;
|
||||||
|
height: 300px;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: auto;
|
||||||
|
resize: both;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make sure scrollbars are visible */
|
||||||
|
.terminal-container::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container::-webkit-scrollbar-track {
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container::-webkit-scrollbar-thumb {
|
||||||
|
background: #555;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollback-line {
|
||||||
|
opacity: 0.8;
|
||||||
|
border-left: 2px solid #444;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-line {
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-line {
|
||||||
|
background-color: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
margin-top: 20px;
|
||||||
|
background: #333;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #4CAF50;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 10px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.success {
|
||||||
|
background: #2d5a2d;
|
||||||
|
color: #90EE90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
background: #5a2d2d;
|
||||||
|
color: #FFB6C1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Terminal Renderer Test</h1>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>1. Test with Pre-recorded Cast File</h2>
|
||||||
|
<div class="controls">
|
||||||
|
<input type="text" id="castUrl" placeholder="Enter cast file URL or use buttons below">
|
||||||
|
<button onclick="loadCastFile()">Load Cast File</button>
|
||||||
|
<button onclick="loadTestFile()">Load Test File via Server</button>
|
||||||
|
<button onclick="loadSampleData()">Load Sample ANSI</button>
|
||||||
|
<button onclick="generateScrollbackTest()">Generate Scrollback Test</button>
|
||||||
|
<button onclick="clearTerminal()">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-container" id="terminal1"></div>
|
||||||
|
<div class="status" id="status1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>2. Test with Live Session Stream</h2>
|
||||||
|
<div class="controls">
|
||||||
|
<input type="text" id="sessionId" placeholder="Enter session ID">
|
||||||
|
<button onclick="connectToSession()">Connect to Stream</button>
|
||||||
|
<button onclick="disconnectStream()">Disconnect</button>
|
||||||
|
<button onclick="clearTerminal2()">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-container" id="terminal2"></div>
|
||||||
|
<div class="status" id="status2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>3. Manual Test</h2>
|
||||||
|
<div class="controls">
|
||||||
|
<input type="text" id="testInput" placeholder="Enter raw ANSI escape sequences to test"
|
||||||
|
value="\x1b[31mRed text\x1b[0m \x1b[1;32mBold green\x1b[0m">
|
||||||
|
<button onclick="sendTestInput()">Send to Terminal</button>
|
||||||
|
<button onclick="clearTerminal3()">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-container" id="terminal3"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<h2>Usage Instructions:</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Cast File Test:</strong> The default URL points to your test cast file. Click "Load Cast File" to render it.</li>
|
||||||
|
<li><strong>Stream Test:</strong> Enter a session ID and click "Connect to Stream" to watch live terminal output.</li>
|
||||||
|
<li><strong>Manual Test:</strong> Enter raw ANSI escape sequences to test specific rendering features.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Features Supported:</h2>
|
||||||
|
<ul>
|
||||||
|
<li>✅ ANSI color codes (standard 16 colors + 256-color + RGB)</li>
|
||||||
|
<li>✅ Text formatting (bold, italic, underline, strikethrough)</li>
|
||||||
|
<li>✅ Cursor movement and positioning</li>
|
||||||
|
<li>✅ Screen clearing and line erasing</li>
|
||||||
|
<li>✅ Alternate screen buffer support</li>
|
||||||
|
<li>✅ Scrollback buffer (up to 1000 lines)</li>
|
||||||
|
<li>✅ Scroll regions</li>
|
||||||
|
<li>✅ Auto-wrap mode</li>
|
||||||
|
<li>✅ Inverse video</li>
|
||||||
|
<li>✅ Cast file format v2 parsing</li>
|
||||||
|
<li>✅ Server-Sent Events streaming</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { TerminalRenderer } from './renderer.js';
|
||||||
|
|
||||||
|
// Initialize terminals
|
||||||
|
const terminal1 = new TerminalRenderer(document.getElementById('terminal1'));
|
||||||
|
const terminal2 = new TerminalRenderer(document.getElementById('terminal2'));
|
||||||
|
const terminal3 = new TerminalRenderer(document.getElementById('terminal3'));
|
||||||
|
|
||||||
|
let currentStream = null;
|
||||||
|
|
||||||
|
function showStatus(terminalNum, message, isError = false) {
|
||||||
|
const status = document.getElementById(`status${terminalNum}`);
|
||||||
|
status.textContent = message;
|
||||||
|
status.className = `status ${isError ? 'error' : 'success'}`;
|
||||||
|
status.style.display = 'block';
|
||||||
|
setTimeout(() => {
|
||||||
|
status.style.display = 'none';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.loadCastFile = async function() {
|
||||||
|
const url = document.getElementById('castUrl').value.trim();
|
||||||
|
if (!url) {
|
||||||
|
showStatus(1, 'Please enter a cast file URL', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
showStatus(1, 'Loading cast file...');
|
||||||
|
await terminal1.loadCastFile(url);
|
||||||
|
showStatus(1, 'Cast file loaded successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading cast file:', error);
|
||||||
|
showStatus(1, `Error loading cast file: ${error.message}`, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.loadTestFile = async function() {
|
||||||
|
try {
|
||||||
|
showStatus(1, 'Loading test cast file...');
|
||||||
|
await terminal1.loadCastFile('/api/test-cast');
|
||||||
|
showStatus(1, 'Test cast file loaded successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading test file:', error);
|
||||||
|
showStatus(1, `Error loading test file: ${error.message}`, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.loadSampleData = function() {
|
||||||
|
try {
|
||||||
|
showStatus(1, 'Loading sample ANSI data...');
|
||||||
|
terminal1.clear();
|
||||||
|
|
||||||
|
// Sample ANSI sequences to demonstrate features
|
||||||
|
const samples = [
|
||||||
|
'{"version":2,"width":80,"height":24}\n',
|
||||||
|
'[0,"o","\\u001b[2J\\u001b[H"]\n', // Clear screen and home cursor
|
||||||
|
'[0.1,"o","\\u001b[1;31m╔════════════════════════════════════════════════════════════════════════════╗\\u001b[0m\\r\\n"]\n',
|
||||||
|
'[0.2,"o","\\u001b[1;31m║\\u001b[0m \\u001b[1;33mTerminal Renderer Demo\\u001b[0m \\u001b[1;31m║\\u001b[0m\\r\\n"]\n',
|
||||||
|
'[0.3,"o","\\u001b[1;31m╚════════════════════════════════════════════════════════════════════════════╝\\u001b[0m\\r\\n"]\n',
|
||||||
|
'[0.4,"o","\\r\\n"]\n',
|
||||||
|
'[0.5,"o","\\u001b[1mColors:\\u001b[0m \\u001b[31mRed\\u001b[0m \\u001b[32mGreen\\u001b[0m \\u001b[33mYellow\\u001b[0m \\u001b[34mBlue\\u001b[0m \\u001b[35mMagenta\\u001b[0m \\u001b[36mCyan\\u001b[0m\\r\\n"]\n',
|
||||||
|
'[0.6,"o","\\u001b[1mStyles:\\u001b[0m \\u001b[1mBold\\u001b[0m \\u001b[3mItalic\\u001b[0m \\u001b[4mUnderline\\u001b[0m \\u001b[9mStrikethrough\\u001b[0m\\r\\n"]\n',
|
||||||
|
'[0.7,"o","\\u001b[1mRGB Colors:\\u001b[0m \\u001b[38;2;255;100;50mOrange\\u001b[0m \\u001b[38;2;100;255;100mLime\\u001b[0m \\u001b[38;2;100;100;255mLightBlue\\u001b[0m\\r\\n"]\n',
|
||||||
|
'[0.8,"o","\\u001b[1mInverse:\\u001b[0m \\u001b[7mWhite on Black\\u001b[0m\\r\\n"]\n',
|
||||||
|
'[0.9,"o","\\r\\n\\u001b[32m$\\u001b[0m ls -la\\r\\n"]\n',
|
||||||
|
'[1.0,"o","drwxr-xr-x 5 user staff 160 Dec 16 10:30 \\u001b[34m.\\u001b[0m\\r\\n"]\n',
|
||||||
|
'[1.1,"o","drwxr-xr-x 10 user staff 320 Dec 16 10:25 \\u001b[34m..\\u001b[0m\\r\\n"]\n',
|
||||||
|
'[1.2,"o","-rw-r--r-- 1 user staff 1024 Dec 16 10:30 \\u001b[32mrenderer.ts\\u001b[0m\\r\\n"]\n',
|
||||||
|
'[1.3,"o","\\r\\n\\u001b[32m$\\u001b[0m \\u001b[7m \\u001b[0m"]\n'
|
||||||
|
];
|
||||||
|
|
||||||
|
terminal1.parseCastFile(samples.join(''));
|
||||||
|
showStatus(1, 'Sample ANSI data loaded!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading sample data:', error);
|
||||||
|
showStatus(1, `Error loading sample data: ${error.message}`, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.connectToSession = function() {
|
||||||
|
const sessionId = document.getElementById('sessionId').value.trim();
|
||||||
|
if (!sessionId) {
|
||||||
|
showStatus(2, 'Please enter a session ID', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (currentStream) {
|
||||||
|
currentStream.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal2.clear();
|
||||||
|
currentStream = terminal2.connectToStream(sessionId);
|
||||||
|
|
||||||
|
currentStream.onopen = () => {
|
||||||
|
showStatus(2, `Connected to session ${sessionId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
currentStream.onerror = (error) => {
|
||||||
|
showStatus(2, `Stream error: ${error.message || 'Connection failed'}`, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error connecting to stream:', error);
|
||||||
|
showStatus(2, `Error connecting: ${error.message}`, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.disconnectStream = function() {
|
||||||
|
if (currentStream) {
|
||||||
|
currentStream.close();
|
||||||
|
currentStream = null;
|
||||||
|
showStatus(2, 'Disconnected from stream');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.sendTestInput = function() {
|
||||||
|
const input = document.getElementById('testInput').value;
|
||||||
|
if (!input) {
|
||||||
|
showStatus(3, 'Please enter some test input', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode escape sequences
|
||||||
|
const decoded = input.replace(/\\x1b/g, '\x1b').replace(/\\n/g, '\n').replace(/\\r/g, '\r');
|
||||||
|
terminal3.processOutput(decoded);
|
||||||
|
showStatus(3, 'Test input processed');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.clearTerminal = function() {
|
||||||
|
terminal1.clear();
|
||||||
|
showStatus(1, 'Terminal cleared');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.clearTerminal2 = function() {
|
||||||
|
terminal2.clear();
|
||||||
|
showStatus(2, 'Terminal cleared');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.clearTerminal3 = function() {
|
||||||
|
terminal3.clear();
|
||||||
|
showStatus(3, 'Terminal cleared');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.generateScrollbackTest = function() {
|
||||||
|
try {
|
||||||
|
showStatus(1, 'Generating scrollback test...');
|
||||||
|
terminal1.clear();
|
||||||
|
|
||||||
|
// Generate lots of output to test scrollback - enough to definitely overflow
|
||||||
|
for (let i = 1; i <= 100; i++) {
|
||||||
|
const colors = [31, 32, 33, 34, 35, 36]; // red, green, yellow, blue, magenta, cyan
|
||||||
|
const color = colors[i % colors.length];
|
||||||
|
const line = `\x1b[1;${color}m[${i.toString().padStart(3, '0')}]\x1b[0m Line ${i}: This is test content with \x1b[4munderline\x1b[0m and \x1b[1mbold\x1b[0m text to test scrollback!\n`;
|
||||||
|
terminal1.processOutput(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a final message
|
||||||
|
terminal1.processOutput('\x1b[1;37m=== End of scrollback test ===\x1b[0m\n');
|
||||||
|
terminal1.processOutput('\x1b[32m$\x1b[0m \x1b[7m \x1b[0m');
|
||||||
|
|
||||||
|
showStatus(1, 'Generated 100 lines! Scroll up to see the full history.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating scrollback test:', error);
|
||||||
|
showStatus(1, `Error generating scrollback test: ${error.message}`, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test the renderer with some sample ANSI sequences on load
|
||||||
|
setTimeout(() => {
|
||||||
|
const testSequences = [
|
||||||
|
'\x1b[1;31m♦ \x1b[1;32m♦ \x1b[1;33m♦ \x1b[1;34m♦ \x1b[1;35m♦ \x1b[1;36m♦ \x1b[0m\n',
|
||||||
|
'\x1b[1mBold\x1b[0m \x1b[3mItalic\x1b[0m \x1b[4mUnderline\x1b[0m \x1b[9mStrikethrough\x1b[0m\n',
|
||||||
|
'\x1b[7mInverse\x1b[0m \x1b[38;2;255;100;50mRGB Color\x1b[0m\n',
|
||||||
|
'Terminal Renderer Test Ready!\n'
|
||||||
|
];
|
||||||
|
|
||||||
|
testSequences.forEach(seq => terminal3.processOutput(seq));
|
||||||
|
}, 100);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
425
web/public/tests/test-xterm-renderer.html
Normal file
|
|
@ -0,0 +1,425 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>XTerm Terminal Renderer Test</title>
|
||||||
|
|
||||||
|
<!-- XTerm.js CSS -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #555;
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls input {
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #555;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container {
|
||||||
|
border: 2px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #000;
|
||||||
|
height: 300px;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: both;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
margin-top: 20px;
|
||||||
|
background: #333;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #4CAF50;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 10px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.success {
|
||||||
|
background: #2d5a2d;
|
||||||
|
color: #90EE90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
background: #5a2d2d;
|
||||||
|
color: #FFB6C1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison .terminal-container {
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison h3 {
|
||||||
|
color: #FFB86C;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>XTerm Terminal Renderer Test</h1>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>1. Test with Pre-recorded Cast File</h2>
|
||||||
|
<div class="controls">
|
||||||
|
<input type="text" id="castUrl" placeholder="Enter cast file URL or use buttons below">
|
||||||
|
<button onclick="loadCastFile()">Load Cast File</button>
|
||||||
|
<button onclick="loadTestFile()">Load Test File via Server</button>
|
||||||
|
<button onclick="loadSampleData()">Load Sample ANSI</button>
|
||||||
|
<button onclick="generateScrollbackTest()">Generate Scrollback Test</button>
|
||||||
|
<button onclick="clearTerminal()">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-container" id="terminal1"></div>
|
||||||
|
<div class="status" id="status1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>2. Test with Live Session Stream</h2>
|
||||||
|
<div class="controls">
|
||||||
|
<input type="text" id="sessionId" placeholder="Enter session ID">
|
||||||
|
<button onclick="connectToSession()">Connect to Stream</button>
|
||||||
|
<button onclick="disconnectStream()">Disconnect</button>
|
||||||
|
<button onclick="clearTerminal2()">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-container" id="terminal2"></div>
|
||||||
|
<div class="status" id="status2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>3. Manual Test</h2>
|
||||||
|
<div class="controls">
|
||||||
|
<input type="text" id="testInput" placeholder="Enter raw ANSI escape sequences to test"
|
||||||
|
value="\x1b[31mRed text\x1b[0m \x1b[1;32mBold green\x1b[0m">
|
||||||
|
<button onclick="sendTestInput()">Send to Terminal</button>
|
||||||
|
<button onclick="clearTerminal3()">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-container" id="terminal3"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>4. Renderer Comparison</h2>
|
||||||
|
<div class="controls">
|
||||||
|
<button onclick="runComparisonTest()">Run Comparison Test</button>
|
||||||
|
<button onclick="clearComparison()">Clear Both</button>
|
||||||
|
</div>
|
||||||
|
<div class="comparison">
|
||||||
|
<div>
|
||||||
|
<h3>Custom Renderer</h3>
|
||||||
|
<div class="terminal-container" id="terminal4"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>XTerm Renderer</h3>
|
||||||
|
<div class="terminal-container" id="terminal5"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<h2>XTerm.js Features:</h2>
|
||||||
|
<ul>
|
||||||
|
<li>✅ Full VT100/VT220/VT320 terminal emulation</li>
|
||||||
|
<li>✅ Professional-grade ANSI escape sequence parsing</li>
|
||||||
|
<li>✅ Advanced cursor movement and screen manipulation</li>
|
||||||
|
<li>✅ True color (24-bit RGB) support</li>
|
||||||
|
<li>✅ Sixel graphics support (addon)</li>
|
||||||
|
<li>✅ Unicode and emoji support</li>
|
||||||
|
<li>✅ Configurable scrollback buffer</li>
|
||||||
|
<li>✅ Responsive resizing with fit addon</li>
|
||||||
|
<li>✅ Web links detection and clickable URLs</li>
|
||||||
|
<li>✅ Better performance for large outputs</li>
|
||||||
|
<li>✅ Industry-standard terminal behavior</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Usage Instructions:</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Cast File Test:</strong> Load asciinema cast files with full compatibility</li>
|
||||||
|
<li><strong>Stream Test:</strong> Connect to live terminal sessions with real-time rendering</li>
|
||||||
|
<li><strong>Manual Test:</strong> Test specific ANSI sequences directly</li>
|
||||||
|
<li><strong>Comparison:</strong> Compare custom renderer vs XTerm.js side by side</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- XTerm.js Scripts -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js"></script>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { XTermRenderer } from './xterm-renderer.js';
|
||||||
|
import { TerminalRenderer } from './renderer.js';
|
||||||
|
|
||||||
|
// Initialize XTerm terminals
|
||||||
|
const terminal1 = new XTermRenderer(document.getElementById('terminal1'));
|
||||||
|
const terminal2 = new XTermRenderer(document.getElementById('terminal2'));
|
||||||
|
const terminal3 = new XTermRenderer(document.getElementById('terminal3'));
|
||||||
|
const terminal5 = new XTermRenderer(document.getElementById('terminal5'));
|
||||||
|
|
||||||
|
// Initialize custom renderer for comparison
|
||||||
|
const terminal4 = new TerminalRenderer(document.getElementById('terminal4'));
|
||||||
|
|
||||||
|
let currentStream = null;
|
||||||
|
|
||||||
|
function showStatus(terminalNum, message, isError = false) {
|
||||||
|
const status = document.getElementById(`status${terminalNum}`);
|
||||||
|
status.textContent = message;
|
||||||
|
status.className = `status ${isError ? 'error' : 'success'}`;
|
||||||
|
status.style.display = 'block';
|
||||||
|
setTimeout(() => {
|
||||||
|
status.style.display = 'none';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.loadCastFile = async function() {
|
||||||
|
const url = document.getElementById('castUrl').value.trim();
|
||||||
|
if (!url) {
|
||||||
|
showStatus(1, 'Please enter a cast file URL', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
showStatus(1, 'Loading cast file...');
|
||||||
|
await terminal1.loadCastFile(url);
|
||||||
|
showStatus(1, 'Cast file loaded successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading cast file:', error);
|
||||||
|
showStatus(1, `Error loading cast file: ${error.message}`, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.loadTestFile = async function() {
|
||||||
|
try {
|
||||||
|
showStatus(1, 'Loading test cast file...');
|
||||||
|
await terminal1.loadCastFile('/api/test-cast');
|
||||||
|
showStatus(1, 'Test cast file loaded successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading test file:', error);
|
||||||
|
showStatus(1, `Error loading test file: ${error.message}`, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.loadSampleData = function() {
|
||||||
|
try {
|
||||||
|
showStatus(1, 'Loading sample ANSI data...');
|
||||||
|
terminal1.clear();
|
||||||
|
|
||||||
|
// Sample ANSI sequences to demonstrate XTerm features
|
||||||
|
const samples = [
|
||||||
|
'{"version":2,"width":80,"height":24}\n',
|
||||||
|
'[0,"o","\\u001b[2J\\u001b[H"]\n', // Clear screen and home cursor
|
||||||
|
'[0.1,"o","\\u001b[1;31m╔════════════════════════════════════════════════════════════════════════════╗\\u001b[0m\\r\\n"]\n',
|
||||||
|
'[0.2,"o","\\u001b[1;31m║\\u001b[0m \\u001b[1;33mXTerm.js Renderer Demo\\u001b[0m \\u001b[1;31m║\\u001b[0m\\r\\n"]\n',
|
||||||
|
'[0.3,"o","\\u001b[1;31m╚════════════════════════════════════════════════════════════════════════════╝\\u001b[0m\\r\\n"]\n',
|
||||||
|
'[0.4,"o","\\r\\n"]\n',
|
||||||
|
'[0.5,"o","\\u001b[1mStandard Colors:\\u001b[0m \\u001b[31mRed\\u001b[0m \\u001b[32mGreen\\u001b[0m \\u001b[33mYellow\\u001b[0m \\u001b[34mBlue\\u001b[0m \\u001b[35mMagenta\\u001b[0m \\u001b[36mCyan\\u001b[0m\\r\\n"]\n',
|
||||||
|
'[0.6,"o","\\u001b[1mBright Colors:\\u001b[0m \\u001b[91mBright Red\\u001b[0m \\u001b[92mBright Green\\u001b[0m \\u001b[93mBright Yellow\\u001b[0m\\r\\n"]\n',
|
||||||
|
'[0.7,"o","\\u001b[1m256 Colors:\\u001b[0m \\u001b[38;5;196mColor 196\\u001b[0m \\u001b[38;5;46mColor 46\\u001b[0m \\u001b[38;5;21mColor 21\\u001b[0m\\r\\n"]\n',
|
||||||
|
'[0.8,"o","\\u001b[1mRGB Colors:\\u001b[0m \\u001b[38;2;255;100;50mOrange\\u001b[0m \\u001b[38;2;100;255;100mLime\\u001b[0m \\u001b[38;2;100;100;255mLight Blue\\u001b[0m\\r\\n"]\n',
|
||||||
|
'[0.9,"o","\\u001b[1mStyles:\\u001b[0m \\u001b[1mBold\\u001b[0m \\u001b[3mItalic\\u001b[0m \\u001b[4mUnderline\\u001b[0m \\u001b[9mStrikethrough\\u001b[0m \\u001b[7mInverse\\u001b[0m\\r\\n"]\n',
|
||||||
|
'[1.0,"o","\\u001b[1mCombined:\\u001b[0m \\u001b[1;3;4;38;2;255;165;0mBold Italic Underline Orange\\u001b[0m\\r\\n"]\n',
|
||||||
|
'[1.1,"o","\\u001b[1mUnicode:\\u001b[0m 🚀 ✨ 🎉 ♦ ♠ ♥ ♣ 中文 日本語 العربية\\r\\n"]\n',
|
||||||
|
'[1.2,"o","\\u001b[1mBox Drawing:\\u001b[0m ┌─┬─┐ │ │ │ ├─┼─┤ │ │ │ └─┴─┘\\r\\n"]\n',
|
||||||
|
'[1.3,"o","\\r\\n\\u001b[32m$\\u001b[0m echo \\"XTerm.js handles complex sequences perfectly\\"\\r\\n"]\n',
|
||||||
|
'[1.4,"o","XTerm.js handles complex sequences perfectly\\r\\n"]\n',
|
||||||
|
'[1.5,"o","\\u001b[32m$\\u001b[0m \\u001b[7m \\u001b[0m"]\n'
|
||||||
|
];
|
||||||
|
|
||||||
|
terminal1.parseCastFile(samples.join(''));
|
||||||
|
showStatus(1, 'Sample ANSI data loaded!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading sample data:', error);
|
||||||
|
showStatus(1, `Error loading sample data: ${error.message}`, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.connectToSession = function() {
|
||||||
|
const sessionId = document.getElementById('sessionId').value.trim();
|
||||||
|
if (!sessionId) {
|
||||||
|
showStatus(2, 'Please enter a session ID', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (currentStream) {
|
||||||
|
currentStream.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal2.clear();
|
||||||
|
currentStream = terminal2.connectToStream(sessionId);
|
||||||
|
|
||||||
|
currentStream.onopen = () => {
|
||||||
|
showStatus(2, `Connected to session ${sessionId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
currentStream.onerror = (error) => {
|
||||||
|
showStatus(2, `Stream error: ${error.message || 'Connection failed'}`, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error connecting to stream:', error);
|
||||||
|
showStatus(2, `Error connecting: ${error.message}`, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.disconnectStream = function() {
|
||||||
|
if (currentStream) {
|
||||||
|
currentStream.close();
|
||||||
|
currentStream = null;
|
||||||
|
showStatus(2, 'Disconnected from stream');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.sendTestInput = function() {
|
||||||
|
const input = document.getElementById('testInput').value;
|
||||||
|
if (!input) {
|
||||||
|
showStatus(3, 'Please enter some test input', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode escape sequences
|
||||||
|
const decoded = input.replace(/\\x1b/g, '\x1b').replace(/\\n/g, '\n').replace(/\\r/g, '\r');
|
||||||
|
terminal3.processOutput(decoded);
|
||||||
|
showStatus(3, 'Test input processed');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.clearTerminal = function() {
|
||||||
|
terminal1.clear();
|
||||||
|
showStatus(1, 'Terminal cleared');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.clearTerminal2 = function() {
|
||||||
|
terminal2.clear();
|
||||||
|
showStatus(2, 'Terminal cleared');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.clearTerminal3 = function() {
|
||||||
|
terminal3.clear();
|
||||||
|
showStatus(3, 'Terminal cleared');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.generateScrollbackTest = function() {
|
||||||
|
try {
|
||||||
|
showStatus(1, 'Generating scrollback test...');
|
||||||
|
terminal1.clear();
|
||||||
|
|
||||||
|
// Generate lots of output to test scrollback
|
||||||
|
for (let i = 1; i <= 100; i++) {
|
||||||
|
const colors = [31, 32, 33, 34, 35, 36];
|
||||||
|
const color = colors[i % colors.length];
|
||||||
|
const line = `\x1b[1;${color}m[${i.toString().padStart(3, '0')}]\x1b[0m Line ${i}: XTerm.js scrollback with \x1b[4munderline\x1b[0m and \x1b[1mbold\x1b[0m text!\n`;
|
||||||
|
terminal1.processOutput(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal1.processOutput('\x1b[1;37m=== End of XTerm scrollback test ===\x1b[0m\n');
|
||||||
|
terminal1.processOutput('\x1b[32m$\x1b[0m \x1b[7m \x1b[0m');
|
||||||
|
|
||||||
|
showStatus(1, 'Generated 100 lines! XTerm.js handles scrollback smoothly.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating scrollback test:', error);
|
||||||
|
showStatus(1, `Error generating scrollback test: ${error.message}`, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.runComparisonTest = function() {
|
||||||
|
const testData = [
|
||||||
|
'\x1b[2J\x1b[H', // Clear and home
|
||||||
|
'\x1b[1;31m=== Renderer Comparison Test ===\x1b[0m\n',
|
||||||
|
'\x1b[1mColors:\x1b[0m \x1b[31mRed\x1b[0m \x1b[32mGreen\x1b[0m \x1b[33mYellow\x1b[0m \x1b[34mBlue\x1b[0m\n',
|
||||||
|
'\x1b[1mRGB:\x1b[0m \x1b[38;2;255;100;50mOrange RGB\x1b[0m \x1b[38;2;100;255;100mLime RGB\x1b[0m\n',
|
||||||
|
'\x1b[1mStyles:\x1b[0m \x1b[1mBold\x1b[0m \x1b[3mItalic\x1b[0m \x1b[4mUnderline\x1b[0m \x1b[7mInverse\x1b[0m\n',
|
||||||
|
'\x1b[1mUnicode:\x1b[0m 🚀 ✨ 🎉 ♦ ♠ ♥ ♣\n',
|
||||||
|
'\x1b[1mBox:\x1b[0m ┌─┬─┐\n │ │ │\n ├─┼─┤\n │ │ │\n └─┴─┘\n',
|
||||||
|
'\x1b[32m$\x1b[0m echo "Compare renderers"\n',
|
||||||
|
'Custom renderer vs XTerm.js\n',
|
||||||
|
'\x1b[32m$\x1b[0m \x1b[7m \x1b[0m'
|
||||||
|
];
|
||||||
|
|
||||||
|
const combined = testData.join('');
|
||||||
|
|
||||||
|
// Clear both terminals
|
||||||
|
terminal4.clear();
|
||||||
|
terminal5.clear();
|
||||||
|
|
||||||
|
// Process on both renderers
|
||||||
|
terminal4.processOutput(combined);
|
||||||
|
terminal5.processOutput(combined);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.clearComparison = function() {
|
||||||
|
terminal4.clear();
|
||||||
|
terminal5.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize with a welcome message showing XTerm capabilities
|
||||||
|
setTimeout(() => {
|
||||||
|
const welcomeSequences = [
|
||||||
|
'\x1b[1;36m♦ XTerm.js Renderer Ready! ♦\x1b[0m\n',
|
||||||
|
'\x1b[38;2;100;255;100mProfessional terminal emulation\x1b[0m\n',
|
||||||
|
'\x1b[1mTry the sample data or comparison test!\x1b[0m\n'
|
||||||
|
];
|
||||||
|
|
||||||
|
welcomeSequences.forEach(seq => terminal3.processOutput(seq));
|
||||||
|
}, 100);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
671
web/src/client/renderer.ts
Normal file
|
|
@ -0,0 +1,671 @@
|
||||||
|
// Terminal renderer for asciinema cast format with DOM rendering
|
||||||
|
// Supports complete cast files and streaming events
|
||||||
|
|
||||||
|
interface CastHeader {
|
||||||
|
version: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
timestamp?: number;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CastEvent {
|
||||||
|
timestamp: number;
|
||||||
|
type: 'o' | 'i'; // output or input
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminalCell {
|
||||||
|
char: string;
|
||||||
|
fg: string;
|
||||||
|
bg: string;
|
||||||
|
bold: boolean;
|
||||||
|
italic: boolean;
|
||||||
|
underline: boolean;
|
||||||
|
strikethrough: boolean;
|
||||||
|
inverse: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminalState {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
cursorX: number;
|
||||||
|
cursorY: number;
|
||||||
|
currentFg: string;
|
||||||
|
currentBg: string;
|
||||||
|
bold: boolean;
|
||||||
|
italic: boolean;
|
||||||
|
underline: boolean;
|
||||||
|
strikethrough: boolean;
|
||||||
|
inverse: boolean;
|
||||||
|
alternateScreen: boolean;
|
||||||
|
scrollRegionTop: number;
|
||||||
|
scrollRegionBottom: number;
|
||||||
|
originMode: boolean;
|
||||||
|
autowrap: boolean;
|
||||||
|
insertMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TerminalRenderer {
|
||||||
|
private container: HTMLElement;
|
||||||
|
private state: TerminalState;
|
||||||
|
private primaryBuffer: TerminalCell[][];
|
||||||
|
private alternateBuffer: TerminalCell[][];
|
||||||
|
private scrollbackBuffer: TerminalCell[][];
|
||||||
|
private maxScrollback: number = 1000;
|
||||||
|
private ansiColorMap: string[] = [
|
||||||
|
'#000000', '#cc241d', '#98971a', '#d79921', // Standard colors (0-7) - brighter
|
||||||
|
'#458588', '#b16286', '#689d6a', '#a89984',
|
||||||
|
'#928374', '#fb4934', '#b8bb26', '#fabd2f', // Bright colors (8-15) - very bright
|
||||||
|
'#83a598', '#d3869b', '#8ec07c', '#ebdbb2'
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor(container: HTMLElement, width: number = 80, height: number = 20) {
|
||||||
|
this.container = container;
|
||||||
|
this.state = {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
cursorX: 0,
|
||||||
|
cursorY: 0,
|
||||||
|
currentFg: '#ffffff',
|
||||||
|
currentBg: '#000000',
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
underline: false,
|
||||||
|
strikethrough: false,
|
||||||
|
inverse: false,
|
||||||
|
alternateScreen: false,
|
||||||
|
scrollRegionTop: 0,
|
||||||
|
scrollRegionBottom: height - 1,
|
||||||
|
originMode: false,
|
||||||
|
autowrap: true,
|
||||||
|
insertMode: false
|
||||||
|
};
|
||||||
|
|
||||||
|
this.primaryBuffer = this.createBuffer(width, height);
|
||||||
|
this.alternateBuffer = this.createBuffer(width, height);
|
||||||
|
this.scrollbackBuffer = [];
|
||||||
|
|
||||||
|
this.setupDOM();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createBuffer(width: number, height: number): TerminalCell[][] {
|
||||||
|
const buffer: TerminalCell[][] = [];
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
buffer[y] = [];
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
buffer[y][x] = {
|
||||||
|
char: ' ',
|
||||||
|
fg: '#ffffff',
|
||||||
|
bg: '#000000',
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
underline: false,
|
||||||
|
strikethrough: false,
|
||||||
|
inverse: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupDOM(): void {
|
||||||
|
this.container.style.fontFamily = 'Monaco, "Lucida Console", monospace';
|
||||||
|
this.container.style.fontSize = '14px';
|
||||||
|
this.container.style.lineHeight = '1.2';
|
||||||
|
this.container.style.backgroundColor = '#000000';
|
||||||
|
this.container.style.color = '#ffffff';
|
||||||
|
this.container.style.padding = '10px';
|
||||||
|
this.container.style.overflow = 'auto';
|
||||||
|
this.container.style.whiteSpace = 'pre';
|
||||||
|
this.container.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCurrentBuffer(): TerminalCell[][] {
|
||||||
|
return this.state.alternateScreen ? this.alternateBuffer : this.primaryBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderBuffer(): void {
|
||||||
|
const buffer = this.getCurrentBuffer();
|
||||||
|
const allLines: string[] = [];
|
||||||
|
|
||||||
|
// Render scrollback buffer first (only for primary screen)
|
||||||
|
if (!this.state.alternateScreen && this.scrollbackBuffer.length > 0) {
|
||||||
|
for (let i = 0; i < this.scrollbackBuffer.length; i++) {
|
||||||
|
const line = this.renderLine(this.scrollbackBuffer[i]);
|
||||||
|
allLines.push(`<div class="scrollback-line">${line}</div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render current buffer
|
||||||
|
for (let y = 0; y < this.state.height; y++) {
|
||||||
|
const line = this.renderLine(buffer[y]);
|
||||||
|
const isCurrentLine = y === this.state.cursorY;
|
||||||
|
allLines.push(`<div class="terminal-line ${isCurrentLine ? 'current-line' : ''}">${line}</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.container.innerHTML = allLines.join('');
|
||||||
|
|
||||||
|
// Auto-scroll to bottom unless user has scrolled up
|
||||||
|
if (this.container.scrollTop + this.container.clientHeight >= this.container.scrollHeight - 10) {
|
||||||
|
this.container.scrollTop = this.container.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderLine(lineBuffer: TerminalCell[]): string {
|
||||||
|
let line = '';
|
||||||
|
let lastBg = '';
|
||||||
|
let lastFg = '';
|
||||||
|
let lastStyles = '';
|
||||||
|
let spanOpen = false;
|
||||||
|
|
||||||
|
for (let x = 0; x < lineBuffer.length; x++) {
|
||||||
|
const cell = lineBuffer[x];
|
||||||
|
const fg = cell.inverse ? cell.bg : cell.fg;
|
||||||
|
const bg = cell.inverse ? cell.fg : cell.bg;
|
||||||
|
|
||||||
|
let styles = '';
|
||||||
|
if (cell.bold) styles += 'font-weight: bold; ';
|
||||||
|
if (cell.italic) styles += 'font-style: italic; ';
|
||||||
|
if (cell.underline) styles += 'text-decoration: underline; ';
|
||||||
|
if (cell.strikethrough) styles += 'text-decoration: line-through; ';
|
||||||
|
|
||||||
|
if (fg !== lastFg || bg !== lastBg || styles !== lastStyles) {
|
||||||
|
if (spanOpen) {
|
||||||
|
line += '</span>';
|
||||||
|
spanOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always add span for consistent rendering
|
||||||
|
line += `<span style="color: ${fg}; background-color: ${bg}; ${styles}">`;
|
||||||
|
spanOpen = true;
|
||||||
|
|
||||||
|
lastFg = fg;
|
||||||
|
lastBg = bg;
|
||||||
|
lastStyles = styles;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char = cell.char || ' ';
|
||||||
|
line += char === ' ' ? ' ' : this.escapeHtml(char);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close any open span
|
||||||
|
if (spanOpen) {
|
||||||
|
line += '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return line || ' '; // Ensure empty lines have height
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeHtml(text: string): string {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseAnsiSequence(data: string): void {
|
||||||
|
let i = 0;
|
||||||
|
while (i < data.length) {
|
||||||
|
const char = data[i];
|
||||||
|
|
||||||
|
if (char === '\x1b' && i + 1 < data.length && data[i + 1] === '[') {
|
||||||
|
// CSI sequence
|
||||||
|
i += 2;
|
||||||
|
let params = '';
|
||||||
|
let finalChar = '';
|
||||||
|
|
||||||
|
while (i < data.length) {
|
||||||
|
const c = data[i];
|
||||||
|
if ((c >= '0' && c <= '9') || c === ';' || c === ':' || c === '?') {
|
||||||
|
params += c;
|
||||||
|
} else {
|
||||||
|
finalChar = c;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug problematic sequences
|
||||||
|
if (params.includes('2004')) {
|
||||||
|
console.log(`CSI sequence: ESC[${params}${finalChar}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleCSI(params, finalChar);
|
||||||
|
} else if (char === '\x1b' && i + 1 < data.length && data[i + 1] === ']') {
|
||||||
|
// OSC sequence - skip for now
|
||||||
|
i += 2;
|
||||||
|
while (i < data.length && data[i] !== '\x07' && data[i] !== '\x1b') {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i < data.length && data[i] === '\x1b' && i + 1 < data.length && data[i + 1] === '\\') {
|
||||||
|
i++; // Skip the backslash too
|
||||||
|
}
|
||||||
|
} else if (char === '\x1b' && i + 1 < data.length && data[i + 1] === '=') {
|
||||||
|
// Application keypad mode - skip
|
||||||
|
i++;
|
||||||
|
} else if (char === '\x1b' && i + 1 < data.length && data[i + 1] === '>') {
|
||||||
|
// Normal keypad mode - skip
|
||||||
|
i++;
|
||||||
|
} else if (char === '\r') {
|
||||||
|
this.state.cursorX = 0;
|
||||||
|
} else if (char === '\n') {
|
||||||
|
this.newline();
|
||||||
|
} else if (char === '\t') {
|
||||||
|
this.state.cursorX = Math.min(this.state.width - 1, (Math.floor(this.state.cursorX / 8) + 1) * 8);
|
||||||
|
} else if (char === '\b') {
|
||||||
|
if (this.state.cursorX > 0) {
|
||||||
|
this.state.cursorX--;
|
||||||
|
}
|
||||||
|
} else if (char >= ' ' || char === '\x00') {
|
||||||
|
this.writeChar(char === '\x00' ? ' ' : char);
|
||||||
|
}
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleCSI(params: string, finalChar: string): void {
|
||||||
|
const paramList = params ? params.split(';').map(p => parseInt(p) || 0) : [0];
|
||||||
|
|
||||||
|
switch (finalChar) {
|
||||||
|
case 'A': // Cursor Up
|
||||||
|
this.state.cursorY = Math.max(this.state.scrollRegionTop, this.state.cursorY - (paramList[0] || 1));
|
||||||
|
break;
|
||||||
|
case 'B': // Cursor Down
|
||||||
|
this.state.cursorY = Math.min(this.state.scrollRegionBottom, this.state.cursorY + (paramList[0] || 1));
|
||||||
|
break;
|
||||||
|
case 'C': // Cursor Forward
|
||||||
|
this.state.cursorX = Math.min(this.state.width - 1, this.state.cursorX + (paramList[0] || 1));
|
||||||
|
break;
|
||||||
|
case 'D': // Cursor Backward
|
||||||
|
this.state.cursorX = Math.max(0, this.state.cursorX - (paramList[0] || 1));
|
||||||
|
break;
|
||||||
|
case 'H': // Cursor Position
|
||||||
|
case 'f':
|
||||||
|
this.state.cursorY = Math.min(this.state.height - 1, Math.max(0, (paramList[0] || 1) - 1));
|
||||||
|
this.state.cursorX = Math.min(this.state.width - 1, Math.max(0, (paramList[1] || 1) - 1));
|
||||||
|
break;
|
||||||
|
case 'J': // Erase Display
|
||||||
|
this.eraseDisplay(paramList[0] || 0);
|
||||||
|
break;
|
||||||
|
case 'K': // Erase Line
|
||||||
|
this.eraseLine(paramList[0] || 0);
|
||||||
|
break;
|
||||||
|
case 'm': // Set Graphics Rendition
|
||||||
|
this.handleSGR(paramList);
|
||||||
|
break;
|
||||||
|
case 'r': // Set Scroll Region
|
||||||
|
this.state.scrollRegionTop = Math.max(0, (paramList[0] || 1) - 1);
|
||||||
|
this.state.scrollRegionBottom = Math.min(this.state.height - 1, (paramList[1] || this.state.height) - 1);
|
||||||
|
this.state.cursorX = 0;
|
||||||
|
this.state.cursorY = this.state.scrollRegionTop;
|
||||||
|
break;
|
||||||
|
case 's': // Save Cursor Position
|
||||||
|
// TODO: Implement cursor save/restore
|
||||||
|
break;
|
||||||
|
case 'u': // Restore Cursor Position
|
||||||
|
// TODO: Implement cursor save/restore
|
||||||
|
break;
|
||||||
|
case 'h': // Set Mode
|
||||||
|
if (params === '?1049' || params === '?47') {
|
||||||
|
this.state.alternateScreen = true;
|
||||||
|
} else if (params === '?2004') {
|
||||||
|
// Bracketed paste mode - ignore (should not display)
|
||||||
|
console.log('Bracketed paste mode enabled');
|
||||||
|
} else if (params === '?1') {
|
||||||
|
// Application cursor keys mode
|
||||||
|
} else {
|
||||||
|
console.log(`Unhandled set mode: ${params}h`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'l': // Reset Mode
|
||||||
|
if (params === '?1049' || params === '?47') {
|
||||||
|
this.state.alternateScreen = false;
|
||||||
|
} else if (params === '?2004') {
|
||||||
|
// Bracketed paste mode - ignore (should not display)
|
||||||
|
console.log('Bracketed paste mode disabled');
|
||||||
|
} else if (params === '?1') {
|
||||||
|
// Normal cursor keys mode
|
||||||
|
} else {
|
||||||
|
console.log(`Unhandled reset mode: ${params}l`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSGR(params: number[]): void {
|
||||||
|
for (let i = 0; i < params.length; i++) {
|
||||||
|
const param = params[i];
|
||||||
|
|
||||||
|
if (param === 0) {
|
||||||
|
// Reset
|
||||||
|
this.state.currentFg = '#ffffff';
|
||||||
|
this.state.currentBg = '#000000';
|
||||||
|
this.state.bold = false;
|
||||||
|
this.state.italic = false;
|
||||||
|
this.state.underline = false;
|
||||||
|
this.state.strikethrough = false;
|
||||||
|
this.state.inverse = false;
|
||||||
|
} else if (param === 1) {
|
||||||
|
this.state.bold = true;
|
||||||
|
} else if (param === 3) {
|
||||||
|
this.state.italic = true;
|
||||||
|
} else if (param === 4) {
|
||||||
|
this.state.underline = true;
|
||||||
|
} else if (param === 7) {
|
||||||
|
this.state.inverse = true;
|
||||||
|
} else if (param === 9) {
|
||||||
|
this.state.strikethrough = true;
|
||||||
|
} else if (param === 22) {
|
||||||
|
this.state.bold = false;
|
||||||
|
} else if (param === 23) {
|
||||||
|
this.state.italic = false;
|
||||||
|
} else if (param === 24) {
|
||||||
|
this.state.underline = false;
|
||||||
|
} else if (param === 27) {
|
||||||
|
this.state.inverse = false;
|
||||||
|
} else if (param === 29) {
|
||||||
|
this.state.strikethrough = false;
|
||||||
|
} else if (param === 39) {
|
||||||
|
// Default foreground color
|
||||||
|
this.state.currentFg = '#ffffff';
|
||||||
|
} else if (param === 49) {
|
||||||
|
// Default background color
|
||||||
|
this.state.currentBg = '#000000';
|
||||||
|
} else if (param >= 30 && param <= 37) {
|
||||||
|
// Standard foreground colors
|
||||||
|
this.state.currentFg = this.ansiColorMap[param - 30];
|
||||||
|
} else if (param >= 40 && param <= 47) {
|
||||||
|
// Standard background colors
|
||||||
|
this.state.currentBg = this.ansiColorMap[param - 40];
|
||||||
|
} else if (param >= 90 && param <= 97) {
|
||||||
|
// Bright foreground colors
|
||||||
|
this.state.currentFg = this.ansiColorMap[param - 90 + 8];
|
||||||
|
} else if (param >= 100 && param <= 107) {
|
||||||
|
// Bright background colors
|
||||||
|
this.state.currentBg = this.ansiColorMap[param - 100 + 8];
|
||||||
|
} else if (param === 38) {
|
||||||
|
// Extended foreground color
|
||||||
|
if (i + 1 < params.length && params[i + 1] === 2 && i + 4 < params.length) {
|
||||||
|
// RGB: 38;2;r;g;b
|
||||||
|
const r = params[i + 2];
|
||||||
|
const g = params[i + 3];
|
||||||
|
const b = params[i + 4];
|
||||||
|
this.state.currentFg = `rgb(${r},${g},${b})`;
|
||||||
|
i += 4;
|
||||||
|
} else if (i + 1 < params.length && params[i + 1] === 5 && i + 2 < params.length) {
|
||||||
|
// 256-color: 38;5;n
|
||||||
|
this.state.currentFg = this.get256Color(params[i + 2]);
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
} else if (param === 48) {
|
||||||
|
// Extended background color
|
||||||
|
if (i + 1 < params.length && params[i + 1] === 2 && i + 4 < params.length) {
|
||||||
|
// RGB: 48;2;r;g;b
|
||||||
|
const r = params[i + 2];
|
||||||
|
const g = params[i + 3];
|
||||||
|
const b = params[i + 4];
|
||||||
|
this.state.currentBg = `rgb(${r},${g},${b})`;
|
||||||
|
i += 4;
|
||||||
|
} else if (i + 1 < params.length && params[i + 1] === 5 && i + 2 < params.length) {
|
||||||
|
// 256-color: 48;5;n
|
||||||
|
this.state.currentBg = this.get256Color(params[i + 2]);
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private get256Color(index: number): string {
|
||||||
|
if (index < 16) {
|
||||||
|
return this.ansiColorMap[index];
|
||||||
|
} else if (index < 232) {
|
||||||
|
// 216 color cube
|
||||||
|
const n = index - 16;
|
||||||
|
const r = Math.floor(n / 36);
|
||||||
|
const g = Math.floor((n % 36) / 6);
|
||||||
|
const b = n % 6;
|
||||||
|
|
||||||
|
const values = [0, 95, 135, 175, 215, 255];
|
||||||
|
return `rgb(${values[r]},${values[g]},${values[b]})`;
|
||||||
|
} else {
|
||||||
|
// Grayscale
|
||||||
|
const gray = 8 + (index - 232) * 10;
|
||||||
|
return `rgb(${gray},${gray},${gray})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private eraseDisplay(mode: number): void {
|
||||||
|
const buffer = this.getCurrentBuffer();
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case 0: // Erase from cursor to end of screen
|
||||||
|
this.eraseLine(0);
|
||||||
|
for (let y = this.state.cursorY + 1; y < this.state.height; y++) {
|
||||||
|
for (let x = 0; x < this.state.width; x++) {
|
||||||
|
buffer[y][x] = this.createEmptyCell();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 1: // Erase from beginning of screen to cursor
|
||||||
|
for (let y = 0; y < this.state.cursorY; y++) {
|
||||||
|
for (let x = 0; x < this.state.width; x++) {
|
||||||
|
buffer[y][x] = this.createEmptyCell();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.eraseLine(1);
|
||||||
|
break;
|
||||||
|
case 2: // Erase entire screen
|
||||||
|
case 3: // Erase entire screen and scrollback
|
||||||
|
for (let y = 0; y < this.state.height; y++) {
|
||||||
|
for (let x = 0; x < this.state.width; x++) {
|
||||||
|
buffer[y][x] = this.createEmptyCell();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mode === 3) {
|
||||||
|
this.scrollbackBuffer = [];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private eraseLine(mode: number): void {
|
||||||
|
const buffer = this.getCurrentBuffer();
|
||||||
|
const y = this.state.cursorY;
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case 0: // Erase from cursor to end of line
|
||||||
|
for (let x = this.state.cursorX; x < this.state.width; x++) {
|
||||||
|
buffer[y][x] = this.createEmptyCell();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 1: // Erase from beginning of line to cursor
|
||||||
|
for (let x = 0; x <= this.state.cursorX; x++) {
|
||||||
|
buffer[y][x] = this.createEmptyCell();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 2: // Erase entire line
|
||||||
|
for (let x = 0; x < this.state.width; x++) {
|
||||||
|
buffer[y][x] = this.createEmptyCell();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createEmptyCell(): TerminalCell {
|
||||||
|
return {
|
||||||
|
char: ' ',
|
||||||
|
fg: this.state.currentFg,
|
||||||
|
bg: this.state.currentBg,
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
underline: false,
|
||||||
|
strikethrough: false,
|
||||||
|
inverse: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeChar(char: string): void {
|
||||||
|
const buffer = this.getCurrentBuffer();
|
||||||
|
|
||||||
|
if (this.state.cursorX >= this.state.width) {
|
||||||
|
if (this.state.autowrap) {
|
||||||
|
this.newline();
|
||||||
|
} else {
|
||||||
|
this.state.cursorX = this.state.width - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer[this.state.cursorY][this.state.cursorX] = {
|
||||||
|
char,
|
||||||
|
fg: this.state.currentFg,
|
||||||
|
bg: this.state.currentBg,
|
||||||
|
bold: this.state.bold,
|
||||||
|
italic: this.state.italic,
|
||||||
|
underline: this.state.underline,
|
||||||
|
strikethrough: this.state.strikethrough,
|
||||||
|
inverse: this.state.inverse
|
||||||
|
};
|
||||||
|
|
||||||
|
this.state.cursorX++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private newline(): void {
|
||||||
|
this.state.cursorX = 0;
|
||||||
|
if (this.state.cursorY >= this.state.scrollRegionBottom) {
|
||||||
|
this.scrollUp();
|
||||||
|
} else {
|
||||||
|
this.state.cursorY++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scrollUp(): void {
|
||||||
|
const buffer = this.getCurrentBuffer();
|
||||||
|
|
||||||
|
// Add the top line to scrollback if we're in primary buffer
|
||||||
|
if (!this.state.alternateScreen) {
|
||||||
|
this.scrollbackBuffer.push([...buffer[this.state.scrollRegionTop]]);
|
||||||
|
if (this.scrollbackBuffer.length > this.maxScrollback) {
|
||||||
|
this.scrollbackBuffer.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll the region
|
||||||
|
for (let y = this.state.scrollRegionTop; y < this.state.scrollRegionBottom; y++) {
|
||||||
|
buffer[y] = [...buffer[y + 1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the bottom line
|
||||||
|
for (let x = 0; x < this.state.width; x++) {
|
||||||
|
buffer[this.state.scrollRegionBottom][x] = this.createEmptyCell();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API methods
|
||||||
|
|
||||||
|
async loadCastFile(url: string): Promise<void> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const text = await response.text();
|
||||||
|
this.parseCastFile(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
parseCastFile(content: string): void {
|
||||||
|
const lines = content.trim().split('\n');
|
||||||
|
let header: CastHeader | null = null;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line);
|
||||||
|
|
||||||
|
if (parsed.version && parsed.width && parsed.height) {
|
||||||
|
// Header
|
||||||
|
header = parsed;
|
||||||
|
this.resize(parsed.width, parsed.height);
|
||||||
|
} else if (Array.isArray(parsed) && parsed.length >= 3) {
|
||||||
|
// Event: [timestamp, type, data]
|
||||||
|
const event: CastEvent = {
|
||||||
|
timestamp: parsed[0],
|
||||||
|
type: parsed[1],
|
||||||
|
data: parsed[2]
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.type === 'o') {
|
||||||
|
this.processOutput(event.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse cast line:', line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
processOutput(data: string): void {
|
||||||
|
this.parseAnsiSequence(data);
|
||||||
|
this.renderBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
processEvent(event: CastEvent): void {
|
||||||
|
if (event.type === 'o') {
|
||||||
|
this.processOutput(event.data);
|
||||||
|
this.renderBuffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(width: number, height: number): void {
|
||||||
|
this.state.width = width;
|
||||||
|
this.state.height = height;
|
||||||
|
this.state.scrollRegionBottom = height - 1;
|
||||||
|
|
||||||
|
this.primaryBuffer = this.createBuffer(width, height);
|
||||||
|
this.alternateBuffer = this.createBuffer(width, height);
|
||||||
|
|
||||||
|
this.state.cursorX = 0;
|
||||||
|
this.state.cursorY = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.primaryBuffer = this.createBuffer(this.state.width, this.state.height);
|
||||||
|
this.alternateBuffer = this.createBuffer(this.state.width, this.state.height);
|
||||||
|
this.scrollbackBuffer = [];
|
||||||
|
this.state.cursorX = 0;
|
||||||
|
this.state.cursorY = 0;
|
||||||
|
this.state.alternateScreen = false;
|
||||||
|
this.renderBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream support - connect to SSE endpoint
|
||||||
|
connectToStream(sessionId: string): EventSource {
|
||||||
|
const eventSource = new EventSource(`/api/sessions/${sessionId}/stream`);
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.version && data.width && data.height) {
|
||||||
|
// Header
|
||||||
|
this.resize(data.width, data.height);
|
||||||
|
} else if (Array.isArray(data) && data.length >= 3) {
|
||||||
|
// Event
|
||||||
|
const castEvent: CastEvent = {
|
||||||
|
timestamp: data[0],
|
||||||
|
type: data[1],
|
||||||
|
data: data[2]
|
||||||
|
};
|
||||||
|
this.processEvent(castEvent);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse stream event:', event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('Stream error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
return eventSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
258
web/src/client/xterm-renderer.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
// XTerm-based terminal renderer for asciinema cast format
|
||||||
|
// Provides the same interface as the custom renderer but uses xterm.js
|
||||||
|
|
||||||
|
import { Terminal } from '@xterm/xterm';
|
||||||
|
import { FitAddon } from '@xterm/addon-fit';
|
||||||
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||||
|
|
||||||
|
interface CastHeader {
|
||||||
|
version: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
timestamp?: number;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CastEvent {
|
||||||
|
timestamp: number;
|
||||||
|
type: 'o' | 'i'; // output or input
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class XTermRenderer {
|
||||||
|
private container: HTMLElement;
|
||||||
|
private terminal: Terminal;
|
||||||
|
private fitAddon: FitAddon;
|
||||||
|
private webLinksAddon: WebLinksAddon;
|
||||||
|
|
||||||
|
constructor(container: HTMLElement, width: number = 80, height: number = 20) {
|
||||||
|
this.container = container;
|
||||||
|
|
||||||
|
// Create terminal with options similar to the custom renderer
|
||||||
|
this.terminal = new Terminal({
|
||||||
|
cols: width,
|
||||||
|
rows: height,
|
||||||
|
fontFamily: 'Monaco, "Lucida Console", monospace',
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
theme: {
|
||||||
|
background: '#000000',
|
||||||
|
foreground: '#ffffff',
|
||||||
|
cursor: '#ffffff',
|
||||||
|
cursorAccent: '#000000',
|
||||||
|
selectionBackground: '#ffffff30',
|
||||||
|
// Standard ANSI colors (matching the custom renderer)
|
||||||
|
black: '#000000',
|
||||||
|
red: '#cc241d',
|
||||||
|
green: '#98971a',
|
||||||
|
yellow: '#d79921',
|
||||||
|
blue: '#458588',
|
||||||
|
magenta: '#b16286',
|
||||||
|
cyan: '#689d6a',
|
||||||
|
white: '#a89984',
|
||||||
|
// Bright ANSI colors
|
||||||
|
brightBlack: '#928374',
|
||||||
|
brightRed: '#fb4934',
|
||||||
|
brightGreen: '#b8bb26',
|
||||||
|
brightYellow: '#fabd2f',
|
||||||
|
brightBlue: '#83a598',
|
||||||
|
brightMagenta: '#d3869b',
|
||||||
|
brightCyan: '#8ec07c',
|
||||||
|
brightWhite: '#ebdbb2'
|
||||||
|
},
|
||||||
|
allowProposedApi: true,
|
||||||
|
scrollback: 1000,
|
||||||
|
convertEol: true,
|
||||||
|
altClickMovesCursor: false,
|
||||||
|
rightClickSelectsWord: false,
|
||||||
|
disableStdin: true // We handle input separately
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add addons
|
||||||
|
this.fitAddon = new FitAddon();
|
||||||
|
this.webLinksAddon = new WebLinksAddon();
|
||||||
|
|
||||||
|
this.terminal.loadAddon(this.fitAddon);
|
||||||
|
this.terminal.loadAddon(this.webLinksAddon);
|
||||||
|
|
||||||
|
this.setupDOM();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupDOM(): void {
|
||||||
|
// Clear container and add CSS
|
||||||
|
this.container.innerHTML = '';
|
||||||
|
this.container.style.padding = '10px';
|
||||||
|
this.container.style.backgroundColor = '#000000';
|
||||||
|
this.container.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
// Create terminal wrapper
|
||||||
|
const terminalWrapper = document.createElement('div');
|
||||||
|
terminalWrapper.style.width = '100%';
|
||||||
|
terminalWrapper.style.height = '100%';
|
||||||
|
this.container.appendChild(terminalWrapper);
|
||||||
|
|
||||||
|
// Open terminal in the wrapper
|
||||||
|
this.terminal.open(terminalWrapper);
|
||||||
|
|
||||||
|
// Fit terminal to container
|
||||||
|
this.fitAddon.fit();
|
||||||
|
|
||||||
|
// Handle container resize
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
this.fitAddon.fit();
|
||||||
|
});
|
||||||
|
resizeObserver.observe(this.container);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API methods - maintain compatibility with custom renderer
|
||||||
|
|
||||||
|
async loadCastFile(url: string): Promise<void> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const text = await response.text();
|
||||||
|
this.parseCastFile(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
parseCastFile(content: string): void {
|
||||||
|
const lines = content.trim().split('\n');
|
||||||
|
let header: CastHeader | null = null;
|
||||||
|
|
||||||
|
// Clear terminal
|
||||||
|
this.terminal.clear();
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line);
|
||||||
|
|
||||||
|
if (parsed.version && parsed.width && parsed.height) {
|
||||||
|
// Header
|
||||||
|
header = parsed;
|
||||||
|
this.resize(parsed.width, parsed.height);
|
||||||
|
} else if (Array.isArray(parsed) && parsed.length >= 3) {
|
||||||
|
// Event: [timestamp, type, data]
|
||||||
|
const event: CastEvent = {
|
||||||
|
timestamp: parsed[0],
|
||||||
|
type: parsed[1],
|
||||||
|
data: parsed[2]
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.type === 'o') {
|
||||||
|
this.processOutput(event.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse cast line:', line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processOutput(data: string): void {
|
||||||
|
// XTerm handles all ANSI escape sequences automatically
|
||||||
|
this.terminal.write(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
processEvent(event: CastEvent): void {
|
||||||
|
if (event.type === 'o') {
|
||||||
|
this.processOutput(event.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(width: number, height: number): void {
|
||||||
|
this.terminal.resize(width, height);
|
||||||
|
// Fit addon will handle the visual resize
|
||||||
|
setTimeout(() => {
|
||||||
|
this.fitAddon.fit();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.terminal.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream support - connect to SSE endpoint
|
||||||
|
connectToStream(sessionId: string): EventSource {
|
||||||
|
const eventSource = new EventSource(`/api/sessions/${sessionId}/stream`);
|
||||||
|
|
||||||
|
// Clear terminal when starting stream
|
||||||
|
this.terminal.clear();
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.version && data.width && data.height) {
|
||||||
|
// Header
|
||||||
|
this.resize(data.width, data.height);
|
||||||
|
} else if (Array.isArray(data) && data.length >= 3) {
|
||||||
|
// Event
|
||||||
|
const castEvent: CastEvent = {
|
||||||
|
timestamp: data[0],
|
||||||
|
type: data[1],
|
||||||
|
data: data[2]
|
||||||
|
};
|
||||||
|
this.processEvent(castEvent);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse stream event:', event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('Stream error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
return eventSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional methods for terminal control
|
||||||
|
|
||||||
|
focus(): void {
|
||||||
|
this.terminal.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
blur(): void {
|
||||||
|
this.terminal.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
getTerminal(): Terminal {
|
||||||
|
return this.terminal;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.terminal.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to fit terminal to container (useful for responsive layouts)
|
||||||
|
fit(): void {
|
||||||
|
this.fitAddon.fit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get terminal dimensions
|
||||||
|
getDimensions(): { cols: number; rows: number } {
|
||||||
|
return {
|
||||||
|
cols: this.terminal.cols,
|
||||||
|
rows: this.terminal.rows
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write raw data to terminal (useful for testing)
|
||||||
|
write(data: string): void {
|
||||||
|
this.terminal.write(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable/disable input (though we keep it disabled by default)
|
||||||
|
setInputEnabled(enabled: boolean): void {
|
||||||
|
// XTerm doesn't have a direct way to disable input, so we override onData
|
||||||
|
if (enabled) {
|
||||||
|
// Remove any existing handler first
|
||||||
|
this.terminal.onData(() => {
|
||||||
|
// Input is handled by the session component
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.terminal.onData(() => {
|
||||||
|
// Do nothing - input disabled
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -312,6 +312,13 @@ app.post('/api/cleanup-exited', async (req, res) => {
|
||||||
|
|
||||||
// === TERMINAL I/O ===
|
// === TERMINAL I/O ===
|
||||||
|
|
||||||
|
// Track active streams per session to avoid multiple tail processes
|
||||||
|
const activeStreams = new Map<string, {
|
||||||
|
clients: Set<any>,
|
||||||
|
tailProcess: any,
|
||||||
|
lastPosition: number
|
||||||
|
}>();
|
||||||
|
|
||||||
// Live streaming cast file for asciinema player
|
// Live streaming cast file for asciinema player
|
||||||
app.get('/api/sessions/:sessionId/stream', async (req, res) => {
|
app.get('/api/sessions/:sessionId/stream', async (req, res) => {
|
||||||
const sessionId = req.params.sessionId;
|
const sessionId = req.params.sessionId;
|
||||||
|
|
@ -321,6 +328,8 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
|
||||||
return res.status(404).json({ error: 'Session not found' });
|
return res.status(404).json({ error: 'Session not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`New SSE client connected to session ${sessionId}`);
|
||||||
|
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
|
|
@ -333,7 +342,6 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
|
||||||
let headerSent = false;
|
let headerSent = false;
|
||||||
|
|
||||||
// Send existing content first
|
// Send existing content first
|
||||||
// NOTE: Small race condition possible between reading file and starting tail
|
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(streamOutPath, 'utf8');
|
const content = fs.readFileSync(streamOutPath, 'utf8');
|
||||||
const lines = content.trim().split('\n');
|
const lines = content.trim().split('\n');
|
||||||
|
|
@ -370,103 +378,115 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
|
||||||
res.write(`data: ${JSON.stringify(defaultHeader)}\n\n`);
|
res.write(`data: ${JSON.stringify(defaultHeader)}\n\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream new content
|
// Get or create shared stream for this session
|
||||||
const tailProcess = spawn('tail', ['-f', streamOutPath]);
|
let streamInfo = activeStreams.get(sessionId);
|
||||||
let buffer = '';
|
|
||||||
let streamCleanedUp = false;
|
if (!streamInfo) {
|
||||||
|
console.log(`Creating new shared tail process for session ${sessionId}`);
|
||||||
|
|
||||||
|
// Create new tail process for this session
|
||||||
|
const tailProcess = spawn('tail', ['-f', streamOutPath]);
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
streamInfo = {
|
||||||
|
clients: new Set(),
|
||||||
|
tailProcess,
|
||||||
|
lastPosition: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
activeStreams.set(sessionId, streamInfo);
|
||||||
|
|
||||||
|
// Handle tail output - broadcast to all clients
|
||||||
|
tailProcess.stdout.on('data', (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim()) {
|
||||||
|
let eventData;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line);
|
||||||
|
if (parsed.version && parsed.width && parsed.height) {
|
||||||
|
continue; // Skip duplicate headers
|
||||||
|
}
|
||||||
|
if (Array.isArray(parsed) && parsed.length >= 3) {
|
||||||
|
const currentTime = Date.now() / 1000;
|
||||||
|
const realTimeEvent = [currentTime - startTime, parsed[1], parsed[2]];
|
||||||
|
eventData = `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];
|
||||||
|
eventData = `data: ${JSON.stringify(castEvent)}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventData && streamInfo) {
|
||||||
|
// Broadcast to all connected clients
|
||||||
|
streamInfo.clients.forEach(client => {
|
||||||
|
try {
|
||||||
|
client.write(eventData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error writing to client:', error);
|
||||||
|
if (streamInfo) {
|
||||||
|
streamInfo.clients.delete(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tailProcess.on('error', (error) => {
|
||||||
|
console.error(`Shared tail process error for session ${sessionId}:`, error);
|
||||||
|
// Cleanup all clients
|
||||||
|
const currentStreamInfo = activeStreams.get(sessionId);
|
||||||
|
if (currentStreamInfo) {
|
||||||
|
currentStreamInfo.clients.forEach(client => {
|
||||||
|
try { client.end(); } catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
activeStreams.delete(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
tailProcess.on('exit', (code) => {
|
||||||
|
console.log(`Shared tail process exited for session ${sessionId} with code ${code}`);
|
||||||
|
// Cleanup all clients
|
||||||
|
const currentStreamInfo = activeStreams.get(sessionId);
|
||||||
|
if (currentStreamInfo) {
|
||||||
|
currentStreamInfo.clients.forEach(client => {
|
||||||
|
try { client.end(); } catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
activeStreams.delete(sessionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this client to the shared stream
|
||||||
|
streamInfo.clients.add(res);
|
||||||
|
console.log(`Added client to session ${sessionId}, total clients: ${streamInfo.clients.size}`);
|
||||||
|
|
||||||
|
// Cleanup when client disconnects
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (!streamCleanedUp) {
|
if (streamInfo && streamInfo.clients.has(res)) {
|
||||||
streamCleanedUp = true;
|
streamInfo.clients.delete(res);
|
||||||
console.log(`Cleaning up tail process for session ${sessionId}`);
|
console.log(`Removed client from session ${sessionId}, remaining clients: ${streamInfo.clients.size}`);
|
||||||
tailProcess.kill('SIGTERM');
|
|
||||||
|
// If no more clients, cleanup the tail process
|
||||||
|
if (streamInfo.clients.size === 0) {
|
||||||
|
console.log(`No more clients for session ${sessionId}, cleaning up tail process`);
|
||||||
|
try {
|
||||||
|
streamInfo.tailProcess.kill('SIGTERM');
|
||||||
|
} catch (e) {}
|
||||||
|
activeStreams.delete(sessionId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get the session info once to get the PID
|
req.on('close', cleanup);
|
||||||
let sessionPid: number | null = null;
|
req.on('aborted', cleanup);
|
||||||
try {
|
|
||||||
const output = await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
|
|
||||||
const sessions: TtyFwdListResponse = JSON.parse(output || '{}');
|
|
||||||
if (sessions[sessionId]) {
|
|
||||||
sessionPid = sessions[sessionId].pid;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting session PID:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set a timeout to check if process is still alive
|
|
||||||
const sessionCheckInterval = setInterval(() => {
|
|
||||||
if (sessionPid) {
|
|
||||||
try {
|
|
||||||
// Signal 0 just checks if process exists without actually sending a signal
|
|
||||||
process.kill(sessionPid, 0);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(`Session ${sessionId} process ${sessionPid} has died, terminating stream`);
|
|
||||||
clearInterval(sessionCheckInterval);
|
|
||||||
cleanup();
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If we don't have a PID, fall back to checking session status
|
|
||||||
console.log(`No PID found for session ${sessionId}, terminating stream`);
|
|
||||||
clearInterval(sessionCheckInterval);
|
|
||||||
cleanup();
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
}, 2000); // Check every 2 seconds
|
|
||||||
|
|
||||||
tailProcess.stdout.on('data', (chunk) => {
|
|
||||||
if (streamCleanedUp) return;
|
|
||||||
|
|
||||||
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`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tailProcess.on('error', (error) => {
|
|
||||||
console.error(`Tail process error for session ${sessionId}:`, error);
|
|
||||||
clearInterval(sessionCheckInterval);
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
tailProcess.on('exit', (code) => {
|
|
||||||
console.log(`Tail process exited for session ${sessionId} with code ${code}`);
|
|
||||||
clearInterval(sessionCheckInterval);
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup on disconnect
|
|
||||||
req.on('close', () => {
|
|
||||||
clearInterval(sessionCheckInterval);
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('aborted', () => {
|
|
||||||
clearInterval(sessionCheckInterval);
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get session snapshot (asciinema cast with adjusted timestamps for immediate playback)
|
// Get session snapshot (asciinema cast with adjusted timestamps for immediate playback)
|
||||||
|
|
@ -616,6 +636,26 @@ app.post('/api/sessions/:sessionId/input', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// === CAST FILE SERVING ===
|
||||||
|
|
||||||
|
// Serve test cast file
|
||||||
|
app.get('/api/test-cast', (req, res) => {
|
||||||
|
const testCastPath = path.join(__dirname, '..', 'public', 'stream-out');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(testCastPath)) {
|
||||||
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
const content = fs.readFileSync(testCastPath, 'utf8');
|
||||||
|
res.send(content);
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Test cast file not found' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error serving test cast file:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to serve test cast file' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// === FILE SYSTEM ===
|
// === FILE SYSTEM ===
|
||||||
|
|
||||||
// Directory listing for file browser
|
// Directory listing for file browser
|
||||||
|
|
|
||||||