mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-25 14:57:37 +00:00
Fix session card UI and file browser issues
- Update asciinema player CSS to use proper aspect ratios - Fix file browser z-index and layout issues - Remove old unused app.ts and server.ts files - Keep working app-new components structure 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
0bee6f13aa
commit
7f63c9e168
15 changed files with 739 additions and 1034 deletions
3
web/public/app-new-entry.js
Normal file
3
web/public/app-new-entry.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Entry point for the new app
|
||||
import './app-new.js';
|
||||
//# sourceMappingURL=app-new-entry.js.map
|
||||
1
web/public/app-new-entry.js.map
Normal file
1
web/public/app-new-entry.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"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';"]}
|
||||
149
web/public/app-new.js
Normal file
149
web/public/app-new.js
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
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-create-form
|
||||
@session-created=${this.handleSessionCreated}
|
||||
@error=${this.handleError}
|
||||
></session-create-form>
|
||||
<session-list
|
||||
.sessions=${this.sessions}
|
||||
.loading=${this.loading}
|
||||
@session-select=${this.handleSessionSelect}
|
||||
@session-killed=${this.handleSessionKilled}
|
||||
@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
web/public/app-new.js.map
Normal file
1
web/public/app-new.js.map
Normal file
File diff suppressed because one or more lines are too long
25
web/public/components/app-header.js
Normal file
25
web/public/components/app-header.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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;
|
||||
}
|
||||
render() {
|
||||
return html `
|
||||
<div class="p-4">
|
||||
<h1 class="text-vs-user font-mono text-sm m-0">VibeTunnel</h1>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
AppHeader = __decorate([
|
||||
customElement('app-header')
|
||||
], AppHeader);
|
||||
export { AppHeader };
|
||||
//# sourceMappingURL=app-header.js.map
|
||||
1
web/public/components/app-header.js.map
Normal file
1
web/public/components/app-header.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"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;IAED,MAAM;QACJ,OAAO,IAAI,CAAA;;;;KAIV,CAAC;IACJ,CAAC;CACF,CAAA;AAZY,SAAS;IADrB,aAAa,CAAC,YAAY,CAAC;GACf,SAAS,CAYrB","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 render() {\n return html`\n <div class=\"p-4\">\n <h1 class=\"text-vs-user font-mono text-sm m-0\">VibeTunnel</h1>\n </div>\n `;\n }\n}"]}
|
||||
148
web/public/components/file-browser.js
Normal file
148
web/public/components/file-browser.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
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">
|
||||
<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 h-64 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">
|
||||
<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
web/public/components/file-browser.js.map
Normal file
1
web/public/components/file-browser.js.map
Normal file
File diff suppressed because one or more lines are too long
193
web/public/components/session-create-form.js
Normal file
193
web/public/components/session-create-form.js
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
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 = '';
|
||||
this.disabled = 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;
|
||||
}
|
||||
render() {
|
||||
return html `
|
||||
<div class="border border-vs-accent font-mono text-sm p-4 m-4 rounded">
|
||||
<div class="text-vs-assistant text-sm mb-4">Create New Session</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-vs-muted 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-muted 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>
|
||||
|
||||
<button
|
||||
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none"
|
||||
@click=${this.handleCreate}
|
||||
?disabled=${this.disabled || this.isCreating || !this.workingDir.trim() || !this.command.trim()}
|
||||
>
|
||||
${this.isCreating ? 'creating...' : 'create'}
|
||||
</button>
|
||||
</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([
|
||||
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
web/public/components/session-create-form.js.map
Normal file
1
web/public/components/session-create-form.js.map
Normal file
File diff suppressed because one or more lines are too long
211
web/public/components/session-list.js
Normal file
211
web/public/components/session-list.js
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
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 SessionList = class SessionList extends LitElement {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.sessions = [];
|
||||
this.loading = false;
|
||||
this.killingSessionIds = new Set();
|
||||
this.loadedSnapshots = new Map();
|
||||
this.loadingSnapshots = 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 all snapshots when sessions change
|
||||
setTimeout(() => this.loadAllSnapshots(), 100);
|
||||
}
|
||||
}
|
||||
createPlayer(sessionId) {
|
||||
const playerElement = this.querySelector(`#player-${sessionId}`);
|
||||
if (playerElement && window.AsciinemaPlayer) {
|
||||
try {
|
||||
const snapshotUrl = `/api/sessions/${sessionId}/snapshot`;
|
||||
window.AsciinemaPlayer.create(snapshotUrl, playerElement, {
|
||||
autoPlay: true,
|
||||
loop: false,
|
||||
controls: false,
|
||||
fit: 'both',
|
||||
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();
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
render() {
|
||||
return html `
|
||||
<div class="font-mono text-sm p-4">
|
||||
${this.sessions.length === 0 ? html `
|
||||
<div class="text-vs-muted text-center py-8">
|
||||
${this.loading ? 'Loading sessions...' : 'No sessions found'}
|
||||
</div>
|
||||
` : html `
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
${this.sessions.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>
|
||||
<button
|
||||
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-1 py-0.5 border-none text-xs disabled:opacity-50 flex-shrink-0 rounded"
|
||||
@click=${(e) => this.handleKillSession(e, session.id)}
|
||||
?disabled=${this.killingSessionIds.has(session.id)}
|
||||
>
|
||||
${this.killingSessionIds.has(session.id) ? '...' : 'x'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Asciinema player (main content) -->
|
||||
<div class="session-preview">
|
||||
${this.loadedSnapshots.has(session.id) ? html `
|
||||
<div id="player-${session.id}" class="bg-black" style="height: 120px; overflow: hidden;"></div>
|
||||
` : html `
|
||||
<div
|
||||
class="bg-black flex items-center justify-center text-vs-muted text-xs"
|
||||
style="height: 120px;"
|
||||
>
|
||||
${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>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
property({ type: Array })
|
||||
], SessionList.prototype, "sessions", void 0);
|
||||
__decorate([
|
||||
property({ type: Boolean })
|
||||
], SessionList.prototype, "loading", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], SessionList.prototype, "killingSessionIds", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], SessionList.prototype, "loadedSnapshots", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], SessionList.prototype, "loadingSnapshots", void 0);
|
||||
SessionList = __decorate([
|
||||
customElement('session-list')
|
||||
], SessionList);
|
||||
export { SessionList };
|
||||
//# sourceMappingURL=session-list.js.map
|
||||
1
web/public/components/session-list.js.map
Normal file
1
web/public/components/session-list.js.map
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -1,527 +0,0 @@
|
|||
import { LitElement, html, TemplateResult } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { choose } from 'lit/directives/choose.js';
|
||||
|
||||
// Type definitions for asciinema-player
|
||||
declare var AsciinemaPlayer: {
|
||||
create(
|
||||
cast: string | { driver: string; url: string; } | { data: any[]; version: number; width: number; height: number; timestamp: number; env: any },
|
||||
element: HTMLElement,
|
||||
options?: { theme?: string; loop?: boolean; autoPlay?: boolean; controls?: boolean; fit?: string; logger?: any }
|
||||
): any;
|
||||
};
|
||||
|
||||
interface ProcessMetadata {
|
||||
processId: string;
|
||||
command: string;
|
||||
workingDir: string;
|
||||
startDate: string;
|
||||
lastModified: string;
|
||||
exitCode?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface FileInfo {
|
||||
name: string;
|
||||
created: string;
|
||||
lastModified: string;
|
||||
size: number;
|
||||
isDir: boolean;
|
||||
}
|
||||
|
||||
interface DirectoryListing {
|
||||
absolutePath: string;
|
||||
files: FileInfo[];
|
||||
}
|
||||
|
||||
type Route = 'processes' | 'terminal';
|
||||
|
||||
@customElement('vibetunnel-app')
|
||||
export class VibeTunnelApp extends LitElement {
|
||||
// Override createRenderRoot to disable shadow DOM and enable Tailwind
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@state() private currentRoute: Route = 'processes';
|
||||
@state() private currentProcess: ProcessMetadata | null = null;
|
||||
@state() private processes: ProcessMetadata[] = [];
|
||||
@state() private workingDir: string = '~/';
|
||||
@state() private command: string = '';
|
||||
@state() private showDirBrowser: boolean = false;
|
||||
@state() private currentDirPath: string = '~/';
|
||||
@state() private dirFiles: FileInfo[] = [];
|
||||
@state() private keyboardCaptured: boolean = false;
|
||||
|
||||
private player: any = null;
|
||||
private eventSource: EventSource | null = null;
|
||||
private hotReloadWs: WebSocket | null = null;
|
||||
private processRefreshInterval: number | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.setupHotReload();
|
||||
this.handleRouting();
|
||||
this.startProcessRefresh();
|
||||
this.setupGlobalKeyCapture();
|
||||
window.addEventListener('popstate', () => this.handleRouting());
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.disconnectStreaming();
|
||||
if (this.processRefreshInterval) {
|
||||
clearInterval(this.processRefreshInterval);
|
||||
}
|
||||
if (this.hotReloadWs) {
|
||||
this.hotReloadWs.close();
|
||||
}
|
||||
window.removeEventListener('popstate', () => this.handleRouting());
|
||||
}
|
||||
|
||||
private handleRouting(): void {
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith('#terminal/')) {
|
||||
const processId = hash.substring(10);
|
||||
this.navigateToTerminal(processId);
|
||||
} else {
|
||||
this.navigateToProcesses();
|
||||
}
|
||||
}
|
||||
|
||||
private navigateToProcesses(): void {
|
||||
this.currentRoute = 'processes';
|
||||
this.currentProcess = null; // Clear current process when leaving terminal
|
||||
this.disconnectStreaming();
|
||||
this.keyboardCaptured = false;
|
||||
this.loadProcesses();
|
||||
window.history.pushState({}, '', '#');
|
||||
}
|
||||
|
||||
private async navigateToTerminal(processId: string): Promise<void> {
|
||||
this.currentRoute = 'terminal';
|
||||
|
||||
if (this.processes.length === 0) {
|
||||
await this.loadProcesses();
|
||||
}
|
||||
|
||||
const process = this.processes.find(p => p.processId === processId);
|
||||
if (process) {
|
||||
this.selectProcess(process);
|
||||
} else {
|
||||
console.error(`Process ${processId} not found`);
|
||||
this.navigateToProcesses();
|
||||
return;
|
||||
}
|
||||
|
||||
window.history.pushState({}, '', `#terminal/${processId}`);
|
||||
}
|
||||
|
||||
private async loadProcesses(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('/api/sessions');
|
||||
const sessions = await response.json();
|
||||
|
||||
this.processes = sessions.map((session: any) => ({
|
||||
processId: session.id,
|
||||
command: session.metadata?.cmdline?.join(' ') || 'Unknown',
|
||||
workingDir: session.metadata?.cwd || 'Unknown',
|
||||
startDate: new Date().toISOString(),
|
||||
lastModified: session.lastModified || new Date().toISOString(),
|
||||
exitCode: session.status === 'running' ? undefined : 1,
|
||||
error: session.status !== 'running' ? 'Not running' : undefined
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to load processes:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private selectProcess(process: ProcessMetadata): void {
|
||||
this.disconnectStreaming();
|
||||
this.currentProcess = process;
|
||||
this.keyboardCaptured = true;
|
||||
|
||||
// Create asciinema player with SSE
|
||||
this.requestUpdate();
|
||||
this.updateComplete.then(() => {
|
||||
this.createPlayerWithSSE(process.processId);
|
||||
});
|
||||
}
|
||||
|
||||
private createPlayerWithSSE(processId: string): void {
|
||||
const playerEl = this.querySelector('.terminal-player') as HTMLElement;
|
||||
if (!playerEl) return;
|
||||
|
||||
const sseUrl = `/api/stream/${processId}`;
|
||||
|
||||
try {
|
||||
playerEl.innerHTML = '';
|
||||
this.player = AsciinemaPlayer.create({
|
||||
driver: 'eventsource',
|
||||
url: sseUrl
|
||||
}, playerEl, {
|
||||
theme: 'asciinema',
|
||||
autoPlay: true,
|
||||
controls: false,
|
||||
fit: 'both'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating asciinema player:', error);
|
||||
playerEl.innerHTML = '<div style="text-align: center; padding: 2em; color: #ff5555;">Error loading terminal</div>';
|
||||
}
|
||||
}
|
||||
|
||||
private disconnectStreaming(): void {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
if (this.player) {
|
||||
this.player = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private setupGlobalKeyCapture(): void {
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (this.currentRoute !== 'terminal' || !this.currentProcess || !this.keyboardCaptured) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
const terminalKey = this.convertKeyToTerminalInput(e);
|
||||
if (terminalKey) {
|
||||
this.sendKeyToTerminal(terminalKey);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private convertKeyToTerminalInput(e: KeyboardEvent): { type: 'key' | 'text', value: string } | null {
|
||||
const ttyFwdKeys: { [key: string]: string } = {
|
||||
'ArrowUp': 'arrow_up', 'ArrowDown': 'arrow_down',
|
||||
'ArrowLeft': 'arrow_left', 'ArrowRight': 'arrow_right',
|
||||
'Escape': 'escape', 'Enter': 'enter'
|
||||
};
|
||||
|
||||
const specialKeys: { [key: string]: string } = {
|
||||
'Backspace': '\x7f', 'Tab': '\t',
|
||||
'Home': '\x1b[H', 'End': '\x1b[F',
|
||||
'PageUp': '\x1b[5~', 'PageDown': '\x1b[6~',
|
||||
'Insert': '\x1b[2~', 'Delete': '\x1b[3~'
|
||||
};
|
||||
|
||||
if (ttyFwdKeys[e.key]) {
|
||||
return { type: 'key', value: ttyFwdKeys[e.key] };
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.key.length === 1) {
|
||||
const code = e.key.toLowerCase().charCodeAt(0);
|
||||
if (code >= 97 && code <= 122) {
|
||||
return { type: 'text', value: String.fromCharCode(code - 96) };
|
||||
}
|
||||
}
|
||||
|
||||
if (e.altKey && e.key.length === 1) {
|
||||
return { type: 'text', value: '\x1b' + e.key };
|
||||
}
|
||||
|
||||
if (specialKeys[e.key]) {
|
||||
return { type: 'text', value: specialKeys[e.key] };
|
||||
}
|
||||
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
return { type: 'text', value: e.key };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async sendKeyToTerminal(keyData: { type: 'key' | 'text', value: string }): Promise<void> {
|
||||
// Only send input if we're actually in terminal mode with a valid process
|
||||
if (this.currentRoute !== 'terminal' || !this.currentProcess || !this.keyboardCaptured) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Send as text to match old working version
|
||||
const response = await fetch(`/api/input/${this.currentProcess.processId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: keyData.value })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to send key to terminal:', response.status, response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending key to terminal:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createProcess(): Promise<void> {
|
||||
if (!this.workingDir.trim() || !this.command.trim()) {
|
||||
alert('Please fill in both working directory and command');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workingDir: this.workingDir,
|
||||
command: [this.command]
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.command = '';
|
||||
await this.loadProcesses();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Failed to create process: ${error.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating process:', error);
|
||||
alert('Failed to create process');
|
||||
}
|
||||
}
|
||||
|
||||
private async killSession(processId: string): Promise<void> {
|
||||
if (!confirm('Are you sure you want to kill this session?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${processId}`, { method: 'DELETE' });
|
||||
|
||||
if (response.ok) {
|
||||
if (this.currentProcess && this.currentProcess.processId === processId) {
|
||||
this.navigateToProcesses();
|
||||
}
|
||||
|
||||
// Wait a moment for server cleanup to complete, then refresh
|
||||
setTimeout(async () => {
|
||||
await this.loadProcesses();
|
||||
}, 1500);
|
||||
|
||||
// Also refresh immediately
|
||||
await this.loadProcesses();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Failed to kill session: ${error.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error killing session:', error);
|
||||
alert('Failed to kill session');
|
||||
}
|
||||
}
|
||||
|
||||
private async openDirectoryBrowser(): Promise<void> {
|
||||
this.showDirBrowser = true;
|
||||
await this.loadDirectoryContents(this.currentDirPath);
|
||||
}
|
||||
|
||||
private async loadDirectoryContents(dirPath: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`/api/ls?dir=${encodeURIComponent(dirPath)}`);
|
||||
const data: DirectoryListing = await response.json();
|
||||
|
||||
this.currentDirPath = data.absolutePath;
|
||||
this.dirFiles = data.files;
|
||||
} catch (error) {
|
||||
console.error('Error loading directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private selectDirectory(): void {
|
||||
this.workingDir = this.currentDirPath;
|
||||
this.showDirBrowser = false;
|
||||
}
|
||||
|
||||
private startProcessRefresh(): void {
|
||||
this.processRefreshInterval = window.setInterval(() => {
|
||||
if (this.currentRoute === 'processes') {
|
||||
this.loadProcesses();
|
||||
}
|
||||
}, 2000); // Refresh every 2 seconds instead of 5
|
||||
}
|
||||
|
||||
private setupHotReload(): void {
|
||||
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}?hotReload=true`;
|
||||
|
||||
this.hotReloadWs = new WebSocket(wsUrl);
|
||||
this.hotReloadWs.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'reload') {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`
|
||||
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: #1e1e1e; color: #cccccc; overflow: hidden;">
|
||||
${choose(this.currentRoute, [
|
||||
['processes', () => this.renderProcessList()],
|
||||
['terminal', () => this.renderTerminal()]
|
||||
])}
|
||||
|
||||
${this.showDirBrowser ? this.renderDirectoryBrowser() : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderProcessList(): TemplateResult {
|
||||
return html`
|
||||
<div style="padding: 1em; max-width: 100%; overflow-x: auto;">
|
||||
<!-- Header -->
|
||||
<div style="margin-bottom: 1.5em; text-align: center;">
|
||||
<div style="color: #569cd6; font-size: 1.2em; font-weight: bold;">VibeTunnel</div>
|
||||
<div style="color: #6a9955; font-size: 0.9em;">Terminal Multiplexer</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Process Form -->
|
||||
<div style="border: 1px solid #3c3c3c; padding: 1em; margin-bottom: 1em; background: #252526;">
|
||||
<div style="color: #4ec9b0; margin-bottom: 1em;">Create New Process</div>
|
||||
<div style="margin-bottom: 1em;">
|
||||
<div style="margin-bottom: 1em;">Working Directory:</div>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 1em; align-items: stretch;">
|
||||
<input style="flex: 1; background: #3c3c3c; color: #cccccc; border: 1px solid #464647; padding: 0.5em; height: 2em; outline: none; font-size: 1em;"
|
||||
.value=${this.workingDir}
|
||||
@input=${(e: Event) => this.workingDir = (e.target as HTMLInputElement).value}
|
||||
placeholder="~/projects/my-app">
|
||||
<button style="min-width: 5em; height: 3em; border: 1px solid #6272a4; background: #44475a; color: #cccccc; cursor: pointer; padding: 0.5em; font-size: 1em;"
|
||||
@click=${this.openDirectoryBrowser}>Browse</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 1em;">
|
||||
<div style="margin-bottom: 1em;">Command:</div>
|
||||
<input style="width: 100%; max-width: 40em; background: #3c3c3c; color: #cccccc; border: 1px solid #464647; padding: 0.5em; height: 2em; outline: none; font-size: 1em;"
|
||||
.value=${this.command}
|
||||
@input=${(e: Event) => this.command = (e.target as HTMLInputElement).value}
|
||||
placeholder="bash">
|
||||
</div>
|
||||
<button style="min-width: 6em; height: 3em; background: #0e639c; color: #ffffff; border: 1px solid #6272a4; cursor: pointer; padding: 0.5em; font-size: 1em; font-weight: bold;"
|
||||
@click=${this.createProcess}>Create</button>
|
||||
</div>
|
||||
|
||||
<!-- Process List -->
|
||||
<div style="border: 1px solid #6272a4; padding: 1em;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1em;">
|
||||
<span style="color: #8be9fd;">Active Processes</span>
|
||||
<button style="min-width: 6em; height: 3em; border: 1px solid #6272a4; background: #44475a; color: #cccccc; cursor: pointer; padding: 0.5em; font-size: 1em;"
|
||||
@click=${this.loadProcesses}>Refresh</button>
|
||||
</div>
|
||||
|
||||
${this.processes.length === 0 ?
|
||||
html`<div style="color: #6a9955;">No processes found</div>` :
|
||||
this.processes.map(process => html`
|
||||
<div style="border: 1px solid #3c3c3c; padding: 1.5em; margin-bottom: 1em; cursor: pointer; min-height: 4em; background: #252526; transition: background-color 0.2s;"
|
||||
@click=${() => this.navigateToTerminal(process.processId)}
|
||||
@mouseover=${(e: Event) => (e.currentTarget as HTMLElement).style.background = '#2d2d30'}
|
||||
@mouseout=${(e: Event) => (e.currentTarget as HTMLElement).style.background = '#252526'}>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
|
||||
<div style="flex: 1;">
|
||||
<div style="color: #cccccc;">${process.command}</div>
|
||||
<div style="color: ${process.exitCode === undefined ? '#4ec9b0' : '#f14c4c'};">
|
||||
${process.exitCode === undefined ? 'running' : 'stopped'}
|
||||
</div>
|
||||
<div style="color: #6272a4;">ID: ${process.processId}</div>
|
||||
<div style="color: #6272a4;">Dir: ${process.workingDir}</div>
|
||||
</div>
|
||||
<button style="min-width: 4em; height: 3em; background: #f14c4c; color: #ffffff; border: 1px solid #6272a4; cursor: pointer; padding: 0.5em; font-size: 1em; font-weight: bold;"
|
||||
@click=${(e: Event) => { e.stopPropagation(); this.killSession(process.processId); }}>
|
||||
Kill
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTerminal(): TemplateResult {
|
||||
return html`
|
||||
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column;">
|
||||
<!-- Header -->
|
||||
<div style="padding: 1em; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #3c3c3c; background: #2d2d30;">
|
||||
<button style="min-width: 5em; height: 3em; border: 1px solid #6272a4; background: #44475a; color: #cccccc; cursor: pointer; padding: 0.5em; font-size: 1em;"
|
||||
@click=${this.navigateToProcesses}>← Back</button>
|
||||
<span style="color: #dcdcaa;">${this.currentProcess?.command} (${this.currentProcess?.processId})</span>
|
||||
<button style="min-width: 4em; height: 3em; background: #f14c4c; color: #ffffff; border: 1px solid #6272a4; cursor: pointer; padding: 0.5em; font-size: 1em; font-weight: bold;"
|
||||
@click=${() => this.currentProcess && this.killSession(this.currentProcess.processId)}>
|
||||
Kill
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Player -->
|
||||
<div class="terminal-player" style="flex: 1; background: #1e1e1e; overflow: hidden;"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDirectoryBrowser(): TemplateResult {
|
||||
return html`
|
||||
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.8); display: flex; align-items: center; justify-content: center; z-index: 1000;"
|
||||
@click=${(e: Event) => e.target === e.currentTarget && (this.showDirBrowser = false)}>
|
||||
<div style="background: #252526; border: 1px solid #3c3c3c; width: 90vw; max-width: 60em; height: 80vh; max-height: 30em; margin: 1em; display: flex; flex-direction: column;">
|
||||
<div style="padding: 1em; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #3c3c3c;">
|
||||
<span style="color: #4ec9b0;">Browse Directory</span>
|
||||
<button style="min-width: 3em; height: 3em; border: 1px solid #6272a4; background: #44475a; color: #cccccc; cursor: pointer; padding: 0.5em; font-size: 1em;"
|
||||
@click=${() => this.showDirBrowser = false}>✕</button>
|
||||
</div>
|
||||
|
||||
<div style="padding: 1em; border-bottom: 1px solid #3c3c3c;">
|
||||
<span style="color: #6a9955;">Current: </span>
|
||||
<span style="color: #4ec9b0;">${this.currentDirPath}</span>
|
||||
</div>
|
||||
|
||||
<div style="flex: 1; overflow: auto; padding: 1em; min-height: 0;">
|
||||
${this.currentDirPath !== '/' ? html`
|
||||
<div style="cursor: pointer; color: #569cd6; padding: 0; margin-bottom: 1em;"
|
||||
@click=${() => this.loadDirectoryContents(this.currentDirPath.split('/').slice(0, -1).join('/') || '/')}
|
||||
@mouseover=${(e: Event) => (e.target as HTMLElement).style.background = '#2d2d30'}
|
||||
@mouseout=${(e: Event) => (e.target as HTMLElement).style.background = '#252526'}>
|
||||
.. (parent directory)
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this.dirFiles.filter(f => f.isDir).map(file => html`
|
||||
<div style="cursor: pointer; padding: 0; margin-bottom: 1em; display: flex;"
|
||||
@click=${() => this.loadDirectoryContents(this.currentDirPath + '/' + file.name)}
|
||||
@mouseover=${(e: Event) => (e.target as HTMLElement).style.background = '#2d2d30'}
|
||||
@mouseout=${(e: Event) => (e.target as HTMLElement).style.background = '#252526'}>
|
||||
<span style="color: #569cd6; width: 2em;">📁</span>
|
||||
<span>${file.name}</span>
|
||||
</div>
|
||||
`)}
|
||||
|
||||
${this.dirFiles.filter(f => !f.isDir).map(file => html`
|
||||
<div style="padding: 0; margin-bottom: 1em; color: #6a9955; display: flex;">
|
||||
<span style="width: 2em;">📄</span>
|
||||
<span>${file.name}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
<div style="padding: 1em; display: flex; gap: 1em; border-top: 1px solid #3c3c3c;">
|
||||
<button style="min-width: 6em; height: 3em; border: 1px solid #6272a4; background: #44475a; color: #cccccc; cursor: pointer; padding: 0.5em; font-size: 1em;"
|
||||
@click=${() => this.showDirBrowser = false}>Cancel</button>
|
||||
<button style="min-width: 6em; height: 3em; background: #0e639c; color: #ffffff; border: 1px solid #6272a4; cursor: pointer; padding: 0.5em; font-size: 1em; font-weight: bold;"
|
||||
@click=${this.selectDirectory}>Select</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -85,13 +85,13 @@ export class FileBrowser extends LitElement {
|
|||
|
||||
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">
|
||||
<div class="p-4 border-b border-vs-border">
|
||||
<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 h-64 overflow-y-auto">
|
||||
<div class="p-4 flex-1 overflow-y-auto">
|
||||
${this.loading ? html`
|
||||
<div class="text-vs-muted">Loading...</div>
|
||||
` : html`
|
||||
|
|
@ -124,7 +124,7 @@ export class FileBrowser extends LitElement {
|
|||
`}
|
||||
</div>
|
||||
|
||||
<div class="p-4 border-t border-vs-border flex gap-4 justify-end">
|
||||
<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}
|
||||
|
|
|
|||
|
|
@ -1,503 +0,0 @@
|
|||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { spawn } from 'child_process';
|
||||
import * as pty from 'node-pty';
|
||||
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// tty-fwd binary path - check multiple possible locations
|
||||
const possibleTtyFwdPaths = [
|
||||
path.resolve(__dirname, '..', '..', 'tty-fwd', 'target', 'release', 'tty-fwd'),
|
||||
path.resolve(__dirname, '..', '..', '..', 'tty-fwd', 'target', 'release', 'tty-fwd'),
|
||||
'tty-fwd' // System PATH
|
||||
];
|
||||
|
||||
let TTY_FWD_PATH = '';
|
||||
for (const pathToCheck of possibleTtyFwdPaths) {
|
||||
if (fs.existsSync(pathToCheck)) {
|
||||
TTY_FWD_PATH = pathToCheck;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!TTY_FWD_PATH) {
|
||||
// Try to find in PATH
|
||||
try {
|
||||
const result = spawn('which', ['tty-fwd'], { stdio: 'pipe' });
|
||||
result.stdout.on('data', (data) => {
|
||||
TTY_FWD_PATH = data.toString().trim();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('tty-fwd binary not found. Please ensure it is built and available.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const TTY_FWD_CONTROL_DIR = path.join(os.homedir(), '.vibetunnel');
|
||||
|
||||
// Ensure control directory exists
|
||||
if (!fs.existsSync(TTY_FWD_CONTROL_DIR)) {
|
||||
fs.mkdirSync(TTY_FWD_CONTROL_DIR, { recursive: true });
|
||||
console.log(`Created control directory: ${TTY_FWD_CONTROL_DIR}`);
|
||||
}
|
||||
|
||||
console.log(`Using tty-fwd at: ${TTY_FWD_PATH}`);
|
||||
console.log(`Control directory: ${TTY_FWD_CONTROL_DIR}`);
|
||||
|
||||
// Parse JSON bodies
|
||||
app.use(express.json());
|
||||
|
||||
// Hot reload functionality for development
|
||||
const hotReloadClients = new Set<any>();
|
||||
|
||||
// Serve static files
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
|
||||
// Thin wrapper: Get sessions by calling tty-fwd --list-sessions
|
||||
app.get('/api/sessions', async (req, res) => {
|
||||
try {
|
||||
const child = spawn(TTY_FWD_PATH, ['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
|
||||
let output = '';
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
try {
|
||||
const sessions = JSON.parse(output || '{}');
|
||||
const sessionData = [];
|
||||
|
||||
for (const [sessionId, sessionInfo] of Object.entries(sessions as Record<string, any>)) {
|
||||
const sessionPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'session.json');
|
||||
const streamOutPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out');
|
||||
|
||||
let metadata = null;
|
||||
let lastModified = new Date().toISOString();
|
||||
|
||||
if (fs.existsSync(sessionPath)) {
|
||||
metadata = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
|
||||
}
|
||||
|
||||
if (fs.existsSync(streamOutPath)) {
|
||||
const stats = fs.statSync(streamOutPath);
|
||||
lastModified = stats.mtime.toISOString();
|
||||
}
|
||||
|
||||
sessionData.push({
|
||||
id: sessionId,
|
||||
status: sessionInfo.status,
|
||||
metadata: metadata,
|
||||
lastModified: lastModified
|
||||
});
|
||||
}
|
||||
|
||||
sessionData.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
res.json(sessionData);
|
||||
} catch (error) {
|
||||
res.json([]);
|
||||
}
|
||||
} else {
|
||||
res.status(500).json({ error: 'Failed to list sessions' });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
res.status(500).json({ error: 'Failed to execute tty-fwd' });
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Thin wrapper: Create session by calling tty-fwd
|
||||
app.post('/api/sessions', async (req, res) => {
|
||||
try {
|
||||
const { command, workingDir } = req.body;
|
||||
|
||||
if (!command || !Array.isArray(command)) {
|
||||
return res.status(400).json({ error: 'Command array is required' });
|
||||
}
|
||||
|
||||
const sessionName = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const cwd = workingDir ? (workingDir.startsWith('~') ?
|
||||
path.join(os.homedir(), workingDir.slice(2)) :
|
||||
path.resolve(workingDir)) : process.cwd();
|
||||
|
||||
const fullCommand = command.join(' ');
|
||||
const commandLine = `${TTY_FWD_PATH} --control-path "${TTY_FWD_CONTROL_DIR}" --session-name "${sessionName}" -- ${fullCommand}`;
|
||||
|
||||
console.log(`Creating session: ${commandLine}`);
|
||||
|
||||
const ptyProcess = pty.spawn('bash', ['-c', commandLine], {
|
||||
name: 'xterm-color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: cwd,
|
||||
env: process.env
|
||||
});
|
||||
|
||||
// Detach after short delay
|
||||
setTimeout(() => {
|
||||
ptyProcess.kill();
|
||||
}, 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
res.json({ sessionName });
|
||||
}, 500);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating session:', error);
|
||||
res.status(500).json({ error: 'Failed to create session' });
|
||||
}
|
||||
});
|
||||
|
||||
// Server-Sent Events for streaming - preserving the existing streaming functionality
|
||||
app.get('/api/stream/:sessionId', (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
const streamOutPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out');
|
||||
|
||||
if (!fs.existsSync(streamOutPath)) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Cache-Control'
|
||||
});
|
||||
|
||||
const startTime = Date.now() / 1000;
|
||||
let headerSent = false;
|
||||
|
||||
// Send existing content
|
||||
try {
|
||||
const content = fs.readFileSync(streamOutPath, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
if (lines.length > 0) {
|
||||
try {
|
||||
const header = JSON.parse(lines[0]);
|
||||
if (header.version && header.width && header.height) {
|
||||
res.write(`data: ${lines[0]}\n\n`);
|
||||
headerSent = true;
|
||||
|
||||
// Send existing events with instant timestamp
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (lines[i].trim()) {
|
||||
try {
|
||||
const event = JSON.parse(lines[i]);
|
||||
if (Array.isArray(event) && event.length >= 3) {
|
||||
const instantEvent = [0, event[1], event[2]];
|
||||
res.write(`data: ${JSON.stringify(instantEvent)}\n\n`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip invalid header
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading existing content:', error);
|
||||
}
|
||||
|
||||
// Send default header if none found
|
||||
if (!headerSent) {
|
||||
const defaultHeader = {
|
||||
version: 2,
|
||||
width: 80,
|
||||
height: 24,
|
||||
timestamp: Math.floor(startTime),
|
||||
env: { TERM: "xterm-256color" }
|
||||
};
|
||||
res.write(`data: ${JSON.stringify(defaultHeader)}\n\n`);
|
||||
headerSent = true;
|
||||
}
|
||||
|
||||
// Use tail -f to follow new content
|
||||
const tailProcess = spawn('tail', ['-f', streamOutPath], {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let buffer = '';
|
||||
|
||||
tailProcess.stdout.on('data', (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
lines.forEach(line => {
|
||||
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 relativeTime = currentTime - startTime;
|
||||
const realTimeEvent = [relativeTime, parsed[1], parsed[2]];
|
||||
res.write(`data: ${JSON.stringify(realTimeEvent)}\n\n`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Handle non-JSON lines as raw output
|
||||
const currentTime = Date.now() / 1000;
|
||||
const relativeTime = currentTime - startTime;
|
||||
const castEvent = [relativeTime, "o", line];
|
||||
res.write(`data: ${JSON.stringify(castEvent)}\n\n`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup on disconnect
|
||||
req.on('close', () => {
|
||||
tailProcess.kill('SIGTERM');
|
||||
});
|
||||
|
||||
req.on('aborted', () => {
|
||||
tailProcess.kill('SIGTERM');
|
||||
});
|
||||
});
|
||||
|
||||
// Input endpoint for sending text/keys to sessions (like old working version)
|
||||
app.post('/api/input/:sessionId', (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
const { type, value, text } = req.body;
|
||||
|
||||
const inputValue = value || text;
|
||||
|
||||
if (inputValue === undefined || inputValue === null) {
|
||||
return res.status(400).json({ error: 'Input value is required' });
|
||||
}
|
||||
|
||||
console.log(`Sending input to session ${sessionId}:`, JSON.stringify(inputValue));
|
||||
|
||||
// Write directly to stdin file like the old working version
|
||||
const stdinPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'stdin');
|
||||
|
||||
if (!fs.existsSync(stdinPath)) {
|
||||
return res.status(404).json({ error: 'Session stdin not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Write the text directly to the stdin named pipe
|
||||
fs.writeFileSync(stdinPath, inputValue, { encoding: 'binary' });
|
||||
console.log(`Successfully wrote ${inputValue.length} bytes to stdin for session ${sessionId}`);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error writing to stdin:', error);
|
||||
|
||||
// Fallback: try using tty-fwd commands based on input type
|
||||
console.log('Falling back to tty-fwd commands');
|
||||
|
||||
// Check if this is a special key that should use --send-key
|
||||
const specialKeys = ['arrow_up', 'arrow_down', 'arrow_left', 'arrow_right', 'escape', 'enter'];
|
||||
const isSpecialKey = specialKeys.includes(inputValue);
|
||||
|
||||
let sendCommand;
|
||||
if (isSpecialKey) {
|
||||
// Use --send-key for special keys
|
||||
sendCommand = `${TTY_FWD_PATH} --control-path "${TTY_FWD_CONTROL_DIR}" --session "${sessionId}" --send-key "${inputValue}"`;
|
||||
console.log(`Using --send-key for: ${inputValue}`);
|
||||
} else if (typeof inputValue === 'string' && inputValue.length <= 10 && inputValue.charCodeAt(0) < 127) {
|
||||
// Use --send-text for regular text
|
||||
const escapedText = inputValue.replace(/'/g, "'\"'\"'");
|
||||
sendCommand = `${TTY_FWD_PATH} --control-path "${TTY_FWD_CONTROL_DIR}" --session "${sessionId}" --send-text '${escapedText}'`;
|
||||
console.log(`Using --send-text for: ${inputValue}`);
|
||||
} else {
|
||||
console.error('Input value not supported by tty-fwd fallback:', inputValue);
|
||||
return res.status(500).json({ error: 'Input type not supported' });
|
||||
}
|
||||
|
||||
const inputChild = spawn('bash', ['-c', sendCommand], {
|
||||
stdio: 'ignore'
|
||||
});
|
||||
|
||||
inputChild.on('error', (error) => {
|
||||
console.error('Error sending input via tty-fwd:', error);
|
||||
return res.status(500).json({ error: 'Failed to send input' });
|
||||
});
|
||||
|
||||
inputChild.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
console.log(`Successfully sent input via tty-fwd: ${inputValue}`);
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
console.error(`tty-fwd failed with code: ${code}`);
|
||||
res.status(500).json({ error: 'Failed to send input' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Thin wrapper: Kill session by calling tty-fwd
|
||||
app.delete('/api/sessions/:sessionId', (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
|
||||
// Get session info first
|
||||
const sessionsChild = spawn(TTY_FWD_PATH, ['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
|
||||
let output = '';
|
||||
|
||||
sessionsChild.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
sessionsChild.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
try {
|
||||
const sessions = JSON.parse(output || '{}');
|
||||
const session = sessions[sessionId];
|
||||
|
||||
if (session && session.pid) {
|
||||
try {
|
||||
process.kill(session.pid, 'SIGTERM');
|
||||
setTimeout(() => {
|
||||
try {
|
||||
process.kill(session.pid, 0);
|
||||
process.kill(session.pid, 'SIGKILL');
|
||||
} catch (e) {
|
||||
// Process already dead
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
// Process already dead
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup using tty-fwd
|
||||
setTimeout(() => {
|
||||
const cleanupChild = spawn(TTY_FWD_PATH, [
|
||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||
'--session', sessionId,
|
||||
'--cleanup'
|
||||
]);
|
||||
|
||||
cleanupChild.on('close', (cleanupCode) => {
|
||||
if (cleanupCode !== 0) {
|
||||
// Force remove if cleanup failed
|
||||
const sessionDir = path.join(TTY_FWD_CONTROL_DIR, sessionId);
|
||||
try {
|
||||
if (fs.existsSync(sessionDir)) {
|
||||
fs.rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error force removing session directory:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
res.json({ success: true, message: 'Session killed' });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error parsing sessions:', error);
|
||||
res.status(500).json({ error: 'Failed to get session info' });
|
||||
}
|
||||
} else {
|
||||
res.status(500).json({ error: 'Failed to list sessions' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Directory listing endpoint (needed by frontend)
|
||||
app.get('/api/ls', (req, res) => {
|
||||
const dirPath = req.query.dir as string || '~';
|
||||
|
||||
try {
|
||||
const expandedPath = dirPath.startsWith('~') ?
|
||||
path.join(os.homedir(), dirPath.slice(1)) :
|
||||
path.resolve(dirPath);
|
||||
|
||||
if (!fs.existsSync(expandedPath)) {
|
||||
return res.status(404).json({ error: 'Directory not found' });
|
||||
}
|
||||
|
||||
const stats = fs.statSync(expandedPath);
|
||||
if (!stats.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Path is not a directory' });
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(expandedPath).map(name => {
|
||||
const filePath = path.join(expandedPath, name);
|
||||
const fileStats = fs.statSync(filePath);
|
||||
|
||||
return {
|
||||
name,
|
||||
created: fileStats.birthtime.toISOString(),
|
||||
lastModified: fileStats.mtime.toISOString(),
|
||||
size: fileStats.size,
|
||||
isDir: fileStats.isDirectory()
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
absolutePath: expandedPath,
|
||||
files: files.sort((a, b) => {
|
||||
// Directories first, then files
|
||||
if (a.isDir && !b.isDir) return -1;
|
||||
if (!a.isDir && b.isDir) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error listing directory:', error);
|
||||
res.status(500).json({ error: 'Failed to list directory' });
|
||||
}
|
||||
});
|
||||
|
||||
// WebSocket for hot reload
|
||||
wss.on('connection', (ws, req) => {
|
||||
const url = new URL(req.url!, `http://${req.headers.host}`);
|
||||
const isHotReload = url.searchParams.get('hotReload') === 'true';
|
||||
|
||||
if (isHotReload) {
|
||||
hotReloadClients.add(ws);
|
||||
ws.on('close', () => {
|
||||
hotReloadClients.delete(ws);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ws.close(1008, 'Only hot reload connections supported');
|
||||
});
|
||||
|
||||
// Hot reload file watching in development
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const chokidar = require('chokidar');
|
||||
const watcher = chokidar.watch(['public/**/*', 'src/**/*'], {
|
||||
ignored: /node_modules/,
|
||||
persistent: true
|
||||
});
|
||||
|
||||
watcher.on('change', (path: string) => {
|
||||
console.log(`File changed: ${path}`);
|
||||
hotReloadClients.forEach((ws: any) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'reload' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`VibeTunnel server running on http://localhost:${PORT}`);
|
||||
console.log(`Using tty-fwd: ${TTY_FWD_PATH}`);
|
||||
});
|
||||
Loading…
Reference in a new issue