mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Fix session management and terminal scaling
- Fix session killing via DELETE endpoint instead of wrong POST /kill - Add proper session card kill animation with ASCII spinner - Fix double key press issue with keyed directive for session-view - Implement URL-based navigation for consistent component lifecycle - Fix session card terminal scaling to show all content at smaller sizes - Modify ScaleFitAddon to only scale font size for previews, not dimensions - Add session card loading and killing states with visual feedback - Remove duplicate event listeners and improve component cleanup 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
eb1622c604
commit
ed3927b4a6
49 changed files with 3682 additions and 1043 deletions
|
|
@ -8,4 +8,7 @@
|
|||
## Development Workflow
|
||||
- Make changes to source files in `src/`
|
||||
- The dev server automatically rebuilds and reloads
|
||||
- Focus on editing source files, not built artifacts
|
||||
- Focus on editing source files, not built artifacts
|
||||
|
||||
## Server Execution
|
||||
- NEVER RUN THE SERVER YOURSELF, I ALWAYS RUN IT ON THE SIDE VIA NPM RUN DEV!
|
||||
16
web/dist/client/app.js
vendored
16
web/dist/client/app.js
vendored
|
|
@ -17,6 +17,7 @@ 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);
|
||||
|
|
@ -93,15 +94,15 @@ let VibeTunnelApp = class VibeTunnelApp extends lit_1.LitElement {
|
|||
}
|
||||
}
|
||||
startAutoRefresh() {
|
||||
// Refresh sessions every 3 seconds
|
||||
// Refresh sessions every 3 seconds, but only when showing session list
|
||||
setInterval(() => {
|
||||
this.loadSessions();
|
||||
if (this.currentView === 'list') {
|
||||
this.loadSessions();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
async handleSessionCreated(e) {
|
||||
console.log('Session created event detail:', e.detail);
|
||||
const sessionId = e.detail.sessionId;
|
||||
console.log('Extracted sessionId:', sessionId);
|
||||
if (!sessionId) {
|
||||
this.showError('Session created but ID not found in response');
|
||||
return;
|
||||
|
|
@ -113,29 +114,22 @@ let VibeTunnelApp = class VibeTunnelApp extends lit_1.LitElement {
|
|||
async waitForSessionAndSwitch(sessionId) {
|
||||
const maxAttempts = 10;
|
||||
const delay = 500; // 500ms between attempts
|
||||
console.log(`Waiting for session ${sessionId} to appear...`);
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
console.log(`Attempt ${attempt + 1}/${maxAttempts} to find session ${sessionId}`);
|
||||
await this.loadSessions();
|
||||
console.log('Current sessions:', this.sessions.map(s => ({ id: s.id, command: s.command })));
|
||||
// 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) {
|
||||
console.log('Session not found by ID, trying to find newest session...');
|
||||
const sortedSessions = [...this.sessions].sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
|
||||
session = sortedSessions[0];
|
||||
console.log('Using newest session:', session.id);
|
||||
}
|
||||
if (session) {
|
||||
// Session found, switch to session view
|
||||
console.log('Session found, switching to session view');
|
||||
this.selectedSession = session;
|
||||
this.currentView = 'session';
|
||||
// Update URL to include session ID
|
||||
this.updateUrl(session.id);
|
||||
this.showError('Session created successfully!');
|
||||
return;
|
||||
}
|
||||
// Wait before next attempt
|
||||
|
|
|
|||
2
web/dist/client/app.js.map
vendored
2
web/dist/client/app.js.map
vendored
File diff suppressed because one or more lines are too long
113
web/dist/client/components/file-browser.js
vendored
113
web/dist/client/components/file-browser.js
vendored
|
|
@ -19,6 +19,9 @@ let FileBrowser = class FileBrowser extends lit_1.LitElement {
|
|||
this.visible = false;
|
||||
this.files = [];
|
||||
this.loading = false;
|
||||
this.showCreateFolder = false;
|
||||
this.newFolderName = '';
|
||||
this.creating = false;
|
||||
}
|
||||
// Disable shadow DOM to use Tailwind
|
||||
createRenderRoot() {
|
||||
|
|
@ -71,6 +74,60 @@ let FileBrowser = class FileBrowser extends lit_1.LitElement {
|
|||
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) ``;
|
||||
|
|
@ -79,7 +136,17 @@ let FileBrowser = class FileBrowser extends lit_1.LitElement {
|
|||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style="z-index: 9999;">
|
||||
<div class="bg-vs-bg-secondary border border-vs-border font-mono text-sm w-96 h-96 flex flex-col">
|
||||
<div class="p-4 border-b border-vs-border flex-shrink-0">
|
||||
<div class="text-vs-assistant text-sm mb-2">Select Directory</div>
|
||||
<div class="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>
|
||||
|
||||
|
|
@ -115,6 +182,38 @@ let FileBrowser = class FileBrowser extends lit_1.LitElement {
|
|||
`)}
|
||||
`}
|
||||
</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
|
||||
|
|
@ -152,6 +251,18 @@ __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);
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
173
web/dist/client/components/session-card.js
vendored
Normal file
173
web/dist/client/components/session-card.js
vendored
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"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
web/dist/client/components/session-card.js.map
vendored
Normal file
1
web/dist/client/components/session-card.js.map
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"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"}
|
||||
328
web/dist/client/components/session-list.js
vendored
328
web/dist/client/components/session-list.js
vendored
|
|
@ -12,7 +12,9 @@ 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);
|
||||
|
|
@ -21,10 +23,7 @@ let SessionList = class SessionList extends lit_1.LitElement {
|
|||
this.hideExited = true;
|
||||
this.showCreateModal = false;
|
||||
this.killingSessionIds = new Set();
|
||||
this.loadedSnapshots = new Map();
|
||||
this.loadingSnapshots = new Set();
|
||||
this.cleaningExited = false;
|
||||
this.newSessionIds = new Set();
|
||||
}
|
||||
// Disable shadow DOM to use Tailwind
|
||||
createRenderRoot() {
|
||||
|
|
@ -33,119 +32,14 @@ let SessionList = class SessionList extends lit_1.LitElement {
|
|||
handleRefresh() {
|
||||
this.dispatchEvent(new CustomEvent('refresh'));
|
||||
}
|
||||
async loadSnapshot(sessionId) {
|
||||
if (this.loadedSnapshots.has(sessionId) || this.loadingSnapshots.has(sessionId)) {
|
||||
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.loadingSnapshots.add(sessionId);
|
||||
this.requestUpdate();
|
||||
try {
|
||||
// Just mark as loaded and create the player with the endpoint URL
|
||||
this.loadedSnapshots.set(sessionId, sessionId);
|
||||
this.requestUpdate();
|
||||
// Create asciinema player after the element is rendered
|
||||
setTimeout(() => this.createPlayer(sessionId), 10);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error loading snapshot:', error);
|
||||
}
|
||||
finally {
|
||||
this.loadingSnapshots.delete(sessionId);
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
loadAllSnapshots() {
|
||||
this.sessions.forEach(session => {
|
||||
this.loadSnapshot(session.id);
|
||||
});
|
||||
}
|
||||
updated(changedProperties) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('sessions')) {
|
||||
// Auto-load snapshots for existing sessions immediately, but delay for new ones
|
||||
const prevSessions = changedProperties.get('sessions') || [];
|
||||
const newSessionIdsList = this.sessions
|
||||
.filter(session => !prevSessions.find((prev) => prev.id === session.id))
|
||||
.map(session => session.id);
|
||||
// Track new sessions
|
||||
newSessionIdsList.forEach(id => this.newSessionIds.add(id));
|
||||
// Load existing sessions immediately
|
||||
const existingSessions = this.sessions.filter(session => !newSessionIdsList.includes(session.id));
|
||||
existingSessions.forEach(session => this.loadSnapshot(session.id));
|
||||
// Load new sessions after a delay to let them generate some output
|
||||
if (newSessionIdsList.length > 0) {
|
||||
setTimeout(() => {
|
||||
newSessionIdsList.forEach(sessionId => {
|
||||
this.newSessionIds.delete(sessionId); // Remove from new sessions set
|
||||
this.loadSnapshot(sessionId);
|
||||
});
|
||||
this.requestUpdate(); // Update UI to show the players
|
||||
}, 500); // Wait 500ms for new sessions
|
||||
}
|
||||
}
|
||||
// If hideExited changed, recreate players for newly visible sessions
|
||||
if (changedProperties.has('hideExited')) {
|
||||
// Use a slight delay to avoid blocking the checkbox click
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.filteredSessions.forEach(session => {
|
||||
const playerElement = this.querySelector(`#player-${session.id}`);
|
||||
if (playerElement && this.loadedSnapshots.has(session.id)) {
|
||||
// Player element exists but might not have a player instance
|
||||
// Check if it's empty and recreate if needed
|
||||
if (!playerElement.hasChildNodes() || playerElement.children.length === 0) {
|
||||
this.createPlayer(session.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
createPlayer(sessionId) {
|
||||
const playerElement = this.querySelector(`#player-${sessionId}`);
|
||||
if (!playerElement) {
|
||||
// Element not ready yet, retry on next frame
|
||||
requestAnimationFrame(() => this.createPlayer(sessionId));
|
||||
return;
|
||||
}
|
||||
if (window.AsciinemaPlayer) {
|
||||
try {
|
||||
// Find the session to check its status
|
||||
const session = this.sessions.find(s => s.id === sessionId);
|
||||
// For ended sessions, use snapshot instead of stream to avoid reloading
|
||||
const url = session?.status === 'exited'
|
||||
? `/api/sessions/${sessionId}/snapshot`
|
||||
: `/api/sessions/${sessionId}/stream`;
|
||||
const config = session?.status === 'exited'
|
||||
? { url } // Static snapshot
|
||||
: { driver: "eventsource", url }; // Live stream
|
||||
window.AsciinemaPlayer.create(config, playerElement, {
|
||||
autoPlay: true,
|
||||
loop: false,
|
||||
controls: false,
|
||||
fit: 'width',
|
||||
terminalFontSize: '8px',
|
||||
idleTimeLimit: 0.5,
|
||||
preload: true,
|
||||
poster: 'npt:999999'
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error creating asciinema player:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
handleSessionClick(session) {
|
||||
this.dispatchEvent(new CustomEvent('session-select', {
|
||||
detail: session
|
||||
}));
|
||||
}
|
||||
async handleKillSession(e, sessionId) {
|
||||
e.stopPropagation(); // Prevent session selection
|
||||
if (!confirm('Are you sure you want to kill this session?')) {
|
||||
return;
|
||||
}
|
||||
this.killingSessionIds.add(sessionId);
|
||||
this.requestUpdate();
|
||||
try {
|
||||
|
|
@ -153,148 +47,50 @@ let SessionList = class SessionList extends lit_1.LitElement {
|
|||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) {
|
||||
this.dispatchEvent(new CustomEvent('session-killed', {
|
||||
detail: { sessionId }
|
||||
}));
|
||||
// Refresh the list after a short delay
|
||||
setTimeout(() => {
|
||||
this.handleRefresh();
|
||||
}, 1000);
|
||||
this.dispatchEvent(new CustomEvent('session-killed', { detail: sessionId }));
|
||||
}
|
||||
else {
|
||||
const error = await response.json();
|
||||
this.dispatchEvent(new CustomEvent('error', {
|
||||
detail: `Failed to kill session: ${error.error}`
|
||||
}));
|
||||
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'
|
||||
}));
|
||||
this.dispatchEvent(new CustomEvent('error', { detail: 'Failed to kill session' }));
|
||||
}
|
||||
finally {
|
||||
this.killingSessionIds.delete(sessionId);
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
async handleCleanSession(e, sessionId) {
|
||||
e.stopPropagation(); // Prevent session selection
|
||||
if (!confirm('Are you sure you want to clean up this session?')) {
|
||||
async handleCleanupExited() {
|
||||
if (this.cleaningExited)
|
||||
return;
|
||||
}
|
||||
this.killingSessionIds.add(sessionId);
|
||||
this.requestUpdate();
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${sessionId}/cleanup`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) {
|
||||
this.dispatchEvent(new CustomEvent('session-killed', {
|
||||
detail: { sessionId }
|
||||
}));
|
||||
// Refresh the list after a short delay
|
||||
setTimeout(() => {
|
||||
this.handleRefresh();
|
||||
}, 500);
|
||||
}
|
||||
else {
|
||||
const error = await response.json();
|
||||
this.dispatchEvent(new CustomEvent('error', {
|
||||
detail: `Failed to clean session: ${error.error}`
|
||||
}));
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error cleaning session:', error);
|
||||
this.dispatchEvent(new CustomEvent('error', {
|
||||
detail: 'Failed to clean session'
|
||||
}));
|
||||
}
|
||||
finally {
|
||||
this.killingSessionIds.delete(sessionId);
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
formatTime(timestamp) {
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString();
|
||||
}
|
||||
catch {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
truncateId(id) {
|
||||
return id.length > 8 ? `${id.substring(0, 8)}...` : id;
|
||||
}
|
||||
handleSessionCreated(e) {
|
||||
this.dispatchEvent(new CustomEvent('session-created', {
|
||||
detail: e.detail
|
||||
}));
|
||||
}
|
||||
handleCreateError(e) {
|
||||
this.dispatchEvent(new CustomEvent('error', {
|
||||
detail: e.detail
|
||||
}));
|
||||
}
|
||||
handleCreateModalClose() {
|
||||
this.dispatchEvent(new CustomEvent('create-modal-close'));
|
||||
}
|
||||
async handleCleanExited() {
|
||||
const exitedSessions = this.sessions.filter(session => session.status === 'exited');
|
||||
if (exitedSessions.length === 0) {
|
||||
this.dispatchEvent(new CustomEvent('error', {
|
||||
detail: 'No exited sessions to clean'
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Are you sure you want to delete ${exitedSessions.length} exited session${exitedSessions.length > 1 ? 's' : ''}?`)) {
|
||||
return;
|
||||
}
|
||||
this.cleaningExited = true;
|
||||
this.requestUpdate();
|
||||
try {
|
||||
// Use the bulk cleanup API endpoint
|
||||
const response = await fetch('/api/cleanup-exited', {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to cleanup exited sessions');
|
||||
if (response.ok) {
|
||||
this.dispatchEvent(new CustomEvent('refresh'));
|
||||
}
|
||||
else {
|
||||
this.dispatchEvent(new CustomEvent('error', { detail: 'Failed to cleanup exited sessions' }));
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('error', {
|
||||
detail: `Successfully cleaned ${exitedSessions.length} exited session${exitedSessions.length > 1 ? 's' : ''}`
|
||||
}));
|
||||
// Refresh the list after cleanup
|
||||
setTimeout(() => {
|
||||
this.handleRefresh();
|
||||
}, 500);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error cleaning exited sessions:', error);
|
||||
this.dispatchEvent(new CustomEvent('error', {
|
||||
detail: 'Failed to clean exited sessions'
|
||||
}));
|
||||
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();
|
||||
}
|
||||
}
|
||||
handleHideExitedChange(e) {
|
||||
const checked = e.target.checked;
|
||||
this.dispatchEvent(new CustomEvent('hide-exited-change', {
|
||||
detail: checked
|
||||
}));
|
||||
}
|
||||
get filteredSessions() {
|
||||
return this.hideExited
|
||||
? this.sessions.filter(session => session.status === 'running')
|
||||
: this.sessions;
|
||||
}
|
||||
render() {
|
||||
const sessionsToShow = this.filteredSessions;
|
||||
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 -->
|
||||
|
|
@ -302,7 +98,7 @@ let SessionList = class SessionList extends lit_1.LitElement {
|
|||
${!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.handleCleanExited}
|
||||
@click=${this.handleCleanupExited}
|
||||
?disabled=${this.cleaningExited || this.sessions.filter(s => s.status === 'exited').length === 0}
|
||||
>
|
||||
${this.cleaningExited ? '[~] CLEANING...' : 'CLEAN EXITED'}
|
||||
|
|
@ -315,7 +111,7 @@ let SessionList = class SessionList extends lit_1.LitElement {
|
|||
type="checkbox"
|
||||
class="sr-only"
|
||||
.checked=${this.hideExited}
|
||||
@change=${this.handleHideExitedChange}
|
||||
@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) `
|
||||
|
|
@ -328,67 +124,27 @@ let SessionList = class SessionList extends lit_1.LitElement {
|
|||
hide exited
|
||||
</label>
|
||||
</div>
|
||||
|
||||
${sessionsToShow.length === 0 ? (0, lit_1.html) `
|
||||
${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">
|
||||
${sessionsToShow.map(session => (0, lit_1.html) `
|
||||
<div
|
||||
class="bg-vs-bg border border-vs-border rounded shadow cursor-pointer overflow-hidden"
|
||||
@click=${() => this.handleSessionClick(session)}
|
||||
>
|
||||
<!-- Compact Header -->
|
||||
<div class="flex justify-between items-center px-3 py-2 border-b border-vs-border">
|
||||
<div class="text-vs-text text-xs font-mono truncate pr-2 flex-1">${session.command}</div>
|
||||
${session.status === 'running' || !this.hideExited ? (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=${(e) => session.status === 'running' ? this.handleKillSession(e, session.id) : this.handleCleanSession(e, session.id)}
|
||||
?disabled=${this.killingSessionIds.has(session.id)}
|
||||
>
|
||||
${this.killingSessionIds.has(session.id)
|
||||
? (session.status === 'running' ? '[~] killing...' : '[~] cleaning...')
|
||||
: (session.status === 'running' ? 'kill' : 'clean')}
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Asciinema player (main content) -->
|
||||
<div class="session-preview bg-black flex items-center justify-center overflow-hidden" style="aspect-ratio: 640/480;">
|
||||
${this.loadedSnapshots.has(session.id) ? (0, lit_1.html) `
|
||||
<div id="player-${session.id}" class="w-full h-full overflow-hidden"></div>
|
||||
` : (0, lit_1.html) `
|
||||
<div class="text-vs-muted text-xs">
|
||||
${this.newSessionIds.has(session.id)
|
||||
? '[~] init_session...'
|
||||
: (this.loadingSnapshots.has(session.id) ? '[~] loading...' : '[~] loading...')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Compact Footer -->
|
||||
<div class="px-3 py-2 text-vs-muted text-xs border-t border-vs-border">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="${session.status === 'running' ? 'text-vs-user' : 'text-vs-warning'} text-xs">
|
||||
${session.status}
|
||||
</span>
|
||||
<span class="truncate">${this.truncateId(session.id)}</span>
|
||||
</div>
|
||||
<div class="truncate text-xs opacity-75" title="${session.workingDir}">${session.workingDir}</div>
|
||||
</div>
|
||||
</div>
|
||||
${(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=${this.handleSessionCreated}
|
||||
@cancel=${this.handleCreateModalClose}
|
||||
@error=${this.handleCreateError}
|
||||
@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>
|
||||
`;
|
||||
|
|
@ -415,22 +171,10 @@ __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, "loadedSnapshots", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], SessionList.prototype, "loadingSnapshots", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], SessionList.prototype, "cleaningExited", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], SessionList.prototype, "newSessionIds", void 0);
|
||||
exports.SessionList = SessionList = __decorate([
|
||||
(0, decorators_js_1.customElement)('session-list')
|
||||
], SessionList);
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
262
web/dist/client/components/session-view.js
vendored
262
web/dist/client/components/session-view.js
vendored
|
|
@ -12,18 +12,22 @@ 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.player = null;
|
||||
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;
|
||||
|
|
@ -63,6 +67,10 @@ let SessionView = class SessionView extends lit_1.LitElement {
|
|||
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;
|
||||
|
|
@ -92,72 +100,83 @@ let SessionView = class SessionView extends lit_1.LitElement {
|
|||
}
|
||||
// Stop polling session status
|
||||
this.stopSessionStatusPolling();
|
||||
// Cleanup player if exists
|
||||
if (this.player) {
|
||||
this.player = null;
|
||||
// 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);
|
||||
if (changedProperties.has('session') && this.session) {
|
||||
// Use setTimeout to ensure DOM is rendered first
|
||||
setTimeout(() => {
|
||||
this.createInteractiveTerminal();
|
||||
}, 10);
|
||||
// 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 && window.AsciinemaPlayer) {
|
||||
try {
|
||||
// For ended sessions, use snapshot instead of stream to avoid reloading
|
||||
const url = this.session.status === 'exited'
|
||||
? `/api/sessions/${this.session.id}/snapshot`
|
||||
: `/api/sessions/${this.session.id}/stream`;
|
||||
const config = this.session.status === 'exited'
|
||||
? { url } // Static snapshot
|
||||
: { driver: "eventsource", url }; // Live stream
|
||||
this.player = window.AsciinemaPlayer.create(config, terminalElement, {
|
||||
autoPlay: true,
|
||||
loop: false,
|
||||
controls: false,
|
||||
fit: 'both',
|
||||
terminalFontSize: '12px',
|
||||
idleTimeLimit: 0.5,
|
||||
preload: true,
|
||||
poster: 'npt:999999'
|
||||
});
|
||||
// Disable focus outline and fullscreen functionality
|
||||
if (this.player && this.player.el) {
|
||||
// Remove focus outline
|
||||
this.player.el.style.outline = 'none';
|
||||
this.player.el.style.border = 'none';
|
||||
// Disable fullscreen hotkey by removing tabindex and preventing focus
|
||||
this.player.el.removeAttribute('tabindex');
|
||||
this.player.el.style.pointerEvents = 'none';
|
||||
// Find the terminal element and make it non-focusable
|
||||
const terminal = this.player.el.querySelector('.ap-terminal, .ap-screen, pre');
|
||||
if (terminal) {
|
||||
terminal.removeAttribute('tabindex');
|
||||
terminal.style.outline = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error creating interactive terminal:', error);
|
||||
}
|
||||
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':
|
||||
inputText = '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';
|
||||
|
|
@ -197,8 +216,8 @@ let SessionView = class SessionView extends lit_1.LitElement {
|
|||
}
|
||||
break;
|
||||
}
|
||||
// Handle Ctrl combinations
|
||||
if (e.ctrlKey && e.key.length === 1) {
|
||||
// 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.
|
||||
|
|
@ -214,7 +233,18 @@ let SessionView = class SessionView extends lit_1.LitElement {
|
|||
body: JSON.stringify({ text: inputText })
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error('Failed to send input to session');
|
||||
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) {
|
||||
|
|
@ -222,20 +252,35 @@ let SessionView = class SessionView extends lit_1.LitElement {
|
|||
}
|
||||
}
|
||||
handleBack() {
|
||||
this.dispatchEvent(new CustomEvent('back'));
|
||||
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
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = this.querySelector('#mobile-input-textarea');
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
this.adjustTextareaForKeyboard();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
else {
|
||||
// Clean up viewport listener when closing overlay
|
||||
|
|
@ -308,7 +353,7 @@ let SessionView = class SessionView extends lit_1.LitElement {
|
|||
textarea._viewportCleanup = cleanup;
|
||||
}
|
||||
// Initial adjustment
|
||||
setTimeout(adjustLayout, 300);
|
||||
requestAnimationFrame(adjustLayout);
|
||||
}
|
||||
handleMobileInputChange(e) {
|
||||
const textarea = e.target;
|
||||
|
|
@ -384,14 +429,39 @@ let SessionView = class SessionView extends lit_1.LitElement {
|
|||
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);
|
||||
}
|
||||
// Poll every 2 seconds
|
||||
this.sessionStatusInterval = window.setInterval(() => {
|
||||
this.checkSessionStatus();
|
||||
}, 2000);
|
||||
// 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) {
|
||||
|
|
@ -409,27 +479,13 @@ let SessionView = class SessionView extends lit_1.LitElement {
|
|||
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();
|
||||
// If session ended, switch from stream to snapshot to prevent restarts
|
||||
if (currentSession.status === 'exited' && this.player && this.session.status === 'running') {
|
||||
console.log('Session ended, switching to snapshot view');
|
||||
try {
|
||||
// Dispose the streaming player
|
||||
if (this.player.dispose) {
|
||||
this.player.dispose();
|
||||
}
|
||||
this.player = null;
|
||||
// Recreate with snapshot
|
||||
setTimeout(() => {
|
||||
this.createInteractiveTerminal();
|
||||
}, 100);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error switching to snapshot:', error);
|
||||
}
|
||||
}
|
||||
// Session status polling is now only for detecting new sessions
|
||||
// Exit events are handled via SSE stream directly
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
|
|
@ -451,7 +507,7 @@ let SessionView = class SessionView extends lit_1.LitElement {
|
|||
box-shadow: none !important;
|
||||
}
|
||||
</style>
|
||||
<div class="h-screen flex flex-col bg-vs-bg font-mono" style="outline: none !important; box-shadow: none !important;">
|
||||
<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">
|
||||
|
|
@ -462,14 +518,11 @@ let SessionView = class SessionView extends lit_1.LitElement {
|
|||
BACK
|
||||
</button>
|
||||
<div class="text-vs-text">
|
||||
<span class="text-vs-accent">${this.session.command}</span>
|
||||
<span class="text-vs-muted text-xs ml-2">(${this.session.id.substring(0, 8)}...)</span>
|
||||
<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="text-vs-muted">
|
||||
${this.session.workingDir}
|
||||
</span>
|
||||
<span class="${this.session.status === 'running' ? 'text-vs-user' : 'text-vs-warning'}">
|
||||
${this.session.status.toUpperCase()}
|
||||
</span>
|
||||
|
|
@ -477,15 +530,23 @@ let SessionView = class SessionView extends lit_1.LitElement {
|
|||
</div>
|
||||
|
||||
<!-- Terminal Container -->
|
||||
<div class="flex-1 bg-black overflow-hidden">
|
||||
<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 ? (0, lit_1.html) `
|
||||
<!-- Quick Action Buttons (only when overlay is closed) -->
|
||||
${!this.showMobileInput ? (0, lit_1.html) `
|
||||
<div class="fixed bottom-4 left-4 right-4 z-40">
|
||||
${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
|
||||
|
|
@ -513,7 +574,7 @@ let SessionView = class SessionView extends lit_1.LitElement {
|
|||
→
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Second row: Special keys -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
|
|
@ -547,11 +608,11 @@ let SessionView = class SessionView extends lit_1.LitElement {
|
|||
TYPE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Full-Screen Input Overlay (only when opened) -->
|
||||
${this.showMobileInput ? (0, lit_1.html) `
|
||||
<!-- 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">
|
||||
|
|
@ -584,7 +645,7 @@ let SessionView = class SessionView extends lit_1.LitElement {
|
|||
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 -->
|
||||
|
|
@ -604,13 +665,12 @@ let SessionView = class SessionView extends lit_1.LitElement {
|
|||
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>
|
||||
`;
|
||||
|
|
@ -628,7 +688,7 @@ __decorate([
|
|||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
], SessionView.prototype, "player", void 0);
|
||||
], SessionView.prototype, "renderer", void 0);
|
||||
__decorate([
|
||||
(0, decorators_js_1.state)(),
|
||||
__metadata("design:type", Object)
|
||||
|
|
@ -653,6 +713,14 @@ __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);
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
7
web/dist/client/renderer-entry.js
vendored
Normal file
7
web/dist/client/renderer-entry.js
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"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
Normal file
1
web/dist/client/renderer-entry.js.map
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"renderer-entry.js","sourceRoot":"","sources":["../../src/client/renderer-entry.ts"],"names":[],"mappings":";;;AAAA,iEAAiE;AACjE,uCAAsC;AAA7B,oGAAA,QAAQ,OAAA"}
|
||||
719
web/dist/client/renderer.js
vendored
719
web/dist/client/renderer.js
vendored
|
|
@ -1,488 +1,99 @@
|
|||
"use strict";
|
||||
// Terminal renderer for asciinema cast format with DOM rendering
|
||||
// Supports complete cast files and streaming events
|
||||
// Terminal renderer for asciinema cast format using XTerm.js
|
||||
// Professional-grade terminal emulation with full VT compatibility
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.TerminalRenderer = void 0;
|
||||
class TerminalRenderer {
|
||||
constructor(container, width = 80, height = 20) {
|
||||
this.maxScrollback = 1000;
|
||||
this.ansiColorMap = [
|
||||
'#000000', '#cc241d', '#98971a', '#d79921', // Standard colors (0-7) - brighter
|
||||
'#458588', '#b16286', '#689d6a', '#a89984',
|
||||
'#928374', '#fb4934', '#b8bb26', '#fabd2f', // Bright colors (8-15) - very bright
|
||||
'#83a598', '#d3869b', '#8ec07c', '#ebdbb2'
|
||||
];
|
||||
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.state = {
|
||||
width,
|
||||
height,
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
currentFg: '#ffffff',
|
||||
currentBg: '#000000',
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
strikethrough: false,
|
||||
inverse: false,
|
||||
alternateScreen: false,
|
||||
scrollRegionTop: 0,
|
||||
scrollRegionBottom: height - 1,
|
||||
originMode: false,
|
||||
autowrap: true,
|
||||
insertMode: false
|
||||
};
|
||||
this.primaryBuffer = this.createBuffer(width, height);
|
||||
this.alternateBuffer = this.createBuffer(width, height);
|
||||
this.scrollbackBuffer = [];
|
||||
this.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();
|
||||
}
|
||||
createBuffer(width, height) {
|
||||
const buffer = [];
|
||||
for (let y = 0; y < height; y++) {
|
||||
buffer[y] = [];
|
||||
for (let x = 0; x < width; x++) {
|
||||
buffer[y][x] = {
|
||||
char: ' ',
|
||||
fg: '#ffffff',
|
||||
bg: '#000000',
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
strikethrough: false,
|
||||
inverse: false
|
||||
};
|
||||
}
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
setupDOM() {
|
||||
this.container.style.fontFamily = 'Monaco, "Lucida Console", monospace';
|
||||
this.container.style.fontSize = '14px';
|
||||
this.container.style.lineHeight = '1.2';
|
||||
this.container.style.backgroundColor = '#000000';
|
||||
this.container.style.color = '#ffffff';
|
||||
this.container.style.padding = '10px';
|
||||
this.container.style.overflow = 'auto';
|
||||
this.container.style.whiteSpace = 'pre';
|
||||
// Clear container and add CSS
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
getCurrentBuffer() {
|
||||
return this.state.alternateScreen ? this.alternateBuffer : this.primaryBuffer;
|
||||
}
|
||||
renderBuffer() {
|
||||
const buffer = this.getCurrentBuffer();
|
||||
const allLines = [];
|
||||
// Render scrollback buffer first (only for primary screen)
|
||||
if (!this.state.alternateScreen && this.scrollbackBuffer.length > 0) {
|
||||
for (let i = 0; i < this.scrollbackBuffer.length; i++) {
|
||||
const line = this.renderLine(this.scrollbackBuffer[i]);
|
||||
allLines.push(`<div class="scrollback-line">${line}</div>`);
|
||||
}
|
||||
}
|
||||
// Render current buffer
|
||||
for (let y = 0; y < this.state.height; y++) {
|
||||
const line = this.renderLine(buffer[y]);
|
||||
const isCurrentLine = y === this.state.cursorY;
|
||||
allLines.push(`<div class="terminal-line ${isCurrentLine ? 'current-line' : ''}">${line}</div>`);
|
||||
}
|
||||
this.container.innerHTML = allLines.join('');
|
||||
// Auto-scroll to bottom unless user has scrolled up
|
||||
if (this.container.scrollTop + this.container.clientHeight >= this.container.scrollHeight - 10) {
|
||||
this.container.scrollTop = this.container.scrollHeight;
|
||||
}
|
||||
}
|
||||
renderLine(lineBuffer) {
|
||||
let line = '';
|
||||
let lastBg = '';
|
||||
let lastFg = '';
|
||||
let lastStyles = '';
|
||||
let spanOpen = false;
|
||||
for (let x = 0; x < lineBuffer.length; x++) {
|
||||
const cell = lineBuffer[x];
|
||||
const fg = cell.inverse ? cell.bg : cell.fg;
|
||||
const bg = cell.inverse ? cell.fg : cell.bg;
|
||||
let styles = '';
|
||||
if (cell.bold)
|
||||
styles += 'font-weight: bold; ';
|
||||
if (cell.italic)
|
||||
styles += 'font-style: italic; ';
|
||||
if (cell.underline)
|
||||
styles += 'text-decoration: underline; ';
|
||||
if (cell.strikethrough)
|
||||
styles += 'text-decoration: line-through; ';
|
||||
if (fg !== lastFg || bg !== lastBg || styles !== lastStyles) {
|
||||
if (spanOpen) {
|
||||
line += '</span>';
|
||||
spanOpen = false;
|
||||
}
|
||||
// Always add span for consistent rendering
|
||||
line += `<span style="color: ${fg}; background-color: ${bg}; ${styles}">`;
|
||||
spanOpen = true;
|
||||
lastFg = fg;
|
||||
lastBg = bg;
|
||||
lastStyles = styles;
|
||||
}
|
||||
const char = cell.char || ' ';
|
||||
line += char === ' ' ? ' ' : this.escapeHtml(char);
|
||||
}
|
||||
// Close any open span
|
||||
if (spanOpen) {
|
||||
line += '</span>';
|
||||
}
|
||||
return line || ' '; // Ensure empty lines have height
|
||||
}
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
parseAnsiSequence(data) {
|
||||
let i = 0;
|
||||
while (i < data.length) {
|
||||
const char = data[i];
|
||||
if (char === '\x1b' && i + 1 < data.length && data[i + 1] === '[') {
|
||||
// CSI sequence
|
||||
i += 2;
|
||||
let params = '';
|
||||
let finalChar = '';
|
||||
while (i < data.length) {
|
||||
const c = data[i];
|
||||
if (c >= '0' && c <= '9' || c === ';' || c === ':') {
|
||||
params += c;
|
||||
}
|
||||
else {
|
||||
finalChar = c;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
this.handleCSI(params, finalChar);
|
||||
}
|
||||
else if (char === '\x1b' && i + 1 < data.length && data[i + 1] === ']') {
|
||||
// OSC sequence - skip for now
|
||||
i += 2;
|
||||
while (i < data.length && data[i] !== '\x07' && data[i] !== '\x1b') {
|
||||
i++;
|
||||
}
|
||||
if (i < data.length && data[i] === '\x1b' && i + 1 < data.length && data[i + 1] === '\\') {
|
||||
i++; // Skip the backslash too
|
||||
}
|
||||
}
|
||||
else if (char === '\r') {
|
||||
this.state.cursorX = 0;
|
||||
}
|
||||
else if (char === '\n') {
|
||||
this.newline();
|
||||
}
|
||||
else if (char === '\t') {
|
||||
this.state.cursorX = Math.min(this.state.width - 1, (Math.floor(this.state.cursorX / 8) + 1) * 8);
|
||||
}
|
||||
else if (char === '\b') {
|
||||
if (this.state.cursorX > 0) {
|
||||
this.state.cursorX--;
|
||||
}
|
||||
}
|
||||
else if (char >= ' ' || char === '\x00') {
|
||||
this.writeChar(char === '\x00' ? ' ' : char);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
handleCSI(params, finalChar) {
|
||||
const paramList = params ? params.split(';').map(p => parseInt(p) || 0) : [0];
|
||||
switch (finalChar) {
|
||||
case 'A': // Cursor Up
|
||||
this.state.cursorY = Math.max(this.state.scrollRegionTop, this.state.cursorY - (paramList[0] || 1));
|
||||
break;
|
||||
case 'B': // Cursor Down
|
||||
this.state.cursorY = Math.min(this.state.scrollRegionBottom, this.state.cursorY + (paramList[0] || 1));
|
||||
break;
|
||||
case 'C': // Cursor Forward
|
||||
this.state.cursorX = Math.min(this.state.width - 1, this.state.cursorX + (paramList[0] || 1));
|
||||
break;
|
||||
case 'D': // Cursor Backward
|
||||
this.state.cursorX = Math.max(0, this.state.cursorX - (paramList[0] || 1));
|
||||
break;
|
||||
case 'H': // Cursor Position
|
||||
case 'f':
|
||||
this.state.cursorY = Math.min(this.state.height - 1, Math.max(0, (paramList[0] || 1) - 1));
|
||||
this.state.cursorX = Math.min(this.state.width - 1, Math.max(0, (paramList[1] || 1) - 1));
|
||||
break;
|
||||
case 'J': // Erase Display
|
||||
this.eraseDisplay(paramList[0] || 0);
|
||||
break;
|
||||
case 'K': // Erase Line
|
||||
this.eraseLine(paramList[0] || 0);
|
||||
break;
|
||||
case 'm': // Set Graphics Rendition
|
||||
this.handleSGR(paramList);
|
||||
break;
|
||||
case 'r': // Set Scroll Region
|
||||
this.state.scrollRegionTop = Math.max(0, (paramList[0] || 1) - 1);
|
||||
this.state.scrollRegionBottom = Math.min(this.state.height - 1, (paramList[1] || this.state.height) - 1);
|
||||
this.state.cursorX = 0;
|
||||
this.state.cursorY = this.state.scrollRegionTop;
|
||||
break;
|
||||
case 's': // Save Cursor Position
|
||||
// TODO: Implement cursor save/restore
|
||||
break;
|
||||
case 'u': // Restore Cursor Position
|
||||
// TODO: Implement cursor save/restore
|
||||
break;
|
||||
case 'h': // Set Mode
|
||||
if (params === '?1049' || params === '?47') {
|
||||
this.state.alternateScreen = true;
|
||||
}
|
||||
else if (params === '?2004') {
|
||||
// Bracketed paste mode - ignore
|
||||
}
|
||||
break;
|
||||
case 'l': // Reset Mode
|
||||
if (params === '?1049' || params === '?47') {
|
||||
this.state.alternateScreen = false;
|
||||
}
|
||||
else if (params === '?2004') {
|
||||
// Bracketed paste mode - ignore
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
handleSGR(params) {
|
||||
for (let i = 0; i < params.length; i++) {
|
||||
const param = params[i];
|
||||
if (param === 0) {
|
||||
// Reset
|
||||
this.state.currentFg = '#ffffff';
|
||||
this.state.currentBg = '#000000';
|
||||
this.state.bold = false;
|
||||
this.state.italic = false;
|
||||
this.state.underline = false;
|
||||
this.state.strikethrough = false;
|
||||
this.state.inverse = false;
|
||||
}
|
||||
else if (param === 1) {
|
||||
this.state.bold = true;
|
||||
}
|
||||
else if (param === 3) {
|
||||
this.state.italic = true;
|
||||
}
|
||||
else if (param === 4) {
|
||||
this.state.underline = true;
|
||||
}
|
||||
else if (param === 7) {
|
||||
this.state.inverse = true;
|
||||
}
|
||||
else if (param === 9) {
|
||||
this.state.strikethrough = true;
|
||||
}
|
||||
else if (param === 22) {
|
||||
this.state.bold = false;
|
||||
}
|
||||
else if (param === 23) {
|
||||
this.state.italic = false;
|
||||
}
|
||||
else if (param === 24) {
|
||||
this.state.underline = false;
|
||||
}
|
||||
else if (param === 27) {
|
||||
this.state.inverse = false;
|
||||
}
|
||||
else if (param === 29) {
|
||||
this.state.strikethrough = false;
|
||||
}
|
||||
else if (param === 39) {
|
||||
// Default foreground color
|
||||
this.state.currentFg = '#ffffff';
|
||||
}
|
||||
else if (param === 49) {
|
||||
// Default background color
|
||||
this.state.currentBg = '#000000';
|
||||
}
|
||||
else if (param >= 30 && param <= 37) {
|
||||
// Standard foreground colors
|
||||
this.state.currentFg = this.ansiColorMap[param - 30];
|
||||
}
|
||||
else if (param >= 40 && param <= 47) {
|
||||
// Standard background colors
|
||||
this.state.currentBg = this.ansiColorMap[param - 40];
|
||||
}
|
||||
else if (param >= 90 && param <= 97) {
|
||||
// Bright foreground colors
|
||||
this.state.currentFg = this.ansiColorMap[param - 90 + 8];
|
||||
}
|
||||
else if (param >= 100 && param <= 107) {
|
||||
// Bright background colors
|
||||
this.state.currentBg = this.ansiColorMap[param - 100 + 8];
|
||||
}
|
||||
else if (param === 38) {
|
||||
// Extended foreground color
|
||||
if (i + 1 < params.length && params[i + 1] === 2 && i + 4 < params.length) {
|
||||
// RGB: 38;2;r;g;b
|
||||
const r = params[i + 2];
|
||||
const g = params[i + 3];
|
||||
const b = params[i + 4];
|
||||
this.state.currentFg = `rgb(${r},${g},${b})`;
|
||||
i += 4;
|
||||
}
|
||||
else if (i + 1 < params.length && params[i + 1] === 5 && i + 2 < params.length) {
|
||||
// 256-color: 38;5;n
|
||||
this.state.currentFg = this.get256Color(params[i + 2]);
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
else if (param === 48) {
|
||||
// Extended background color
|
||||
if (i + 1 < params.length && params[i + 1] === 2 && i + 4 < params.length) {
|
||||
// RGB: 48;2;r;g;b
|
||||
const r = params[i + 2];
|
||||
const g = params[i + 3];
|
||||
const b = params[i + 4];
|
||||
this.state.currentBg = `rgb(${r},${g},${b})`;
|
||||
i += 4;
|
||||
}
|
||||
else if (i + 1 < params.length && params[i + 1] === 5 && i + 2 < params.length) {
|
||||
// 256-color: 48;5;n
|
||||
this.state.currentBg = this.get256Color(params[i + 2]);
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
get256Color(index) {
|
||||
if (index < 16) {
|
||||
return this.ansiColorMap[index];
|
||||
}
|
||||
else if (index < 232) {
|
||||
// 216 color cube
|
||||
const n = index - 16;
|
||||
const r = Math.floor(n / 36);
|
||||
const g = Math.floor((n % 36) / 6);
|
||||
const b = n % 6;
|
||||
const values = [0, 95, 135, 175, 215, 255];
|
||||
return `rgb(${values[r]},${values[g]},${values[b]})`;
|
||||
// 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 {
|
||||
// Grayscale
|
||||
const gray = 8 + (index - 232) * 10;
|
||||
return `rgb(${gray},${gray},${gray})`;
|
||||
// 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);
|
||||
}
|
||||
eraseDisplay(mode) {
|
||||
const buffer = this.getCurrentBuffer();
|
||||
switch (mode) {
|
||||
case 0: // Erase from cursor to end of screen
|
||||
this.eraseLine(0);
|
||||
for (let y = this.state.cursorY + 1; y < this.state.height; y++) {
|
||||
for (let x = 0; x < this.state.width; x++) {
|
||||
buffer[y][x] = this.createEmptyCell();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 1: // Erase from beginning of screen to cursor
|
||||
for (let y = 0; y < this.state.cursorY; y++) {
|
||||
for (let x = 0; x < this.state.width; x++) {
|
||||
buffer[y][x] = this.createEmptyCell();
|
||||
}
|
||||
}
|
||||
this.eraseLine(1);
|
||||
break;
|
||||
case 2: // Erase entire screen
|
||||
case 3: // Erase entire screen and scrollback
|
||||
for (let y = 0; y < this.state.height; y++) {
|
||||
for (let x = 0; x < this.state.width; x++) {
|
||||
buffer[y][x] = this.createEmptyCell();
|
||||
}
|
||||
}
|
||||
if (mode === 3) {
|
||||
this.scrollbackBuffer = [];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
eraseLine(mode) {
|
||||
const buffer = this.getCurrentBuffer();
|
||||
const y = this.state.cursorY;
|
||||
switch (mode) {
|
||||
case 0: // Erase from cursor to end of line
|
||||
for (let x = this.state.cursorX; x < this.state.width; x++) {
|
||||
buffer[y][x] = this.createEmptyCell();
|
||||
}
|
||||
break;
|
||||
case 1: // Erase from beginning of line to cursor
|
||||
for (let x = 0; x <= this.state.cursorX; x++) {
|
||||
buffer[y][x] = this.createEmptyCell();
|
||||
}
|
||||
break;
|
||||
case 2: // Erase entire line
|
||||
for (let x = 0; x < this.state.width; x++) {
|
||||
buffer[y][x] = this.createEmptyCell();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
createEmptyCell() {
|
||||
return {
|
||||
char: ' ',
|
||||
fg: this.state.currentFg,
|
||||
bg: this.state.currentBg,
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
strikethrough: false,
|
||||
inverse: false
|
||||
};
|
||||
}
|
||||
writeChar(char) {
|
||||
const buffer = this.getCurrentBuffer();
|
||||
if (this.state.cursorX >= this.state.width) {
|
||||
if (this.state.autowrap) {
|
||||
this.newline();
|
||||
}
|
||||
else {
|
||||
this.state.cursorX = this.state.width - 1;
|
||||
}
|
||||
}
|
||||
buffer[this.state.cursorY][this.state.cursorX] = {
|
||||
char,
|
||||
fg: this.state.currentFg,
|
||||
bg: this.state.currentBg,
|
||||
bold: this.state.bold,
|
||||
italic: this.state.italic,
|
||||
underline: this.state.underline,
|
||||
strikethrough: this.state.strikethrough,
|
||||
inverse: this.state.inverse
|
||||
};
|
||||
this.state.cursorX++;
|
||||
}
|
||||
newline() {
|
||||
this.state.cursorX = 0;
|
||||
if (this.state.cursorY >= this.state.scrollRegionBottom) {
|
||||
this.scrollUp();
|
||||
}
|
||||
else {
|
||||
this.state.cursorY++;
|
||||
}
|
||||
}
|
||||
scrollUp() {
|
||||
const buffer = this.getCurrentBuffer();
|
||||
// Add the top line to scrollback if we're in primary buffer
|
||||
if (!this.state.alternateScreen) {
|
||||
this.scrollbackBuffer.push([...buffer[this.state.scrollRegionTop]]);
|
||||
if (this.scrollbackBuffer.length > this.maxScrollback) {
|
||||
this.scrollbackBuffer.shift();
|
||||
}
|
||||
}
|
||||
// Scroll the region
|
||||
for (let y = this.state.scrollRegionTop; y < this.state.scrollRegionBottom; y++) {
|
||||
buffer[y] = [...buffer[y + 1]];
|
||||
}
|
||||
// Clear the bottom line
|
||||
for (let x = 0; x < this.state.width; x++) {
|
||||
buffer[this.state.scrollRegionBottom][x] = this.createEmptyCell();
|
||||
}
|
||||
}
|
||||
// Public API methods
|
||||
// Public API methods - maintain compatibility with custom renderer
|
||||
async loadCastFile(url) {
|
||||
const response = await fetch(url);
|
||||
const text = await response.text();
|
||||
|
|
@ -491,6 +102,8 @@ class TerminalRenderer {
|
|||
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;
|
||||
|
|
@ -511,59 +124,92 @@ class TerminalRenderer {
|
|||
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);
|
||||
}
|
||||
}
|
||||
this.renderBuffer();
|
||||
}
|
||||
processOutput(data) {
|
||||
this.parseAnsiSequence(data);
|
||||
this.renderBuffer();
|
||||
// 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);
|
||||
this.renderBuffer();
|
||||
}
|
||||
else if (event.type === 'r') {
|
||||
this.processResize(event.data);
|
||||
}
|
||||
}
|
||||
resize(width, height) {
|
||||
this.state.width = width;
|
||||
this.state.height = height;
|
||||
this.state.scrollRegionBottom = height - 1;
|
||||
this.primaryBuffer = this.createBuffer(width, height);
|
||||
this.alternateBuffer = this.createBuffer(width, height);
|
||||
this.state.cursorX = 0;
|
||||
this.state.cursorY = 0;
|
||||
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.primaryBuffer = this.createBuffer(this.state.width, this.state.height);
|
||||
this.alternateBuffer = this.createBuffer(this.state.width, this.state.height);
|
||||
this.scrollbackBuffer = [];
|
||||
this.state.cursorX = 0;
|
||||
this.state.cursorY = 0;
|
||||
this.state.alternateScreen = false;
|
||||
this.renderBuffer();
|
||||
this.terminal.clear();
|
||||
}
|
||||
// Stream support - connect to SSE endpoint
|
||||
connectToStream(sessionId) {
|
||||
const eventSource = new EventSource(`/api/sessions/${sessionId}/stream`);
|
||||
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) {
|
||||
// Event
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -573,9 +219,92 @@ class TerminalRenderer {
|
|||
};
|
||||
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.TerminalRenderer = TerminalRenderer;
|
||||
exports.Renderer = Renderer;
|
||||
Renderer.activeCount = 0;
|
||||
//# sourceMappingURL=renderer.js.map
|
||||
2
web/dist/client/renderer.js.map
vendored
2
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
Normal file
109
web/dist/client/scale-fit-addon.js
vendored
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"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
Normal file
1
web/dist/client/scale-fit-addon.js.map
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"scale-fit-addon.js","sourceRoot":"","sources":["../../src/client/scale-fit-addon.ts"],"names":[],"mappings":";AAAA;;;GAGG;;;AASH,MAAM,YAAY,GAAG,CAAC,CAAC;AACvB,MAAM,aAAa,GAAG,CAAC,CAAC;AACxB,MAAM,aAAa,GAAG,EAAE,CAAC;AAEzB,MAAa,aAAa;IAGjB,QAAQ,CAAC,QAAkB;QAChC,IAAI,CAAC,SAAS,GAAG,QAAQ,CAAC;IAC5B,CAAC;IAEM,OAAO,KAAU,CAAC;IAElB,GAAG;QACR,MAAM,IAAI,GAAG,IAAI,CAAC,iBAAiB,EAAE,CAAC;QACtC,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;YACrE,OAAO;QACT,CAAC;QAED,oEAAoE;QACpE,IAAI,IAAI,CAAC,SAAS,CAAC,IAAI,KAAK,IAAI,CAAC,IAAI,EAAE,CAAC;YACtC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QACxD,CAAC;IACH,CAAC;IAEM,iBAAiB;QACtB,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,OAAO,EAAE,aAAa,EAAE,CAAC;YAC5C,OAAO,SAAS,CAAC;QACnB,CAAC;QAED,4EAA4E;QAC5E,MAAM,eAAe,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,aAAa,CAAC;QAC7D,MAAM,iBAAiB,GAAG,eAAe,CAAC,aAAa,CAAC;QAExD,IAAI,CAAC,iBAAiB;YAAE,OAAO,SAAS,CAAC;QAEzC,6CAA6C;QAC7C,MAAM,cAAc,GAAG,MAAM,CAAC,gBAAgB,CAAC,iBAAiB,CAAC,CAAC;QAClE,MAAM,cAAc,GAAG,QAAQ,CAAC,cAAc,CAAC,gBAAgB,CAAC,OAAO,CAAC,CAAC,CAAC;QAC1E,MAAM,eAAe,GAAG,QAAQ,CAAC,cAAc,CAAC,gBAAgB,CAAC,QAAQ,CAAC,CAAC,CAAC;QAC5E,MAAM,gBAAgB,GAAG;YACvB,GAAG,EAAE,QAAQ,CAAC,cAAc,CAAC,gBAAgB,CAAC,aAAa,CAAC,CAAC;YAC7D,MAAM,EAAE,QAAQ,CAAC,cAAc,CAAC,gBAAgB,CAAC,gBAAgB,CAAC,CAAC;YACnE,IAAI,EAAE,QAAQ,CAAC,cAAc,CAAC,gBAAgB,CAAC,cAAc,CAAC,CAAC;YAC/D,KAAK,EAAE,QAAQ,CAAC,cAAc,CAAC,gBAAgB,CAAC,eAAe,CAAC,CAAC;SAClE,CAAC;QAEF,sDAAsD;QACtD,MAAM,cAAc,GAAG,cAAc,GAAG,gBAAgB,CAAC,IAAI,GAAG,gBAAgB,CAAC,KAAK,CAAC;QACvF,MAAM,eAAe,GAAG,eAAe,GAAG,gBAAgB,CAAC,GAAG,GAAG,gBAAgB,CAAC,MAAM,CAAC;QAEzF,8BAA8B;QAC9B,MAAM,WAAW,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;QAExC,qEAAqE;QACrE,sEAAsE;QACtE,MAAM,cAAc,GAAG,GAAG,CAAC;QAC3B,MAAM,kBAAkB,GAAG,cAAc,GAAG,CAAC,WAAW,GAAG,cAAc,CAAC,CAAC;QAC3E,MAAM,eAAe,GAAG,IAAI,CAAC,GAAG,CAAC,aAAa,EAAE,IAAI,CAAC,GAAG,CAAC,aAAa,EAAE,kBAAkB,CAAC,CAAC,CAAC;QAE7F,mFAAmF;QACnF,qBAAqB,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,eAAe,CAAC,CAAC,CAAC;QAEjE,6DAA6D;QAC7D,MAAM,YAAY,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;QAC5C,MAAM,YAAY,GAAG,MAAM,CAAC,gBAAgB,CAAC,YAAY,CAAC,CAAC;QAC3D,MAAM,gBAAgB,GAAG,UAAU,CAAC,YAAY,CAAC,UAAU,CAAC,CAAC;QAE7D,8DAA8D;QAC9D,MAAM,UAAU,GAAG,gBAAgB,IAAI,CAAC,eAAe,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,UAAU,IAAI,GAAG,CAAC,CAAC,CAAC;QAEtG,oDAAoD;QACpD,MAAM,WAAW,GAAG,IAAI,CAAC,GAAG,CAAC,YAAY,EAAE,IAAI,CAAC,KAAK,CAAC,eAAe,GAAG,UAAU,CAAC,CAAC,CAAC;QAErF,OAAO;YACL,IAAI,EAAE,WAAW,EAAE,qBAAqB;YACxC,IAAI,EAAE,WAAW,CAAE,+BAA+B;SACnD,CAAC;IACJ,CAAC;IAEO,aAAa,CAAC,QAAgB;QACpC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,OAAO;YAAE,OAAO;QAErC,4EAA4E;QAC5E,MAAM,eAAe,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,QAAQ,IAAI,EAAE,CAAC;QAC9D,IAAI,IAAI,CAAC,GAAG,CAAC,QAAQ,GAAG,eAAe,CAAC,GAAG,GAAG;YAAE,OAAO;QAEvD,MAAM,eAAe,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;QAE/C,8BAA8B;QAC9B,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,QAAQ,GAAG,QAAQ,CAAC;QAE3C,qCAAqC;QACrC,eAAe,CAAC,KAAK,CAAC,QAAQ,GAAG,GAAG,QAAQ,IAAI,CAAC;QAEjD,6CAA6C;QAC7C,qBAAqB,CAAC,GAAG,EAAE;YACzB,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;gBACnB,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC;YACrD,CAAC;QACH,CAAC,CAAC,CAAC;IACL,CAAC;IAED;;OAEG;IACI,kBAAkB;QACvB,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,OAAO,EAAE,aAAa,EAAE,CAAC;YAC5C,OAAO,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,QAAQ,IAAI,EAAE,CAAC;QAChD,CAAC;QAED,MAAM,aAAa,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,aAAa,CAAC;QAC3D,MAAM,WAAW,GAAG,MAAM,CAAC,gBAAgB,CAAC,aAAa,CAAC,CAAC;QAC3D,MAAM,WAAW,GAAG,QAAQ,CAAC,WAAW,CAAC,gBAAgB,CAAC,OAAO,CAAC,CAAC,CAAC;QAEpE,MAAM,YAAY,GAAG,MAAM,CAAC,gBAAgB,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;QACrE,MAAM,UAAU,GAAG,QAAQ,CAAC,YAAY,CAAC,gBAAgB,CAAC,cAAc,CAAC,CAAC;YACxD,QAAQ,CAAC,YAAY,CAAC,gBAAgB,CAAC,eAAe,CAAC,CAAC,CAAC;QAE3E,MAAM,cAAc,GAAG,WAAW,GAAG,UAAU,CAAC;QAChD,MAAM,cAAc,GAAG,GAAG,CAAC;QAC3B,MAAM,kBAAkB,GAAG,cAAc,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,GAAG,cAAc,CAAC,CAAC;QAEnF,OAAO,IAAI,CAAC,GAAG,CAAC,aAAa,EAAE,IAAI,CAAC,GAAG,CAAC,aAAa,EAAE,kBAAkB,CAAC,CAAC,CAAC;IAC9E,CAAC;CACF;AAzHD,sCAyHC"}
|
||||
122
web/dist/server.js
vendored
122
web/dist/server.js
vendored
|
|
@ -170,18 +170,59 @@ app.post('/api/sessions', async (req, res) => {
|
|||
detached: false,
|
||||
stdio: 'pipe'
|
||||
});
|
||||
// Log output for debugging
|
||||
// Capture session ID from stdout
|
||||
let sessionId = '';
|
||||
child.stdout.on('data', (data) => {
|
||||
console.log(`Session ${sessionName} stdout:`, data.toString());
|
||||
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) => {
|
||||
console.log(`Session ${sessionName} stderr:`, data.toString());
|
||||
// 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', (code) => {
|
||||
console.log(`Session ${sessionName} exited with code: ${code}`);
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// Respond immediately - don't wait for completion
|
||||
res.json({ sessionId: sessionName });
|
||||
// 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);
|
||||
|
|
@ -266,14 +307,14 @@ app.post('/api/cleanup-exited', async (req, res) => {
|
|||
// === TERMINAL I/O ===
|
||||
// Track active streams per session to avoid multiple tail processes
|
||||
const activeStreams = new Map();
|
||||
// Live streaming cast file for asciinema player
|
||||
// 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}`);
|
||||
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',
|
||||
|
|
@ -368,6 +409,7 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
|
|||
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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -391,7 +433,7 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
|
|||
});
|
||||
tailProcess.on('exit', (code) => {
|
||||
console.log(`Shared tail process exited for session ${sessionId} with code ${code}`);
|
||||
// Cleanup all clients
|
||||
// Cleanup all clients
|
||||
const currentStreamInfo = activeStreams.get(sessionId);
|
||||
if (currentStreamInfo) {
|
||||
currentStreamInfo.clients.forEach(client => {
|
||||
|
|
@ -425,8 +467,11 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
|
|||
};
|
||||
req.on('close', cleanup);
|
||||
req.on('aborted', cleanup);
|
||||
req.on('error', cleanup);
|
||||
res.on('close', cleanup);
|
||||
res.on('finish', cleanup);
|
||||
});
|
||||
// Get session snapshot (asciinema cast with adjusted timestamps for immediate playback)
|
||||
// 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');
|
||||
|
|
@ -460,7 +505,7 @@ app.get('/api/sessions/:sessionId/snapshot', (req, res) => {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Build the complete asciinema cast
|
||||
// Build the complete cast
|
||||
const cast = [];
|
||||
// Add header if found, otherwise use default
|
||||
if (header) {
|
||||
|
|
@ -530,7 +575,7 @@ app.post('/api/sessions/:sessionId/input', async (req, res) => {
|
|||
}
|
||||
}
|
||||
// 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 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) {
|
||||
|
|
@ -539,7 +584,7 @@ app.post('/api/sessions/:sessionId/input', async (req, res) => {
|
|||
'--session', sessionId,
|
||||
'--send-key', text
|
||||
]);
|
||||
console.log(`Successfully sent key: ${text} (${Date.now() - startTime}ms)`);
|
||||
// Key sent successfully (removed verbose logging)
|
||||
}
|
||||
else {
|
||||
await executeTtyFwd([
|
||||
|
|
@ -547,7 +592,7 @@ app.post('/api/sessions/:sessionId/input', async (req, res) => {
|
|||
'--session', sessionId,
|
||||
'--send-text', text
|
||||
]);
|
||||
console.log(`Successfully sent text: ${text} (${Date.now() - startTime}ms)`);
|
||||
// Text sent successfully (removed verbose logging)
|
||||
}
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
|
@ -617,6 +662,53 @@ app.get('/api/fs/browse', (req, res) => {
|
|||
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) => {
|
||||
|
|
|
|||
2
web/dist/server.js.map
vendored
2
web/dist/server.js.map
vendored
File diff suppressed because one or more lines are too long
3
web/public/app-entry.js
Normal file
3
web/public/app-entry.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Entry point for the app
|
||||
import './app.js';
|
||||
//# sourceMappingURL=app-entry.js.map
|
||||
1
web/public/app-entry.js.map
Normal file
1
web/public/app-entry.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"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';"]}
|
||||
298
web/public/app.js
Normal file
298
web/public/app.js
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
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
|
||||
1
web/public/app.js.map
Normal file
1
web/public/app.js.map
Normal file
File diff suppressed because one or more lines are too long
36
web/public/components/app-header.js
Normal file
36
web/public/components/app-header.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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
web/public/components/app-header.js.map
Normal file
1
web/public/components/app-header.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"app-header.js","sourceRoot":"","sources":["../../src/client/components/app-header.ts"],"names":[],"mappings":";;;;;;AAAA,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,KAAK,CAAC;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAG3C,IAAM,SAAS,GAAf,MAAM,SAAU,SAAQ,UAAU;IACvC,gBAAgB;QACd,OAAO,IAAI,CAAC;IACd,CAAC;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}"]}
|
||||
256
web/public/components/file-browser.js
Normal file
256
web/public/components/file-browser.js
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
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
|
||||
1
web/public/components/file-browser.js.map
Normal file
1
web/public/components/file-browser.js.map
Normal file
File diff suppressed because one or more lines are too long
165
web/public/components/session-card.js
Normal file
165
web/public/components/session-card.js
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
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
|
||||
1
web/public/components/session-card.js.map
Normal file
1
web/public/components/session-card.js.map
Normal file
File diff suppressed because one or more lines are too long
223
web/public/components/session-create-form.js
Normal file
223
web/public/components/session-create-form.js
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
import { LitElement, html } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import './file-browser.js';
|
||||
let SessionCreateForm = class SessionCreateForm extends LitElement {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.workingDir = '~/';
|
||||
this.command = 'zsh';
|
||||
this.disabled = false;
|
||||
this.visible = false;
|
||||
this.isCreating = false;
|
||||
this.showFileBrowser = false;
|
||||
}
|
||||
// Disable shadow DOM to use Tailwind
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
handleWorkingDirChange(e) {
|
||||
const input = e.target;
|
||||
this.workingDir = input.value;
|
||||
this.dispatchEvent(new CustomEvent('working-dir-change', {
|
||||
detail: this.workingDir
|
||||
}));
|
||||
}
|
||||
handleCommandChange(e) {
|
||||
const input = e.target;
|
||||
this.command = input.value;
|
||||
}
|
||||
handleBrowse() {
|
||||
this.showFileBrowser = true;
|
||||
}
|
||||
handleDirectorySelected(e) {
|
||||
this.workingDir = e.detail;
|
||||
this.showFileBrowser = false;
|
||||
}
|
||||
handleBrowserCancel() {
|
||||
this.showFileBrowser = false;
|
||||
}
|
||||
async handleCreate() {
|
||||
if (!this.workingDir.trim() || !this.command.trim()) {
|
||||
this.dispatchEvent(new CustomEvent('error', {
|
||||
detail: 'Please fill in both working directory and command'
|
||||
}));
|
||||
return;
|
||||
}
|
||||
this.isCreating = true;
|
||||
const sessionData = {
|
||||
command: this.parseCommand(this.command.trim()),
|
||||
workingDir: this.workingDir.trim()
|
||||
};
|
||||
try {
|
||||
const response = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sessionData)
|
||||
});
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
this.command = ''; // Clear command on success
|
||||
this.dispatchEvent(new CustomEvent('session-created', {
|
||||
detail: result
|
||||
}));
|
||||
}
|
||||
else {
|
||||
const error = await response.json();
|
||||
this.dispatchEvent(new CustomEvent('error', {
|
||||
detail: `Failed to create session: ${error.error}`
|
||||
}));
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error creating session:', error);
|
||||
this.dispatchEvent(new CustomEvent('error', {
|
||||
detail: 'Failed to create session'
|
||||
}));
|
||||
}
|
||||
finally {
|
||||
this.isCreating = false;
|
||||
}
|
||||
}
|
||||
parseCommand(commandStr) {
|
||||
// Simple command parsing - split by spaces but respect quotes
|
||||
const args = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
let quoteChar = '';
|
||||
for (let i = 0; i < commandStr.length; i++) {
|
||||
const char = commandStr[i];
|
||||
if ((char === '"' || char === "'") && !inQuotes) {
|
||||
inQuotes = true;
|
||||
quoteChar = char;
|
||||
}
|
||||
else if (char === quoteChar && inQuotes) {
|
||||
inQuotes = false;
|
||||
quoteChar = '';
|
||||
}
|
||||
else if (char === ' ' && !inQuotes) {
|
||||
if (current) {
|
||||
args.push(current);
|
||||
current = '';
|
||||
}
|
||||
}
|
||||
else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
args.push(current);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
handleCancel() {
|
||||
this.dispatchEvent(new CustomEvent('cancel'));
|
||||
}
|
||||
render() {
|
||||
if (!this.visible) {
|
||||
return html ``;
|
||||
}
|
||||
return html `
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style="z-index: 9999;">
|
||||
<div class="bg-vs-bg-secondary border border-vs-border font-mono text-sm w-96 max-w-full mx-4">
|
||||
<div class="p-4 border-b border-vs-border flex justify-between items-center">
|
||||
<div class="text-vs-assistant text-sm">Create New Session</div>
|
||||
<button
|
||||
class="text-vs-muted hover:text-vs-text text-lg leading-none border-none bg-transparent cursor-pointer"
|
||||
@click=${this.handleCancel}
|
||||
>×</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-vs-text mb-2">Working Directory:</div>
|
||||
<div class="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 bg-vs-bg text-vs-text border border-vs-border outline-none font-mono px-4 py-2"
|
||||
.value=${this.workingDir}
|
||||
@input=${this.handleWorkingDirChange}
|
||||
placeholder="~/"
|
||||
?disabled=${this.disabled || this.isCreating}
|
||||
/>
|
||||
<button
|
||||
class="bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-4 py-2 border-none"
|
||||
@click=${this.handleBrowse}
|
||||
?disabled=${this.disabled || this.isCreating}
|
||||
>
|
||||
browse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-vs-text mb-2">Command:</div>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-vs-bg text-vs-text border border-vs-border outline-none font-mono px-4 py-2"
|
||||
.value=${this.command}
|
||||
@input=${this.handleCommandChange}
|
||||
@keydown=${(e) => e.key === 'Enter' && this.handleCreate()}
|
||||
placeholder="zsh"
|
||||
?disabled=${this.disabled || this.isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 justify-end">
|
||||
<button
|
||||
class="bg-vs-muted text-vs-bg hover:bg-vs-text font-mono px-4 py-2 border-none"
|
||||
@click=${this.handleCancel}
|
||||
?disabled=${this.isCreating}
|
||||
>
|
||||
cancel
|
||||
</button>
|
||||
<button
|
||||
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-vs-user"
|
||||
@click=${this.handleCreate}
|
||||
?disabled=${this.disabled || this.isCreating || !this.workingDir.trim() || !this.command.trim()}
|
||||
>
|
||||
${this.isCreating ? 'creating...' : 'create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<file-browser
|
||||
.visible=${this.showFileBrowser}
|
||||
.currentPath=${this.workingDir}
|
||||
@directory-selected=${this.handleDirectorySelected}
|
||||
@browser-cancel=${this.handleBrowserCancel}
|
||||
></file-browser>
|
||||
`;
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
property({ type: String })
|
||||
], SessionCreateForm.prototype, "workingDir", void 0);
|
||||
__decorate([
|
||||
property({ type: String })
|
||||
], SessionCreateForm.prototype, "command", void 0);
|
||||
__decorate([
|
||||
property({ type: Boolean })
|
||||
], SessionCreateForm.prototype, "disabled", void 0);
|
||||
__decorate([
|
||||
property({ type: Boolean })
|
||||
], SessionCreateForm.prototype, "visible", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], SessionCreateForm.prototype, "isCreating", void 0);
|
||||
__decorate([
|
||||
state()
|
||||
], SessionCreateForm.prototype, "showFileBrowser", void 0);
|
||||
SessionCreateForm = __decorate([
|
||||
customElement('session-create-form')
|
||||
], SessionCreateForm);
|
||||
export { SessionCreateForm };
|
||||
//# sourceMappingURL=session-create-form.js.map
|
||||
1
web/public/components/session-create-form.js.map
Normal file
1
web/public/components/session-create-form.js.map
Normal file
File diff suppressed because one or more lines are too long
1
web/public/components/session-list.js.map
Normal file
1
web/public/components/session-list.js.map
Normal file
File diff suppressed because one or more lines are too long
710
web/public/components/session-view.js
Normal file
710
web/public/components/session-view.js
Normal file
|
|
@ -0,0 +1,710 @@
|
|||
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
|
||||
1
web/public/components/session-view.js.map
Normal file
1
web/public/components/session-view.js.map
Normal file
File diff suppressed because one or more lines are too long
3
web/public/renderer-entry.js
Normal file
3
web/public/renderer-entry.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Entry point for renderer bundle - exports XTerm-based renderer
|
||||
export { Renderer } from './renderer';
|
||||
//# sourceMappingURL=renderer-entry.js.map
|
||||
1
web/public/renderer-entry.js.map
Normal file
1
web/public/renderer-entry.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"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';"]}
|
||||
306
web/public/renderer.js
Normal file
306
web/public/renderer.js
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
// 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
|
||||
1
web/public/renderer.js.map
Normal file
1
web/public/renderer.js.map
Normal file
File diff suppressed because one or more lines are too long
105
web/public/scale-fit-addon.js
Normal file
105
web/public/scale-fit-addon.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* 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
|
||||
1
web/public/scale-fit-addon.js.map
Normal file
1
web/public/scale-fit-addon.js.map
Normal file
File diff suppressed because one or more lines are too long
218
web/public/xterm.css
Normal file
218
web/public/xterm.css
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility:not(.debug),
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree {
|
||||
user-select: text;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { LitElement, html } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
|
||||
// Import components
|
||||
import './components/app-header.js';
|
||||
|
|
@ -21,7 +22,7 @@ export class VibeTunnelApp extends LitElement {
|
|||
@state() private sessions: Session[] = [];
|
||||
@state() private loading = false;
|
||||
@state() private currentView: 'list' | 'session' = 'list';
|
||||
@state() private selectedSession: Session | null = null;
|
||||
@state() private selectedSessionId: string | null = null;
|
||||
@state() private hideExited = true;
|
||||
@state() private showCreateModal = false;
|
||||
|
||||
|
|
@ -127,11 +128,8 @@ export class VibeTunnelApp extends LitElement {
|
|||
}
|
||||
|
||||
if (session) {
|
||||
// Session found, switch to session view
|
||||
this.selectedSession = session;
|
||||
this.currentView = 'session';
|
||||
// Update URL to include session ID
|
||||
this.updateUrl(session.id);
|
||||
// Session found, switch to session view via URL
|
||||
window.location.search = `?session=${session.id}`;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -144,21 +142,6 @@ export class VibeTunnelApp extends LitElement {
|
|||
this.showError('Session created but could not be found. Please refresh.');
|
||||
}
|
||||
|
||||
private handleSessionSelect(e: CustomEvent) {
|
||||
const session = e.detail as Session;
|
||||
console.log('Session selected:', session);
|
||||
this.selectedSession = session;
|
||||
this.currentView = 'session';
|
||||
// Update URL to include session ID
|
||||
this.updateUrl(session.id);
|
||||
}
|
||||
|
||||
private handleBack() {
|
||||
this.currentView = 'list';
|
||||
this.selectedSession = null;
|
||||
// Update URL to remove session parameter
|
||||
this.updateUrl();
|
||||
}
|
||||
|
||||
|
||||
private handleSessionKilled(e: CustomEvent) {
|
||||
|
|
@ -205,32 +188,11 @@ export class VibeTunnelApp extends LitElement {
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadSessionFromUrl(sessionId: string) {
|
||||
// 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.selectedSessionId = sessionId;
|
||||
this.currentView = 'session';
|
||||
} else {
|
||||
// Session not found, go to list view
|
||||
this.selectedSessionId = null;
|
||||
this.currentView = 'list';
|
||||
this.selectedSession = null;
|
||||
// Update URL to remove invalid session ID
|
||||
this.updateUrl();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -275,12 +237,12 @@ export class VibeTunnelApp extends LitElement {
|
|||
` : ''}
|
||||
|
||||
<!-- Main content -->
|
||||
${this.currentView === 'session' ? html`
|
||||
<session-view
|
||||
.session=${this.selectedSession}
|
||||
@back=${this.handleBack}
|
||||
></session-view>
|
||||
` : html`
|
||||
${this.currentView === 'session' && this.selectedSessionId ?
|
||||
keyed(this.selectedSessionId, html`
|
||||
<session-view
|
||||
.session=${this.sessions.find(s => s.id === this.selectedSessionId)}
|
||||
></session-view>
|
||||
`) : html`
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<app-header
|
||||
@create-session=${this.handleCreateSession}
|
||||
|
|
@ -290,7 +252,6 @@ export class VibeTunnelApp extends LitElement {
|
|||
.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}
|
||||
|
|
@ -299,7 +260,7 @@ export class VibeTunnelApp extends LitElement {
|
|||
@hide-exited-change=${this.handleHideExitedChange}
|
||||
></session-list>
|
||||
</div>
|
||||
`}
|
||||
`}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,9 @@ export class FileBrowser extends LitElement {
|
|||
|
||||
@state() private files: FileInfo[] = [];
|
||||
@state() private loading = false;
|
||||
@state() private showCreateFolder = false;
|
||||
@state() private newFolderName = '';
|
||||
@state() private creating = false;
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
|
@ -78,6 +81,62 @@ export class FileBrowser extends LitElement {
|
|||
this.dispatchEvent(new CustomEvent('browser-cancel'));
|
||||
}
|
||||
|
||||
private handleCreateFolder() {
|
||||
this.showCreateFolder = true;
|
||||
this.newFolderName = '';
|
||||
}
|
||||
|
||||
private handleCancelCreateFolder() {
|
||||
this.showCreateFolder = false;
|
||||
this.newFolderName = '';
|
||||
}
|
||||
|
||||
private handleFolderNameInput(e: Event) {
|
||||
this.newFolderName = (e.target as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
private handleFolderNameKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.createFolder();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.handleCancelCreateFolder();
|
||||
}
|
||||
}
|
||||
|
||||
private 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``;
|
||||
|
|
@ -87,7 +146,17 @@ export class FileBrowser extends LitElement {
|
|||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style="z-index: 9999;">
|
||||
<div class="bg-vs-bg-secondary border border-vs-border font-mono text-sm w-96 h-96 flex flex-col">
|
||||
<div class="p-4 border-b border-vs-border flex-shrink-0">
|
||||
<div class="text-vs-assistant text-sm mb-2">Select Directory</div>
|
||||
<div class="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>
|
||||
|
||||
|
|
@ -123,6 +192,38 @@ export class FileBrowser extends LitElement {
|
|||
`)}
|
||||
`}
|
||||
</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
|
||||
|
|
|
|||
|
|
@ -22,8 +22,11 @@ export class SessionCard extends LitElement {
|
|||
|
||||
@property({ type: Object }) session!: Session;
|
||||
@state() private renderer: Renderer | null = null;
|
||||
@state() private killing = false;
|
||||
@state() private killingFrame = 0;
|
||||
|
||||
private refreshInterval: number | null = null;
|
||||
private killingInterval: number | null = null;
|
||||
|
||||
firstUpdated(changedProperties: PropertyValues) {
|
||||
super.firstUpdated(changedProperties);
|
||||
|
|
@ -31,11 +34,15 @@ export class SessionCard extends LitElement {
|
|||
this.startRefresh();
|
||||
}
|
||||
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
if (this.killingInterval) {
|
||||
clearInterval(this.killingInterval);
|
||||
}
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose();
|
||||
this.renderer = null;
|
||||
|
|
@ -47,7 +54,7 @@ export class SessionCard extends LitElement {
|
|||
if (!playerElement) return;
|
||||
|
||||
// Create single renderer for this card
|
||||
this.renderer = new Renderer(playerElement, 40, 12, 10000, 6, true);
|
||||
this.renderer = new Renderer(playerElement, 80, 24, 10000, 4, true);
|
||||
|
||||
// Always use snapshot endpoint for cards
|
||||
const url = `/api/sessions/${this.session.id}/snapshot`;
|
||||
|
|
@ -61,6 +68,12 @@ export class SessionCard extends LitElement {
|
|||
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);
|
||||
// Force fit after loading to ensure proper scaling in card
|
||||
setTimeout(() => {
|
||||
if (this.renderer) {
|
||||
this.renderer.fit();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
|
@ -72,6 +85,12 @@ export class SessionCard extends LitElement {
|
|||
this.renderer.loadFromUrl(url, false);
|
||||
// Ensure pointer events stay disabled after refresh
|
||||
this.renderer.setPointerEventsEnabled(false);
|
||||
// Force fit after refresh to maintain proper scaling
|
||||
setTimeout(() => {
|
||||
if (this.renderer) {
|
||||
this.renderer.fit();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, 10000); // Refresh every 10 seconds
|
||||
}
|
||||
|
|
@ -84,14 +103,48 @@ export class SessionCard extends LitElement {
|
|||
}));
|
||||
}
|
||||
|
||||
private handleKillClick(e: Event) {
|
||||
private async handleKillClick(e: Event) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.dispatchEvent(new CustomEvent('session-kill', {
|
||||
detail: this.session.id,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
|
||||
// Start killing animation
|
||||
this.killing = true;
|
||||
this.killingFrame = 0;
|
||||
this.killingInterval = window.setInterval(() => {
|
||||
this.killingFrame = (this.killingFrame + 1) % 4;
|
||||
this.requestUpdate();
|
||||
}, 200);
|
||||
|
||||
// Send kill request
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${this.session.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to kill session');
|
||||
// Stop animation on error
|
||||
this.stopKillingAnimation();
|
||||
}
|
||||
// Note: We don't stop the animation on success - let the session list refresh handle it
|
||||
} catch (error) {
|
||||
console.error('Error killing session:', error);
|
||||
// Stop animation on error
|
||||
this.stopKillingAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
private stopKillingAnimation() {
|
||||
this.killing = false;
|
||||
if (this.killingInterval) {
|
||||
clearInterval(this.killingInterval);
|
||||
this.killingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private getKillingText(): string {
|
||||
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
return frames[this.killingFrame % frames.length];
|
||||
}
|
||||
|
||||
private async handlePidClick(e: Event) {
|
||||
|
|
@ -129,7 +182,7 @@ export class SessionCard extends LitElement {
|
|||
const isRunning = this.session.status === 'running';
|
||||
|
||||
return html`
|
||||
<div class="bg-vs-bg border border-vs-border rounded shadow cursor-pointer overflow-hidden"
|
||||
<div class="bg-vs-bg border border-vs-border rounded shadow cursor-pointer overflow-hidden ${this.killing ? 'opacity-60' : ''}"
|
||||
@click=${this.handleCardClick}>
|
||||
<!-- Compact Header -->
|
||||
<div class="flex justify-between items-center px-3 py-2 border-b border-vs-border">
|
||||
|
|
@ -138,15 +191,25 @@ export class SessionCard extends LitElement {
|
|||
<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}
|
||||
?disabled=${this.killing}
|
||||
>
|
||||
${this.session.status === 'running' ? 'kill' : 'clean'}
|
||||
${this.killing ? 'killing...' : 'kill'}
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- XTerm renderer (main content) -->
|
||||
<div class="session-preview bg-black flex items-center justify-center overflow-hidden" style="aspect-ratio: 640/480;">
|
||||
<div id="player" class="w-full h-full overflow-hidden"></div>
|
||||
<div class="session-preview bg-black overflow-hidden" style="aspect-ratio: 640/480;">
|
||||
${this.killing ? html`
|
||||
<div class="w-full h-full flex items-center justify-center text-vs-warning">
|
||||
<div class="text-center font-mono">
|
||||
<div class="text-4xl mb-2">${this.getKillingText()}</div>
|
||||
<div class="text-sm">Killing session...</div>
|
||||
</div>
|
||||
</div>
|
||||
` : html`
|
||||
<div id="player" class="w-full h-full"></div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Compact Footer -->
|
||||
|
|
@ -161,7 +224,7 @@ export class SessionCard extends LitElement {
|
|||
@click=${this.handlePidClick}
|
||||
title="Click to copy PID"
|
||||
>
|
||||
PID: ${this.session.pid}
|
||||
PID: ${this.session.pid} <span class="opacity-50">(click to copy)</span>
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { LitElement, html } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import './session-create-form.js';
|
||||
import './session-card.js';
|
||||
|
||||
|
|
@ -26,7 +27,6 @@ export class SessionList extends LitElement {
|
|||
@property({ type: Boolean }) hideExited = true;
|
||||
@property({ type: Boolean }) showCreateModal = false;
|
||||
|
||||
@state() private killingSessionIds = new Set<string>();
|
||||
@state() private cleaningExited = false;
|
||||
|
||||
private handleRefresh() {
|
||||
|
|
@ -34,38 +34,10 @@ export class SessionList extends LitElement {
|
|||
}
|
||||
|
||||
private handleSessionSelect(e: CustomEvent) {
|
||||
this.dispatchEvent(new CustomEvent('session-select', {
|
||||
detail: e.detail,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
const session = e.detail as Session;
|
||||
window.location.search = `?session=${session.id}`;
|
||||
}
|
||||
|
||||
private async handleSessionKill(e: CustomEvent) {
|
||||
const sessionId = e.detail;
|
||||
if (this.killingSessionIds.has(sessionId)) return;
|
||||
|
||||
this.killingSessionIds.add(sessionId);
|
||||
this.requestUpdate();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${sessionId}/kill`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCleanupExited() {
|
||||
if (this.cleaningExited) return;
|
||||
|
|
@ -138,11 +110,10 @@ export class SessionList extends LitElement {
|
|||
</div>
|
||||
` : html`
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
${filteredSessions.map(session => html`
|
||||
${repeat(filteredSessions, (session) => session.id, (session) => html`
|
||||
<session-card
|
||||
.session=${session}
|
||||
@session-select=${this.handleSessionSelect}
|
||||
@session-kill=${this.handleSessionKill}>
|
||||
@session-select=${this.handleSessionSelect}>
|
||||
</session-card>
|
||||
`)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ export class SessionView extends LitElement {
|
|||
@state() private isMobile = false;
|
||||
@state() private touchStartX = 0;
|
||||
@state() private touchStartY = 0;
|
||||
@state() private loading = false;
|
||||
@state() private loadingFrame = 0;
|
||||
|
||||
private loadingInterval: number | null = null;
|
||||
private keyboardListenerAdded = false;
|
||||
private touchListenersAdded = false;
|
||||
|
||||
private keyboardHandler = (e: KeyboardEvent) => {
|
||||
if (!this.session) return;
|
||||
|
|
@ -62,17 +68,24 @@ export class SessionView extends LitElement {
|
|||
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) {
|
||||
// Only add listeners if not already added
|
||||
if (!this.isMobile && !this.keyboardListenerAdded) {
|
||||
document.addEventListener('keydown', this.keyboardHandler);
|
||||
} else {
|
||||
this.keyboardListenerAdded = true;
|
||||
} else if (this.isMobile && !this.touchListenersAdded) {
|
||||
// Add touch event listeners for mobile swipe gestures
|
||||
document.addEventListener('touchstart', this.touchStartHandler, { passive: true });
|
||||
document.addEventListener('touchend', this.touchEndHandler, { passive: true });
|
||||
this.touchListenersAdded = true;
|
||||
}
|
||||
|
||||
// Start polling session status
|
||||
|
|
@ -84,17 +97,22 @@ export class SessionView extends LitElement {
|
|||
this.connected = false;
|
||||
|
||||
// Remove global keyboard event listener
|
||||
if (!this.isMobile) {
|
||||
if (!this.isMobile && this.keyboardListenerAdded) {
|
||||
document.removeEventListener('keydown', this.keyboardHandler);
|
||||
} else {
|
||||
this.keyboardListenerAdded = false;
|
||||
} else if (this.isMobile && this.touchListenersAdded) {
|
||||
// Remove touch event listeners
|
||||
document.removeEventListener('touchstart', this.touchStartHandler);
|
||||
document.removeEventListener('touchend', this.touchEndHandler);
|
||||
this.touchListenersAdded = false;
|
||||
}
|
||||
|
||||
// Stop polling session status
|
||||
this.stopSessionStatusPolling();
|
||||
|
||||
// Stop loading animation
|
||||
this.stopLoading();
|
||||
|
||||
// Cleanup renderer if it exists
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose();
|
||||
|
|
@ -104,12 +122,21 @@ export class SessionView extends LitElement {
|
|||
|
||||
firstUpdated(changedProperties: PropertyValues) {
|
||||
super.firstUpdated(changedProperties);
|
||||
this.createInteractiveTerminal();
|
||||
if (this.session) {
|
||||
this.stopLoading();
|
||||
this.createInteractiveTerminal();
|
||||
}
|
||||
}
|
||||
|
||||
updated(changedProperties: any) {
|
||||
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(() => {
|
||||
|
|
@ -131,8 +158,14 @@ export class SessionView extends LitElement {
|
|||
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);
|
||||
|
|
@ -241,7 +274,7 @@ export class SessionView extends LitElement {
|
|||
}
|
||||
|
||||
private handleBack() {
|
||||
this.dispatchEvent(new CustomEvent('back'));
|
||||
window.location.search = '';
|
||||
}
|
||||
|
||||
private handleSessionExit(e: Event) {
|
||||
|
|
@ -446,6 +479,28 @@ export class SessionView extends LitElement {
|
|||
// The mobile buttons will overlay the terminal
|
||||
}
|
||||
|
||||
private 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
|
||||
}
|
||||
|
||||
private stopLoading() {
|
||||
this.loading = false;
|
||||
if (this.loadingInterval) {
|
||||
clearInterval(this.loadingInterval);
|
||||
this.loadingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private getLoadingText(): string {
|
||||
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
return frames[this.loadingFrame % frames.length];
|
||||
}
|
||||
|
||||
private startSessionStatusPolling() {
|
||||
if (this.sessionStatusInterval) {
|
||||
clearInterval(this.sessionStatusInterval);
|
||||
|
|
@ -531,8 +586,18 @@ export class SessionView extends LitElement {
|
|||
</div>
|
||||
|
||||
<!-- Terminal Container -->
|
||||
<div class="flex-1 bg-black overflow-x-auto overflow-y-hidden min-h-0" id="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 -->
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export class Renderer {
|
|||
|
||||
// Add addons
|
||||
this.fitAddon = new FitAddon();
|
||||
this.scaleFitAddon = new ScaleFitAddon();
|
||||
this.scaleFitAddon = new ScaleFitAddon(isPreview);
|
||||
this.webLinksAddon = new WebLinksAddon();
|
||||
|
||||
this.terminal.loadAddon(this.fitAddon);
|
||||
|
|
@ -91,9 +91,19 @@ export class Renderer {
|
|||
private setupDOM(): void {
|
||||
// Clear container and add CSS
|
||||
this.container.innerHTML = '';
|
||||
this.container.style.padding = '10px';
|
||||
this.container.style.backgroundColor = '#1e1e1e';
|
||||
this.container.style.overflow = 'hidden';
|
||||
|
||||
// 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');
|
||||
|
|
@ -184,10 +194,8 @@ export class Renderer {
|
|||
}
|
||||
|
||||
resize(width: number, height: number): void {
|
||||
if (this.isPreview) {
|
||||
// For previews, resize to session dimensions then apply scaling
|
||||
this.terminal.resize(width, height);
|
||||
}
|
||||
// Resize terminal to session dimensions
|
||||
this.terminal.resize(width, height);
|
||||
// Always use ScaleFitAddon for consistent scaling behavior
|
||||
this.scaleFitAddon.fit();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ const MAX_FONT_SIZE = 16;
|
|||
|
||||
export class ScaleFitAddon implements ITerminalAddon {
|
||||
private _terminal: Terminal | undefined;
|
||||
private _isPreview: boolean;
|
||||
|
||||
constructor(isPreview: boolean = false) {
|
||||
this._isPreview = isPreview;
|
||||
}
|
||||
|
||||
public activate(terminal: Terminal): void {
|
||||
this._terminal = terminal;
|
||||
|
|
@ -24,14 +29,20 @@ export class ScaleFitAddon implements ITerminalAddon {
|
|||
public dispose(): void {}
|
||||
|
||||
public fit(): void {
|
||||
const dims = this.proposeDimensions();
|
||||
if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) {
|
||||
return;
|
||||
}
|
||||
if (this._isPreview) {
|
||||
// For previews, only scale font size, don't change terminal dimensions
|
||||
this.scaleFontOnly();
|
||||
} else {
|
||||
// For full terminals, resize both font and dimensions
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -66,7 +77,8 @@ export class ScaleFitAddon implements ITerminalAddon {
|
|||
|
||||
// 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;
|
||||
// Use a slightly smaller ratio for better fitting in constrained spaces
|
||||
const charWidthRatio = 0.55;
|
||||
const calculatedFontSize = availableWidth / (currentCols * charWidthRatio);
|
||||
const optimalFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
|
||||
|
||||
|
|
@ -78,8 +90,11 @@ export class ScaleFitAddon implements ITerminalAddon {
|
|||
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));
|
||||
// XTerm typically uses a line height of around 1.0 for the character cell height
|
||||
// Use a more accurate fallback based on XTerm's actual behavior
|
||||
const lineHeight = (actualLineHeight && !isNaN(actualLineHeight)) ?
|
||||
actualLineHeight :
|
||||
(optimalFontSize * (this._terminal.options.lineHeight || 1.0));
|
||||
|
||||
// Calculate how many rows fit with this line height
|
||||
const optimalRows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / lineHeight));
|
||||
|
|
@ -116,6 +131,33 @@ export class ScaleFitAddon implements ITerminalAddon {
|
|||
/**
|
||||
* Get the calculated font size that would fit the current columns in the container
|
||||
*/
|
||||
private scaleFontOnly(): void {
|
||||
if (!this._terminal?.element?.parentElement) return;
|
||||
|
||||
// Get container dimensions for font scaling
|
||||
const terminalWrapper = this._terminal.element.parentElement;
|
||||
const rendererContainer = terminalWrapper.parentElement;
|
||||
if (!rendererContainer) return;
|
||||
|
||||
const containerStyle = window.getComputedStyle(rendererContainer);
|
||||
const containerWidth = parseInt(containerStyle.getPropertyValue('width'));
|
||||
const containerPadding = {
|
||||
left: parseInt(containerStyle.getPropertyValue('padding-left')),
|
||||
right: parseInt(containerStyle.getPropertyValue('padding-right'))
|
||||
};
|
||||
|
||||
const availableWidth = containerWidth - containerPadding.left - containerPadding.right;
|
||||
const currentCols = this._terminal.cols;
|
||||
|
||||
// Calculate font size to fit columns in available width
|
||||
const charWidthRatio = 0.55;
|
||||
const calculatedFontSize = availableWidth / (currentCols * charWidthRatio);
|
||||
const optimalFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
|
||||
|
||||
// Apply the font size without changing terminal dimensions
|
||||
this.applyFontSize(optimalFontSize);
|
||||
}
|
||||
|
||||
public getOptimalFontSize(): number {
|
||||
if (!this._terminal?.element?.parentElement) {
|
||||
return this._terminal?.options.fontSize || 14;
|
||||
|
|
|
|||
|
|
@ -750,6 +750,67 @@ app.get('/api/fs/browse', (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// 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.join(os.homedir(), dirPath.slice(1))
|
||||
: path.resolve(dirPath);
|
||||
|
||||
// Security check: ensure we're not trying to access outside allowed areas
|
||||
const allowedBasePaths = [os.homedir(), process.cwd()];
|
||||
const isAllowed = allowedBasePaths.some(basePath =>
|
||||
expandedPath.startsWith(path.resolve(basePath))
|
||||
);
|
||||
|
||||
if (!isAllowed) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
// Check if parent directory exists
|
||||
if (!fs.existsSync(expandedPath)) {
|
||||
return res.status(404).json({ error: 'Parent directory not found' });
|
||||
}
|
||||
|
||||
const stats = fs.statSync(expandedPath);
|
||||
if (!stats.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Parent path is not a directory' });
|
||||
}
|
||||
|
||||
const newDirPath = path.join(expandedPath, name);
|
||||
|
||||
// Check if directory already exists
|
||||
if (fs.existsSync(newDirPath)) {
|
||||
return res.status(409).json({ error: 'Directory already exists' });
|
||||
}
|
||||
|
||||
// Create the directory
|
||||
fs.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
|
||||
|
|
|
|||
Loading…
Reference in a new issue