mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-22 14:06:02 +00:00
cleanup git
This commit is contained in:
parent
f7d72acfca
commit
d013019b16
47 changed files with 0 additions and 5643 deletions
Binary file not shown.
5
web/dist/client/app-entry.js
vendored
5
web/dist/client/app-entry.js
vendored
|
|
@ -1,5 +0,0 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
// Entry point for the app
|
||||
require("./app.js");
|
||||
//# sourceMappingURL=app-entry.js.map
|
||||
1
web/dist/client/app-entry.js.map
vendored
1
web/dist/client/app-entry.js.map
vendored
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"app-entry.js","sourceRoot":"","sources":["../../src/client/app-entry.ts"],"names":[],"mappings":";;AAAA,0BAA0B;AAC1B,oBAAkB"}
|
||||
311
web/dist/client/app.js
vendored
311
web/dist/client/app.js
vendored
|
|
@ -1,311 +0,0 @@
|
|||
"use strict";
|
||||
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;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.VibeTunnelApp = void 0;
|
||||
const lit_1 = require("lit");
|
||||
const decorators_js_1 = require("lit/decorators.js");
|
||||
// Import components
|
||||
require("./components/app-header.js");
|
||||
require("./components/session-create-form.js");
|
||||
require("./components/session-list.js");
|
||||
require("./components/session-view.js");
|
||||
require("./components/session-card.js");
|
||||
let VibeTunnelApp = class VibeTunnelApp extends lit_1.LitElement {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.errorMessage = '';
|
||||
this.sessions = [];
|
||||
this.loading = false;
|
||||
this.currentView = 'list';
|
||||
this.selectedSession = null;
|
||||
this.hideExited = true;
|
||||
this.showCreateModal = false;
|
||||
this.hotReloadWs = null;
|
||||
this.handlePopState = (event) => {
|
||||
// Handle browser back/forward navigation
|
||||
this.parseUrlAndSetState();
|
||||
};
|
||||
}
|
||||
// Disable shadow DOM to use Tailwind
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setupHotReload();
|
||||
this.loadSessions();
|
||||
this.startAutoRefresh();
|
||||
this.setupRouting();
|
||||
}
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.hotReloadWs) {
|
||||
this.hotReloadWs.close();
|
||||
}
|
||||
// Clean up routing listeners
|
||||
window.removeEventListener('popstate', this.handlePopState);
|
||||
}
|
||||
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, but only when showing session list
|
||||
setInterval(() => {
|
||||
if (this.currentView === 'list') {
|
||||
this.loadSessions();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
async handleSessionCreated(e) {
|
||||
const sessionId = e.detail.sessionId;
|
||||
if (!sessionId) {
|
||||
this.showError('Session created but ID not found in response');
|
||||
return;
|
||||
}
|
||||
this.showCreateModal = false;
|
||||
// Wait for session to appear in the list and then switch to it
|
||||
await this.waitForSessionAndSwitch(sessionId);
|
||||
}
|
||||
async waitForSessionAndSwitch(sessionId) {
|
||||
const maxAttempts = 10;
|
||||
const delay = 500; // 500ms between attempts
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
await this.loadSessions();
|
||||
// Try to find by exact ID match first
|
||||
let session = this.sessions.find(s => s.id === sessionId);
|
||||
// If not found by ID, find the most recently created session
|
||||
// This works around tty-fwd potentially using different IDs internally
|
||||
if (!session && this.sessions.length > 0) {
|
||||
const sortedSessions = [...this.sessions].sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
|
||||
session = sortedSessions[0];
|
||||
}
|
||||
if (session) {
|
||||
// Session found, switch to session view
|
||||
this.selectedSession = session;
|
||||
this.currentView = 'session';
|
||||
// Update URL to include session ID
|
||||
this.updateUrl(session.id);
|
||||
return;
|
||||
}
|
||||
// Wait before next attempt
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
// If we get here, session creation might have failed
|
||||
console.log('Session not found after all attempts');
|
||||
this.showError('Session created but could not be found. Please refresh.');
|
||||
}
|
||||
handleSessionSelect(e) {
|
||||
const session = e.detail;
|
||||
console.log('Session selected:', session);
|
||||
this.selectedSession = session;
|
||||
this.currentView = 'session';
|
||||
// Update URL to include session ID
|
||||
this.updateUrl(session.id);
|
||||
}
|
||||
handleBack() {
|
||||
this.currentView = 'list';
|
||||
this.selectedSession = null;
|
||||
// Update URL to remove session parameter
|
||||
this.updateUrl();
|
||||
}
|
||||
handleSessionKilled(e) {
|
||||
console.log('Session killed:', e.detail);
|
||||
this.loadSessions(); // Refresh the list
|
||||
}
|
||||
handleRefresh() {
|
||||
this.loadSessions();
|
||||
}
|
||||
handleError(e) {
|
||||
this.showError(e.detail);
|
||||
}
|
||||
handleHideExitedChange(e) {
|
||||
this.hideExited = e.detail;
|
||||
}
|
||||
handleCreateSession() {
|
||||
this.showCreateModal = true;
|
||||
}
|
||||
handleCreateModalClose() {
|
||||
this.showCreateModal = false;
|
||||
}
|
||||
// URL Routing methods
|
||||
setupRouting() {
|
||||
// Handle browser back/forward navigation
|
||||
window.addEventListener('popstate', this.handlePopState.bind(this));
|
||||
// Parse initial URL and set state
|
||||
this.parseUrlAndSetState();
|
||||
}
|
||||
parseUrlAndSetState() {
|
||||
const url = new URL(window.location.href);
|
||||
const sessionId = url.searchParams.get('session');
|
||||
if (sessionId) {
|
||||
// Load the specific session
|
||||
this.loadSessionFromUrl(sessionId);
|
||||
}
|
||||
else {
|
||||
// Show session list
|
||||
this.currentView = 'list';
|
||||
this.selectedSession = null;
|
||||
}
|
||||
}
|
||||
async loadSessionFromUrl(sessionId) {
|
||||
// First ensure sessions are loaded
|
||||
if (this.sessions.length === 0) {
|
||||
await this.loadSessions();
|
||||
}
|
||||
// Find the session
|
||||
const session = this.sessions.find(s => s.id === sessionId);
|
||||
if (session) {
|
||||
this.selectedSession = session;
|
||||
this.currentView = 'session';
|
||||
}
|
||||
else {
|
||||
// Session not found, go to list view
|
||||
this.currentView = 'list';
|
||||
this.selectedSession = null;
|
||||
// Update URL to remove invalid session ID
|
||||
this.updateUrl();
|
||||
}
|
||||
}
|
||||
updateUrl(sessionId) {
|
||||
const url = new URL(window.location.href);
|
||||
if (sessionId) {
|
||||
url.searchParams.set('session', sessionId);
|
||||
}
|
||||
else {
|
||||
url.searchParams.delete('session');
|
||||
}
|
||||
// Update browser URL without triggering page reload
|
||||
window.history.pushState(null, '', url.toString());
|
||||
}
|
||||
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 (0, lit_1.html) `
|
||||
<!-- Error notification overlay -->
|
||||
${this.errorMessage ? (0, lit_1.html) `
|
||||
<div class="fixed top-4 right-4 z-50">
|
||||
<div class="bg-vs-warning text-vs-bg px-4 py-2 rounded shadow-lg font-mono text-sm">
|
||||
${this.errorMessage}
|
||||
<button @click=${this.clearError} class="ml-2 text-vs-bg hover:text-vs-muted">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Main content -->
|
||||
${this.currentView === 'session' ? (0, lit_1.html) `
|
||||
<session-view
|
||||
.session=${this.selectedSession}
|
||||
@back=${this.handleBack}
|
||||
></session-view>
|
||||
` : (0, lit_1.html) `
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<app-header
|
||||
@create-session=${this.handleCreateSession}
|
||||
></app-header>
|
||||
<session-list
|
||||
.sessions=${this.sessions}
|
||||
.loading=${this.loading}
|
||||
.hideExited=${this.hideExited}
|
||||
.showCreateModal=${this.showCreateModal}
|
||||
@session-select=${this.handleSessionSelect}
|
||||
@session-killed=${this.handleSessionKilled}
|
||||
@session-created=${this.handleSessionCreated}
|
||||
@create-modal-close=${this.handleCreateModalClose}
|
||||
@refresh=${this.handleRefresh}
|
||||
@error=${this.handleError}
|
||||
@hide-exited-change=${this.handleHideExitedChange}
|
||||
></session-list>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
};
|
||||
exports.VibeTunnelApp = VibeTunnelApp;
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], VibeTunnelApp.prototype, "errorMessage", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Array)
|
||||
], VibeTunnelApp.prototype, "sessions", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], VibeTunnelApp.prototype, "loading", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", String)
|
||||
], VibeTunnelApp.prototype, "currentView", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], VibeTunnelApp.prototype, "selectedSession", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], VibeTunnelApp.prototype, "hideExited", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], VibeTunnelApp.prototype, "showCreateModal", void 0);
|
||||
exports.VibeTunnelApp = VibeTunnelApp = __decorate([
|
||||
(0, decorators_js_1.customElement)('vibetunnel-app')
|
||||
], VibeTunnelApp);
|
||||
//# sourceMappingURL=app.js.map
|
||||
1
web/dist/client/app.js.map
vendored
1
web/dist/client/app.js.map
vendored
File diff suppressed because one or more lines are too long
39
web/dist/client/components/app-header.js
vendored
39
web/dist/client/components/app-header.js
vendored
|
|
@ -1,39 +0,0 @@
|
|||
"use strict";
|
||||
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;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AppHeader = void 0;
|
||||
const lit_1 = require("lit");
|
||||
const decorators_js_1 = require("lit/decorators.js");
|
||||
let AppHeader = class AppHeader extends lit_1.LitElement {
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
handleCreateSession() {
|
||||
this.dispatchEvent(new CustomEvent('create-session'));
|
||||
}
|
||||
render() {
|
||||
return (0, lit_1.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>
|
||||
`;
|
||||
}
|
||||
};
|
||||
exports.AppHeader = AppHeader;
|
||||
exports.AppHeader = AppHeader = __decorate([
|
||||
(0, decorators_js_1.customElement)('app-header')
|
||||
], AppHeader);
|
||||
//# sourceMappingURL=app-header.js.map
|
||||
1
web/dist/client/components/app-header.js.map
vendored
1
web/dist/client/components/app-header.js.map
vendored
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"app-header.js","sourceRoot":"","sources":["../../../src/client/components/app-header.ts"],"names":[],"mappings":";;;;;;;;;AAAA,6BAAuC;AACvC,qDAAkD;AAG3C,IAAM,SAAS,GAAf,MAAM,SAAU,SAAQ,gBAAU;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,IAAA,UAAI,EAAA;;;;;;qBAMM,IAAI,CAAC,mBAAmB;;;;;;KAMxC,CAAC;IACJ,CAAC;CACF,CAAA;AAxBY,8BAAS;oBAAT,SAAS;IADrB,IAAA,6BAAa,EAAC,YAAY,CAAC;GACf,SAAS,CAwBrB"}
|
||||
269
web/dist/client/components/file-browser.js
vendored
269
web/dist/client/components/file-browser.js
vendored
|
|
@ -1,269 +0,0 @@
|
|||
"use strict";
|
||||
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;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.FileBrowser = void 0;
|
||||
const lit_1 = require("lit");
|
||||
const decorators_js_1 = require("lit/decorators.js");
|
||||
let FileBrowser = class FileBrowser extends lit_1.LitElement {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.currentPath = '~';
|
||||
this.visible = false;
|
||||
this.files = [];
|
||||
this.loading = false;
|
||||
this.showCreateFolder = false;
|
||||
this.newFolderName = '';
|
||||
this.creating = 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'));
|
||||
}
|
||||
handleCreateFolder() {
|
||||
this.showCreateFolder = true;
|
||||
this.newFolderName = '';
|
||||
}
|
||||
handleCancelCreateFolder() {
|
||||
this.showCreateFolder = false;
|
||||
this.newFolderName = '';
|
||||
}
|
||||
handleFolderNameInput(e) {
|
||||
this.newFolderName = e.target.value;
|
||||
}
|
||||
handleFolderNameKeydown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.createFolder();
|
||||
}
|
||||
else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.handleCancelCreateFolder();
|
||||
}
|
||||
}
|
||||
async createFolder() {
|
||||
if (!this.newFolderName.trim())
|
||||
return;
|
||||
this.creating = true;
|
||||
try {
|
||||
const response = await fetch('/api/mkdir', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: this.currentPath,
|
||||
name: this.newFolderName.trim()
|
||||
})
|
||||
});
|
||||
if (response.ok) {
|
||||
// Refresh directory listing
|
||||
await this.loadDirectory(this.currentPath);
|
||||
this.handleCancelCreateFolder();
|
||||
}
|
||||
else {
|
||||
const error = await response.json();
|
||||
alert(`Failed to create folder: ${error.error}`);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error creating folder:', error);
|
||||
alert('Failed to create folder');
|
||||
}
|
||||
finally {
|
||||
this.creating = false;
|
||||
}
|
||||
}
|
||||
render() {
|
||||
if (!this.visible) {
|
||||
return (0, lit_1.html) ``;
|
||||
}
|
||||
return (0, lit_1.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="flex justify-between items-center mb-2">
|
||||
<div class="text-vs-assistant text-sm">Select Directory</div>
|
||||
<button
|
||||
class="bg-vs-user text-vs-bg hover:bg-vs-accent font-mono px-2 py-1 text-xs border-none rounded"
|
||||
@click=${this.handleCreateFolder}
|
||||
?disabled=${this.loading}
|
||||
title="Create new folder"
|
||||
>
|
||||
+ folder
|
||||
</button>
|
||||
</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 ? (0, lit_1.html) `
|
||||
<div class="text-vs-muted">Loading...</div>
|
||||
` : (0, lit_1.html) `
|
||||
${this.currentPath !== '/' ? (0, lit_1.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 => (0, lit_1.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 => (0, lit_1.html) `
|
||||
<div class="flex items-center gap-2 p-2 text-vs-muted">
|
||||
<span>📄</span>
|
||||
<span>${file.name}</span>
|
||||
</div>
|
||||
`)}
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Create folder dialog -->
|
||||
${this.showCreateFolder ? (0, lit_1.html) `
|
||||
<div class="p-4 border-t border-vs-border flex-shrink-0">
|
||||
<div class="text-vs-assistant text-sm mb-2">Create New Folder</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 bg-vs-bg border border-vs-border text-vs-text px-2 py-1 text-sm font-mono"
|
||||
placeholder="Folder name"
|
||||
.value=${this.newFolderName}
|
||||
@input=${this.handleFolderNameInput}
|
||||
@keydown=${this.handleFolderNameKeydown}
|
||||
?disabled=${this.creating}
|
||||
/>
|
||||
<button
|
||||
class="bg-vs-user text-vs-bg hover:bg-vs-accent font-mono px-2 py-1 text-xs border-none"
|
||||
@click=${this.createFolder}
|
||||
?disabled=${this.creating || !this.newFolderName.trim()}
|
||||
>
|
||||
${this.creating ? '...' : 'create'}
|
||||
</button>
|
||||
<button
|
||||
class="bg-vs-muted text-vs-bg hover:bg-vs-text font-mono px-2 py-1 text-xs border-none"
|
||||
@click=${this.handleCancelCreateFolder}
|
||||
?disabled=${this.creating}
|
||||
>
|
||||
cancel
|
||||
</button>
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
};
|
||||
exports.FileBrowser = FileBrowser;
|
||||
__decorate([
|
||||
(0, decorators_js_1.property)({ type: String }),
|
||||
__metadata("design:type", Object)
|
||||
], FileBrowser.prototype, "currentPath", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.property)({ type: Boolean }),
|
||||
__metadata("design:type", Object)
|
||||
], FileBrowser.prototype, "visible", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Array)
|
||||
], FileBrowser.prototype, "files", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], FileBrowser.prototype, "loading", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], FileBrowser.prototype, "showCreateFolder", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], FileBrowser.prototype, "newFolderName", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], FileBrowser.prototype, "creating", void 0);
|
||||
exports.FileBrowser = FileBrowser = __decorate([
|
||||
(0, decorators_js_1.customElement)('file-browser')
|
||||
], FileBrowser);
|
||||
//# sourceMappingURL=file-browser.js.map
|
||||
File diff suppressed because one or more lines are too long
173
web/dist/client/components/session-card.js
vendored
173
web/dist/client/components/session-card.js
vendored
|
|
@ -1,173 +0,0 @@
|
|||
"use strict";
|
||||
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;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.SessionCard = void 0;
|
||||
const lit_1 = require("lit");
|
||||
const decorators_js_1 = require("lit/decorators.js");
|
||||
const renderer_js_1 = require("../renderer.js");
|
||||
let SessionCard = class SessionCard extends lit_1.LitElement {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.renderer = null;
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
// Disable shadow DOM to use Tailwind
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
firstUpdated(changedProperties) {
|
||||
super.firstUpdated(changedProperties);
|
||||
this.createRenderer();
|
||||
this.startRefresh();
|
||||
}
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose();
|
||||
this.renderer = null;
|
||||
}
|
||||
}
|
||||
createRenderer() {
|
||||
const playerElement = this.querySelector('#player');
|
||||
if (!playerElement)
|
||||
return;
|
||||
// Create single renderer for this card - use larger dimensions for better preview
|
||||
this.renderer = new renderer_js_1.Renderer(playerElement, 80, 24, 10000, 8, true);
|
||||
// Always use snapshot endpoint for cards
|
||||
const url = `/api/sessions/${this.session.id}/snapshot`;
|
||||
// Wait a moment for freshly created sessions before connecting
|
||||
const sessionAge = Date.now() - new Date(this.session.startedAt).getTime();
|
||||
const delay = sessionAge < 5000 ? 2000 : 0; // 2 second delay if session is less than 5 seconds old
|
||||
setTimeout(() => {
|
||||
if (this.renderer) {
|
||||
this.renderer.loadFromUrl(url, false); // false = not a stream, use snapshot
|
||||
// Disable pointer events so clicks pass through to the card
|
||||
this.renderer.setPointerEventsEnabled(false);
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
startRefresh() {
|
||||
this.refreshInterval = window.setInterval(() => {
|
||||
if (this.renderer) {
|
||||
const url = `/api/sessions/${this.session.id}/snapshot`;
|
||||
this.renderer.loadFromUrl(url, false);
|
||||
// Ensure pointer events stay disabled after refresh
|
||||
this.renderer.setPointerEventsEnabled(false);
|
||||
}
|
||||
}, 10000); // Refresh every 10 seconds
|
||||
}
|
||||
handleCardClick() {
|
||||
this.dispatchEvent(new CustomEvent('session-select', {
|
||||
detail: this.session,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
handleKillClick(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.dispatchEvent(new CustomEvent('session-kill', {
|
||||
detail: this.session.id,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
async handlePidClick(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (this.session.pid) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.session.pid.toString());
|
||||
console.log('PID copied to clipboard:', this.session.pid);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to copy PID to clipboard:', error);
|
||||
// Fallback: select text manually
|
||||
this.fallbackCopyToClipboard(this.session.pid.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
fallbackCopyToClipboard(text) {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
console.log('PID copied to clipboard (fallback):', text);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Fallback copy failed:', error);
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
render() {
|
||||
const isRunning = this.session.status === 'running';
|
||||
return (0, lit_1.html) `
|
||||
<div class="bg-vs-bg border border-vs-border rounded shadow cursor-pointer overflow-hidden"
|
||||
@click=${this.handleCardClick}>
|
||||
<!-- 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">${this.session.command}</div>
|
||||
${this.session.status === 'running' ? (0, lit_1.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=${this.handleKillClick}
|
||||
>
|
||||
${this.session.status === 'running' ? 'kill' : 'clean'}
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- XTerm renderer (main content) -->
|
||||
<div class="session-preview bg-black overflow-hidden" style="aspect-ratio: 640/480;">
|
||||
<div id="player" class="w-full h-full"></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="${this.session.status === 'running' ? 'text-vs-user' : 'text-vs-warning'} text-xs">
|
||||
${this.session.status}
|
||||
</span>
|
||||
${this.session.pid ? (0, lit_1.html) `
|
||||
<span
|
||||
class="cursor-pointer hover:text-vs-accent transition-colors"
|
||||
@click=${this.handlePidClick}
|
||||
title="Click to copy PID"
|
||||
>
|
||||
PID: ${this.session.pid} <span class="opacity-50">(click to copy)</span>
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="truncate text-xs opacity-75" title="${this.session.workingDir}">${this.session.workingDir}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
exports.SessionCard = SessionCard;
|
||||
__decorate([
|
||||
(0, decorators_js_1.property)({ type: Object }),
|
||||
__metadata("design:type", Object)
|
||||
], SessionCard.prototype, "session", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], SessionCard.prototype, "renderer", void 0);
|
||||
exports.SessionCard = SessionCard = __decorate([
|
||||
(0, decorators_js_1.customElement)('session-card')
|
||||
], SessionCard);
|
||||
//# sourceMappingURL=session-card.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"session-card.js","sourceRoot":"","sources":["../../../src/client/components/session-card.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,6BAA4D;AAC5D,qDAAmE;AACnE,gDAA0C;AAcnC,IAAM,WAAW,GAAjB,MAAM,WAAY,SAAQ,gBAAU;IAApC;;QAOY,aAAQ,GAAoB,IAAI,CAAC;QAE1C,oBAAe,GAAkB,IAAI,CAAC;IAoJhD,CAAC;IA5JC,qCAAqC;IACrC,gBAAgB;QACd,OAAO,IAAI,CAAC;IACd,CAAC;IAOD,YAAY,CAAC,iBAAiC;QAC5C,KAAK,CAAC,YAAY,CAAC,iBAAiB,CAAC,CAAC;QACtC,IAAI,CAAC,cAAc,EAAE,CAAC;QACtB,IAAI,CAAC,YAAY,EAAE,CAAC;IACtB,CAAC;IAED,oBAAoB;QAClB,KAAK,CAAC,oBAAoB,EAAE,CAAC;QAC7B,IAAI,IAAI,CAAC,eAAe,EAAE,CAAC;YACzB,aAAa,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;QACtC,CAAC;QACD,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;YAClB,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC;YACxB,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;QACvB,CAAC;IACH,CAAC;IAEO,cAAc;QACpB,MAAM,aAAa,GAAG,IAAI,CAAC,aAAa,CAAC,SAAS,CAAgB,CAAC;QACnE,IAAI,CAAC,aAAa;YAAE,OAAO;QAE3B,kFAAkF;QAClF,IAAI,CAAC,QAAQ,GAAG,IAAI,sBAAQ,CAAC,aAAa,EAAE,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC;QAEpE,yCAAyC;QACzC,MAAM,GAAG,GAAG,iBAAiB,IAAI,CAAC,OAAO,CAAC,EAAE,WAAW,CAAC;QAExD,+DAA+D;QAC/D,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,OAAO,EAAE,CAAC;QAC3E,MAAM,KAAK,GAAG,UAAU,GAAG,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,uDAAuD;QAEnG,UAAU,CAAC,GAAG,EAAE;YACd,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;gBAClB,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC,qCAAqC;gBAC5E,4DAA4D;gBAC5D,IAAI,CAAC,QAAQ,CAAC,uBAAuB,CAAC,KAAK,CAAC,CAAC;YAC/C,CAAC;QACH,CAAC,EAAE,KAAK,CAAC,CAAC;IACZ,CAAC;IAEO,YAAY;QAClB,IAAI,CAAC,eAAe,GAAG,MAAM,CAAC,WAAW,CAAC,GAAG,EAAE;YAC7C,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;gBAClB,MAAM,GAAG,GAAG,iBAAiB,IAAI,CAAC,OAAO,CAAC,EAAE,WAAW,CAAC;gBACxD,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;gBACtC,oDAAoD;gBACpD,IAAI,CAAC,QAAQ,CAAC,uBAAuB,CAAC,KAAK,CAAC,CAAC;YAC/C,CAAC;QACH,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,2BAA2B;IACxC,CAAC;IAEO,eAAe;QACrB,IAAI,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,gBAAgB,EAAE;YACnD,MAAM,EAAE,IAAI,CAAC,OAAO;YACpB,OAAO,EAAE,IAAI;YACb,QAAQ,EAAE,IAAI;SACf,CAAC,CAAC,CAAC;IACN,CAAC;IAEO,eAAe,CAAC,CAAQ;QAC9B,CAAC,CAAC,eAAe,EAAE,CAAC;QACpB,CAAC,CAAC,cAAc,EAAE,CAAC;QACnB,IAAI,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,cAAc,EAAE;YACjD,MAAM,EAAE,IAAI,CAAC,OAAO,CAAC,EAAE;YACvB,OAAO,EAAE,IAAI;YACb,QAAQ,EAAE,IAAI;SACf,CAAC,CAAC,CAAC;IACN,CAAC;IAEO,KAAK,CAAC,cAAc,CAAC,CAAQ;QACnC,CAAC,CAAC,eAAe,EAAE,CAAC;QACpB,CAAC,CAAC,cAAc,EAAE,CAAC;QAEnB,IAAI,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;YACrB,IAAI,CAAC;gBACH,MAAM,SAAS,CAAC,SAAS,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAC;gBACjE,OAAO,CAAC,GAAG,CAAC,0BAA0B,EAAE,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;YAC5D,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,OAAO,CAAC,KAAK,CAAC,kCAAkC,EAAE,KAAK,CAAC,CAAC;gBACzD,iCAAiC;gBACjC,IAAI,CAAC,uBAAuB,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAC;YAC5D,CAAC;QACH,CAAC;IACH,CAAC;IAEO,uBAAuB,CAAC,IAAY;QAC1C,MAAM,QAAQ,GAAG,QAAQ,CAAC,aAAa,CAAC,UAAU,CAAC,CAAC;QACpD,QAAQ,CAAC,KAAK,GAAG,IAAI,CAAC;QACtB,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC;QACpC,QAAQ,CAAC,KAAK,EAAE,CAAC;QACjB,QAAQ,CAAC,MAAM,EAAE,CAAC;QAClB,IAAI,CAAC;YACH,QAAQ,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;YAC7B,OAAO,CAAC,GAAG,CAAC,qCAAqC,EAAE,IAAI,CAAC,CAAC;QAC3D,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,uBAAuB,EAAE,KAAK,CAAC,CAAC;QAChD,CAAC;QACD,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC;IACtC,CAAC;IAED,MAAM;QACJ,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC;QAEpD,OAAO,IAAA,UAAI,EAAA;;oBAEK,IAAI,CAAC,eAAe;;;6EAGqC,IAAI,CAAC,OAAO,CAAC,OAAO;YACrF,IAAI,CAAC,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,IAAA,UAAI,EAAA;;;uBAG7B,IAAI,CAAC,eAAe;;gBAE3B,IAAI,CAAC,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO;;WAEzD,CAAC,CAAC,CAAC,EAAE;;;;;;;;;;;2BAWW,IAAI,CAAC,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,iBAAiB;gBACjF,IAAI,CAAC,OAAO,CAAC,MAAM;;cAErB,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,IAAA,UAAI,EAAA;;;yBAGZ,IAAI,CAAC,cAAc;;;uBAGrB,IAAI,CAAC,OAAO,CAAC,GAAG;;aAE1B,CAAC,CAAC,CAAC,EAAE;;4DAE0C,IAAI,CAAC,OAAO,CAAC,UAAU,KAAK,IAAI,CAAC,OAAO,CAAC,UAAU;;;KAG1G,CAAC;IACJ,CAAC;CAEF,CAAA;AA7JY,kCAAW;AAMM;IAA3B,IAAA,wBAAQ,EAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;;4CAAmB;AAC7B;IAAhB,IAAA,qBAAK,GAAE;;6CAA0C;sBAPvC,WAAW;IADvB,IAAA,6BAAa,EAAC,cAAc,CAAC;GACjB,WAAW,CA6JvB"}
|
||||
235
web/dist/client/components/session-create-form.js
vendored
235
web/dist/client/components/session-create-form.js
vendored
|
|
@ -1,235 +0,0 @@
|
|||
"use strict";
|
||||
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;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.SessionCreateForm = void 0;
|
||||
const lit_1 = require("lit");
|
||||
const decorators_js_1 = require("lit/decorators.js");
|
||||
require("./file-browser.js");
|
||||
let SessionCreateForm = class SessionCreateForm extends lit_1.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 (0, lit_1.html) ``;
|
||||
}
|
||||
return (0, lit_1.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>
|
||||
`;
|
||||
}
|
||||
};
|
||||
exports.SessionCreateForm = SessionCreateForm;
|
||||
__decorate([
|
||||
(0, decorators_js_1.property)({ type: String }),
|
||||
__metadata("design:type", Object)
|
||||
], SessionCreateForm.prototype, "workingDir", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.property)({ type: String }),
|
||||
__metadata("design:type", Object)
|
||||
], SessionCreateForm.prototype, "command", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.property)({ type: Boolean }),
|
||||
__metadata("design:type", Object)
|
||||
], SessionCreateForm.prototype, "disabled", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.property)({ type: Boolean }),
|
||||
__metadata("design:type", Object)
|
||||
], SessionCreateForm.prototype, "visible", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], SessionCreateForm.prototype, "isCreating", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], SessionCreateForm.prototype, "showFileBrowser", void 0);
|
||||
exports.SessionCreateForm = SessionCreateForm = __decorate([
|
||||
(0, decorators_js_1.customElement)('session-create-form')
|
||||
], SessionCreateForm);
|
||||
//# sourceMappingURL=session-create-form.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"session-create-form.js","sourceRoot":"","sources":["../../../src/client/components/session-create-form.ts"],"names":[],"mappings":"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"}
|
||||
181
web/dist/client/components/session-list.js
vendored
181
web/dist/client/components/session-list.js
vendored
|
|
@ -1,181 +0,0 @@
|
|||
"use strict";
|
||||
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;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.SessionList = void 0;
|
||||
const lit_1 = require("lit");
|
||||
const decorators_js_1 = require("lit/decorators.js");
|
||||
const repeat_js_1 = require("lit/directives/repeat.js");
|
||||
require("./session-create-form.js");
|
||||
require("./session-card.js");
|
||||
let SessionList = class SessionList extends lit_1.LitElement {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.sessions = [];
|
||||
this.loading = false;
|
||||
this.hideExited = true;
|
||||
this.showCreateModal = false;
|
||||
this.killingSessionIds = new Set();
|
||||
this.cleaningExited = false;
|
||||
}
|
||||
// Disable shadow DOM to use Tailwind
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
handleRefresh() {
|
||||
this.dispatchEvent(new CustomEvent('refresh'));
|
||||
}
|
||||
handleSessionSelect(e) {
|
||||
const session = e.detail;
|
||||
window.location.search = `?session=${session.id}`;
|
||||
}
|
||||
async handleSessionKill(e) {
|
||||
const sessionId = e.detail;
|
||||
if (this.killingSessionIds.has(sessionId))
|
||||
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 }));
|
||||
}
|
||||
else {
|
||||
this.dispatchEvent(new CustomEvent('error', { detail: 'Failed to kill session' }));
|
||||
}
|
||||
}
|
||||
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 handleCleanupExited() {
|
||||
if (this.cleaningExited)
|
||||
return;
|
||||
this.cleaningExited = true;
|
||||
this.requestUpdate();
|
||||
try {
|
||||
const response = await fetch('/api/cleanup-exited', {
|
||||
method: 'POST'
|
||||
});
|
||||
if (response.ok) {
|
||||
this.dispatchEvent(new CustomEvent('refresh'));
|
||||
}
|
||||
else {
|
||||
this.dispatchEvent(new CustomEvent('error', { detail: 'Failed to cleanup exited sessions' }));
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error cleaning up exited sessions:', error);
|
||||
this.dispatchEvent(new CustomEvent('error', { detail: 'Failed to cleanup exited sessions' }));
|
||||
}
|
||||
finally {
|
||||
this.cleaningExited = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const filteredSessions = this.hideExited
|
||||
? this.sessions.filter(session => session.status !== 'exited')
|
||||
: this.sessions;
|
||||
return (0, lit_1.html) `
|
||||
<div class="font-mono text-sm p-4">
|
||||
<!-- Controls -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
${!this.hideExited ? (0, lit_1.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.handleCleanupExited}
|
||||
?disabled=${this.cleaningExited || this.sessions.filter(s => s.status === 'exited').length === 0}
|
||||
>
|
||||
${this.cleaningExited ? '[~] CLEANING...' : 'CLEAN EXITED'}
|
||||
</button>
|
||||
` : (0, lit_1.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=${(e) => this.dispatchEvent(new CustomEvent('hide-exited-change', { detail: e.target.checked }))}
|
||||
>
|
||||
<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 ? (0, lit_1.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>
|
||||
${filteredSessions.length === 0 ? (0, lit_1.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>
|
||||
` : (0, lit_1.html) `
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
${(0, repeat_js_1.repeat)(filteredSessions, (session) => session.id, (session) => (0, lit_1.html) `
|
||||
<session-card
|
||||
.session=${session}
|
||||
@session-select=${this.handleSessionSelect}
|
||||
@session-kill=${this.handleSessionKill}>
|
||||
</session-card>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
|
||||
<session-create-form
|
||||
.visible=${this.showCreateModal}
|
||||
@session-created=${(e) => this.dispatchEvent(new CustomEvent('session-created', { detail: e.detail }))}
|
||||
@cancel=${() => this.dispatchEvent(new CustomEvent('create-modal-close'))}
|
||||
@error=${(e) => this.dispatchEvent(new CustomEvent('error', { detail: e.detail }))}
|
||||
></session-create-form>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
exports.SessionList = SessionList;
|
||||
__decorate([
|
||||
(0, decorators_js_1.property)({ type: Array }),
|
||||
__metadata("design:type", Array)
|
||||
], SessionList.prototype, "sessions", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.property)({ type: Boolean }),
|
||||
__metadata("design:type", Object)
|
||||
], SessionList.prototype, "loading", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.property)({ type: Boolean }),
|
||||
__metadata("design:type", Object)
|
||||
], SessionList.prototype, "hideExited", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.property)({ type: Boolean }),
|
||||
__metadata("design:type", Object)
|
||||
], SessionList.prototype, "showCreateModal", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], SessionList.prototype, "killingSessionIds", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], SessionList.prototype, "cleaningExited", void 0);
|
||||
exports.SessionList = SessionList = __decorate([
|
||||
(0, decorators_js_1.customElement)('session-list')
|
||||
], SessionList);
|
||||
//# sourceMappingURL=session-list.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"session-list.js","sourceRoot":"","sources":["../../../src/client/components/session-list.ts"],"names":[],"mappings":"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"}
|
||||
727
web/dist/client/components/session-view.js
vendored
727
web/dist/client/components/session-view.js
vendored
|
|
@ -1,727 +0,0 @@
|
|||
"use strict";
|
||||
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;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.SessionView = void 0;
|
||||
const lit_1 = require("lit");
|
||||
const decorators_js_1 = require("lit/decorators.js");
|
||||
const renderer_js_1 = require("../renderer.js");
|
||||
let SessionView = class SessionView extends lit_1.LitElement {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.session = null;
|
||||
this.connected = false;
|
||||
this.renderer = null;
|
||||
this.sessionStatusInterval = null;
|
||||
this.showMobileInput = false;
|
||||
this.mobileInputText = '';
|
||||
this.isMobile = false;
|
||||
this.touchStartX = 0;
|
||||
this.touchStartY = 0;
|
||||
this.loading = false;
|
||||
this.loadingFrame = 0;
|
||||
this.loadingInterval = null;
|
||||
this.keyboardHandler = (e) => {
|
||||
if (!this.session)
|
||||
return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.handleKeyboardInput(e);
|
||||
};
|
||||
this.touchStartHandler = (e) => {
|
||||
if (!this.isMobile)
|
||||
return;
|
||||
const touch = e.touches[0];
|
||||
this.touchStartX = touch.clientX;
|
||||
this.touchStartY = touch.clientY;
|
||||
};
|
||||
this.touchEndHandler = (e) => {
|
||||
if (!this.isMobile)
|
||||
return;
|
||||
const touch = e.changedTouches[0];
|
||||
const touchEndX = touch.clientX;
|
||||
const touchEndY = touch.clientY;
|
||||
const deltaX = touchEndX - this.touchStartX;
|
||||
const deltaY = touchEndY - this.touchStartY;
|
||||
// Check for horizontal swipe from left edge (back gesture)
|
||||
const isSwipeRight = deltaX > 100;
|
||||
const isVerticallyStable = Math.abs(deltaY) < 100;
|
||||
const startedFromLeftEdge = this.touchStartX < 50;
|
||||
if (isSwipeRight && isVerticallyStable && startedFromLeftEdge) {
|
||||
// Trigger back navigation
|
||||
this.handleBack();
|
||||
}
|
||||
};
|
||||
}
|
||||
// Disable shadow DOM to use Tailwind
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.connected = true;
|
||||
// Show loading animation if no session yet
|
||||
if (!this.session) {
|
||||
this.startLoading();
|
||||
}
|
||||
// Detect mobile device
|
||||
this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
||||
window.innerWidth <= 768;
|
||||
// Add global keyboard event listener only for desktop
|
||||
if (!this.isMobile) {
|
||||
document.addEventListener('keydown', this.keyboardHandler);
|
||||
}
|
||||
else {
|
||||
// Add touch event listeners for mobile swipe gestures
|
||||
document.addEventListener('touchstart', this.touchStartHandler, { passive: true });
|
||||
document.addEventListener('touchend', this.touchEndHandler, { passive: true });
|
||||
}
|
||||
// Start polling session status
|
||||
this.startSessionStatusPolling();
|
||||
}
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.connected = false;
|
||||
// Remove global keyboard event listener
|
||||
if (!this.isMobile) {
|
||||
document.removeEventListener('keydown', this.keyboardHandler);
|
||||
}
|
||||
else {
|
||||
// Remove touch event listeners
|
||||
document.removeEventListener('touchstart', this.touchStartHandler);
|
||||
document.removeEventListener('touchend', this.touchEndHandler);
|
||||
}
|
||||
// Stop polling session status
|
||||
this.stopSessionStatusPolling();
|
||||
// Stop loading animation
|
||||
this.stopLoading();
|
||||
// Cleanup renderer if it exists
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose();
|
||||
this.renderer = null;
|
||||
}
|
||||
}
|
||||
firstUpdated(changedProperties) {
|
||||
super.firstUpdated(changedProperties);
|
||||
if (this.session) {
|
||||
this.stopLoading();
|
||||
this.createInteractiveTerminal();
|
||||
}
|
||||
}
|
||||
updated(changedProperties) {
|
||||
super.updated(changedProperties);
|
||||
// Stop loading and create terminal when session becomes available
|
||||
if (changedProperties.has('session') && this.session && this.loading) {
|
||||
this.stopLoading();
|
||||
this.createInteractiveTerminal();
|
||||
}
|
||||
// Adjust terminal height for mobile buttons after render
|
||||
if (changedProperties.has('showMobileInput') || changedProperties.has('isMobile')) {
|
||||
requestAnimationFrame(() => {
|
||||
this.adjustTerminalForMobileButtons();
|
||||
});
|
||||
}
|
||||
}
|
||||
createInteractiveTerminal() {
|
||||
if (!this.session)
|
||||
return;
|
||||
const terminalElement = this.querySelector('#interactive-terminal');
|
||||
if (!terminalElement)
|
||||
return;
|
||||
// Create renderer once and connect to current session
|
||||
this.renderer = new renderer_js_1.Renderer(terminalElement);
|
||||
// Wait a moment for freshly created sessions before connecting
|
||||
const sessionAge = Date.now() - new Date(this.session.startedAt).getTime();
|
||||
const delay = sessionAge < 5000 ? 2000 : 0; // 2 second delay if session is less than 5 seconds old
|
||||
if (delay > 0) {
|
||||
// Show loading animation during delay for fresh sessions
|
||||
this.startLoading();
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (this.renderer && this.session) {
|
||||
this.stopLoading(); // Stop loading before connecting
|
||||
this.renderer.connectToStream(this.session.id);
|
||||
}
|
||||
}, delay);
|
||||
// Listen for session exit events
|
||||
terminalElement.addEventListener('session-exit', this.handleSessionExit.bind(this));
|
||||
}
|
||||
async handleKeyboardInput(e) {
|
||||
if (!this.session)
|
||||
return;
|
||||
// Don't send input to exited sessions
|
||||
if (this.session.status === 'exited') {
|
||||
console.log('Ignoring keyboard input - session has exited');
|
||||
return;
|
||||
}
|
||||
let inputText = '';
|
||||
// Handle special keys
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
if (e.ctrlKey) {
|
||||
// Ctrl+Enter - send to tty-fwd for proper handling
|
||||
inputText = 'ctrl_enter';
|
||||
}
|
||||
else if (e.shiftKey) {
|
||||
// Shift+Enter - send to tty-fwd for proper handling
|
||||
inputText = 'shift_enter';
|
||||
}
|
||||
else {
|
||||
// Regular Enter
|
||||
inputText = 'enter';
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
inputText = 'escape';
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
inputText = 'arrow_up';
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
inputText = 'arrow_down';
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
inputText = 'arrow_left';
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
inputText = 'arrow_right';
|
||||
break;
|
||||
case 'Tab':
|
||||
inputText = '\t';
|
||||
break;
|
||||
case 'Backspace':
|
||||
inputText = '\b';
|
||||
break;
|
||||
case 'Delete':
|
||||
inputText = '\x7f';
|
||||
break;
|
||||
case ' ':
|
||||
inputText = ' ';
|
||||
break;
|
||||
default:
|
||||
// Handle regular printable characters
|
||||
if (e.key.length === 1) {
|
||||
inputText = e.key;
|
||||
}
|
||||
else {
|
||||
// Ignore other special keys
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Handle Ctrl combinations (but not if we already handled Ctrl+Enter above)
|
||||
if (e.ctrlKey && e.key.length === 1 && e.key !== 'Enter') {
|
||||
const charCode = e.key.toLowerCase().charCodeAt(0);
|
||||
if (charCode >= 97 && charCode <= 122) { // a-z
|
||||
inputText = String.fromCharCode(charCode - 96); // Ctrl+A = \x01, etc.
|
||||
}
|
||||
}
|
||||
// Send the input to the session
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${this.session.id}/input`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ text: inputText })
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 400) {
|
||||
console.log('Session no longer accepting input (likely exited)');
|
||||
// Update session status to exited if we get 400 error
|
||||
if (this.session && this.session.status !== 'exited') {
|
||||
this.session = { ...this.session, status: 'exited' };
|
||||
this.requestUpdate();
|
||||
this.stopSessionStatusPolling();
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.error('Failed to send input to session:', response.status);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error sending input:', error);
|
||||
}
|
||||
}
|
||||
handleBack() {
|
||||
window.location.search = '';
|
||||
}
|
||||
handleSessionExit(e) {
|
||||
const customEvent = e;
|
||||
console.log('Session exit event received:', customEvent.detail);
|
||||
if (this.session && customEvent.detail.sessionId === this.session.id) {
|
||||
// Update session status to exited
|
||||
this.session = { ...this.session, status: 'exited' };
|
||||
this.requestUpdate();
|
||||
// Stop polling immediately
|
||||
this.stopSessionStatusPolling();
|
||||
// Switch to snapshot mode
|
||||
requestAnimationFrame(() => {
|
||||
this.createInteractiveTerminal();
|
||||
});
|
||||
}
|
||||
}
|
||||
// Mobile input methods
|
||||
handleMobileInputToggle() {
|
||||
this.showMobileInput = !this.showMobileInput;
|
||||
if (this.showMobileInput) {
|
||||
// Focus the textarea after a short delay to ensure it's rendered
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = this.querySelector('#mobile-input-textarea');
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
this.adjustTextareaForKeyboard();
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
// Clean up viewport listener when closing overlay
|
||||
const textarea = this.querySelector('#mobile-input-textarea');
|
||||
if (textarea && textarea._viewportCleanup) {
|
||||
textarea._viewportCleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
adjustTextareaForKeyboard() {
|
||||
// Adjust the layout when virtual keyboard appears
|
||||
const textarea = this.querySelector('#mobile-input-textarea');
|
||||
const controls = this.querySelector('#mobile-controls');
|
||||
if (!textarea || !controls)
|
||||
return;
|
||||
const adjustLayout = () => {
|
||||
const viewportHeight = window.visualViewport?.height || window.innerHeight;
|
||||
const windowHeight = window.innerHeight;
|
||||
const keyboardHeight = windowHeight - viewportHeight;
|
||||
// If keyboard is visible (viewport height is significantly smaller)
|
||||
if (keyboardHeight > 100) {
|
||||
// Move controls above the keyboard
|
||||
controls.style.transform = `translateY(-${keyboardHeight}px)`;
|
||||
controls.style.transition = 'transform 0.3s ease';
|
||||
// Calculate available space for textarea
|
||||
const header = this.querySelector('.flex.items-center.justify-between.p-4.border-b');
|
||||
const headerHeight = header?.offsetHeight || 60;
|
||||
const controlsHeight = controls?.offsetHeight || 120;
|
||||
const padding = 48; // Additional padding for spacing
|
||||
// Available height is viewport height minus header and controls (controls are now above keyboard)
|
||||
const maxTextareaHeight = viewportHeight - headerHeight - controlsHeight - padding;
|
||||
const inputArea = textarea.parentElement;
|
||||
if (inputArea && maxTextareaHeight > 0) {
|
||||
// Set the input area to not exceed the available space
|
||||
inputArea.style.height = `${maxTextareaHeight}px`;
|
||||
inputArea.style.maxHeight = `${maxTextareaHeight}px`;
|
||||
inputArea.style.overflow = 'hidden';
|
||||
// Set textarea height within the container
|
||||
const labelHeight = 40; // Height of the label above textarea
|
||||
const textareaMaxHeight = Math.max(maxTextareaHeight - labelHeight, 80);
|
||||
textarea.style.height = `${textareaMaxHeight}px`;
|
||||
textarea.style.maxHeight = `${textareaMaxHeight}px`;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Reset position when keyboard is hidden
|
||||
controls.style.transform = 'translateY(0px)';
|
||||
controls.style.transition = 'transform 0.3s ease';
|
||||
// Reset textarea height and constraints
|
||||
const inputArea = textarea.parentElement;
|
||||
if (inputArea) {
|
||||
inputArea.style.height = '';
|
||||
inputArea.style.maxHeight = '';
|
||||
inputArea.style.overflow = '';
|
||||
textarea.style.height = '';
|
||||
textarea.style.maxHeight = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
// Listen for viewport changes (keyboard show/hide)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', adjustLayout);
|
||||
// Clean up listener when overlay is closed
|
||||
const cleanup = () => {
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', adjustLayout);
|
||||
}
|
||||
};
|
||||
// Store cleanup function for later use
|
||||
textarea._viewportCleanup = cleanup;
|
||||
}
|
||||
// Initial adjustment
|
||||
requestAnimationFrame(adjustLayout);
|
||||
}
|
||||
handleMobileInputChange(e) {
|
||||
const textarea = e.target;
|
||||
this.mobileInputText = textarea.value;
|
||||
}
|
||||
async handleMobileInputSendOnly() {
|
||||
// Get the current value from the textarea directly
|
||||
const textarea = this.querySelector('#mobile-input-textarea');
|
||||
const textToSend = textarea?.value?.trim() || this.mobileInputText.trim();
|
||||
if (!textToSend)
|
||||
return;
|
||||
try {
|
||||
// Send text without enter key
|
||||
await this.sendInputText(textToSend);
|
||||
// Clear both the reactive property and textarea
|
||||
this.mobileInputText = '';
|
||||
if (textarea) {
|
||||
textarea.value = '';
|
||||
}
|
||||
// Trigger re-render to update button state
|
||||
this.requestUpdate();
|
||||
// Hide the input overlay after sending
|
||||
this.showMobileInput = false;
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error sending mobile input:', error);
|
||||
// Don't hide the overlay if there was an error
|
||||
}
|
||||
}
|
||||
async handleMobileInputSend() {
|
||||
// Get the current value from the textarea directly
|
||||
const textarea = this.querySelector('#mobile-input-textarea');
|
||||
const textToSend = textarea?.value?.trim() || this.mobileInputText.trim();
|
||||
if (!textToSend)
|
||||
return;
|
||||
try {
|
||||
// Add enter key at the end to execute the command
|
||||
await this.sendInputText(textToSend + '\n');
|
||||
// Clear both the reactive property and textarea
|
||||
this.mobileInputText = '';
|
||||
if (textarea) {
|
||||
textarea.value = '';
|
||||
}
|
||||
// Trigger re-render to update button state
|
||||
this.requestUpdate();
|
||||
// Hide the input overlay after sending
|
||||
this.showMobileInput = false;
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error sending mobile input:', error);
|
||||
// Don't hide the overlay if there was an error
|
||||
}
|
||||
}
|
||||
async handleSpecialKey(key) {
|
||||
await this.sendInputText(key);
|
||||
}
|
||||
async sendInputText(text) {
|
||||
if (!this.session)
|
||||
return;
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${this.session.id}/input`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ text })
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error('Failed to send input to session');
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error sending input:', error);
|
||||
}
|
||||
}
|
||||
adjustTerminalForMobileButtons() {
|
||||
// Disabled for now to avoid viewport issues
|
||||
// The mobile buttons will overlay the terminal
|
||||
}
|
||||
startLoading() {
|
||||
this.loading = true;
|
||||
this.loadingFrame = 0;
|
||||
this.loadingInterval = window.setInterval(() => {
|
||||
this.loadingFrame = (this.loadingFrame + 1) % 4;
|
||||
this.requestUpdate();
|
||||
}, 200); // Update every 200ms for smooth animation
|
||||
}
|
||||
stopLoading() {
|
||||
this.loading = false;
|
||||
if (this.loadingInterval) {
|
||||
clearInterval(this.loadingInterval);
|
||||
this.loadingInterval = null;
|
||||
}
|
||||
}
|
||||
getLoadingText() {
|
||||
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
return frames[this.loadingFrame % frames.length];
|
||||
}
|
||||
startSessionStatusPolling() {
|
||||
if (this.sessionStatusInterval) {
|
||||
clearInterval(this.sessionStatusInterval);
|
||||
}
|
||||
// Only poll for running sessions - exited sessions don't need polling
|
||||
if (this.session?.status !== 'exited') {
|
||||
this.sessionStatusInterval = window.setInterval(() => {
|
||||
this.checkSessionStatus();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
stopSessionStatusPolling() {
|
||||
if (this.sessionStatusInterval) {
|
||||
clearInterval(this.sessionStatusInterval);
|
||||
this.sessionStatusInterval = null;
|
||||
}
|
||||
}
|
||||
async checkSessionStatus() {
|
||||
if (!this.session)
|
||||
return;
|
||||
try {
|
||||
const response = await fetch('/api/sessions');
|
||||
if (!response.ok)
|
||||
return;
|
||||
const sessions = await response.json();
|
||||
const currentSession = sessions.find((s) => s.id === this.session.id);
|
||||
if (currentSession && currentSession.status !== this.session.status) {
|
||||
// Store old status before updating
|
||||
const oldStatus = this.session.status;
|
||||
// Session status changed
|
||||
this.session = { ...this.session, status: currentSession.status };
|
||||
this.requestUpdate();
|
||||
// Session status polling is now only for detecting new sessions
|
||||
// Exit events are handled via SSE stream directly
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error checking session status:', error);
|
||||
}
|
||||
}
|
||||
render() {
|
||||
if (!this.session) {
|
||||
return (0, lit_1.html) `
|
||||
<div class="p-4 text-vs-muted">
|
||||
No session selected
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return (0, lit_1.html) `
|
||||
<style>
|
||||
session-view *, session-view *:focus, session-view *:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
</style>
|
||||
<div class="flex flex-col bg-vs-bg font-mono" style="height: 100vh; outline: none !important; box-shadow: none !important;">
|
||||
<!-- Compact Header -->
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-vs-border bg-vs-bg-secondary text-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-2 py-1 border-none rounded transition-colors text-xs"
|
||||
@click=${this.handleBack}
|
||||
>
|
||||
BACK
|
||||
</button>
|
||||
<div class="text-vs-text">
|
||||
<div class="text-vs-accent">${this.session.command}</div>
|
||||
<div class="text-vs-muted text-xs">${this.session.workingDir}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<span class="${this.session.status === 'running' ? 'text-vs-user' : 'text-vs-warning'}">
|
||||
${this.session.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Container -->
|
||||
<div class="flex-1 bg-black overflow-x-auto overflow-y-hidden min-h-0 relative" id="terminal-container">
|
||||
<div id="interactive-terminal" class="w-full h-full"></div>
|
||||
|
||||
${this.loading ? (0, lit_1.html) `
|
||||
<!-- Loading overlay -->
|
||||
<div class="absolute inset-0 bg-black bg-opacity-80 flex items-center justify-center">
|
||||
<div class="text-vs-text font-mono text-center">
|
||||
<div class="text-2xl mb-2">${this.getLoadingText()}</div>
|
||||
<div class="text-sm text-vs-muted">Connecting to session...</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Mobile Input Controls -->
|
||||
${this.isMobile && !this.showMobileInput ? (0, lit_1.html) `
|
||||
<div class="flex-shrink-0 p-4 bg-vs-bg">
|
||||
<!-- First row: Arrow keys -->
|
||||
<div class="flex gap-2 mb-2">
|
||||
<button
|
||||
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('arrow_up')}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('arrow_down')}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('arrow_left')}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('arrow_right')}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Second row: Special keys -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('\t')}
|
||||
>
|
||||
TAB
|
||||
</button>
|
||||
<button
|
||||
class="bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('enter')}
|
||||
>
|
||||
ENTER
|
||||
</button>
|
||||
<button
|
||||
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('escape')}
|
||||
>
|
||||
ESC
|
||||
</button>
|
||||
<button
|
||||
class="bg-vs-error text-vs-text hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('\x03')}
|
||||
>
|
||||
^C
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${this.handleMobileInputToggle}
|
||||
>
|
||||
TYPE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Full-Screen Input Overlay (only when opened) -->
|
||||
${this.isMobile && this.showMobileInput ? (0, lit_1.html) `
|
||||
<div class="fixed inset-0 bg-vs-bg-secondary bg-opacity-95 z-50 flex flex-col" style="height: 100vh; height: 100dvh;">
|
||||
<!-- Input Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-vs-border flex-shrink-0">
|
||||
<div class="text-vs-text font-mono text-sm">Terminal Input</div>
|
||||
<button
|
||||
class="text-vs-muted hover:text-vs-text text-lg leading-none border-none bg-transparent cursor-pointer"
|
||||
@click=${this.handleMobileInputToggle}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Input Area with dynamic height -->
|
||||
<div class="flex-1 p-4 flex flex-col min-h-0">
|
||||
<div class="text-vs-muted text-sm mb-2 flex-shrink-0">
|
||||
Type your command(s) below. Supports multiline input.
|
||||
</div>
|
||||
<textarea
|
||||
id="mobile-input-textarea"
|
||||
class="flex-1 bg-vs-bg text-vs-text border border-vs-border font-mono text-sm p-4 resize-none outline-none"
|
||||
placeholder="Enter your command here..."
|
||||
.value=${this.mobileInputText}
|
||||
@input=${this.handleMobileInputChange}
|
||||
@keydown=${(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.handleMobileInputSend();
|
||||
}
|
||||
}}
|
||||
style="min-height: 120px; margin-bottom: 16px;"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Controls - Fixed above keyboard -->
|
||||
<div id="mobile-controls" class="fixed bottom-0 left-0 right-0 p-4 border-t border-vs-border bg-vs-bg-secondary z-60" style="padding-bottom: max(1rem, env(safe-area-inset-bottom)); transform: translateY(0px);">
|
||||
<!-- Send Buttons Row -->
|
||||
<div class="flex gap-2 mb-3">
|
||||
<button
|
||||
class="flex-1 bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-3 border-none rounded transition-colors text-sm font-bold"
|
||||
@click=${this.handleMobileInputSendOnly}
|
||||
?disabled=${!this.mobileInputText.trim()}
|
||||
>
|
||||
SEND
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-4 py-3 border-none rounded transition-colors text-sm font-bold"
|
||||
@click=${this.handleMobileInputSend}
|
||||
?disabled=${!this.mobileInputText.trim()}
|
||||
>
|
||||
SEND + ENTER
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-vs-muted text-xs text-center">
|
||||
SEND: text only • SEND + ENTER: text with enter key
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
exports.SessionView = SessionView;
|
||||
__decorate([
|
||||
(0, decorators_js_1.property)({ type: Object }),
|
||||
__metadata("design:type", Object)
|
||||
], SessionView.prototype, "session", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], SessionView.prototype, "connected", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], SessionView.prototype, "renderer", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], SessionView.prototype, "sessionStatusInterval", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], SessionView.prototype, "showMobileInput", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], SessionView.prototype, "mobileInputText", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], SessionView.prototype, "isMobile", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], SessionView.prototype, "touchStartX", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], SessionView.prototype, "touchStartY", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], SessionView.prototype, "loading", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], SessionView.prototype, "loadingFrame", void 0);
|
||||
exports.SessionView = SessionView = __decorate([
|
||||
(0, decorators_js_1.customElement)('session-view')
|
||||
], SessionView);
|
||||
//# sourceMappingURL=session-view.js.map
|
||||
File diff suppressed because one or more lines are too long
7
web/dist/client/renderer-entry.js
vendored
7
web/dist/client/renderer-entry.js
vendored
|
|
@ -1,7 +0,0 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.Renderer = void 0;
|
||||
// Entry point for renderer bundle - exports XTerm-based renderer
|
||||
var renderer_1 = require("./renderer");
|
||||
Object.defineProperty(exports, "Renderer", { enumerable: true, get: function () { return renderer_1.Renderer; } });
|
||||
//# sourceMappingURL=renderer-entry.js.map
|
||||
1
web/dist/client/renderer-entry.js.map
vendored
1
web/dist/client/renderer-entry.js.map
vendored
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"renderer-entry.js","sourceRoot":"","sources":["../../src/client/renderer-entry.ts"],"names":[],"mappings":";;;AAAA,iEAAiE;AACjE,uCAAsC;AAA7B,oGAAA,QAAQ,OAAA"}
|
||||
310
web/dist/client/renderer.js
vendored
310
web/dist/client/renderer.js
vendored
|
|
@ -1,310 +0,0 @@
|
|||
"use strict";
|
||||
// Terminal renderer for asciinema cast format using XTerm.js
|
||||
// Professional-grade terminal emulation with full VT compatibility
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.Renderer = void 0;
|
||||
const xterm_1 = require("@xterm/xterm");
|
||||
const addon_fit_1 = require("@xterm/addon-fit");
|
||||
const addon_web_links_1 = require("@xterm/addon-web-links");
|
||||
const scale_fit_addon_js_1 = require("./scale-fit-addon.js");
|
||||
class Renderer {
|
||||
constructor(container, width = 80, height = 20, scrollback = 1000000, fontSize = 14, isPreview = false) {
|
||||
this.eventSource = null;
|
||||
Renderer.activeCount++;
|
||||
console.log(`Renderer constructor called (active: ${Renderer.activeCount})`);
|
||||
this.container = container;
|
||||
this.isPreview = isPreview;
|
||||
// Create terminal with options similar to the custom renderer
|
||||
this.terminal = new xterm_1.Terminal({
|
||||
cols: width,
|
||||
rows: height,
|
||||
fontFamily: 'Monaco, "Lucida Console", monospace',
|
||||
fontSize: fontSize,
|
||||
lineHeight: 1.2,
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#ffffff',
|
||||
cursorAccent: '#1e1e1e',
|
||||
selectionBackground: '#264f78',
|
||||
// VS Code Dark theme colors
|
||||
black: '#000000',
|
||||
red: '#f14c4c',
|
||||
green: '#23d18b',
|
||||
yellow: '#f5f543',
|
||||
blue: '#3b8eea',
|
||||
magenta: '#d670d6',
|
||||
cyan: '#29b8db',
|
||||
white: '#e5e5e5',
|
||||
// Bright colors
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
brightYellow: '#f5f543',
|
||||
brightBlue: '#3b8eea',
|
||||
brightMagenta: '#d670d6',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#ffffff'
|
||||
},
|
||||
allowProposedApi: true,
|
||||
scrollback: scrollback, // Configurable scrollback buffer
|
||||
convertEol: true,
|
||||
altClickMovesCursor: false,
|
||||
rightClickSelectsWord: false,
|
||||
disableStdin: true, // We handle input separately
|
||||
});
|
||||
// Add addons
|
||||
this.fitAddon = new addon_fit_1.FitAddon();
|
||||
this.scaleFitAddon = new scale_fit_addon_js_1.ScaleFitAddon();
|
||||
this.webLinksAddon = new addon_web_links_1.WebLinksAddon();
|
||||
this.terminal.loadAddon(this.fitAddon);
|
||||
this.terminal.loadAddon(this.scaleFitAddon);
|
||||
this.terminal.loadAddon(this.webLinksAddon);
|
||||
this.setupDOM();
|
||||
}
|
||||
setupDOM() {
|
||||
// Clear container and add CSS
|
||||
this.container.innerHTML = '';
|
||||
// Different styling for preview vs full terminals
|
||||
if (this.isPreview) {
|
||||
// No padding for previews, let container control sizing
|
||||
this.container.style.padding = '0';
|
||||
this.container.style.backgroundColor = '#1e1e1e';
|
||||
this.container.style.overflow = 'hidden';
|
||||
}
|
||||
else {
|
||||
// Full terminals get padding
|
||||
this.container.style.padding = '10px';
|
||||
this.container.style.backgroundColor = '#1e1e1e';
|
||||
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);
|
||||
// Always use ScaleFitAddon for better scaling
|
||||
this.scaleFitAddon.fit();
|
||||
// Handle container resize
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
this.scaleFitAddon.fit();
|
||||
});
|
||||
resizeObserver.observe(this.container);
|
||||
}
|
||||
// Public API methods - maintain compatibility with custom renderer
|
||||
async loadCastFile(url) {
|
||||
const response = await fetch(url);
|
||||
const text = await response.text();
|
||||
this.parseCastFile(text);
|
||||
}
|
||||
parseCastFile(content) {
|
||||
const lines = content.trim().split('\n');
|
||||
let header = 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 = {
|
||||
timestamp: parsed[0],
|
||||
type: parsed[1],
|
||||
data: parsed[2]
|
||||
};
|
||||
if (event.type === 'o') {
|
||||
this.processOutput(event.data);
|
||||
}
|
||||
else if (event.type === 'r') {
|
||||
this.processResize(event.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('Failed to parse cast line:', line);
|
||||
}
|
||||
}
|
||||
}
|
||||
processOutput(data) {
|
||||
// XTerm handles all ANSI escape sequences automatically
|
||||
this.terminal.write(data);
|
||||
}
|
||||
processResize(data) {
|
||||
// Parse resize data in format "WIDTHxHEIGHT" (e.g., "80x24")
|
||||
const match = data.match(/^(\d+)x(\d+)$/);
|
||||
if (match) {
|
||||
const width = parseInt(match[1], 10);
|
||||
const height = parseInt(match[2], 10);
|
||||
this.resize(width, height);
|
||||
}
|
||||
}
|
||||
processEvent(event) {
|
||||
if (event.type === 'o') {
|
||||
this.processOutput(event.data);
|
||||
}
|
||||
else if (event.type === 'r') {
|
||||
this.processResize(event.data);
|
||||
}
|
||||
}
|
||||
resize(width, height) {
|
||||
if (this.isPreview) {
|
||||
// For previews, resize to session dimensions then apply scaling
|
||||
this.terminal.resize(width, height);
|
||||
}
|
||||
// Always use ScaleFitAddon for consistent scaling behavior
|
||||
this.scaleFitAddon.fit();
|
||||
}
|
||||
clear() {
|
||||
this.terminal.clear();
|
||||
}
|
||||
// Stream support - connect to SSE endpoint
|
||||
connectToStream(sessionId) {
|
||||
console.log('connectToStream called for session:', sessionId);
|
||||
return this.connectToUrl(`/api/sessions/${sessionId}/stream`);
|
||||
}
|
||||
// Connect to any SSE URL
|
||||
connectToUrl(url) {
|
||||
console.log('Creating new EventSource connection to:', url);
|
||||
const eventSource = new EventSource(url);
|
||||
// Don't clear terminal for live streams - just append new content
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.version && data.width && data.height) {
|
||||
// Header
|
||||
console.log('Received header:', data);
|
||||
this.resize(data.width, data.height);
|
||||
}
|
||||
else if (Array.isArray(data) && data.length >= 3) {
|
||||
// Check if this is an exit event
|
||||
if (data[0] === 'exit') {
|
||||
const exitCode = data[1];
|
||||
const sessionId = data[2];
|
||||
console.log(`Session ${sessionId} exited with code ${exitCode}`);
|
||||
// Close the SSE connection immediately
|
||||
if (this.eventSource) {
|
||||
console.log('Closing SSE connection due to session exit');
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
// Dispatch custom event that session-view can listen to
|
||||
const exitEvent = new CustomEvent('session-exit', {
|
||||
detail: { sessionId, exitCode }
|
||||
});
|
||||
this.container.dispatchEvent(exitEvent);
|
||||
return;
|
||||
}
|
||||
// Regular cast event
|
||||
const castEvent = {
|
||||
timestamp: data[0],
|
||||
type: data[1],
|
||||
data: data[2]
|
||||
};
|
||||
// Process event without verbose logging
|
||||
this.processEvent(castEvent);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('Failed to parse stream event:', event.data);
|
||||
}
|
||||
};
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('Stream error:', error);
|
||||
// Close the connection to prevent automatic reconnection attempts
|
||||
if (eventSource.readyState === EventSource.CLOSED) {
|
||||
console.log('Stream closed, cleaning up...');
|
||||
if (this.eventSource === eventSource) {
|
||||
this.eventSource = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
return eventSource;
|
||||
}
|
||||
// Load content from URL - pass isStream to determine how to handle it
|
||||
async loadFromUrl(url, isStream) {
|
||||
// Clean up existing connection
|
||||
if (this.eventSource) {
|
||||
console.log('Explicitly closing existing EventSource connection');
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
if (isStream) {
|
||||
// It's a stream URL, connect via SSE (don't clear - append to existing content)
|
||||
this.eventSource = this.connectToUrl(url);
|
||||
}
|
||||
else {
|
||||
// It's a snapshot URL, clear first then load as cast file
|
||||
this.terminal.clear();
|
||||
await this.loadCastFile(url);
|
||||
}
|
||||
}
|
||||
// Additional methods for terminal control
|
||||
focus() {
|
||||
this.terminal.focus();
|
||||
}
|
||||
blur() {
|
||||
this.terminal.blur();
|
||||
}
|
||||
getTerminal() {
|
||||
return this.terminal;
|
||||
}
|
||||
dispose() {
|
||||
if (this.eventSource) {
|
||||
console.log('Explicitly closing EventSource connection in dispose()');
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
this.terminal.dispose();
|
||||
Renderer.activeCount--;
|
||||
console.log(`Renderer disposed (active: ${Renderer.activeCount})`);
|
||||
}
|
||||
// Method to fit terminal to container (useful for responsive layouts)
|
||||
fit() {
|
||||
this.fitAddon.fit();
|
||||
}
|
||||
// Get terminal dimensions
|
||||
getDimensions() {
|
||||
return {
|
||||
cols: this.terminal.cols,
|
||||
rows: this.terminal.rows
|
||||
};
|
||||
}
|
||||
// Write raw data to terminal (useful for testing)
|
||||
write(data) {
|
||||
this.terminal.write(data);
|
||||
}
|
||||
// Enable/disable input (though we keep it disabled by default)
|
||||
setInputEnabled(enabled) {
|
||||
// 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
|
||||
});
|
||||
}
|
||||
}
|
||||
// Disable all pointer events for previews so clicks pass through to parent
|
||||
setPointerEventsEnabled(enabled) {
|
||||
const terminalElement = this.container.querySelector('.xterm');
|
||||
if (terminalElement) {
|
||||
terminalElement.style.pointerEvents = enabled ? 'auto' : 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.Renderer = Renderer;
|
||||
Renderer.activeCount = 0;
|
||||
//# sourceMappingURL=renderer.js.map
|
||||
1
web/dist/client/renderer.js.map
vendored
1
web/dist/client/renderer.js.map
vendored
File diff suppressed because one or more lines are too long
109
web/dist/client/scale-fit-addon.js
vendored
109
web/dist/client/scale-fit-addon.js
vendored
|
|
@ -1,109 +0,0 @@
|
|||
"use strict";
|
||||
/**
|
||||
* Custom FitAddon that scales font size to fit terminal columns to container width,
|
||||
* then calculates optimal rows for the container height.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ScaleFitAddon = void 0;
|
||||
const MINIMUM_ROWS = 1;
|
||||
const MIN_FONT_SIZE = 6;
|
||||
const MAX_FONT_SIZE = 16;
|
||||
class ScaleFitAddon {
|
||||
activate(terminal) {
|
||||
this._terminal = terminal;
|
||||
}
|
||||
dispose() { }
|
||||
fit() {
|
||||
const dims = this.proposeDimensions();
|
||||
if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) {
|
||||
return;
|
||||
}
|
||||
// Only resize rows, keep cols the same (font scaling handles width)
|
||||
if (this._terminal.rows !== dims.rows) {
|
||||
this._terminal.resize(this._terminal.cols, dims.rows);
|
||||
}
|
||||
}
|
||||
proposeDimensions() {
|
||||
if (!this._terminal?.element?.parentElement) {
|
||||
return undefined;
|
||||
}
|
||||
// Get the renderer container (parent of parent - the one with 10px padding)
|
||||
const terminalWrapper = this._terminal.element.parentElement;
|
||||
const rendererContainer = terminalWrapper.parentElement;
|
||||
if (!rendererContainer)
|
||||
return undefined;
|
||||
// Get container dimensions and exact padding
|
||||
const containerStyle = window.getComputedStyle(rendererContainer);
|
||||
const containerWidth = parseInt(containerStyle.getPropertyValue('width'));
|
||||
const containerHeight = parseInt(containerStyle.getPropertyValue('height'));
|
||||
const containerPadding = {
|
||||
top: parseInt(containerStyle.getPropertyValue('padding-top')),
|
||||
bottom: parseInt(containerStyle.getPropertyValue('padding-bottom')),
|
||||
left: parseInt(containerStyle.getPropertyValue('padding-left')),
|
||||
right: parseInt(containerStyle.getPropertyValue('padding-right'))
|
||||
};
|
||||
// Calculate exact available space using known padding
|
||||
const availableWidth = containerWidth - containerPadding.left - containerPadding.right;
|
||||
const availableHeight = containerHeight - containerPadding.top - containerPadding.bottom;
|
||||
// Current terminal dimensions
|
||||
const currentCols = this._terminal.cols;
|
||||
// Calculate optimal font size to fit current cols in available width
|
||||
// Character width is approximately 0.6 * fontSize for monospace fonts
|
||||
const charWidthRatio = 0.6;
|
||||
const calculatedFontSize = availableWidth / (currentCols * charWidthRatio);
|
||||
const optimalFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
|
||||
// Apply the calculated font size (outside of proposeDimensions to avoid recursion)
|
||||
requestAnimationFrame(() => this.applyFontSize(optimalFontSize));
|
||||
// Get the actual line height from the rendered XTerm element
|
||||
const xtermElement = this._terminal.element;
|
||||
const currentStyle = window.getComputedStyle(xtermElement);
|
||||
const actualLineHeight = parseFloat(currentStyle.lineHeight);
|
||||
// If we can't get the line height, fall back to configuration
|
||||
const lineHeight = actualLineHeight || (optimalFontSize * (this._terminal.options.lineHeight || 1.2));
|
||||
// Calculate how many rows fit with this line height
|
||||
const optimalRows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / lineHeight));
|
||||
return {
|
||||
cols: currentCols, // Keep existing cols
|
||||
rows: optimalRows // Fit as many rows as possible
|
||||
};
|
||||
}
|
||||
applyFontSize(fontSize) {
|
||||
if (!this._terminal?.element)
|
||||
return;
|
||||
// Prevent infinite recursion by checking if font size changed significantly
|
||||
const currentFontSize = this._terminal.options.fontSize || 14;
|
||||
if (Math.abs(fontSize - currentFontSize) < 0.1)
|
||||
return;
|
||||
const terminalElement = this._terminal.element;
|
||||
// Update terminal's font size
|
||||
this._terminal.options.fontSize = fontSize;
|
||||
// Apply CSS font size to the element
|
||||
terminalElement.style.fontSize = `${fontSize}px`;
|
||||
// Force a refresh to apply the new font size
|
||||
requestAnimationFrame(() => {
|
||||
if (this._terminal) {
|
||||
this._terminal.refresh(0, this._terminal.rows - 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Get the calculated font size that would fit the current columns in the container
|
||||
*/
|
||||
getOptimalFontSize() {
|
||||
if (!this._terminal?.element?.parentElement) {
|
||||
return this._terminal?.options.fontSize || 14;
|
||||
}
|
||||
const parentElement = this._terminal.element.parentElement;
|
||||
const parentStyle = window.getComputedStyle(parentElement);
|
||||
const parentWidth = parseInt(parentStyle.getPropertyValue('width'));
|
||||
const elementStyle = window.getComputedStyle(this._terminal.element);
|
||||
const paddingHor = parseInt(elementStyle.getPropertyValue('padding-left')) +
|
||||
parseInt(elementStyle.getPropertyValue('padding-right'));
|
||||
const availableWidth = parentWidth - paddingHor;
|
||||
const charWidthRatio = 0.6;
|
||||
const calculatedFontSize = availableWidth / (this._terminal.cols * charWidthRatio);
|
||||
return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
|
||||
}
|
||||
}
|
||||
exports.ScaleFitAddon = ScaleFitAddon;
|
||||
//# sourceMappingURL=scale-fit-addon.js.map
|
||||
1
web/dist/client/scale-fit-addon.js.map
vendored
1
web/dist/client/scale-fit-addon.js.map
vendored
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"scale-fit-addon.js","sourceRoot":"","sources":["../../src/client/scale-fit-addon.ts"],"names":[],"mappings":"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"}
|
||||
403
web/dist/server-new.js
vendored
403
web/dist/server-new.js
vendored
|
|
@ -1,403 +0,0 @@
|
|||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const express_1 = __importDefault(require("express"));
|
||||
const http_1 = require("http");
|
||||
const ws_1 = require("ws");
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const os_1 = __importDefault(require("os"));
|
||||
const child_process_1 = require("child_process");
|
||||
const app = (0, express_1.default)();
|
||||
const server = (0, http_1.createServer)(app);
|
||||
const wss = new ws_1.WebSocketServer({ server });
|
||||
const PORT = process.env.PORT || 3000;
|
||||
// tty-fwd binary path - check multiple possible locations
|
||||
const possibleTtyFwdPaths = [
|
||||
path_1.default.resolve(__dirname, '..', '..', 'tty-fwd', 'target', 'release', 'tty-fwd'),
|
||||
path_1.default.resolve(__dirname, '..', '..', '..', 'tty-fwd', 'target', 'release', 'tty-fwd'),
|
||||
'tty-fwd' // System PATH
|
||||
];
|
||||
let TTY_FWD_PATH = '';
|
||||
for (const pathToCheck of possibleTtyFwdPaths) {
|
||||
if (fs_1.default.existsSync(pathToCheck)) {
|
||||
TTY_FWD_PATH = pathToCheck;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!TTY_FWD_PATH) {
|
||||
console.error('tty-fwd binary not found. Please ensure it is built and available.');
|
||||
process.exit(1);
|
||||
}
|
||||
const TTY_FWD_CONTROL_DIR = process.env.TTY_FWD_CONTROL_DIR || path_1.default.join(os_1.default.homedir(), '.vibetunnel');
|
||||
// Ensure control directory exists
|
||||
if (!fs_1.default.existsSync(TTY_FWD_CONTROL_DIR)) {
|
||||
fs_1.default.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}`);
|
||||
// Helper function to execute tty-fwd commands
|
||||
async function executeTtyFwd(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = (0, child_process_1.spawn)(TTY_FWD_PATH, args);
|
||||
let output = '';
|
||||
child.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(output);
|
||||
}
|
||||
else {
|
||||
reject(new Error(`tty-fwd failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
// Helper function to resolve paths with ~ expansion
|
||||
function resolvePath(inputPath, fallback) {
|
||||
if (!inputPath) {
|
||||
return fallback || process.cwd();
|
||||
}
|
||||
if (inputPath.startsWith('~')) {
|
||||
return path_1.default.join(os_1.default.homedir(), inputPath.slice(1));
|
||||
}
|
||||
return path_1.default.resolve(inputPath);
|
||||
}
|
||||
// Middleware
|
||||
app.use(express_1.default.json());
|
||||
app.use(express_1.default.static(path_1.default.join(__dirname, '..', 'public')));
|
||||
// Hot reload functionality for development
|
||||
const hotReloadClients = new Set();
|
||||
// === SESSION MANAGEMENT ===
|
||||
// List all sessions
|
||||
app.get('/api/sessions', async (req, res) => {
|
||||
try {
|
||||
const output = await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
|
||||
const sessions = JSON.parse(output || '{}');
|
||||
const sessionData = Object.entries(sessions).map(([sessionId, sessionInfo]) => {
|
||||
// Get actual last modified time from stream-out file
|
||||
let lastModified = sessionInfo.started_at;
|
||||
try {
|
||||
if (fs_1.default.existsSync(sessionInfo["stream-out"])) {
|
||||
const stats = fs_1.default.statSync(sessionInfo["stream-out"]);
|
||||
lastModified = stats.mtime.toISOString();
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// Use started_at as fallback
|
||||
}
|
||||
return {
|
||||
id: sessionId,
|
||||
command: sessionInfo.cmdline.join(' '),
|
||||
workingDir: sessionInfo.cwd,
|
||||
status: sessionInfo.status,
|
||||
exitCode: sessionInfo.exit_code,
|
||||
startedAt: sessionInfo.started_at,
|
||||
lastModified: lastModified,
|
||||
pid: sessionInfo.pid
|
||||
};
|
||||
});
|
||||
// Sort by lastModified, most recent first
|
||||
sessionData.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
res.json(sessionData);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to list sessions:', error);
|
||||
res.status(500).json({ error: 'Failed to list sessions' });
|
||||
}
|
||||
});
|
||||
// Create new session
|
||||
app.post('/api/sessions', async (req, res) => {
|
||||
try {
|
||||
const { command, workingDir } = req.body;
|
||||
if (!command || !Array.isArray(command)) {
|
||||
return res.status(400).json({ error: 'Command array is required' });
|
||||
}
|
||||
const sessionName = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const cwd = resolvePath(workingDir, process.cwd());
|
||||
const args = [
|
||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||
'--session-name', sessionName,
|
||||
'--'
|
||||
].concat(command);
|
||||
console.log(`Creating session: ${TTY_FWD_PATH} ${args.join(' ')}`);
|
||||
const child = (0, child_process_1.spawn)(TTY_FWD_PATH, args, {
|
||||
cwd: cwd,
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
child.unref();
|
||||
// Respond immediately - session creation is detached
|
||||
res.json({ sessionId: sessionName });
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error creating session:', error);
|
||||
res.status(500).json({ error: 'Failed to create session' });
|
||||
}
|
||||
});
|
||||
// Kill session (just kill the process)
|
||||
app.delete('/api/sessions/:sessionId', async (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
try {
|
||||
const output = await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
|
||||
const sessions = JSON.parse(output || '{}');
|
||||
const session = sessions[sessionId];
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
if (session.pid) {
|
||||
try {
|
||||
process.kill(session.pid, 'SIGTERM');
|
||||
setTimeout(() => {
|
||||
try {
|
||||
process.kill(session.pid, 0); // Check if still alive
|
||||
process.kill(session.pid, 'SIGKILL'); // Force kill
|
||||
}
|
||||
catch (e) {
|
||||
// Process already dead
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
catch (error) {
|
||||
// Process already dead
|
||||
}
|
||||
}
|
||||
res.json({ success: true, message: 'Session killed' });
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error killing session:', error);
|
||||
res.status(500).json({ error: 'Failed to kill session' });
|
||||
}
|
||||
});
|
||||
// Cleanup session files
|
||||
app.delete('/api/sessions/:sessionId/cleanup', async (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
try {
|
||||
await executeTtyFwd([
|
||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||
'--session', sessionId,
|
||||
'--cleanup'
|
||||
]);
|
||||
res.json({ success: true, message: 'Session cleaned up' });
|
||||
}
|
||||
catch (error) {
|
||||
// If tty-fwd cleanup fails, force remove directory
|
||||
console.log('tty-fwd cleanup failed, force removing directory');
|
||||
const sessionDir = path_1.default.join(TTY_FWD_CONTROL_DIR, sessionId);
|
||||
try {
|
||||
if (fs_1.default.existsSync(sessionDir)) {
|
||||
fs_1.default.rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
res.json({ success: true, message: 'Session force cleaned up' });
|
||||
}
|
||||
catch (fsError) {
|
||||
console.error('Error force removing session directory:', fsError);
|
||||
res.status(500).json({ error: 'Failed to cleanup session' });
|
||||
}
|
||||
}
|
||||
});
|
||||
// === TERMINAL I/O ===
|
||||
// Server-sent events for terminal output streaming
|
||||
app.get('/api/sessions/:sessionId/stream', (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
const streamOutPath = path_1.default.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out');
|
||||
if (!fs_1.default.existsSync(streamOutPath)) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Cache-Control'
|
||||
});
|
||||
const startTime = Date.now() / 1000;
|
||||
let headerSent = false;
|
||||
// Send existing content first
|
||||
// NOTE: Small race condition possible between reading file and starting tail
|
||||
try {
|
||||
const content = fs_1.default.readFileSync(streamOutPath, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed.version && parsed.width && parsed.height) {
|
||||
res.write(`data: ${line}\n\n`);
|
||||
headerSent = true;
|
||||
}
|
||||
else if (Array.isArray(parsed) && parsed.length >= 3) {
|
||||
const instantEvent = [0, parsed[1], parsed[2]];
|
||||
res.write(`data: ${JSON.stringify(instantEvent)}\n\n`);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error reading existing content:', error);
|
||||
}
|
||||
// Send default header if none found
|
||||
if (!headerSent) {
|
||||
const defaultHeader = {
|
||||
version: 2,
|
||||
width: 80,
|
||||
height: 24,
|
||||
timestamp: Math.floor(startTime),
|
||||
env: { TERM: "xterm-256color" }
|
||||
};
|
||||
res.write(`data: ${JSON.stringify(defaultHeader)}\n\n`);
|
||||
}
|
||||
// Stream new content
|
||||
const tailProcess = (0, child_process_1.spawn)('tail', ['-f', streamOutPath]);
|
||||
let buffer = '';
|
||||
tailProcess.stdout.on('data', (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed.version && parsed.width && parsed.height) {
|
||||
return; // Skip duplicate headers
|
||||
}
|
||||
if (Array.isArray(parsed) && parsed.length >= 3) {
|
||||
const currentTime = Date.now() / 1000;
|
||||
const realTimeEvent = [currentTime - startTime, parsed[1], parsed[2]];
|
||||
res.write(`data: ${JSON.stringify(realTimeEvent)}\n\n`);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// Handle non-JSON as raw output
|
||||
const currentTime = Date.now() / 1000;
|
||||
const castEvent = [currentTime - startTime, "o", line];
|
||||
res.write(`data: ${JSON.stringify(castEvent)}\n\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Cleanup on disconnect
|
||||
req.on('close', () => tailProcess.kill('SIGTERM'));
|
||||
req.on('aborted', () => tailProcess.kill('SIGTERM'));
|
||||
});
|
||||
// Send input to session
|
||||
app.post('/api/sessions/:sessionId/input', async (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
const { text } = req.body;
|
||||
if (text === undefined || text === null) {
|
||||
return res.status(400).json({ error: 'Text is required' });
|
||||
}
|
||||
console.log(`Sending input to session ${sessionId}:`, JSON.stringify(text));
|
||||
try {
|
||||
// Check if this is a special key that should use --send-key
|
||||
const specialKeys = ['arrow_up', 'arrow_down', 'arrow_left', 'arrow_right', 'escape', 'enter'];
|
||||
const isSpecialKey = specialKeys.includes(text);
|
||||
if (isSpecialKey) {
|
||||
await executeTtyFwd([
|
||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||
'--session', sessionId,
|
||||
'--send-key', text
|
||||
]);
|
||||
console.log(`Successfully sent key: ${text}`);
|
||||
}
|
||||
else {
|
||||
await executeTtyFwd([
|
||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||
'--session', sessionId,
|
||||
'--send-text', text
|
||||
]);
|
||||
console.log(`Successfully sent text: ${text}`);
|
||||
}
|
||||
res.json({ success: true });
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error sending input via tty-fwd:', error);
|
||||
res.status(500).json({ error: 'Failed to send input' });
|
||||
}
|
||||
});
|
||||
// === FILE SYSTEM ===
|
||||
// Directory listing for file browser
|
||||
app.get('/api/fs/browse', (req, res) => {
|
||||
const dirPath = req.query.path || '~';
|
||||
try {
|
||||
const expandedPath = resolvePath(dirPath, '~');
|
||||
if (!fs_1.default.existsSync(expandedPath)) {
|
||||
return res.status(404).json({ error: 'Directory not found' });
|
||||
}
|
||||
const stats = fs_1.default.statSync(expandedPath);
|
||||
if (!stats.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Path is not a directory' });
|
||||
}
|
||||
const files = fs_1.default.readdirSync(expandedPath).map(name => {
|
||||
const filePath = path_1.default.join(expandedPath, name);
|
||||
const fileStats = fs_1.default.statSync(filePath);
|
||||
return {
|
||||
name,
|
||||
created: fileStats.birthtime.toISOString(),
|
||||
lastModified: fileStats.mtime.toISOString(),
|
||||
size: fileStats.size,
|
||||
isDir: fileStats.isDirectory()
|
||||
};
|
||||
});
|
||||
res.json({
|
||||
absolutePath: expandedPath,
|
||||
files: files.sort((a, b) => {
|
||||
// Directories first, then files
|
||||
if (a.isDir && !b.isDir)
|
||||
return -1;
|
||||
if (!a.isDir && b.isDir)
|
||||
return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error listing directory:', error);
|
||||
res.status(500).json({ error: 'Failed to list directory' });
|
||||
}
|
||||
});
|
||||
// === WEBSOCKETS ===
|
||||
// WebSocket for hot reload
|
||||
wss.on('connection', (ws, req) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const isHotReload = url.searchParams.get('hotReload') === 'true';
|
||||
if (isHotReload) {
|
||||
hotReloadClients.add(ws);
|
||||
ws.on('close', () => {
|
||||
hotReloadClients.delete(ws);
|
||||
});
|
||||
return;
|
||||
}
|
||||
ws.close(1008, 'Only hot reload connections supported');
|
||||
});
|
||||
// Hot reload file watching in development
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const chokidar = require('chokidar');
|
||||
const watcher = chokidar.watch(['public/**/*', 'src/**/*'], {
|
||||
ignored: /node_modules/,
|
||||
persistent: true
|
||||
});
|
||||
watcher.on('change', (path) => {
|
||||
console.log(`File changed: ${path}`);
|
||||
hotReloadClients.forEach((ws) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'reload' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
server.listen(PORT, () => {
|
||||
console.log(`VibeTunnel New Server running on http://localhost:${PORT}`);
|
||||
console.log(`Using tty-fwd: ${TTY_FWD_PATH}`);
|
||||
});
|
||||
746
web/dist/server.js
vendored
746
web/dist/server.js
vendored
|
|
@ -1,746 +0,0 @@
|
|||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const express_1 = __importDefault(require("express"));
|
||||
const http_1 = require("http");
|
||||
const ws_1 = require("ws");
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const os_1 = __importDefault(require("os"));
|
||||
const child_process_1 = require("child_process");
|
||||
const app = (0, express_1.default)();
|
||||
const server = (0, http_1.createServer)(app);
|
||||
const wss = new ws_1.WebSocketServer({ server });
|
||||
const PORT = process.env.PORT || 3000;
|
||||
// tty-fwd binary path - check multiple possible locations
|
||||
const possibleTtyFwdPaths = [
|
||||
path_1.default.resolve(__dirname, '..', '..', 'tty-fwd', 'target', 'release', 'tty-fwd'),
|
||||
path_1.default.resolve(__dirname, '..', '..', '..', 'tty-fwd', 'target', 'release', 'tty-fwd'),
|
||||
'tty-fwd' // System PATH
|
||||
];
|
||||
let TTY_FWD_PATH = '';
|
||||
for (const pathToCheck of possibleTtyFwdPaths) {
|
||||
if (fs_1.default.existsSync(pathToCheck)) {
|
||||
TTY_FWD_PATH = pathToCheck;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!TTY_FWD_PATH) {
|
||||
console.error('tty-fwd binary not found. Please ensure it is built and available.');
|
||||
process.exit(1);
|
||||
}
|
||||
const TTY_FWD_CONTROL_DIR = process.env.TTY_FWD_CONTROL_DIR || path_1.default.join(os_1.default.homedir(), '.vibetunnel');
|
||||
// Ensure control directory exists and is clean
|
||||
if (fs_1.default.existsSync(TTY_FWD_CONTROL_DIR)) {
|
||||
// Clean existing directory contents
|
||||
try {
|
||||
const files = fs_1.default.readdirSync(TTY_FWD_CONTROL_DIR);
|
||||
for (const file of files) {
|
||||
const filePath = path_1.default.join(TTY_FWD_CONTROL_DIR, file);
|
||||
const stat = fs_1.default.statSync(filePath);
|
||||
if (stat.isDirectory()) {
|
||||
fs_1.default.rmSync(filePath, { recursive: true, force: true });
|
||||
}
|
||||
else {
|
||||
fs_1.default.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
console.log(`Cleaned control directory: ${TTY_FWD_CONTROL_DIR}`);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error cleaning control directory:', error);
|
||||
}
|
||||
}
|
||||
else {
|
||||
fs_1.default.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}`);
|
||||
// Helper function to execute tty-fwd commands
|
||||
async function executeTtyFwd(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = (0, child_process_1.spawn)(TTY_FWD_PATH, args);
|
||||
let output = '';
|
||||
let isResolved = false;
|
||||
// Set a timeout to prevent hanging
|
||||
const timeout = setTimeout(() => {
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
child.kill('SIGTERM');
|
||||
reject(new Error('tty-fwd command timed out after 5 seconds'));
|
||||
}
|
||||
}, 5000);
|
||||
child.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
clearTimeout(timeout);
|
||||
if (code === 0) {
|
||||
resolve(output);
|
||||
}
|
||||
else {
|
||||
reject(new Error(`tty-fwd failed with code ${code}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
// Helper function to resolve paths with ~ expansion
|
||||
function resolvePath(inputPath, fallback) {
|
||||
if (!inputPath) {
|
||||
return fallback || process.cwd();
|
||||
}
|
||||
if (inputPath.startsWith('~')) {
|
||||
return path_1.default.join(os_1.default.homedir(), inputPath.slice(1));
|
||||
}
|
||||
return path_1.default.resolve(inputPath);
|
||||
}
|
||||
// Middleware
|
||||
app.use(express_1.default.json());
|
||||
app.use(express_1.default.static(path_1.default.join(__dirname, '..', 'public')));
|
||||
// Hot reload functionality for development
|
||||
const hotReloadClients = new Set();
|
||||
// === SESSION MANAGEMENT ===
|
||||
// List all sessions
|
||||
app.get('/api/sessions', async (req, res) => {
|
||||
try {
|
||||
const output = await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
|
||||
const sessions = JSON.parse(output || '{}');
|
||||
const sessionData = Object.entries(sessions).map(([sessionId, sessionInfo]) => {
|
||||
// Get actual last modified time from stream-out file
|
||||
let lastModified = sessionInfo.started_at;
|
||||
try {
|
||||
if (fs_1.default.existsSync(sessionInfo["stream-out"])) {
|
||||
const stats = fs_1.default.statSync(sessionInfo["stream-out"]);
|
||||
lastModified = stats.mtime.toISOString();
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// Use started_at as fallback
|
||||
}
|
||||
return {
|
||||
id: sessionId,
|
||||
command: sessionInfo.cmdline.join(' '),
|
||||
workingDir: sessionInfo.cwd,
|
||||
status: sessionInfo.status,
|
||||
exitCode: sessionInfo.exit_code,
|
||||
startedAt: sessionInfo.started_at,
|
||||
lastModified: lastModified,
|
||||
pid: sessionInfo.pid
|
||||
};
|
||||
});
|
||||
// Sort by lastModified, most recent first
|
||||
sessionData.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
res.json(sessionData);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to list sessions:', error);
|
||||
res.status(500).json({ error: 'Failed to list sessions' });
|
||||
}
|
||||
});
|
||||
// Create new session
|
||||
app.post('/api/sessions', async (req, res) => {
|
||||
try {
|
||||
const { command, workingDir } = req.body;
|
||||
if (!command || !Array.isArray(command) || command.length === 0) {
|
||||
return res.status(400).json({ error: 'Command array is required and cannot be empty' });
|
||||
}
|
||||
const sessionName = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const cwd = resolvePath(workingDir, process.cwd());
|
||||
const args = [
|
||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||
'--session-name', sessionName,
|
||||
'--'
|
||||
].concat(command);
|
||||
console.log(`Creating session: ${TTY_FWD_PATH} ${args.join(' ')}`);
|
||||
const child = (0, child_process_1.spawn)(TTY_FWD_PATH, args, {
|
||||
cwd: cwd,
|
||||
detached: false,
|
||||
stdio: 'pipe'
|
||||
});
|
||||
// Capture session ID from stdout
|
||||
let sessionId = '';
|
||||
child.stdout.on('data', (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output && !sessionId) {
|
||||
// First line of output should be the session ID
|
||||
sessionId = output;
|
||||
console.log(`Session created with ID: ${sessionId}`);
|
||||
}
|
||||
});
|
||||
child.stderr.on('data', (data) => {
|
||||
// Only log stderr if it contains actual errors
|
||||
const output = data.toString();
|
||||
if (output.includes('error') || output.includes('Error')) {
|
||||
console.error(`Session ${sessionName} stderr:`, output);
|
||||
}
|
||||
});
|
||||
child.on('close', async (code) => {
|
||||
console.log(`Session ${sessionId || sessionName} exited with code: ${code}`);
|
||||
// Send exit event to all clients watching this session
|
||||
const streamInfo = activeStreams.get(sessionId);
|
||||
if (streamInfo) {
|
||||
console.log(`Sending exit event to stream ${sessionId}`);
|
||||
const exitEvent = JSON.stringify(['exit', code, sessionId]);
|
||||
const eventData = `data: ${exitEvent}\n\n`;
|
||||
streamInfo.clients.forEach(client => {
|
||||
try {
|
||||
client.write(eventData);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error sending exit event to client:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// Wait for session ID from tty-fwd or timeout after 3 seconds
|
||||
const waitForSessionId = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('Failed to get session ID from tty-fwd within 3 seconds'));
|
||||
}, 3000);
|
||||
const checkSessionId = () => {
|
||||
if (sessionId) {
|
||||
clearTimeout(timeout);
|
||||
resolve(sessionId);
|
||||
}
|
||||
else {
|
||||
setTimeout(checkSessionId, 100);
|
||||
}
|
||||
};
|
||||
checkSessionId();
|
||||
});
|
||||
const finalSessionId = await waitForSessionId;
|
||||
res.json({ sessionId: finalSessionId });
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error creating session:', error);
|
||||
res.status(500).json({ error: 'Failed to create session' });
|
||||
}
|
||||
});
|
||||
// Kill session (just kill the process)
|
||||
app.delete('/api/sessions/:sessionId', async (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
try {
|
||||
const output = await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
|
||||
const sessions = JSON.parse(output || '{}');
|
||||
const session = sessions[sessionId];
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
if (session.pid) {
|
||||
try {
|
||||
process.kill(session.pid, 'SIGTERM');
|
||||
setTimeout(() => {
|
||||
try {
|
||||
process.kill(session.pid, 0); // Check if still alive
|
||||
process.kill(session.pid, 'SIGKILL'); // Force kill
|
||||
}
|
||||
catch (e) {
|
||||
// Process already dead
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
catch (error) {
|
||||
// Process already dead
|
||||
}
|
||||
}
|
||||
res.json({ success: true, message: 'Session killed' });
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error killing session:', error);
|
||||
res.status(500).json({ error: 'Failed to kill session' });
|
||||
}
|
||||
});
|
||||
// Cleanup session files
|
||||
app.delete('/api/sessions/:sessionId/cleanup', async (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
try {
|
||||
await executeTtyFwd([
|
||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||
'--session', sessionId,
|
||||
'--cleanup'
|
||||
]);
|
||||
res.json({ success: true, message: 'Session cleaned up' });
|
||||
}
|
||||
catch (error) {
|
||||
// If tty-fwd cleanup fails, force remove directory
|
||||
console.log('tty-fwd cleanup failed, force removing directory');
|
||||
const sessionDir = path_1.default.join(TTY_FWD_CONTROL_DIR, sessionId);
|
||||
try {
|
||||
if (fs_1.default.existsSync(sessionDir)) {
|
||||
fs_1.default.rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
res.json({ success: true, message: 'Session force cleaned up' });
|
||||
}
|
||||
catch (fsError) {
|
||||
console.error('Error force removing session directory:', fsError);
|
||||
res.status(500).json({ error: 'Failed to cleanup session' });
|
||||
}
|
||||
}
|
||||
});
|
||||
// Cleanup all exited sessions
|
||||
app.post('/api/cleanup-exited', async (req, res) => {
|
||||
try {
|
||||
await executeTtyFwd([
|
||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||
'--cleanup'
|
||||
]);
|
||||
res.json({ success: true, message: 'All exited sessions cleaned up' });
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error cleaning up exited sessions:', error);
|
||||
res.status(500).json({ error: 'Failed to cleanup exited sessions' });
|
||||
}
|
||||
});
|
||||
// === TERMINAL I/O ===
|
||||
// Track active streams per session to avoid multiple tail processes
|
||||
const activeStreams = new Map();
|
||||
// Live streaming cast file for XTerm renderer
|
||||
app.get('/api/sessions/:sessionId/stream', async (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
const streamOutPath = path_1.default.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out');
|
||||
if (!fs_1.default.existsSync(streamOutPath)) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
console.log(`New SSE client connected to session ${sessionId} from ${req.get('User-Agent')?.substring(0, 50) || 'unknown'}`);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Cache-Control'
|
||||
});
|
||||
const startTime = Date.now() / 1000;
|
||||
let headerSent = false;
|
||||
// Send existing content first
|
||||
try {
|
||||
const content = fs_1.default.readFileSync(streamOutPath, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed.version && parsed.width && parsed.height) {
|
||||
res.write(`data: ${line}\n\n`);
|
||||
headerSent = true;
|
||||
}
|
||||
else if (Array.isArray(parsed) && parsed.length >= 3) {
|
||||
const instantEvent = [0, parsed[1], parsed[2]];
|
||||
res.write(`data: ${JSON.stringify(instantEvent)}\n\n`);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error reading existing content:', error);
|
||||
}
|
||||
// Send default header if none found
|
||||
if (!headerSent) {
|
||||
const defaultHeader = {
|
||||
version: 2,
|
||||
width: 80,
|
||||
height: 24,
|
||||
timestamp: Math.floor(startTime),
|
||||
env: { TERM: "xterm-256color" }
|
||||
};
|
||||
res.write(`data: ${JSON.stringify(defaultHeader)}\n\n`);
|
||||
}
|
||||
// Get or create shared stream for this session
|
||||
let streamInfo = activeStreams.get(sessionId);
|
||||
if (!streamInfo) {
|
||||
console.log(`Creating new shared tail process for session ${sessionId}`);
|
||||
// Create new tail process for this session
|
||||
const tailProcess = (0, child_process_1.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);
|
||||
console.log(`Removed failed client from session ${sessionId}, remaining clients: ${streamInfo.clients.size}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
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 = () => {
|
||||
if (streamInfo && streamInfo.clients.has(res)) {
|
||||
streamInfo.clients.delete(res);
|
||||
console.log(`Removed client from session ${sessionId}, remaining clients: ${streamInfo.clients.size}`);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
req.on('close', cleanup);
|
||||
req.on('aborted', cleanup);
|
||||
req.on('error', cleanup);
|
||||
res.on('close', cleanup);
|
||||
res.on('finish', cleanup);
|
||||
});
|
||||
// Get session snapshot (cast with adjusted timestamps for immediate playback)
|
||||
app.get('/api/sessions/:sessionId/snapshot', (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
const streamOutPath = path_1.default.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out');
|
||||
if (!fs_1.default.existsSync(streamOutPath)) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
try {
|
||||
const content = fs_1.default.readFileSync(streamOutPath, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
let header = null;
|
||||
const events = [];
|
||||
let startTime = null;
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
// Header line
|
||||
if (parsed.version && parsed.width && parsed.height) {
|
||||
header = parsed;
|
||||
}
|
||||
// Event line [timestamp, type, data]
|
||||
else if (Array.isArray(parsed) && parsed.length >= 3) {
|
||||
if (startTime === null) {
|
||||
startTime = parsed[0];
|
||||
}
|
||||
events.push([0, parsed[1], parsed[2]]);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
}
|
||||
// Build the complete cast
|
||||
const cast = [];
|
||||
// Add header if found, otherwise use default
|
||||
if (header) {
|
||||
cast.push(JSON.stringify(header));
|
||||
}
|
||||
else {
|
||||
cast.push(JSON.stringify({
|
||||
version: 2,
|
||||
width: 80,
|
||||
height: 24,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
env: { TERM: "xterm-256color" }
|
||||
}));
|
||||
}
|
||||
// Add all events
|
||||
events.forEach(event => {
|
||||
cast.push(JSON.stringify(event));
|
||||
});
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.send(cast.join('\n'));
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error reading session snapshot:', error);
|
||||
res.status(500).json({ error: 'Failed to read session snapshot' });
|
||||
}
|
||||
});
|
||||
// Send input to session
|
||||
app.post('/api/sessions/:sessionId/input', async (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
const { text } = req.body;
|
||||
if (text === undefined || text === null) {
|
||||
return res.status(400).json({ error: 'Text is required' });
|
||||
}
|
||||
console.log(`Sending input to session ${sessionId}:`, JSON.stringify(text));
|
||||
try {
|
||||
// Validate session exists and is running
|
||||
const output = await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
|
||||
const sessions = JSON.parse(output || '{}');
|
||||
if (!sessions[sessionId]) {
|
||||
console.error(`Session ${sessionId} not found in active sessions`);
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
const session = sessions[sessionId];
|
||||
if (session.status !== 'running') {
|
||||
console.error(`Session ${sessionId} is not running (status: ${session.status})`);
|
||||
return res.status(400).json({ error: 'Session is not running' });
|
||||
}
|
||||
// Check if the process is actually still alive
|
||||
if (session.pid) {
|
||||
try {
|
||||
process.kill(session.pid, 0); // Signal 0 just checks if process exists
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`Session ${sessionId} process ${session.pid} is dead, cleaning up`);
|
||||
// Try to cleanup the stale session
|
||||
try {
|
||||
await executeTtyFwd([
|
||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||
'--session', sessionId,
|
||||
'--cleanup'
|
||||
]);
|
||||
}
|
||||
catch (cleanupError) {
|
||||
console.error('Failed to cleanup stale session:', cleanupError);
|
||||
}
|
||||
return res.status(410).json({ error: 'Session process has died' });
|
||||
}
|
||||
}
|
||||
// Check if this is a special key that should use --send-key
|
||||
const specialKeys = ['arrow_up', 'arrow_down', 'arrow_left', 'arrow_right', 'escape', 'enter', 'ctrl_enter', 'shift_enter'];
|
||||
const isSpecialKey = specialKeys.includes(text);
|
||||
const startTime = Date.now();
|
||||
if (isSpecialKey) {
|
||||
await executeTtyFwd([
|
||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||
'--session', sessionId,
|
||||
'--send-key', text
|
||||
]);
|
||||
// Key sent successfully (removed verbose logging)
|
||||
}
|
||||
else {
|
||||
await executeTtyFwd([
|
||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||
'--session', sessionId,
|
||||
'--send-text', text
|
||||
]);
|
||||
// Text sent successfully (removed verbose logging)
|
||||
}
|
||||
res.json({ success: true });
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error sending input via tty-fwd:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: 'Failed to send input', details: errorMessage });
|
||||
}
|
||||
});
|
||||
// === CAST FILE SERVING ===
|
||||
// Serve test cast file
|
||||
app.get('/api/test-cast', (req, res) => {
|
||||
const testCastPath = path_1.default.join(__dirname, '..', 'public', 'stream-out');
|
||||
try {
|
||||
if (fs_1.default.existsSync(testCastPath)) {
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
const content = fs_1.default.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 ===
|
||||
// Directory listing for file browser
|
||||
app.get('/api/fs/browse', (req, res) => {
|
||||
const dirPath = req.query.path || '~';
|
||||
try {
|
||||
const expandedPath = resolvePath(dirPath, '~');
|
||||
if (!fs_1.default.existsSync(expandedPath)) {
|
||||
return res.status(404).json({ error: 'Directory not found' });
|
||||
}
|
||||
const stats = fs_1.default.statSync(expandedPath);
|
||||
if (!stats.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Path is not a directory' });
|
||||
}
|
||||
const files = fs_1.default.readdirSync(expandedPath).map(name => {
|
||||
const filePath = path_1.default.join(expandedPath, name);
|
||||
const fileStats = fs_1.default.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' });
|
||||
}
|
||||
});
|
||||
// Create directory
|
||||
app.post('/api/mkdir', (req, res) => {
|
||||
try {
|
||||
const { path: dirPath, name } = req.body;
|
||||
if (!dirPath || !name) {
|
||||
return res.status(400).json({ error: 'Missing path or name parameter' });
|
||||
}
|
||||
// Validate directory name (no path separators, no hidden files starting with .)
|
||||
if (name.includes('/') || name.includes('\\') || name.startsWith('.')) {
|
||||
return res.status(400).json({ error: 'Invalid directory name' });
|
||||
}
|
||||
// Expand tilde in path
|
||||
const expandedPath = dirPath.startsWith('~')
|
||||
? path_1.default.join(os_1.default.homedir(), dirPath.slice(1))
|
||||
: path_1.default.resolve(dirPath);
|
||||
// Security check: ensure we're not trying to access outside allowed areas
|
||||
const allowedBasePaths = [os_1.default.homedir(), process.cwd()];
|
||||
const isAllowed = allowedBasePaths.some(basePath => expandedPath.startsWith(path_1.default.resolve(basePath)));
|
||||
if (!isAllowed) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
// Check if parent directory exists
|
||||
if (!fs_1.default.existsSync(expandedPath)) {
|
||||
return res.status(404).json({ error: 'Parent directory not found' });
|
||||
}
|
||||
const stats = fs_1.default.statSync(expandedPath);
|
||||
if (!stats.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Parent path is not a directory' });
|
||||
}
|
||||
const newDirPath = path_1.default.join(expandedPath, name);
|
||||
// Check if directory already exists
|
||||
if (fs_1.default.existsSync(newDirPath)) {
|
||||
return res.status(409).json({ error: 'Directory already exists' });
|
||||
}
|
||||
// Create the directory
|
||||
fs_1.default.mkdirSync(newDirPath, { recursive: false });
|
||||
res.json({
|
||||
success: true,
|
||||
path: newDirPath,
|
||||
message: `Directory '${name}' created successfully`
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error creating directory:', error);
|
||||
res.status(500).json({ error: 'Failed to create directory' });
|
||||
}
|
||||
});
|
||||
// === WEBSOCKETS ===
|
||||
// WebSocket for hot reload
|
||||
wss.on('connection', (ws, req) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const isHotReload = url.searchParams.get('hotReload') === 'true';
|
||||
if (isHotReload) {
|
||||
hotReloadClients.add(ws);
|
||||
ws.on('close', () => {
|
||||
hotReloadClients.delete(ws);
|
||||
});
|
||||
return;
|
||||
}
|
||||
ws.close(1008, 'Only hot reload connections supported');
|
||||
});
|
||||
// Hot reload file watching in development
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const chokidar = require('chokidar');
|
||||
const watcher = chokidar.watch(['public/**/*', 'src/**/*'], {
|
||||
ignored: /node_modules/,
|
||||
persistent: true
|
||||
});
|
||||
watcher.on('change', (path) => {
|
||||
console.log(`File changed: ${path}`);
|
||||
hotReloadClients.forEach((ws) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'reload' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
server.listen(PORT, () => {
|
||||
console.log(`VibeTunnel New Server running on http://localhost:${PORT}`);
|
||||
console.log(`Using tty-fwd: ${TTY_FWD_PATH}`);
|
||||
});
|
||||
//# sourceMappingURL=server.js.map
|
||||
1
web/dist/server.js.map
vendored
1
web/dist/server.js.map
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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,298 +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';
|
||||
import './components/session-view.js';
|
||||
import './components/session-card.js';
|
||||
let VibeTunnelApp = class VibeTunnelApp extends LitElement {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.errorMessage = '';
|
||||
this.sessions = [];
|
||||
this.loading = false;
|
||||
this.currentView = 'list';
|
||||
this.selectedSession = null;
|
||||
this.hideExited = true;
|
||||
this.showCreateModal = false;
|
||||
this.hotReloadWs = null;
|
||||
this.handlePopState = (event) => {
|
||||
// Handle browser back/forward navigation
|
||||
this.parseUrlAndSetState();
|
||||
};
|
||||
}
|
||||
// Disable shadow DOM to use Tailwind
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setupHotReload();
|
||||
this.loadSessions();
|
||||
this.startAutoRefresh();
|
||||
this.setupRouting();
|
||||
}
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.hotReloadWs) {
|
||||
this.hotReloadWs.close();
|
||||
}
|
||||
// Clean up routing listeners
|
||||
window.removeEventListener('popstate', this.handlePopState);
|
||||
}
|
||||
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, but only when showing session list
|
||||
setInterval(() => {
|
||||
if (this.currentView === 'list') {
|
||||
this.loadSessions();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
async handleSessionCreated(e) {
|
||||
const sessionId = e.detail.sessionId;
|
||||
if (!sessionId) {
|
||||
this.showError('Session created but ID not found in response');
|
||||
return;
|
||||
}
|
||||
this.showCreateModal = false;
|
||||
// Wait for session to appear in the list and then switch to it
|
||||
await this.waitForSessionAndSwitch(sessionId);
|
||||
}
|
||||
async waitForSessionAndSwitch(sessionId) {
|
||||
const maxAttempts = 10;
|
||||
const delay = 500; // 500ms between attempts
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
await this.loadSessions();
|
||||
// Try to find by exact ID match first
|
||||
let session = this.sessions.find(s => s.id === sessionId);
|
||||
// If not found by ID, find the most recently created session
|
||||
// This works around tty-fwd potentially using different IDs internally
|
||||
if (!session && this.sessions.length > 0) {
|
||||
const sortedSessions = [...this.sessions].sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
|
||||
session = sortedSessions[0];
|
||||
}
|
||||
if (session) {
|
||||
// Session found, switch to session view
|
||||
this.selectedSession = session;
|
||||
this.currentView = 'session';
|
||||
// Update URL to include session ID
|
||||
this.updateUrl(session.id);
|
||||
return;
|
||||
}
|
||||
// Wait before next attempt
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
// If we get here, session creation might have failed
|
||||
console.log('Session not found after all attempts');
|
||||
this.showError('Session created but could not be found. Please refresh.');
|
||||
}
|
||||
handleSessionSelect(e) {
|
||||
const session = e.detail;
|
||||
console.log('Session selected:', session);
|
||||
this.selectedSession = session;
|
||||
this.currentView = 'session';
|
||||
// Update URL to include session ID
|
||||
this.updateUrl(session.id);
|
||||
}
|
||||
handleBack() {
|
||||
this.currentView = 'list';
|
||||
this.selectedSession = null;
|
||||
// Update URL to remove session parameter
|
||||
this.updateUrl();
|
||||
}
|
||||
handleSessionKilled(e) {
|
||||
console.log('Session killed:', e.detail);
|
||||
this.loadSessions(); // Refresh the list
|
||||
}
|
||||
handleRefresh() {
|
||||
this.loadSessions();
|
||||
}
|
||||
handleError(e) {
|
||||
this.showError(e.detail);
|
||||
}
|
||||
handleHideExitedChange(e) {
|
||||
this.hideExited = e.detail;
|
||||
}
|
||||
handleCreateSession() {
|
||||
this.showCreateModal = true;
|
||||
}
|
||||
handleCreateModalClose() {
|
||||
this.showCreateModal = false;
|
||||
}
|
||||
// URL Routing methods
|
||||
setupRouting() {
|
||||
// Handle browser back/forward navigation
|
||||
window.addEventListener('popstate', this.handlePopState.bind(this));
|
||||
// Parse initial URL and set state
|
||||
this.parseUrlAndSetState();
|
||||
}
|
||||
parseUrlAndSetState() {
|
||||
const url = new URL(window.location.href);
|
||||
const sessionId = url.searchParams.get('session');
|
||||
if (sessionId) {
|
||||
// Load the specific session
|
||||
this.loadSessionFromUrl(sessionId);
|
||||
}
|
||||
else {
|
||||
// Show session list
|
||||
this.currentView = 'list';
|
||||
this.selectedSession = null;
|
||||
}
|
||||
}
|
||||
async loadSessionFromUrl(sessionId) {
|
||||
// First ensure sessions are loaded
|
||||
if (this.sessions.length === 0) {
|
||||
await this.loadSessions();
|
||||
}
|
||||
// Find the session
|
||||
const session = this.sessions.find(s => s.id === sessionId);
|
||||
if (session) {
|
||||
this.selectedSession = session;
|
||||
this.currentView = 'session';
|
||||
}
|
||||
else {
|
||||
// Session not found, go to list view
|
||||
this.currentView = 'list';
|
||||
this.selectedSession = null;
|
||||
// Update URL to remove invalid session ID
|
||||
this.updateUrl();
|
||||
}
|
||||
}
|
||||
updateUrl(sessionId) {
|
||||
const url = new URL(window.location.href);
|
||||
if (sessionId) {
|
||||
url.searchParams.set('session', sessionId);
|
||||
}
|
||||
else {
|
||||
url.searchParams.delete('session');
|
||||
}
|
||||
// Update browser URL without triggering page reload
|
||||
window.history.pushState(null, '', url.toString());
|
||||
}
|
||||
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 `
|
||||
<!-- Error notification overlay -->
|
||||
${this.errorMessage ? html `
|
||||
<div class="fixed top-4 right-4 z-50">
|
||||
<div class="bg-vs-warning text-vs-bg px-4 py-2 rounded shadow-lg font-mono text-sm">
|
||||
${this.errorMessage}
|
||||
<button @click=${this.clearError} class="ml-2 text-vs-bg hover:text-vs-muted">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Main content -->
|
||||
${this.currentView === 'session' ? html `
|
||||
<session-view
|
||||
.session=${this.selectedSession}
|
||||
@back=${this.handleBack}
|
||||
></session-view>
|
||||
` : html `
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<app-header
|
||||
@create-session=${this.handleCreateSession}
|
||||
></app-header>
|
||||
<session-list
|
||||
.sessions=${this.sessions}
|
||||
.loading=${this.loading}
|
||||
.hideExited=${this.hideExited}
|
||||
.showCreateModal=${this.showCreateModal}
|
||||
@session-select=${this.handleSessionSelect}
|
||||
@session-killed=${this.handleSessionKilled}
|
||||
@session-created=${this.handleSessionCreated}
|
||||
@create-modal-close=${this.handleCreateModalClose}
|
||||
@refresh=${this.handleRefresh}
|
||||
@error=${this.handleError}
|
||||
@hide-exited-change=${this.handleHideExitedChange}
|
||||
></session-list>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
state()
|
||||
], VibeTunnelApp.prototype, "errorMessage", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], VibeTunnelApp.prototype, "sessions", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], VibeTunnelApp.prototype, "loading", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], VibeTunnelApp.prototype, "currentView", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], VibeTunnelApp.prototype, "selectedSession", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], VibeTunnelApp.prototype, "hideExited", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], VibeTunnelApp.prototype, "showCreateModal", void 0);
|
||||
VibeTunnelApp = __decorate([
|
||||
customElement('vibetunnel-app')
|
||||
], VibeTunnelApp);
|
||||
export { VibeTunnelApp };
|
||||
//# sourceMappingURL=app.js.map
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -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,256 +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;
|
||||
this.showCreateFolder = false;
|
||||
this.newFolderName = '';
|
||||
this.creating = 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'));
|
||||
}
|
||||
handleCreateFolder() {
|
||||
this.showCreateFolder = true;
|
||||
this.newFolderName = '';
|
||||
}
|
||||
handleCancelCreateFolder() {
|
||||
this.showCreateFolder = false;
|
||||
this.newFolderName = '';
|
||||
}
|
||||
handleFolderNameInput(e) {
|
||||
this.newFolderName = e.target.value;
|
||||
}
|
||||
handleFolderNameKeydown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.createFolder();
|
||||
}
|
||||
else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.handleCancelCreateFolder();
|
||||
}
|
||||
}
|
||||
async createFolder() {
|
||||
if (!this.newFolderName.trim())
|
||||
return;
|
||||
this.creating = true;
|
||||
try {
|
||||
const response = await fetch('/api/mkdir', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: this.currentPath,
|
||||
name: this.newFolderName.trim()
|
||||
})
|
||||
});
|
||||
if (response.ok) {
|
||||
// Refresh directory listing
|
||||
await this.loadDirectory(this.currentPath);
|
||||
this.handleCancelCreateFolder();
|
||||
}
|
||||
else {
|
||||
const error = await response.json();
|
||||
alert(`Failed to create folder: ${error.error}`);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error creating folder:', error);
|
||||
alert('Failed to create folder');
|
||||
}
|
||||
finally {
|
||||
this.creating = false;
|
||||
}
|
||||
}
|
||||
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="flex justify-between items-center mb-2">
|
||||
<div class="text-vs-assistant text-sm">Select Directory</div>
|
||||
<button
|
||||
class="bg-vs-user text-vs-bg hover:bg-vs-accent font-mono px-2 py-1 text-xs border-none rounded"
|
||||
@click=${this.handleCreateFolder}
|
||||
?disabled=${this.loading}
|
||||
title="Create new folder"
|
||||
>
|
||||
+ folder
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<!-- Create folder dialog -->
|
||||
${this.showCreateFolder ? html `
|
||||
<div class="p-4 border-t border-vs-border flex-shrink-0">
|
||||
<div class="text-vs-assistant text-sm mb-2">Create New Folder</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 bg-vs-bg border border-vs-border text-vs-text px-2 py-1 text-sm font-mono"
|
||||
placeholder="Folder name"
|
||||
.value=${this.newFolderName}
|
||||
@input=${this.handleFolderNameInput}
|
||||
@keydown=${this.handleFolderNameKeydown}
|
||||
?disabled=${this.creating}
|
||||
/>
|
||||
<button
|
||||
class="bg-vs-user text-vs-bg hover:bg-vs-accent font-mono px-2 py-1 text-xs border-none"
|
||||
@click=${this.createFolder}
|
||||
?disabled=${this.creating || !this.newFolderName.trim()}
|
||||
>
|
||||
${this.creating ? '...' : 'create'}
|
||||
</button>
|
||||
<button
|
||||
class="bg-vs-muted text-vs-bg hover:bg-vs-text font-mono px-2 py-1 text-xs border-none"
|
||||
@click=${this.handleCancelCreateFolder}
|
||||
?disabled=${this.creating}
|
||||
>
|
||||
cancel
|
||||
</button>
|
||||
</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);
|
||||
__decorate([
|
||||
state()
|
||||
], FileBrowser.prototype, "showCreateFolder", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], FileBrowser.prototype, "newFolderName", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], FileBrowser.prototype, "creating", void 0);
|
||||
FileBrowser = __decorate([
|
||||
customElement('file-browser')
|
||||
], FileBrowser);
|
||||
export { FileBrowser };
|
||||
//# sourceMappingURL=file-browser.js.map
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,165 +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 { Renderer } from '../renderer.js';
|
||||
let SessionCard = class SessionCard extends LitElement {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.renderer = null;
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
// Disable shadow DOM to use Tailwind
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
firstUpdated(changedProperties) {
|
||||
super.firstUpdated(changedProperties);
|
||||
this.createRenderer();
|
||||
this.startRefresh();
|
||||
}
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose();
|
||||
this.renderer = null;
|
||||
}
|
||||
}
|
||||
createRenderer() {
|
||||
const playerElement = this.querySelector('#player');
|
||||
if (!playerElement)
|
||||
return;
|
||||
// Create single renderer for this card - use larger dimensions for better preview
|
||||
this.renderer = new Renderer(playerElement, 80, 24, 10000, 8, true);
|
||||
// Always use snapshot endpoint for cards
|
||||
const url = `/api/sessions/${this.session.id}/snapshot`;
|
||||
// Wait a moment for freshly created sessions before connecting
|
||||
const sessionAge = Date.now() - new Date(this.session.startedAt).getTime();
|
||||
const delay = sessionAge < 5000 ? 2000 : 0; // 2 second delay if session is less than 5 seconds old
|
||||
setTimeout(() => {
|
||||
if (this.renderer) {
|
||||
this.renderer.loadFromUrl(url, false); // false = not a stream, use snapshot
|
||||
// Disable pointer events so clicks pass through to the card
|
||||
this.renderer.setPointerEventsEnabled(false);
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
startRefresh() {
|
||||
this.refreshInterval = window.setInterval(() => {
|
||||
if (this.renderer) {
|
||||
const url = `/api/sessions/${this.session.id}/snapshot`;
|
||||
this.renderer.loadFromUrl(url, false);
|
||||
// Ensure pointer events stay disabled after refresh
|
||||
this.renderer.setPointerEventsEnabled(false);
|
||||
}
|
||||
}, 10000); // Refresh every 10 seconds
|
||||
}
|
||||
handleCardClick() {
|
||||
this.dispatchEvent(new CustomEvent('session-select', {
|
||||
detail: this.session,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
handleKillClick(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.dispatchEvent(new CustomEvent('session-kill', {
|
||||
detail: this.session.id,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
async handlePidClick(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (this.session.pid) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.session.pid.toString());
|
||||
console.log('PID copied to clipboard:', this.session.pid);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to copy PID to clipboard:', error);
|
||||
// Fallback: select text manually
|
||||
this.fallbackCopyToClipboard(this.session.pid.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
fallbackCopyToClipboard(text) {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
console.log('PID copied to clipboard (fallback):', text);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Fallback copy failed:', error);
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
render() {
|
||||
const isRunning = this.session.status === 'running';
|
||||
return html `
|
||||
<div class="bg-vs-bg border border-vs-border rounded shadow cursor-pointer overflow-hidden"
|
||||
@click=${this.handleCardClick}>
|
||||
<!-- 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">${this.session.command}</div>
|
||||
${this.session.status === 'running' ? 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=${this.handleKillClick}
|
||||
>
|
||||
${this.session.status === 'running' ? 'kill' : 'clean'}
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- XTerm renderer (main content) -->
|
||||
<div class="session-preview bg-black overflow-hidden" style="aspect-ratio: 640/480;">
|
||||
<div id="player" class="w-full h-full"></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="${this.session.status === 'running' ? 'text-vs-user' : 'text-vs-warning'} text-xs">
|
||||
${this.session.status}
|
||||
</span>
|
||||
${this.session.pid ? html `
|
||||
<span
|
||||
class="cursor-pointer hover:text-vs-accent transition-colors"
|
||||
@click=${this.handlePidClick}
|
||||
title="Click to copy PID"
|
||||
>
|
||||
PID: ${this.session.pid} <span class="opacity-50">(click to copy)</span>
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="truncate text-xs opacity-75" title="${this.session.workingDir}">${this.session.workingDir}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
property({ type: Object })
|
||||
], SessionCard.prototype, "session", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], SessionCard.prototype, "renderer", void 0);
|
||||
SessionCard = __decorate([
|
||||
customElement('session-card')
|
||||
], SessionCard);
|
||||
export { SessionCard };
|
||||
//# sourceMappingURL=session-card.js.map
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,710 +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 { Renderer } from '../renderer.js';
|
||||
let SessionView = class SessionView extends LitElement {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.session = null;
|
||||
this.connected = false;
|
||||
this.renderer = null;
|
||||
this.sessionStatusInterval = null;
|
||||
this.showMobileInput = false;
|
||||
this.mobileInputText = '';
|
||||
this.isMobile = false;
|
||||
this.touchStartX = 0;
|
||||
this.touchStartY = 0;
|
||||
this.loading = false;
|
||||
this.loadingFrame = 0;
|
||||
this.loadingInterval = null;
|
||||
this.keyboardHandler = (e) => {
|
||||
if (!this.session)
|
||||
return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.handleKeyboardInput(e);
|
||||
};
|
||||
this.touchStartHandler = (e) => {
|
||||
if (!this.isMobile)
|
||||
return;
|
||||
const touch = e.touches[0];
|
||||
this.touchStartX = touch.clientX;
|
||||
this.touchStartY = touch.clientY;
|
||||
};
|
||||
this.touchEndHandler = (e) => {
|
||||
if (!this.isMobile)
|
||||
return;
|
||||
const touch = e.changedTouches[0];
|
||||
const touchEndX = touch.clientX;
|
||||
const touchEndY = touch.clientY;
|
||||
const deltaX = touchEndX - this.touchStartX;
|
||||
const deltaY = touchEndY - this.touchStartY;
|
||||
// Check for horizontal swipe from left edge (back gesture)
|
||||
const isSwipeRight = deltaX > 100;
|
||||
const isVerticallyStable = Math.abs(deltaY) < 100;
|
||||
const startedFromLeftEdge = this.touchStartX < 50;
|
||||
if (isSwipeRight && isVerticallyStable && startedFromLeftEdge) {
|
||||
// Trigger back navigation
|
||||
this.handleBack();
|
||||
}
|
||||
};
|
||||
}
|
||||
// Disable shadow DOM to use Tailwind
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.connected = true;
|
||||
// Show loading animation if no session yet
|
||||
if (!this.session) {
|
||||
this.startLoading();
|
||||
}
|
||||
// Detect mobile device
|
||||
this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
||||
window.innerWidth <= 768;
|
||||
// Add global keyboard event listener only for desktop
|
||||
if (!this.isMobile) {
|
||||
document.addEventListener('keydown', this.keyboardHandler);
|
||||
}
|
||||
else {
|
||||
// Add touch event listeners for mobile swipe gestures
|
||||
document.addEventListener('touchstart', this.touchStartHandler, { passive: true });
|
||||
document.addEventListener('touchend', this.touchEndHandler, { passive: true });
|
||||
}
|
||||
// Start polling session status
|
||||
this.startSessionStatusPolling();
|
||||
}
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.connected = false;
|
||||
// Remove global keyboard event listener
|
||||
if (!this.isMobile) {
|
||||
document.removeEventListener('keydown', this.keyboardHandler);
|
||||
}
|
||||
else {
|
||||
// Remove touch event listeners
|
||||
document.removeEventListener('touchstart', this.touchStartHandler);
|
||||
document.removeEventListener('touchend', this.touchEndHandler);
|
||||
}
|
||||
// Stop polling session status
|
||||
this.stopSessionStatusPolling();
|
||||
// Stop loading animation
|
||||
this.stopLoading();
|
||||
// Cleanup renderer if it exists
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose();
|
||||
this.renderer = null;
|
||||
}
|
||||
}
|
||||
firstUpdated(changedProperties) {
|
||||
super.firstUpdated(changedProperties);
|
||||
if (this.session) {
|
||||
this.stopLoading();
|
||||
this.createInteractiveTerminal();
|
||||
}
|
||||
}
|
||||
updated(changedProperties) {
|
||||
super.updated(changedProperties);
|
||||
// Stop loading and create terminal when session becomes available
|
||||
if (changedProperties.has('session') && this.session && this.loading) {
|
||||
this.stopLoading();
|
||||
this.createInteractiveTerminal();
|
||||
}
|
||||
// Adjust terminal height for mobile buttons after render
|
||||
if (changedProperties.has('showMobileInput') || changedProperties.has('isMobile')) {
|
||||
requestAnimationFrame(() => {
|
||||
this.adjustTerminalForMobileButtons();
|
||||
});
|
||||
}
|
||||
}
|
||||
createInteractiveTerminal() {
|
||||
if (!this.session)
|
||||
return;
|
||||
const terminalElement = this.querySelector('#interactive-terminal');
|
||||
if (!terminalElement)
|
||||
return;
|
||||
// Create renderer once and connect to current session
|
||||
this.renderer = new Renderer(terminalElement);
|
||||
// Wait a moment for freshly created sessions before connecting
|
||||
const sessionAge = Date.now() - new Date(this.session.startedAt).getTime();
|
||||
const delay = sessionAge < 5000 ? 2000 : 0; // 2 second delay if session is less than 5 seconds old
|
||||
if (delay > 0) {
|
||||
// Show loading animation during delay for fresh sessions
|
||||
this.startLoading();
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (this.renderer && this.session) {
|
||||
this.stopLoading(); // Stop loading before connecting
|
||||
this.renderer.connectToStream(this.session.id);
|
||||
}
|
||||
}, delay);
|
||||
// Listen for session exit events
|
||||
terminalElement.addEventListener('session-exit', this.handleSessionExit.bind(this));
|
||||
}
|
||||
async handleKeyboardInput(e) {
|
||||
if (!this.session)
|
||||
return;
|
||||
// Don't send input to exited sessions
|
||||
if (this.session.status === 'exited') {
|
||||
console.log('Ignoring keyboard input - session has exited');
|
||||
return;
|
||||
}
|
||||
let inputText = '';
|
||||
// Handle special keys
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
if (e.ctrlKey) {
|
||||
// Ctrl+Enter - send to tty-fwd for proper handling
|
||||
inputText = 'ctrl_enter';
|
||||
}
|
||||
else if (e.shiftKey) {
|
||||
// Shift+Enter - send to tty-fwd for proper handling
|
||||
inputText = 'shift_enter';
|
||||
}
|
||||
else {
|
||||
// Regular Enter
|
||||
inputText = 'enter';
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
inputText = 'escape';
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
inputText = 'arrow_up';
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
inputText = 'arrow_down';
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
inputText = 'arrow_left';
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
inputText = 'arrow_right';
|
||||
break;
|
||||
case 'Tab':
|
||||
inputText = '\t';
|
||||
break;
|
||||
case 'Backspace':
|
||||
inputText = '\b';
|
||||
break;
|
||||
case 'Delete':
|
||||
inputText = '\x7f';
|
||||
break;
|
||||
case ' ':
|
||||
inputText = ' ';
|
||||
break;
|
||||
default:
|
||||
// Handle regular printable characters
|
||||
if (e.key.length === 1) {
|
||||
inputText = e.key;
|
||||
}
|
||||
else {
|
||||
// Ignore other special keys
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Handle Ctrl combinations (but not if we already handled Ctrl+Enter above)
|
||||
if (e.ctrlKey && e.key.length === 1 && e.key !== 'Enter') {
|
||||
const charCode = e.key.toLowerCase().charCodeAt(0);
|
||||
if (charCode >= 97 && charCode <= 122) { // a-z
|
||||
inputText = String.fromCharCode(charCode - 96); // Ctrl+A = \x01, etc.
|
||||
}
|
||||
}
|
||||
// Send the input to the session
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${this.session.id}/input`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ text: inputText })
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 400) {
|
||||
console.log('Session no longer accepting input (likely exited)');
|
||||
// Update session status to exited if we get 400 error
|
||||
if (this.session && this.session.status !== 'exited') {
|
||||
this.session = { ...this.session, status: 'exited' };
|
||||
this.requestUpdate();
|
||||
this.stopSessionStatusPolling();
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.error('Failed to send input to session:', response.status);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error sending input:', error);
|
||||
}
|
||||
}
|
||||
handleBack() {
|
||||
window.location.search = '';
|
||||
}
|
||||
handleSessionExit(e) {
|
||||
const customEvent = e;
|
||||
console.log('Session exit event received:', customEvent.detail);
|
||||
if (this.session && customEvent.detail.sessionId === this.session.id) {
|
||||
// Update session status to exited
|
||||
this.session = { ...this.session, status: 'exited' };
|
||||
this.requestUpdate();
|
||||
// Stop polling immediately
|
||||
this.stopSessionStatusPolling();
|
||||
// Switch to snapshot mode
|
||||
requestAnimationFrame(() => {
|
||||
this.createInteractiveTerminal();
|
||||
});
|
||||
}
|
||||
}
|
||||
// Mobile input methods
|
||||
handleMobileInputToggle() {
|
||||
this.showMobileInput = !this.showMobileInput;
|
||||
if (this.showMobileInput) {
|
||||
// Focus the textarea after a short delay to ensure it's rendered
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = this.querySelector('#mobile-input-textarea');
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
this.adjustTextareaForKeyboard();
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
// Clean up viewport listener when closing overlay
|
||||
const textarea = this.querySelector('#mobile-input-textarea');
|
||||
if (textarea && textarea._viewportCleanup) {
|
||||
textarea._viewportCleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
adjustTextareaForKeyboard() {
|
||||
// Adjust the layout when virtual keyboard appears
|
||||
const textarea = this.querySelector('#mobile-input-textarea');
|
||||
const controls = this.querySelector('#mobile-controls');
|
||||
if (!textarea || !controls)
|
||||
return;
|
||||
const adjustLayout = () => {
|
||||
const viewportHeight = window.visualViewport?.height || window.innerHeight;
|
||||
const windowHeight = window.innerHeight;
|
||||
const keyboardHeight = windowHeight - viewportHeight;
|
||||
// If keyboard is visible (viewport height is significantly smaller)
|
||||
if (keyboardHeight > 100) {
|
||||
// Move controls above the keyboard
|
||||
controls.style.transform = `translateY(-${keyboardHeight}px)`;
|
||||
controls.style.transition = 'transform 0.3s ease';
|
||||
// Calculate available space for textarea
|
||||
const header = this.querySelector('.flex.items-center.justify-between.p-4.border-b');
|
||||
const headerHeight = header?.offsetHeight || 60;
|
||||
const controlsHeight = controls?.offsetHeight || 120;
|
||||
const padding = 48; // Additional padding for spacing
|
||||
// Available height is viewport height minus header and controls (controls are now above keyboard)
|
||||
const maxTextareaHeight = viewportHeight - headerHeight - controlsHeight - padding;
|
||||
const inputArea = textarea.parentElement;
|
||||
if (inputArea && maxTextareaHeight > 0) {
|
||||
// Set the input area to not exceed the available space
|
||||
inputArea.style.height = `${maxTextareaHeight}px`;
|
||||
inputArea.style.maxHeight = `${maxTextareaHeight}px`;
|
||||
inputArea.style.overflow = 'hidden';
|
||||
// Set textarea height within the container
|
||||
const labelHeight = 40; // Height of the label above textarea
|
||||
const textareaMaxHeight = Math.max(maxTextareaHeight - labelHeight, 80);
|
||||
textarea.style.height = `${textareaMaxHeight}px`;
|
||||
textarea.style.maxHeight = `${textareaMaxHeight}px`;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Reset position when keyboard is hidden
|
||||
controls.style.transform = 'translateY(0px)';
|
||||
controls.style.transition = 'transform 0.3s ease';
|
||||
// Reset textarea height and constraints
|
||||
const inputArea = textarea.parentElement;
|
||||
if (inputArea) {
|
||||
inputArea.style.height = '';
|
||||
inputArea.style.maxHeight = '';
|
||||
inputArea.style.overflow = '';
|
||||
textarea.style.height = '';
|
||||
textarea.style.maxHeight = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
// Listen for viewport changes (keyboard show/hide)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', adjustLayout);
|
||||
// Clean up listener when overlay is closed
|
||||
const cleanup = () => {
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', adjustLayout);
|
||||
}
|
||||
};
|
||||
// Store cleanup function for later use
|
||||
textarea._viewportCleanup = cleanup;
|
||||
}
|
||||
// Initial adjustment
|
||||
requestAnimationFrame(adjustLayout);
|
||||
}
|
||||
handleMobileInputChange(e) {
|
||||
const textarea = e.target;
|
||||
this.mobileInputText = textarea.value;
|
||||
}
|
||||
async handleMobileInputSendOnly() {
|
||||
// Get the current value from the textarea directly
|
||||
const textarea = this.querySelector('#mobile-input-textarea');
|
||||
const textToSend = textarea?.value?.trim() || this.mobileInputText.trim();
|
||||
if (!textToSend)
|
||||
return;
|
||||
try {
|
||||
// Send text without enter key
|
||||
await this.sendInputText(textToSend);
|
||||
// Clear both the reactive property and textarea
|
||||
this.mobileInputText = '';
|
||||
if (textarea) {
|
||||
textarea.value = '';
|
||||
}
|
||||
// Trigger re-render to update button state
|
||||
this.requestUpdate();
|
||||
// Hide the input overlay after sending
|
||||
this.showMobileInput = false;
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error sending mobile input:', error);
|
||||
// Don't hide the overlay if there was an error
|
||||
}
|
||||
}
|
||||
async handleMobileInputSend() {
|
||||
// Get the current value from the textarea directly
|
||||
const textarea = this.querySelector('#mobile-input-textarea');
|
||||
const textToSend = textarea?.value?.trim() || this.mobileInputText.trim();
|
||||
if (!textToSend)
|
||||
return;
|
||||
try {
|
||||
// Add enter key at the end to execute the command
|
||||
await this.sendInputText(textToSend + '\n');
|
||||
// Clear both the reactive property and textarea
|
||||
this.mobileInputText = '';
|
||||
if (textarea) {
|
||||
textarea.value = '';
|
||||
}
|
||||
// Trigger re-render to update button state
|
||||
this.requestUpdate();
|
||||
// Hide the input overlay after sending
|
||||
this.showMobileInput = false;
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error sending mobile input:', error);
|
||||
// Don't hide the overlay if there was an error
|
||||
}
|
||||
}
|
||||
async handleSpecialKey(key) {
|
||||
await this.sendInputText(key);
|
||||
}
|
||||
async sendInputText(text) {
|
||||
if (!this.session)
|
||||
return;
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${this.session.id}/input`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ text })
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error('Failed to send input to session');
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error sending input:', error);
|
||||
}
|
||||
}
|
||||
adjustTerminalForMobileButtons() {
|
||||
// Disabled for now to avoid viewport issues
|
||||
// The mobile buttons will overlay the terminal
|
||||
}
|
||||
startLoading() {
|
||||
this.loading = true;
|
||||
this.loadingFrame = 0;
|
||||
this.loadingInterval = window.setInterval(() => {
|
||||
this.loadingFrame = (this.loadingFrame + 1) % 4;
|
||||
this.requestUpdate();
|
||||
}, 200); // Update every 200ms for smooth animation
|
||||
}
|
||||
stopLoading() {
|
||||
this.loading = false;
|
||||
if (this.loadingInterval) {
|
||||
clearInterval(this.loadingInterval);
|
||||
this.loadingInterval = null;
|
||||
}
|
||||
}
|
||||
getLoadingText() {
|
||||
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
return frames[this.loadingFrame % frames.length];
|
||||
}
|
||||
startSessionStatusPolling() {
|
||||
if (this.sessionStatusInterval) {
|
||||
clearInterval(this.sessionStatusInterval);
|
||||
}
|
||||
// Only poll for running sessions - exited sessions don't need polling
|
||||
if (this.session?.status !== 'exited') {
|
||||
this.sessionStatusInterval = window.setInterval(() => {
|
||||
this.checkSessionStatus();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
stopSessionStatusPolling() {
|
||||
if (this.sessionStatusInterval) {
|
||||
clearInterval(this.sessionStatusInterval);
|
||||
this.sessionStatusInterval = null;
|
||||
}
|
||||
}
|
||||
async checkSessionStatus() {
|
||||
if (!this.session)
|
||||
return;
|
||||
try {
|
||||
const response = await fetch('/api/sessions');
|
||||
if (!response.ok)
|
||||
return;
|
||||
const sessions = await response.json();
|
||||
const currentSession = sessions.find((s) => s.id === this.session.id);
|
||||
if (currentSession && currentSession.status !== this.session.status) {
|
||||
// Store old status before updating
|
||||
const oldStatus = this.session.status;
|
||||
// Session status changed
|
||||
this.session = { ...this.session, status: currentSession.status };
|
||||
this.requestUpdate();
|
||||
// Session status polling is now only for detecting new sessions
|
||||
// Exit events are handled via SSE stream directly
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error checking session status:', error);
|
||||
}
|
||||
}
|
||||
render() {
|
||||
if (!this.session) {
|
||||
return html `
|
||||
<div class="p-4 text-vs-muted">
|
||||
No session selected
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html `
|
||||
<style>
|
||||
session-view *, session-view *:focus, session-view *:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
</style>
|
||||
<div class="flex flex-col bg-vs-bg font-mono" style="height: 100vh; outline: none !important; box-shadow: none !important;">
|
||||
<!-- Compact Header -->
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-vs-border bg-vs-bg-secondary text-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-2 py-1 border-none rounded transition-colors text-xs"
|
||||
@click=${this.handleBack}
|
||||
>
|
||||
BACK
|
||||
</button>
|
||||
<div class="text-vs-text">
|
||||
<div class="text-vs-accent">${this.session.command}</div>
|
||||
<div class="text-vs-muted text-xs">${this.session.workingDir}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<span class="${this.session.status === 'running' ? 'text-vs-user' : 'text-vs-warning'}">
|
||||
${this.session.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Container -->
|
||||
<div class="flex-1 bg-black overflow-x-auto overflow-y-hidden min-h-0 relative" id="terminal-container">
|
||||
<div id="interactive-terminal" class="w-full h-full"></div>
|
||||
|
||||
${this.loading ? html `
|
||||
<!-- Loading overlay -->
|
||||
<div class="absolute inset-0 bg-black bg-opacity-80 flex items-center justify-center">
|
||||
<div class="text-vs-text font-mono text-center">
|
||||
<div class="text-2xl mb-2">${this.getLoadingText()}</div>
|
||||
<div class="text-sm text-vs-muted">Connecting to session...</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Mobile Input Controls -->
|
||||
${this.isMobile && !this.showMobileInput ? html `
|
||||
<div class="flex-shrink-0 p-4 bg-vs-bg">
|
||||
<!-- First row: Arrow keys -->
|
||||
<div class="flex gap-2 mb-2">
|
||||
<button
|
||||
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('arrow_up')}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('arrow_down')}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('arrow_left')}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('arrow_right')}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Second row: Special keys -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('\t')}
|
||||
>
|
||||
TAB
|
||||
</button>
|
||||
<button
|
||||
class="bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('enter')}
|
||||
>
|
||||
ENTER
|
||||
</button>
|
||||
<button
|
||||
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('escape')}
|
||||
>
|
||||
ESC
|
||||
</button>
|
||||
<button
|
||||
class="bg-vs-error text-vs-text hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('\x03')}
|
||||
>
|
||||
^C
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${this.handleMobileInputToggle}
|
||||
>
|
||||
TYPE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Full-Screen Input Overlay (only when opened) -->
|
||||
${this.isMobile && this.showMobileInput ? html `
|
||||
<div class="fixed inset-0 bg-vs-bg-secondary bg-opacity-95 z-50 flex flex-col" style="height: 100vh; height: 100dvh;">
|
||||
<!-- Input Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-vs-border flex-shrink-0">
|
||||
<div class="text-vs-text font-mono text-sm">Terminal Input</div>
|
||||
<button
|
||||
class="text-vs-muted hover:text-vs-text text-lg leading-none border-none bg-transparent cursor-pointer"
|
||||
@click=${this.handleMobileInputToggle}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Input Area with dynamic height -->
|
||||
<div class="flex-1 p-4 flex flex-col min-h-0">
|
||||
<div class="text-vs-muted text-sm mb-2 flex-shrink-0">
|
||||
Type your command(s) below. Supports multiline input.
|
||||
</div>
|
||||
<textarea
|
||||
id="mobile-input-textarea"
|
||||
class="flex-1 bg-vs-bg text-vs-text border border-vs-border font-mono text-sm p-4 resize-none outline-none"
|
||||
placeholder="Enter your command here..."
|
||||
.value=${this.mobileInputText}
|
||||
@input=${this.handleMobileInputChange}
|
||||
@keydown=${(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.handleMobileInputSend();
|
||||
}
|
||||
}}
|
||||
style="min-height: 120px; margin-bottom: 16px;"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Controls - Fixed above keyboard -->
|
||||
<div id="mobile-controls" class="fixed bottom-0 left-0 right-0 p-4 border-t border-vs-border bg-vs-bg-secondary z-60" style="padding-bottom: max(1rem, env(safe-area-inset-bottom)); transform: translateY(0px);">
|
||||
<!-- Send Buttons Row -->
|
||||
<div class="flex gap-2 mb-3">
|
||||
<button
|
||||
class="flex-1 bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-3 border-none rounded transition-colors text-sm font-bold"
|
||||
@click=${this.handleMobileInputSendOnly}
|
||||
?disabled=${!this.mobileInputText.trim()}
|
||||
>
|
||||
SEND
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-4 py-3 border-none rounded transition-colors text-sm font-bold"
|
||||
@click=${this.handleMobileInputSend}
|
||||
?disabled=${!this.mobileInputText.trim()}
|
||||
>
|
||||
SEND + ENTER
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-vs-muted text-xs text-center">
|
||||
SEND: text only • SEND + ENTER: text with enter key
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
property({ type: Object })
|
||||
], SessionView.prototype, "session", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], SessionView.prototype, "connected", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], SessionView.prototype, "renderer", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], SessionView.prototype, "sessionStatusInterval", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], SessionView.prototype, "showMobileInput", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], SessionView.prototype, "mobileInputText", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], SessionView.prototype, "isMobile", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], SessionView.prototype, "touchStartX", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], SessionView.prototype, "touchStartY", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], SessionView.prototype, "loading", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], SessionView.prototype, "loadingFrame", void 0);
|
||||
SessionView = __decorate([
|
||||
customElement('session-view')
|
||||
], SessionView);
|
||||
export { SessionView };
|
||||
//# sourceMappingURL=session-view.js.map
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +0,0 @@
|
|||
// Entry point for renderer bundle - exports XTerm-based renderer
|
||||
export { Renderer } from './renderer';
|
||||
//# sourceMappingURL=renderer-entry.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"renderer-entry.js","sourceRoot":"","sources":["../src/client/renderer-entry.ts"],"names":[],"mappings":"AAAA,iEAAiE;AACjE,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC","sourcesContent":["// Entry point for renderer bundle - exports XTerm-based renderer\nexport { Renderer } from './renderer';"]}
|
||||
|
|
@ -1,306 +0,0 @@
|
|||
// Terminal renderer for asciinema cast format using XTerm.js
|
||||
// Professional-grade terminal emulation with full VT compatibility
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { ScaleFitAddon } from './scale-fit-addon.js';
|
||||
export class Renderer {
|
||||
constructor(container, width = 80, height = 20, scrollback = 1000000, fontSize = 14, isPreview = false) {
|
||||
this.eventSource = null;
|
||||
Renderer.activeCount++;
|
||||
console.log(`Renderer constructor called (active: ${Renderer.activeCount})`);
|
||||
this.container = container;
|
||||
this.isPreview = isPreview;
|
||||
// Create terminal with options similar to the custom renderer
|
||||
this.terminal = new Terminal({
|
||||
cols: width,
|
||||
rows: height,
|
||||
fontFamily: 'Monaco, "Lucida Console", monospace',
|
||||
fontSize: fontSize,
|
||||
lineHeight: 1.2,
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#ffffff',
|
||||
cursorAccent: '#1e1e1e',
|
||||
selectionBackground: '#264f78',
|
||||
// VS Code Dark theme colors
|
||||
black: '#000000',
|
||||
red: '#f14c4c',
|
||||
green: '#23d18b',
|
||||
yellow: '#f5f543',
|
||||
blue: '#3b8eea',
|
||||
magenta: '#d670d6',
|
||||
cyan: '#29b8db',
|
||||
white: '#e5e5e5',
|
||||
// Bright colors
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
brightYellow: '#f5f543',
|
||||
brightBlue: '#3b8eea',
|
||||
brightMagenta: '#d670d6',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#ffffff'
|
||||
},
|
||||
allowProposedApi: true,
|
||||
scrollback: scrollback, // Configurable scrollback buffer
|
||||
convertEol: true,
|
||||
altClickMovesCursor: false,
|
||||
rightClickSelectsWord: false,
|
||||
disableStdin: true, // We handle input separately
|
||||
});
|
||||
// Add addons
|
||||
this.fitAddon = new FitAddon();
|
||||
this.scaleFitAddon = new ScaleFitAddon();
|
||||
this.webLinksAddon = new WebLinksAddon();
|
||||
this.terminal.loadAddon(this.fitAddon);
|
||||
this.terminal.loadAddon(this.scaleFitAddon);
|
||||
this.terminal.loadAddon(this.webLinksAddon);
|
||||
this.setupDOM();
|
||||
}
|
||||
setupDOM() {
|
||||
// Clear container and add CSS
|
||||
this.container.innerHTML = '';
|
||||
// Different styling for preview vs full terminals
|
||||
if (this.isPreview) {
|
||||
// No padding for previews, let container control sizing
|
||||
this.container.style.padding = '0';
|
||||
this.container.style.backgroundColor = '#1e1e1e';
|
||||
this.container.style.overflow = 'hidden';
|
||||
}
|
||||
else {
|
||||
// Full terminals get padding
|
||||
this.container.style.padding = '10px';
|
||||
this.container.style.backgroundColor = '#1e1e1e';
|
||||
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);
|
||||
// Always use ScaleFitAddon for better scaling
|
||||
this.scaleFitAddon.fit();
|
||||
// Handle container resize
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
this.scaleFitAddon.fit();
|
||||
});
|
||||
resizeObserver.observe(this.container);
|
||||
}
|
||||
// Public API methods - maintain compatibility with custom renderer
|
||||
async loadCastFile(url) {
|
||||
const response = await fetch(url);
|
||||
const text = await response.text();
|
||||
this.parseCastFile(text);
|
||||
}
|
||||
parseCastFile(content) {
|
||||
const lines = content.trim().split('\n');
|
||||
let header = 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 = {
|
||||
timestamp: parsed[0],
|
||||
type: parsed[1],
|
||||
data: parsed[2]
|
||||
};
|
||||
if (event.type === 'o') {
|
||||
this.processOutput(event.data);
|
||||
}
|
||||
else if (event.type === 'r') {
|
||||
this.processResize(event.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('Failed to parse cast line:', line);
|
||||
}
|
||||
}
|
||||
}
|
||||
processOutput(data) {
|
||||
// XTerm handles all ANSI escape sequences automatically
|
||||
this.terminal.write(data);
|
||||
}
|
||||
processResize(data) {
|
||||
// Parse resize data in format "WIDTHxHEIGHT" (e.g., "80x24")
|
||||
const match = data.match(/^(\d+)x(\d+)$/);
|
||||
if (match) {
|
||||
const width = parseInt(match[1], 10);
|
||||
const height = parseInt(match[2], 10);
|
||||
this.resize(width, height);
|
||||
}
|
||||
}
|
||||
processEvent(event) {
|
||||
if (event.type === 'o') {
|
||||
this.processOutput(event.data);
|
||||
}
|
||||
else if (event.type === 'r') {
|
||||
this.processResize(event.data);
|
||||
}
|
||||
}
|
||||
resize(width, height) {
|
||||
if (this.isPreview) {
|
||||
// For previews, resize to session dimensions then apply scaling
|
||||
this.terminal.resize(width, height);
|
||||
}
|
||||
// Always use ScaleFitAddon for consistent scaling behavior
|
||||
this.scaleFitAddon.fit();
|
||||
}
|
||||
clear() {
|
||||
this.terminal.clear();
|
||||
}
|
||||
// Stream support - connect to SSE endpoint
|
||||
connectToStream(sessionId) {
|
||||
console.log('connectToStream called for session:', sessionId);
|
||||
return this.connectToUrl(`/api/sessions/${sessionId}/stream`);
|
||||
}
|
||||
// Connect to any SSE URL
|
||||
connectToUrl(url) {
|
||||
console.log('Creating new EventSource connection to:', url);
|
||||
const eventSource = new EventSource(url);
|
||||
// Don't clear terminal for live streams - just append new content
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.version && data.width && data.height) {
|
||||
// Header
|
||||
console.log('Received header:', data);
|
||||
this.resize(data.width, data.height);
|
||||
}
|
||||
else if (Array.isArray(data) && data.length >= 3) {
|
||||
// Check if this is an exit event
|
||||
if (data[0] === 'exit') {
|
||||
const exitCode = data[1];
|
||||
const sessionId = data[2];
|
||||
console.log(`Session ${sessionId} exited with code ${exitCode}`);
|
||||
// Close the SSE connection immediately
|
||||
if (this.eventSource) {
|
||||
console.log('Closing SSE connection due to session exit');
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
// Dispatch custom event that session-view can listen to
|
||||
const exitEvent = new CustomEvent('session-exit', {
|
||||
detail: { sessionId, exitCode }
|
||||
});
|
||||
this.container.dispatchEvent(exitEvent);
|
||||
return;
|
||||
}
|
||||
// Regular cast event
|
||||
const castEvent = {
|
||||
timestamp: data[0],
|
||||
type: data[1],
|
||||
data: data[2]
|
||||
};
|
||||
// Process event without verbose logging
|
||||
this.processEvent(castEvent);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('Failed to parse stream event:', event.data);
|
||||
}
|
||||
};
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('Stream error:', error);
|
||||
// Close the connection to prevent automatic reconnection attempts
|
||||
if (eventSource.readyState === EventSource.CLOSED) {
|
||||
console.log('Stream closed, cleaning up...');
|
||||
if (this.eventSource === eventSource) {
|
||||
this.eventSource = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
return eventSource;
|
||||
}
|
||||
// Load content from URL - pass isStream to determine how to handle it
|
||||
async loadFromUrl(url, isStream) {
|
||||
// Clean up existing connection
|
||||
if (this.eventSource) {
|
||||
console.log('Explicitly closing existing EventSource connection');
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
if (isStream) {
|
||||
// It's a stream URL, connect via SSE (don't clear - append to existing content)
|
||||
this.eventSource = this.connectToUrl(url);
|
||||
}
|
||||
else {
|
||||
// It's a snapshot URL, clear first then load as cast file
|
||||
this.terminal.clear();
|
||||
await this.loadCastFile(url);
|
||||
}
|
||||
}
|
||||
// Additional methods for terminal control
|
||||
focus() {
|
||||
this.terminal.focus();
|
||||
}
|
||||
blur() {
|
||||
this.terminal.blur();
|
||||
}
|
||||
getTerminal() {
|
||||
return this.terminal;
|
||||
}
|
||||
dispose() {
|
||||
if (this.eventSource) {
|
||||
console.log('Explicitly closing EventSource connection in dispose()');
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
this.terminal.dispose();
|
||||
Renderer.activeCount--;
|
||||
console.log(`Renderer disposed (active: ${Renderer.activeCount})`);
|
||||
}
|
||||
// Method to fit terminal to container (useful for responsive layouts)
|
||||
fit() {
|
||||
this.fitAddon.fit();
|
||||
}
|
||||
// Get terminal dimensions
|
||||
getDimensions() {
|
||||
return {
|
||||
cols: this.terminal.cols,
|
||||
rows: this.terminal.rows
|
||||
};
|
||||
}
|
||||
// Write raw data to terminal (useful for testing)
|
||||
write(data) {
|
||||
this.terminal.write(data);
|
||||
}
|
||||
// Enable/disable input (though we keep it disabled by default)
|
||||
setInputEnabled(enabled) {
|
||||
// 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
|
||||
});
|
||||
}
|
||||
}
|
||||
// Disable all pointer events for previews so clicks pass through to parent
|
||||
setPointerEventsEnabled(enabled) {
|
||||
const terminalElement = this.container.querySelector('.xterm');
|
||||
if (terminalElement) {
|
||||
terminalElement.style.pointerEvents = enabled ? 'auto' : 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
Renderer.activeCount = 0;
|
||||
//# sourceMappingURL=renderer.js.map
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,105 +0,0 @@
|
|||
/**
|
||||
* Custom FitAddon that scales font size to fit terminal columns to container width,
|
||||
* then calculates optimal rows for the container height.
|
||||
*/
|
||||
const MINIMUM_ROWS = 1;
|
||||
const MIN_FONT_SIZE = 6;
|
||||
const MAX_FONT_SIZE = 16;
|
||||
export class ScaleFitAddon {
|
||||
activate(terminal) {
|
||||
this._terminal = terminal;
|
||||
}
|
||||
dispose() { }
|
||||
fit() {
|
||||
const dims = this.proposeDimensions();
|
||||
if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) {
|
||||
return;
|
||||
}
|
||||
// Only resize rows, keep cols the same (font scaling handles width)
|
||||
if (this._terminal.rows !== dims.rows) {
|
||||
this._terminal.resize(this._terminal.cols, dims.rows);
|
||||
}
|
||||
}
|
||||
proposeDimensions() {
|
||||
if (!this._terminal?.element?.parentElement) {
|
||||
return undefined;
|
||||
}
|
||||
// Get the renderer container (parent of parent - the one with 10px padding)
|
||||
const terminalWrapper = this._terminal.element.parentElement;
|
||||
const rendererContainer = terminalWrapper.parentElement;
|
||||
if (!rendererContainer)
|
||||
return undefined;
|
||||
// Get container dimensions and exact padding
|
||||
const containerStyle = window.getComputedStyle(rendererContainer);
|
||||
const containerWidth = parseInt(containerStyle.getPropertyValue('width'));
|
||||
const containerHeight = parseInt(containerStyle.getPropertyValue('height'));
|
||||
const containerPadding = {
|
||||
top: parseInt(containerStyle.getPropertyValue('padding-top')),
|
||||
bottom: parseInt(containerStyle.getPropertyValue('padding-bottom')),
|
||||
left: parseInt(containerStyle.getPropertyValue('padding-left')),
|
||||
right: parseInt(containerStyle.getPropertyValue('padding-right'))
|
||||
};
|
||||
// Calculate exact available space using known padding
|
||||
const availableWidth = containerWidth - containerPadding.left - containerPadding.right;
|
||||
const availableHeight = containerHeight - containerPadding.top - containerPadding.bottom;
|
||||
// Current terminal dimensions
|
||||
const currentCols = this._terminal.cols;
|
||||
// Calculate optimal font size to fit current cols in available width
|
||||
// Character width is approximately 0.6 * fontSize for monospace fonts
|
||||
const charWidthRatio = 0.6;
|
||||
const calculatedFontSize = availableWidth / (currentCols * charWidthRatio);
|
||||
const optimalFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
|
||||
// Apply the calculated font size (outside of proposeDimensions to avoid recursion)
|
||||
requestAnimationFrame(() => this.applyFontSize(optimalFontSize));
|
||||
// Get the actual line height from the rendered XTerm element
|
||||
const xtermElement = this._terminal.element;
|
||||
const currentStyle = window.getComputedStyle(xtermElement);
|
||||
const actualLineHeight = parseFloat(currentStyle.lineHeight);
|
||||
// If we can't get the line height, fall back to configuration
|
||||
const lineHeight = actualLineHeight || (optimalFontSize * (this._terminal.options.lineHeight || 1.2));
|
||||
// Calculate how many rows fit with this line height
|
||||
const optimalRows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / lineHeight));
|
||||
return {
|
||||
cols: currentCols, // Keep existing cols
|
||||
rows: optimalRows // Fit as many rows as possible
|
||||
};
|
||||
}
|
||||
applyFontSize(fontSize) {
|
||||
if (!this._terminal?.element)
|
||||
return;
|
||||
// Prevent infinite recursion by checking if font size changed significantly
|
||||
const currentFontSize = this._terminal.options.fontSize || 14;
|
||||
if (Math.abs(fontSize - currentFontSize) < 0.1)
|
||||
return;
|
||||
const terminalElement = this._terminal.element;
|
||||
// Update terminal's font size
|
||||
this._terminal.options.fontSize = fontSize;
|
||||
// Apply CSS font size to the element
|
||||
terminalElement.style.fontSize = `${fontSize}px`;
|
||||
// Force a refresh to apply the new font size
|
||||
requestAnimationFrame(() => {
|
||||
if (this._terminal) {
|
||||
this._terminal.refresh(0, this._terminal.rows - 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Get the calculated font size that would fit the current columns in the container
|
||||
*/
|
||||
getOptimalFontSize() {
|
||||
if (!this._terminal?.element?.parentElement) {
|
||||
return this._terminal?.options.fontSize || 14;
|
||||
}
|
||||
const parentElement = this._terminal.element.parentElement;
|
||||
const parentStyle = window.getComputedStyle(parentElement);
|
||||
const parentWidth = parseInt(parentStyle.getPropertyValue('width'));
|
||||
const elementStyle = window.getComputedStyle(this._terminal.element);
|
||||
const paddingHor = parseInt(elementStyle.getPropertyValue('padding-left')) +
|
||||
parseInt(elementStyle.getPropertyValue('padding-right'));
|
||||
const availableWidth = parentWidth - paddingHor;
|
||||
const charWidthRatio = 0.6;
|
||||
const calculatedFontSize = availableWidth / (this._terminal.cols * charWidthRatio);
|
||||
return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=scale-fit-addon.js.map
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue