mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
lint web
This commit is contained in:
parent
12cef6f5c8
commit
bb20c3a833
17 changed files with 2730 additions and 1184 deletions
8
web/.prettierignore
Normal file
8
web/.prettierignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
dist/
|
||||
public/
|
||||
node_modules/
|
||||
coverage/
|
||||
.next/
|
||||
build/
|
||||
*.min.js
|
||||
*.min.css
|
||||
11
web/.prettierrc.json
Normal file
11
web/.prettierrc.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
41
web/eslint.config.js
Normal file
41
web/eslint.config.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
const eslint = require('@eslint/js');
|
||||
const tseslint = require('typescript-eslint');
|
||||
const prettierConfig = require('eslint-config-prettier');
|
||||
const prettierPlugin = require('eslint-plugin-prettier');
|
||||
|
||||
module.exports = tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
prettierConfig,
|
||||
{
|
||||
ignores: ['dist/**', 'public/**', 'node_modules/**', '*.js', '!eslint.config.js'],
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.ts', 'src/**/*.tsx'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json', './tsconfig.client.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
prettier: prettierPlugin,
|
||||
},
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
},
|
||||
}
|
||||
);
|
||||
1318
web/package-lock.json
generated
1318
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -18,7 +18,11 @@
|
|||
"bundle:watch": "concurrently \"npm run bundle:client -- --watch\" \"npm run bundle:renderer -- --watch\"",
|
||||
"start": "node dist/server.js",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch"
|
||||
"test:watch": "jest --watch",
|
||||
"lint": "eslint 'src/**/*.{ts,tsx}'",
|
||||
"lint:fix": "eslint 'src/**/*.{ts,tsx}' --fix",
|
||||
"format": "prettier --write 'src/**/*.{ts,tsx,js,jsx,json,css,md}'",
|
||||
"format:check": "prettier --check 'src/**/*.{ts,tsx,js,jsx,json,css,md}'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
|
|
@ -31,23 +35,31 @@
|
|||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.29.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.34.1",
|
||||
"@typescript-eslint/parser": "^8.34.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"chokidar": "^3.5.3",
|
||||
"concurrently": "^8.2.2",
|
||||
"esbuild": "^0.25.5",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-prettier": "^5.4.1",
|
||||
"jest": "^29.7.0",
|
||||
"postcss": "^8.5.5",
|
||||
"prettier": "^3.5.3",
|
||||
"puppeteer": "^21.0.0",
|
||||
"supertest": "^6.3.4",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"ts-jest": "^29.1.2",
|
||||
"tsx": "^4.6.2",
|
||||
"typescript": "^5.3.3"
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.34.1"
|
||||
},
|
||||
"keywords": [
|
||||
"terminal",
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export class VibeTunnelApp extends LitElement {
|
|||
exitCode: session.exitCode,
|
||||
startedAt: session.startedAt,
|
||||
lastModified: session.lastModified,
|
||||
pid: session.pid
|
||||
pid: session.pid,
|
||||
}));
|
||||
this.clearError();
|
||||
} else {
|
||||
|
|
@ -116,13 +116,13 @@ export class VibeTunnelApp extends LitElement {
|
|||
await this.loadSessions();
|
||||
|
||||
// Try to find by exact ID match first
|
||||
let session = this.sessions.find(s => s.id === sessionId);
|
||||
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()
|
||||
const sortedSessions = [...this.sessions].sort(
|
||||
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
|
||||
);
|
||||
session = sortedSessions[0];
|
||||
}
|
||||
|
|
@ -134,7 +134,7 @@ export class VibeTunnelApp extends LitElement {
|
|||
}
|
||||
|
||||
// Wait before next attempt
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
// If we get here, session creation might have failed
|
||||
|
|
@ -142,8 +142,6 @@ export class VibeTunnelApp extends LitElement {
|
|||
this.showError('Session created but could not be found. Please refresh.');
|
||||
}
|
||||
|
||||
|
||||
|
||||
private handleSessionKilled(e: CustomEvent) {
|
||||
console.log('Session killed:', e.detail);
|
||||
this.loadSessions(); // Refresh the list
|
||||
|
|
@ -198,10 +196,10 @@ export class VibeTunnelApp extends LitElement {
|
|||
this.parseUrlAndSetState();
|
||||
}
|
||||
|
||||
private handlePopState = (event: PopStateEvent) => {
|
||||
private handlePopState = (_event: PopStateEvent) => {
|
||||
// Handle browser back/forward navigation
|
||||
this.parseUrlAndSetState();
|
||||
}
|
||||
};
|
||||
|
||||
private parseUrlAndSetState() {
|
||||
const url = new URL(window.location.href);
|
||||
|
|
@ -251,22 +249,30 @@ export class VibeTunnelApp extends LitElement {
|
|||
render() {
|
||||
return html`
|
||||
<!-- Error notification overlay -->
|
||||
${this.errorMessage ? html`
|
||||
${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>
|
||||
<button @click=${this.clearError} class="ml-2 text-vs-bg hover:text-vs-muted">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`
|
||||
: ''}
|
||||
|
||||
<!-- Main content -->
|
||||
${this.currentView === 'session' && this.selectedSessionId ?
|
||||
keyed(this.selectedSessionId, html`
|
||||
${this.currentView === 'session' && this.selectedSessionId
|
||||
? keyed(
|
||||
this.selectedSessionId,
|
||||
html`
|
||||
<session-view
|
||||
.session=${this.sessions.find(s => s.id === this.selectedSessionId)}
|
||||
.session=${this.sessions.find((s) => s.id === this.selectedSessionId)}
|
||||
></session-view>
|
||||
`) : html`
|
||||
`
|
||||
)
|
||||
: html`
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<app-header
|
||||
.sessions=${this.sessions}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export class AppHeader extends LitElement {
|
|||
}
|
||||
|
||||
render() {
|
||||
const runningSessions = this.sessions.filter(session => session.status === 'running');
|
||||
const runningSessions = this.sessions.filter((session) => session.status === 'running');
|
||||
|
||||
// Reset killing state if no more running sessions
|
||||
if (this.killingAll && runningSessions.length === 0) {
|
||||
|
|
@ -44,34 +44,52 @@ export class AppHeader extends LitElement {
|
|||
<div class="flex items-center justify-between">
|
||||
<div class="text-vs-user font-mono text-sm">VibeTunnel</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-2 text-vs-text text-sm cursor-pointer hover:text-vs-accent transition-colors">
|
||||
<label
|
||||
class="flex items-center gap-2 text-vs-text text-sm cursor-pointer hover:text-vs-accent transition-colors"
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="sr-only"
|
||||
.checked=${this.hideExited}
|
||||
@change=${(e: Event) => this.dispatchEvent(new CustomEvent('hide-exited-change', { detail: (e.target as HTMLInputElement).checked }))}
|
||||
@change=${(e: Event) =>
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('hide-exited-change', {
|
||||
detail: (e.target as HTMLInputElement).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'}"
|
||||
>
|
||||
<div class="w-4 h-4 border border-vs-border rounded bg-vs-bg-secondary flex items-center justify-center transition-all ${
|
||||
this.hideExited ? 'bg-vs-user border-vs-user' : 'hover:border-vs-accent'
|
||||
}">
|
||||
${this.hideExited ? html`
|
||||
${this.hideExited
|
||||
? html`
|
||||
<svg class="w-3 h-3 text-vs-bg" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
` : ''}
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
hide exited
|
||||
</label>
|
||||
${runningSessions.length > 0 && !this.killingAll ? html`
|
||||
${runningSessions.length > 0 && !this.killingAll
|
||||
? html`
|
||||
<button
|
||||
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-4 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${this.handleKillAll}
|
||||
>
|
||||
KILL ALL (${runningSessions.length})
|
||||
</button>
|
||||
` : ''}
|
||||
`
|
||||
: ''}
|
||||
<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}
|
||||
|
|
|
|||
|
|
@ -72,9 +72,11 @@ export class FileBrowser extends LitElement {
|
|||
}
|
||||
|
||||
private handleSelect() {
|
||||
this.dispatchEvent(new CustomEvent('directory-selected', {
|
||||
detail: this.currentPath
|
||||
}));
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('directory-selected', {
|
||||
detail: this.currentPath,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private handleCancel() {
|
||||
|
|
@ -113,12 +115,12 @@ export class FileBrowser extends LitElement {
|
|||
const response = await fetch('/api/mkdir', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: this.currentPath,
|
||||
name: this.newFolderName.trim()
|
||||
})
|
||||
name: this.newFolderName.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
|
|
@ -143,8 +145,13 @@ export class FileBrowser extends LitElement {
|
|||
}
|
||||
|
||||
return html`
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style="z-index: 9999;">
|
||||
<div class="bg-vs-bg-secondary border border-vs-border font-mono text-sm w-96 h-96 flex flex-col">
|
||||
<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>
|
||||
|
|
@ -161,10 +168,11 @@ export class FileBrowser extends LitElement {
|
|||
</div>
|
||||
|
||||
<div class="p-4 flex-1 overflow-y-auto">
|
||||
${this.loading ? html`
|
||||
<div class="text-vs-muted">Loading...</div>
|
||||
` : html`
|
||||
${this.currentPath !== '/' ? html`
|
||||
${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}
|
||||
|
|
@ -172,9 +180,12 @@ export class FileBrowser extends LitElement {
|
|||
<span>📁</span>
|
||||
<span>.. (parent directory)</span>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this.files.filter(f => f.isDir).map(file => html`
|
||||
`
|
||||
: ''}
|
||||
${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)}
|
||||
|
|
@ -182,19 +193,24 @@ export class FileBrowser extends LitElement {
|
|||
<span>📁</span>
|
||||
<span>${file.name}</span>
|
||||
</div>
|
||||
`)}
|
||||
|
||||
${this.files.filter(f => !f.isDir).map(file => html`
|
||||
`
|
||||
)}
|
||||
${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`
|
||||
${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">
|
||||
|
|
@ -223,7 +239,8 @@ export class FileBrowser extends LitElement {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`
|
||||
: ''}
|
||||
|
||||
<div class="p-4 border-t border-vs-border flex gap-4 justify-end flex-shrink-0">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { LitElement, html, css, PropertyValues } from 'lit';
|
||||
import { LitElement, html, PropertyValues } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { Renderer } from '../renderer.js';
|
||||
|
||||
|
|
@ -35,7 +35,6 @@ export class SessionCard extends LitElement {
|
|||
this.startRefresh();
|
||||
}
|
||||
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.refreshInterval) {
|
||||
|
|
@ -97,11 +96,13 @@ export class SessionCard extends LitElement {
|
|||
}
|
||||
|
||||
private handleCardClick() {
|
||||
this.dispatchEvent(new CustomEvent('session-select', {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('session-select', {
|
||||
detail: this.session,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async handleKillClick(e: Event) {
|
||||
|
|
@ -119,7 +120,7 @@ export class SessionCard extends LitElement {
|
|||
// Send kill request
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${this.session.id}`, {
|
||||
method: 'DELETE'
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -180,15 +181,23 @@ export class SessionCard extends LitElement {
|
|||
}
|
||||
|
||||
render() {
|
||||
const isRunning = this.session.status === 'running';
|
||||
const _isRunning = this.session.status === 'running';
|
||||
|
||||
return html`
|
||||
<div class="bg-vs-bg border border-vs-border rounded shadow cursor-pointer overflow-hidden ${this.killing ? 'opacity-60' : ''}"
|
||||
@click=${this.handleCardClick}>
|
||||
<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">
|
||||
<div class="text-vs-text text-xs font-mono truncate pr-2 flex-1">${this.session.command}</div>
|
||||
${this.session.status === 'running' ? html`
|
||||
<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}
|
||||
|
|
@ -196,21 +205,22 @@ export class SessionCard extends LitElement {
|
|||
>
|
||||
${this.killing ? 'killing...' : 'kill'}
|
||||
</button>
|
||||
` : ''}
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
|
||||
<!-- XTerm renderer (main content) -->
|
||||
<div class="session-preview bg-black overflow-hidden" style="aspect-ratio: 640/480;">
|
||||
${this.killing ? html`
|
||||
${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>
|
||||
`}
|
||||
`
|
||||
: html` <div id="player" class="w-full h-full"></div> `}
|
||||
</div>
|
||||
|
||||
<!-- Compact Footer -->
|
||||
|
|
@ -220,7 +230,8 @@ export class SessionCard extends LitElement {
|
|||
<div class="w-2 h-2 rounded-full ${this.getStatusDotColor()}"></div>
|
||||
${this.getStatusText()}
|
||||
</span>
|
||||
${this.session.pid ? html`
|
||||
${this.session.pid
|
||||
? html`
|
||||
<span
|
||||
class="cursor-pointer hover:text-vs-accent transition-colors"
|
||||
@click=${this.handlePidClick}
|
||||
|
|
@ -228,9 +239,12 @@ export class SessionCard extends LitElement {
|
|||
>
|
||||
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 class="truncate text-xs opacity-75" title="${this.session.workingDir}">${this.session.workingDir}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -256,5 +270,4 @@ export class SessionCard extends LitElement {
|
|||
}
|
||||
return this.session.status === 'running' ? 'bg-green-500' : 'bg-orange-500';
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -83,9 +83,11 @@ export class SessionCreateForm extends LitElement {
|
|||
private handleWorkingDirChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
this.workingDir = input.value;
|
||||
this.dispatchEvent(new CustomEvent('working-dir-change', {
|
||||
detail: this.workingDir
|
||||
}));
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('working-dir-change', {
|
||||
detail: this.workingDir,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private handleCommandChange(e: Event) {
|
||||
|
|
@ -108,9 +110,11 @@ export class SessionCreateForm extends LitElement {
|
|||
|
||||
private async handleCreate() {
|
||||
if (!this.workingDir.trim() || !this.command.trim()) {
|
||||
this.dispatchEvent(new CustomEvent('error', {
|
||||
detail: 'Please fill in both working directory and command'
|
||||
}));
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('error', {
|
||||
detail: 'Please fill in both working directory and command',
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -118,14 +122,14 @@ export class SessionCreateForm extends LitElement {
|
|||
|
||||
const sessionData: SessionCreateData = {
|
||||
command: this.parseCommand(this.command.trim()),
|
||||
workingDir: this.workingDir.trim()
|
||||
workingDir: this.workingDir.trim(),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sessionData)
|
||||
body: JSON.stringify(sessionData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
|
|
@ -135,20 +139,26 @@ export class SessionCreateForm extends LitElement {
|
|||
this.saveToLocalStorage();
|
||||
|
||||
this.command = ''; // Clear command on success
|
||||
this.dispatchEvent(new CustomEvent('session-created', {
|
||||
detail: result
|
||||
}));
|
||||
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}`
|
||||
}));
|
||||
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'
|
||||
}));
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('error', {
|
||||
detail: 'Failed to create session',
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
this.isCreating = false;
|
||||
}
|
||||
|
|
@ -197,18 +207,24 @@ export class SessionCreateForm extends LitElement {
|
|||
}
|
||||
|
||||
return html`
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style="z-index: 9999;">
|
||||
<div class="bg-vs-bg-secondary border border-vs-border font-mono text-sm w-96 max-w-full mx-4">
|
||||
<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>
|
||||
>
|
||||
×
|
||||
</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">
|
||||
|
|
@ -254,7 +270,10 @@ export class SessionCreateForm extends LitElement {
|
|||
<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()}
|
||||
?disabled=${this.disabled ||
|
||||
this.isCreating ||
|
||||
!this.workingDir.trim() ||
|
||||
!this.command.trim()}
|
||||
>
|
||||
${this.isCreating ? 'creating...' : 'create'}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ export class SessionList extends LitElement {
|
|||
window.location.search = `?session=${session.id}`;
|
||||
}
|
||||
|
||||
|
||||
private async handleCleanupExited() {
|
||||
if (this.cleaningExited) return;
|
||||
|
||||
|
|
@ -48,13 +47,15 @@ export class SessionList extends LitElement {
|
|||
|
||||
try {
|
||||
const response = await fetch('/api/cleanup-exited', {
|
||||
method: 'POST'
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
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: 'Failed to cleanup exited sessions' })
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up exited sessions:', error);
|
||||
|
|
@ -65,16 +66,16 @@ export class SessionList extends LitElement {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
const filteredSessions = this.hideExited
|
||||
? this.sessions.filter(session => session.status !== 'exited')
|
||||
? this.sessions.filter((session) => session.status !== 'exited')
|
||||
: this.sessions;
|
||||
|
||||
return html`
|
||||
<div class="font-mono text-sm p-4">
|
||||
<!-- Controls -->
|
||||
${!this.hideExited && this.sessions.filter(s => s.status === 'exited').length > 0 ? html`
|
||||
${!this.hideExited && this.sessions.filter((s) => s.status === 'exited').length > 0
|
||||
? html`
|
||||
<div class="mb-4">
|
||||
<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"
|
||||
|
|
@ -84,27 +85,38 @@ export class SessionList extends LitElement {
|
|||
${this.cleaningExited ? '[~] CLEANING...' : 'CLEAN EXITED'}
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
${filteredSessions.length === 0 ? html`
|
||||
`
|
||||
: ''}
|
||||
${filteredSessions.length === 0
|
||||
? html`
|
||||
<div class="text-vs-muted text-center py-8">
|
||||
${this.loading ? 'Loading sessions...' : (this.hideExited && this.sessions.length > 0 ? 'No running sessions' : 'No sessions found')}
|
||||
${this.loading
|
||||
? 'Loading sessions...'
|
||||
: this.hideExited && this.sessions.length > 0
|
||||
? 'No running sessions'
|
||||
: 'No sessions found'}
|
||||
</div>
|
||||
` : html`
|
||||
`
|
||||
: html`
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
${repeat(filteredSessions, (session) => session.id, (session) => html`
|
||||
<session-card
|
||||
.session=${session}
|
||||
@session-select=${this.handleSessionSelect}>
|
||||
${repeat(
|
||||
filteredSessions,
|
||||
(session) => session.id,
|
||||
(session) => html`
|
||||
<session-card .session=${session} @session-select=${this.handleSessionSelect}>
|
||||
</session-card>
|
||||
`)}
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
|
||||
<session-create-form
|
||||
.visible=${this.showCreateModal}
|
||||
@session-created=${(e: CustomEvent) => this.dispatchEvent(new CustomEvent('session-created', { detail: e.detail }))}
|
||||
@session-created=${(e: CustomEvent) =>
|
||||
this.dispatchEvent(new CustomEvent('session-created', { detail: e.detail }))}
|
||||
@cancel=${() => this.dispatchEvent(new CustomEvent('create-modal-close'))}
|
||||
@error=${(e: CustomEvent) => this.dispatchEvent(new CustomEvent('error', { detail: e.detail }))}
|
||||
@error=${(e: CustomEvent) =>
|
||||
this.dispatchEvent(new CustomEvent('error', { detail: e.detail }))}
|
||||
></session-create-form>
|
||||
</div>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -78,7 +78,8 @@ export class SessionView extends LitElement {
|
|||
}
|
||||
|
||||
// Detect mobile device
|
||||
this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
||||
this.isMobile =
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
||||
window.innerWidth <= 768;
|
||||
|
||||
// Only add listeners if not already added
|
||||
|
|
@ -91,7 +92,6 @@ export class SessionView extends LitElement {
|
|||
document.addEventListener('touchend', this.touchEndHandler, { passive: true });
|
||||
this.touchListenersAdded = true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
|
|
@ -113,7 +113,6 @@ export class SessionView extends LitElement {
|
|||
this.touchListenersAdded = false;
|
||||
}
|
||||
|
||||
|
||||
// Stop loading animation
|
||||
this.stopLoading();
|
||||
|
||||
|
|
@ -186,7 +185,10 @@ export class SessionView extends LitElement {
|
|||
}, delay);
|
||||
|
||||
// Listen for session exit events
|
||||
terminalElement.addEventListener('session-exit', this.handleSessionExit.bind(this) as EventListener);
|
||||
terminalElement.addEventListener(
|
||||
'session-exit',
|
||||
this.handleSessionExit.bind(this) as EventListener
|
||||
);
|
||||
}
|
||||
|
||||
private async handleKeyboardInput(e: KeyboardEvent) {
|
||||
|
|
@ -274,7 +276,8 @@ export class SessionView extends LitElement {
|
|||
// 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
|
||||
if (charCode >= 97 && charCode <= 122) {
|
||||
// a-z
|
||||
inputText = String.fromCharCode(charCode - 96); // Ctrl+A = \x01, etc.
|
||||
}
|
||||
}
|
||||
|
|
@ -284,9 +287,9 @@ export class SessionView extends LitElement {
|
|||
const response = await fetch(`/api/sessions/${this.session.id}/input`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text: inputText })
|
||||
body: JSON.stringify({ text: inputText }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -319,7 +322,6 @@ export class SessionView extends LitElement {
|
|||
this.session = { ...this.session, status: 'exited' };
|
||||
this.requestUpdate();
|
||||
|
||||
|
||||
// Switch to snapshot mode
|
||||
requestAnimationFrame(() => {
|
||||
this.createInteractiveTerminal();
|
||||
|
|
@ -366,7 +368,9 @@ export class SessionView extends LitElement {
|
|||
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') as HTMLElement;
|
||||
const header = this.querySelector(
|
||||
'.flex.items-center.justify-between.p-4.border-b'
|
||||
) as HTMLElement;
|
||||
const headerHeight = header?.offsetHeight || 60;
|
||||
const controlsHeight = controls?.offsetHeight || 120;
|
||||
const padding = 48; // Additional padding for spacing
|
||||
|
|
@ -501,7 +505,6 @@ export class SessionView extends LitElement {
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
private async handlePaste() {
|
||||
if (!this.session) return;
|
||||
|
||||
|
|
@ -556,7 +559,10 @@ export class SessionView extends LitElement {
|
|||
if (selectedText) {
|
||||
// Write the selected text to clipboard
|
||||
await navigator.clipboard.writeText(selectedText);
|
||||
console.log('Text copied to clipboard:', selectedText.substring(0, 50) + (selectedText.length > 50 ? '...' : ''));
|
||||
console.log(
|
||||
'Text copied to clipboard:',
|
||||
selectedText.substring(0, 50) + (selectedText.length > 50 ? '...' : '')
|
||||
);
|
||||
} else {
|
||||
console.log('No text selected for copying');
|
||||
}
|
||||
|
|
@ -577,7 +583,10 @@ export class SessionView extends LitElement {
|
|||
textArea.select();
|
||||
|
||||
if (document.execCommand('copy')) {
|
||||
console.log('Text copied to clipboard (fallback):', selectedText.substring(0, 50) + (selectedText.length > 50 ? '...' : ''));
|
||||
console.log(
|
||||
'Text copied to clipboard (fallback):',
|
||||
selectedText.substring(0, 50) + (selectedText.length > 50 ? '...' : '')
|
||||
);
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
|
|
@ -596,9 +605,9 @@ export class SessionView extends LitElement {
|
|||
const response = await fetch(`/api/sessions/${this.session.id}/input`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text })
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -636,30 +645,32 @@ export class SessionView extends LitElement {
|
|||
return frames[this.loadingFrame % frames.length];
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
if (!this.session) {
|
||||
return html`
|
||||
<div class="p-4 text-vs-muted">
|
||||
No session selected
|
||||
</div>
|
||||
`;
|
||||
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 {
|
||||
session-view *,
|
||||
session-view *:focus,
|
||||
session-view *:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
session-view:focus {
|
||||
outline: 2px solid #007ACC !important;
|
||||
outline: 2px solid #007acc !important;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
</style>
|
||||
<div class="flex flex-col bg-vs-bg font-mono" style="height: 100vh; height: 100dvh; outline: none !important; box-shadow: none !important;">
|
||||
<div
|
||||
class="flex flex-col bg-vs-bg font-mono"
|
||||
style="height: 100vh; height: 100dvh; 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 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"
|
||||
|
|
@ -680,20 +691,29 @@ export class SessionView extends LitElement {
|
|||
</div>
|
||||
|
||||
<!-- Terminal Container -->
|
||||
<div class="flex-1 bg-black overflow-hidden min-h-0 relative" id="terminal-container" style="max-width: 100vw; height: 100%;">
|
||||
${this.loading ? html`
|
||||
<div
|
||||
class="flex-1 bg-black overflow-hidden min-h-0 relative"
|
||||
id="terminal-container"
|
||||
style="max-width: 100vw; height: 100%;"
|
||||
>
|
||||
${this.loading
|
||||
? html`
|
||||
<!-- Loading overlay -->
|
||||
<div class="absolute inset-0 bg-black bg-opacity-80 flex items-center justify-center">
|
||||
<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`
|
||||
${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">
|
||||
|
|
@ -757,13 +777,20 @@ export class SessionView extends LitElement {
|
|||
</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;">
|
||||
${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="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"
|
||||
|
|
@ -795,7 +822,11 @@ export class SessionView extends LitElement {
|
|||
</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);">
|
||||
<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
|
||||
|
|
@ -819,7 +850,8 @@ export class SessionView extends LitElement {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,13 @@ export class Renderer {
|
|||
private scaleFitAddon: ScaleFitAddon;
|
||||
private webLinksAddon: WebLinksAddon;
|
||||
|
||||
constructor(container: HTMLElement, width: number = 80, height: number = 20, scrollback: number = 1000000, fontSize: number = 14) {
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
width: number = 80,
|
||||
height: number = 20,
|
||||
scrollback: number = 1000000,
|
||||
fontSize: number = 14
|
||||
) {
|
||||
Renderer.activeCount++;
|
||||
console.log(`Renderer constructor called (active: ${Renderer.activeCount})`);
|
||||
this.container = container;
|
||||
|
|
@ -62,7 +68,7 @@ export class Renderer {
|
|||
brightBlue: '#3b8eea',
|
||||
brightMagenta: '#d670d6',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#ffffff'
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
allowProposedApi: true,
|
||||
scrollback: scrollback, // Configurable scrollback buffer
|
||||
|
|
@ -73,7 +79,7 @@ export class Renderer {
|
|||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
cursorWidth: 1,
|
||||
cursorInactiveStyle: "block"
|
||||
cursorInactiveStyle: 'block',
|
||||
});
|
||||
|
||||
// Add addons
|
||||
|
|
@ -116,7 +122,9 @@ export class Renderer {
|
|||
// Ensure cursor is visible without focus by forcing it to stay active
|
||||
requestAnimationFrame(() => {
|
||||
if (this.terminal.element) {
|
||||
const helperTextarea = this.terminal.element.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement;
|
||||
const helperTextarea = this.terminal.element.querySelector(
|
||||
'.xterm-helper-textarea'
|
||||
) as HTMLTextAreaElement;
|
||||
if (helperTextarea) {
|
||||
// Prevent the helper textarea from stealing focus but allow cursor rendering
|
||||
helperTextarea.addEventListener('focus', (e) => {
|
||||
|
|
@ -142,7 +150,6 @@ export class Renderer {
|
|||
resizeObserver.observe(this.container);
|
||||
}
|
||||
|
||||
|
||||
// Public API methods - maintain compatibility with custom renderer
|
||||
|
||||
async loadCastFile(url: string): Promise<void> {
|
||||
|
|
@ -166,14 +173,14 @@ export class Renderer {
|
|||
|
||||
if (parsed.version && parsed.width && parsed.height) {
|
||||
// Header
|
||||
header = parsed;
|
||||
// header = parsed;
|
||||
this.resize(parsed.width, parsed.height);
|
||||
} else if (Array.isArray(parsed) && parsed.length >= 3) {
|
||||
// Event: [timestamp, type, data]
|
||||
const event: CastEvent = {
|
||||
timestamp: parsed[0],
|
||||
type: parsed[1],
|
||||
data: parsed[2]
|
||||
data: parsed[2],
|
||||
};
|
||||
|
||||
if (event.type === 'o') {
|
||||
|
|
@ -182,7 +189,7 @@ export class Renderer {
|
|||
this.processResize(event.data);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
console.warn('Failed to parse cast line:', line);
|
||||
}
|
||||
}
|
||||
|
|
@ -259,7 +266,7 @@ export class Renderer {
|
|||
|
||||
// Dispatch custom event that session-view can listen to
|
||||
const exitEvent = new CustomEvent('session-exit', {
|
||||
detail: { sessionId, exitCode }
|
||||
detail: { sessionId, exitCode },
|
||||
});
|
||||
this.container.dispatchEvent(exitEvent);
|
||||
return;
|
||||
|
|
@ -269,12 +276,12 @@ export class Renderer {
|
|||
const castEvent: CastEvent = {
|
||||
timestamp: data[0],
|
||||
type: data[1],
|
||||
data: data[2]
|
||||
data: data[2],
|
||||
};
|
||||
// Process event without verbose logging
|
||||
this.processEvent(castEvent);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
console.warn('Failed to parse stream event:', event.data);
|
||||
}
|
||||
};
|
||||
|
|
@ -348,7 +355,7 @@ export class Renderer {
|
|||
getDimensions(): { cols: number; rows: number } {
|
||||
return {
|
||||
cols: this.terminal.cols,
|
||||
rows: this.terminal.rows
|
||||
rows: this.terminal.rows,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,7 @@ const MAX_FONT_SIZE = 16;
|
|||
export class ScaleFitAddon implements ITerminalAddon {
|
||||
private _terminal: Terminal | undefined;
|
||||
|
||||
constructor() {
|
||||
}
|
||||
constructor() {}
|
||||
|
||||
public activate(terminal: Terminal): void {
|
||||
this._terminal = terminal;
|
||||
|
|
@ -61,7 +60,7 @@ export class ScaleFitAddon implements ITerminalAddon {
|
|||
top: parseInt(containerStyle.getPropertyValue('padding-top')),
|
||||
bottom: parseInt(containerStyle.getPropertyValue('padding-bottom')),
|
||||
left: parseInt(containerStyle.getPropertyValue('padding-left')),
|
||||
right: parseInt(containerStyle.getPropertyValue('padding-right'))
|
||||
right: parseInt(containerStyle.getPropertyValue('padding-right')),
|
||||
};
|
||||
|
||||
// Calculate exact available space using known padding
|
||||
|
|
@ -102,16 +101,19 @@ export class ScaleFitAddon implements ITerminalAddon {
|
|||
requestAnimationFrame(() => this.applyFontSize(clampedFontSize));
|
||||
|
||||
// Log all calculations for debugging
|
||||
console.log(`ScaleFitAddon: ${availableWidth}×${availableHeight}px available, ${currentCols}×${this._terminal.rows} terminal, charWidth=${charWidth.toFixed(2)}px, lineHeight=${lineHeight.toFixed(2)}px, currentRenderedWidth=${currentRenderedWidth.toFixed(2)}px, scaleFactor=${scaleFactor.toFixed(3)}, actualFontScaling=${actualFontScaling.toFixed(3)}, fontSize ${currentFontSize}px→${clampedFontSize.toFixed(2)}px, lineHeight ${lineHeight.toFixed(2)}px→${newLineHeight.toFixed(2)}px, rows ${this._terminal.rows}→${optimalRows}`);
|
||||
console.log(
|
||||
`ScaleFitAddon: ${availableWidth}×${availableHeight}px available, ${currentCols}×${this._terminal.rows} terminal, charWidth=${charWidth.toFixed(2)}px, lineHeight=${lineHeight.toFixed(2)}px, currentRenderedWidth=${currentRenderedWidth.toFixed(2)}px, scaleFactor=${scaleFactor.toFixed(3)}, actualFontScaling=${actualFontScaling.toFixed(3)}, fontSize ${currentFontSize}px→${clampedFontSize.toFixed(2)}px, lineHeight ${lineHeight.toFixed(2)}px→${newLineHeight.toFixed(2)}px, rows ${this._terminal.rows}→${optimalRows}`
|
||||
);
|
||||
|
||||
return {
|
||||
cols: currentCols, // ALWAYS keep exact column count
|
||||
rows: optimalRows // Maximize rows that fit
|
||||
rows: optimalRows, // Maximize rows that fit
|
||||
};
|
||||
} else {
|
||||
// Fallback: estimate font size and dimensions if measurements aren't available
|
||||
const charWidthRatio = 0.63;
|
||||
const calculatedFontSize = Math.floor((availableWidth / (currentCols * charWidthRatio)) * 10) / 10;
|
||||
const calculatedFontSize =
|
||||
Math.floor((availableWidth / (currentCols * charWidthRatio)) * 10) / 10;
|
||||
const optimalFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
|
||||
|
||||
// Apply the calculated font size
|
||||
|
|
@ -122,7 +124,7 @@ export class ScaleFitAddon implements ITerminalAddon {
|
|||
|
||||
return {
|
||||
cols: currentCols,
|
||||
rows: optimalRows
|
||||
rows: optimalRows,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -167,7 +169,7 @@ export class ScaleFitAddon implements ITerminalAddon {
|
|||
const containerWidth = parseInt(containerStyle.getPropertyValue('width'));
|
||||
const containerPadding = {
|
||||
left: parseInt(containerStyle.getPropertyValue('padding-left')),
|
||||
right: parseInt(containerStyle.getPropertyValue('padding-right'))
|
||||
right: parseInt(containerStyle.getPropertyValue('padding-right')),
|
||||
};
|
||||
|
||||
const availableWidth = containerWidth - containerPadding.left - containerPadding.right;
|
||||
|
|
@ -176,7 +178,8 @@ export class ScaleFitAddon implements ITerminalAddon {
|
|||
// Calculate font size to fit columns in available width
|
||||
const charWidthRatio = 0.63;
|
||||
// Calculate font size and round down for precision
|
||||
const calculatedFontSize = Math.floor((availableWidth / (currentCols * charWidthRatio)) * 10) / 10;
|
||||
const calculatedFontSize =
|
||||
Math.floor((availableWidth / (currentCols * charWidthRatio)) * 10) / 10;
|
||||
const optimalFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
|
||||
|
||||
// Apply the font size without changing terminal dimensions
|
||||
|
|
@ -196,7 +199,8 @@ export class ScaleFitAddon implements ITerminalAddon {
|
|||
const parentWidth = parseInt(parentStyle.getPropertyValue('width'));
|
||||
|
||||
const elementStyle = window.getComputedStyle(this._terminal.element);
|
||||
const paddingHor = parseInt(elementStyle.getPropertyValue('padding-left')) +
|
||||
const paddingHor =
|
||||
parseInt(elementStyle.getPropertyValue('padding-left')) +
|
||||
parseInt(elementStyle.getPropertyValue('padding-right'));
|
||||
|
||||
const availableWidth = parentWidth - paddingHor;
|
||||
|
|
@ -213,7 +217,9 @@ export class ScaleFitAddon implements ITerminalAddon {
|
|||
if (!this._terminal?.element) return null;
|
||||
|
||||
// XTerm has a built-in character measurement system with multiple font styles
|
||||
const measureContainer = this._terminal.element.querySelector('.xterm-width-cache-measure-container');
|
||||
const measureContainer = this._terminal.element.querySelector(
|
||||
'.xterm-width-cache-measure-container'
|
||||
);
|
||||
|
||||
// Find the first measurement element (normal weight, usually 'm' characters)
|
||||
// This is what XTerm uses for baseline character width calculations
|
||||
|
|
@ -242,7 +248,7 @@ export class ScaleFitAddon implements ITerminalAddon {
|
|||
|
||||
return {
|
||||
charWidth: actualCharWidth,
|
||||
lineHeight: lineHeight
|
||||
lineHeight: lineHeight,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const PORT = process.env.PORT || 3000;
|
|||
const possibleTtyFwdPaths = [
|
||||
path.resolve(__dirname, '..', '..', 'tty-fwd', 'target', 'release', 'tty-fwd'),
|
||||
path.resolve(__dirname, '..', '..', '..', 'tty-fwd', 'target', 'release', 'tty-fwd'),
|
||||
'tty-fwd' // System PATH
|
||||
'tty-fwd', // System PATH
|
||||
];
|
||||
|
||||
let TTY_FWD_PATH = '';
|
||||
|
|
@ -32,7 +32,8 @@ if (!TTY_FWD_PATH) {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
const TTY_FWD_CONTROL_DIR = process.env.TTY_FWD_CONTROL_DIR || path.join(os.homedir(), '.vibetunnel/control');
|
||||
const TTY_FWD_CONTROL_DIR =
|
||||
process.env.TTY_FWD_CONTROL_DIR || path.join(os.homedir(), '.vibetunnel/control');
|
||||
|
||||
// Ensure control directory exists and is clean
|
||||
if (fs.existsSync(TTY_FWD_CONTROL_DIR)) {
|
||||
|
|
@ -49,8 +50,8 @@ if (fs.existsSync(TTY_FWD_CONTROL_DIR)) {
|
|||
}
|
||||
}
|
||||
console.log(`Cleaned control directory: ${TTY_FWD_CONTROL_DIR}`);
|
||||
} catch (error) {
|
||||
console.error('Error cleaning control directory:', error);
|
||||
} catch (_error) {
|
||||
console.error('Error cleaning control directory:', _error);
|
||||
}
|
||||
} else {
|
||||
fs.mkdirSync(TTY_FWD_CONTROL_DIR, { recursive: true });
|
||||
|
|
@ -68,9 +69,9 @@ interface TtyFwdSession {
|
|||
name: string;
|
||||
pid: number;
|
||||
started_at: string;
|
||||
status: "running" | "exited";
|
||||
status: 'running' | 'exited';
|
||||
stdin: string;
|
||||
"stream-out": string;
|
||||
'stream-out': string;
|
||||
waiting: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -152,11 +153,11 @@ app.get('/api/sessions', async (req, res) => {
|
|||
// Get actual last modified time from stream-out file
|
||||
let lastModified = sessionInfo.started_at;
|
||||
try {
|
||||
if (fs.existsSync(sessionInfo["stream-out"])) {
|
||||
const stats = fs.statSync(sessionInfo["stream-out"]);
|
||||
if (fs.existsSync(sessionInfo['stream-out'])) {
|
||||
const stats = fs.statSync(sessionInfo['stream-out']);
|
||||
lastModified = stats.mtime.toISOString();
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// Use started_at as fallback
|
||||
}
|
||||
|
||||
|
|
@ -169,15 +170,17 @@ app.get('/api/sessions', async (req, res) => {
|
|||
startedAt: sessionInfo.started_at,
|
||||
lastModified: lastModified,
|
||||
pid: sessionInfo.pid,
|
||||
waiting: sessionInfo.waiting
|
||||
waiting: sessionInfo.waiting,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by lastModified, most recent first
|
||||
sessionData.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
sessionData.sort(
|
||||
(a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()
|
||||
);
|
||||
res.json(sessionData);
|
||||
} catch (error) {
|
||||
console.error('Failed to list sessions:', error);
|
||||
} catch (_error) {
|
||||
console.error('Failed to list sessions:', _error);
|
||||
res.status(500).json({ error: 'Failed to list sessions' });
|
||||
}
|
||||
});
|
||||
|
|
@ -195,9 +198,11 @@ app.post('/api/sessions', async (req, res) => {
|
|||
const cwd = resolvePath(workingDir, process.cwd());
|
||||
|
||||
const args = [
|
||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||
'--session-name', sessionName,
|
||||
'--'
|
||||
'--control-path',
|
||||
TTY_FWD_CONTROL_DIR,
|
||||
'--session-name',
|
||||
sessionName,
|
||||
'--',
|
||||
].concat(command);
|
||||
|
||||
console.log(`Creating session: ${TTY_FWD_PATH} ${args.join(' ')}`);
|
||||
|
|
@ -205,7 +210,7 @@ app.post('/api/sessions', async (req, res) => {
|
|||
const child = spawn(TTY_FWD_PATH, args, {
|
||||
cwd: cwd,
|
||||
detached: false,
|
||||
stdio: 'pipe'
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
// Capture session ID from stdout
|
||||
|
|
@ -237,11 +242,11 @@ app.post('/api/sessions', async (req, res) => {
|
|||
const exitEvent = JSON.stringify(['exit', code, sessionId]);
|
||||
const eventData = `data: ${exitEvent}\n\n`;
|
||||
|
||||
streamInfo.clients.forEach(client => {
|
||||
streamInfo.clients.forEach((client) => {
|
||||
try {
|
||||
client.write(eventData);
|
||||
} catch (error) {
|
||||
console.error('Error sending exit event to client:', error);
|
||||
} catch (_error) {
|
||||
console.error('Error sending exit event to client:', _error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -266,9 +271,8 @@ app.post('/api/sessions', async (req, res) => {
|
|||
|
||||
const finalSessionId = await waitForSessionId;
|
||||
res.json({ sessionId: finalSessionId });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating session:', error);
|
||||
} catch (_error) {
|
||||
console.error('Error creating session:', _error);
|
||||
res.status(500).json({ error: 'Failed to create session' });
|
||||
}
|
||||
});
|
||||
|
|
@ -293,19 +297,18 @@ app.delete('/api/sessions/:sessionId', async (req, res) => {
|
|||
try {
|
||||
process.kill(session.pid, 0); // Check if still alive
|
||||
process.kill(session.pid, 'SIGKILL'); // Force kill
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// Process already dead
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
// Process already dead
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Session killed' });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error killing session:', error);
|
||||
} catch (_error) {
|
||||
console.error('Error killing session:', _error);
|
||||
res.status(500).json({ error: 'Failed to kill session' });
|
||||
}
|
||||
});
|
||||
|
|
@ -316,14 +319,15 @@ app.delete('/api/sessions/:sessionId/cleanup', async (req, res) => {
|
|||
|
||||
try {
|
||||
await executeTtyFwd([
|
||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||
'--session', sessionId,
|
||||
'--cleanup'
|
||||
'--control-path',
|
||||
TTY_FWD_CONTROL_DIR,
|
||||
'--session',
|
||||
sessionId,
|
||||
'--cleanup',
|
||||
]);
|
||||
|
||||
res.json({ success: true, message: 'Session cleaned up' });
|
||||
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
// If tty-fwd cleanup fails, force remove directory
|
||||
console.log('tty-fwd cleanup failed, force removing directory');
|
||||
const sessionDir = path.join(TTY_FWD_CONTROL_DIR, sessionId);
|
||||
|
|
@ -342,15 +346,11 @@ app.delete('/api/sessions/:sessionId/cleanup', async (req, res) => {
|
|||
// Cleanup all exited sessions
|
||||
app.post('/api/cleanup-exited', async (req, res) => {
|
||||
try {
|
||||
await executeTtyFwd([
|
||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||
'--cleanup'
|
||||
]);
|
||||
await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--cleanup']);
|
||||
|
||||
res.json({ success: true, message: 'All exited sessions cleaned up' });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up exited sessions:', error);
|
||||
} catch (_error) {
|
||||
console.error('Error cleaning up exited sessions:', _error);
|
||||
res.status(500).json({ error: 'Failed to cleanup exited sessions' });
|
||||
}
|
||||
});
|
||||
|
|
@ -358,11 +358,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<string, {
|
||||
clients: Set<any>,
|
||||
tailProcess: any,
|
||||
lastPosition: number
|
||||
}>();
|
||||
const activeStreams = new Map<
|
||||
string,
|
||||
{
|
||||
clients: Set<any>;
|
||||
tailProcess: any;
|
||||
lastPosition: number;
|
||||
}
|
||||
>();
|
||||
|
||||
// Live streaming cast file for XTerm renderer
|
||||
app.get('/api/sessions/:sessionId/stream', async (req, res) => {
|
||||
|
|
@ -373,14 +376,16 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
|
|||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
console.log(`New SSE client connected to session ${sessionId} from ${req.get('User-Agent')?.substring(0, 50) || 'unknown'}`);
|
||||
console.log(
|
||||
`New SSE client connected to session ${sessionId} from ${req.get('User-Agent')?.substring(0, 50) || 'unknown'}`
|
||||
);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
Connection: 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Cache-Control'
|
||||
'Access-Control-Allow-Headers': 'Cache-Control',
|
||||
});
|
||||
|
||||
const startTime = Date.now() / 1000;
|
||||
|
|
@ -402,13 +407,13 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
|
|||
const instantEvent = [0, parsed[1], parsed[2]];
|
||||
res.write(`data: ${JSON.stringify(instantEvent)}\n\n`);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading existing content:', error);
|
||||
} catch (_error) {
|
||||
console.error('Error reading existing content:', _error);
|
||||
}
|
||||
|
||||
// Send default header if none found
|
||||
|
|
@ -418,7 +423,7 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
|
|||
width: 80,
|
||||
height: 24,
|
||||
timestamp: Math.floor(startTime),
|
||||
env: { TERM: "xterm-256color" }
|
||||
env: { TERM: 'xterm-256color' },
|
||||
};
|
||||
res.write(`data: ${JSON.stringify(defaultHeader)}\n\n`);
|
||||
}
|
||||
|
|
@ -436,7 +441,7 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
|
|||
streamInfo = {
|
||||
clients: new Set(),
|
||||
tailProcess,
|
||||
lastPosition: 0
|
||||
lastPosition: 0,
|
||||
};
|
||||
|
||||
activeStreams.set(sessionId, streamInfo);
|
||||
|
|
@ -460,23 +465,25 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
|
|||
const realTimeEvent = [currentTime - startTime, parsed[1], parsed[2]];
|
||||
eventData = `data: ${JSON.stringify(realTimeEvent)}\n\n`;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// Handle non-JSON as raw output
|
||||
const currentTime = Date.now() / 1000;
|
||||
const castEvent = [currentTime - startTime, "o", line];
|
||||
const castEvent = [currentTime - startTime, 'o', line];
|
||||
eventData = `data: ${JSON.stringify(castEvent)}\n\n`;
|
||||
}
|
||||
|
||||
if (eventData && streamInfo) {
|
||||
// Broadcast to all connected clients
|
||||
streamInfo.clients.forEach(client => {
|
||||
streamInfo.clients.forEach((client) => {
|
||||
try {
|
||||
client.write(eventData);
|
||||
} catch (error) {
|
||||
console.error('Error writing to client:', error);
|
||||
} catch (_error) {
|
||||
console.error('Error writing to client:', _error);
|
||||
if (streamInfo) {
|
||||
streamInfo.clients.delete(client);
|
||||
console.log(`Removed failed client from session ${sessionId}, remaining clients: ${streamInfo.clients.size}`);
|
||||
console.log(
|
||||
`Removed failed client from session ${sessionId}, remaining clients: ${streamInfo.clients.size}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -490,8 +497,10 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
|
|||
// Cleanup all clients
|
||||
const currentStreamInfo = activeStreams.get(sessionId);
|
||||
if (currentStreamInfo) {
|
||||
currentStreamInfo.clients.forEach(client => {
|
||||
try { client.end(); } catch (e) {}
|
||||
currentStreamInfo.clients.forEach((client) => {
|
||||
try {
|
||||
client.end();
|
||||
} catch (_e) {}
|
||||
});
|
||||
}
|
||||
activeStreams.delete(sessionId);
|
||||
|
|
@ -502,8 +511,10 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
|
|||
// Cleanup all clients
|
||||
const currentStreamInfo = activeStreams.get(sessionId);
|
||||
if (currentStreamInfo) {
|
||||
currentStreamInfo.clients.forEach(client => {
|
||||
try { client.end(); } catch (e) {}
|
||||
currentStreamInfo.clients.forEach((client) => {
|
||||
try {
|
||||
client.end();
|
||||
} catch (_e) {}
|
||||
});
|
||||
}
|
||||
activeStreams.delete(sessionId);
|
||||
|
|
@ -518,14 +529,16 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
|
|||
const cleanup = () => {
|
||||
if (streamInfo && streamInfo.clients.has(res)) {
|
||||
streamInfo.clients.delete(res);
|
||||
console.log(`Removed client from session ${sessionId}, remaining clients: ${streamInfo.clients.size}`);
|
||||
console.log(
|
||||
`Removed client from session ${sessionId}, remaining clients: ${streamInfo.clients.size}`
|
||||
);
|
||||
|
||||
// If no more clients, cleanup the tail process
|
||||
if (streamInfo.clients.size === 0) {
|
||||
console.log(`No more clients for session ${sessionId}, cleaning up tail process`);
|
||||
try {
|
||||
streamInfo.tailProcess.kill('SIGTERM');
|
||||
} catch (e) {}
|
||||
} catch (_e) {}
|
||||
activeStreams.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
|
@ -571,7 +584,7 @@ app.get('/api/sessions/:sessionId/snapshot', (req, res) => {
|
|||
}
|
||||
events.push([0, parsed[1], parsed[2]]);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
|
|
@ -584,25 +597,26 @@ app.get('/api/sessions/:sessionId/snapshot', (req, res) => {
|
|||
if (header) {
|
||||
cast.push(JSON.stringify(header));
|
||||
} else {
|
||||
cast.push(JSON.stringify({
|
||||
cast.push(
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
width: 80,
|
||||
height: 24,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
env: { TERM: "xterm-256color" }
|
||||
}));
|
||||
env: { TERM: 'xterm-256color' },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Add all events
|
||||
events.forEach(event => {
|
||||
events.forEach((event) => {
|
||||
cast.push(JSON.stringify(event));
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.send(cast.join('\n'));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error reading session snapshot:', error);
|
||||
} catch (_error) {
|
||||
console.error('Error reading session snapshot:', _error);
|
||||
res.status(500).json({ error: 'Failed to read session snapshot' });
|
||||
}
|
||||
});
|
||||
|
|
@ -638,14 +652,16 @@ app.post('/api/sessions/:sessionId/input', async (req, res) => {
|
|||
if (session.pid) {
|
||||
try {
|
||||
process.kill(session.pid, 0); // Signal 0 just checks if process exists
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
console.error(`Session ${sessionId} process ${session.pid} is dead, cleaning up`);
|
||||
// Try to cleanup the stale session
|
||||
try {
|
||||
await executeTtyFwd([
|
||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||
'--session', sessionId,
|
||||
'--cleanup'
|
||||
'--control-path',
|
||||
TTY_FWD_CONTROL_DIR,
|
||||
'--session',
|
||||
sessionId,
|
||||
'--cleanup',
|
||||
]);
|
||||
} catch (cleanupError) {
|
||||
console.error('Failed to cleanup stale session:', cleanupError);
|
||||
|
|
@ -655,32 +671,46 @@ 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', 'ctrl_enter', 'shift_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) {
|
||||
await executeTtyFwd([
|
||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||
'--session', sessionId,
|
||||
'--send-key', text
|
||||
'--control-path',
|
||||
TTY_FWD_CONTROL_DIR,
|
||||
'--session',
|
||||
sessionId,
|
||||
'--send-key',
|
||||
text,
|
||||
]);
|
||||
// Key sent successfully (removed verbose logging)
|
||||
} else {
|
||||
await executeTtyFwd([
|
||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||
'--session', sessionId,
|
||||
'--send-text', text
|
||||
'--control-path',
|
||||
TTY_FWD_CONTROL_DIR,
|
||||
'--session',
|
||||
sessionId,
|
||||
'--send-text',
|
||||
text,
|
||||
]);
|
||||
// Text sent successfully (removed verbose logging)
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending input via tty-fwd:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
} catch (_error) {
|
||||
console.error('Error sending input via tty-fwd:', _error);
|
||||
const errorMessage = _error instanceof Error ? _error.message : 'Unknown error';
|
||||
res.status(500).json({ error: 'Failed to send input', details: errorMessage });
|
||||
}
|
||||
});
|
||||
|
|
@ -699,8 +729,8 @@ app.get('/api/test-cast', (req, res) => {
|
|||
} else {
|
||||
res.status(404).json({ error: 'Test cast file not found' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error serving test cast file:', error);
|
||||
} catch (_error) {
|
||||
console.error('Error serving test cast file:', _error);
|
||||
res.status(500).json({ error: 'Failed to serve test cast file' });
|
||||
}
|
||||
});
|
||||
|
|
@ -709,7 +739,7 @@ app.get('/api/test-cast', (req, res) => {
|
|||
|
||||
// Directory listing for file browser
|
||||
app.get('/api/fs/browse', (req, res) => {
|
||||
const dirPath = req.query.path as string || '~';
|
||||
const dirPath = (req.query.path as string) || '~';
|
||||
|
||||
try {
|
||||
const expandedPath = resolvePath(dirPath, '~');
|
||||
|
|
@ -723,7 +753,7 @@ app.get('/api/fs/browse', (req, res) => {
|
|||
return res.status(400).json({ error: 'Path is not a directory' });
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(expandedPath).map(name => {
|
||||
const files = fs.readdirSync(expandedPath).map((name) => {
|
||||
const filePath = path.join(expandedPath, name);
|
||||
const fileStats = fs.statSync(filePath);
|
||||
|
||||
|
|
@ -732,7 +762,7 @@ app.get('/api/fs/browse', (req, res) => {
|
|||
created: fileStats.birthtime.toISOString(),
|
||||
lastModified: fileStats.mtime.toISOString(),
|
||||
size: fileStats.size,
|
||||
isDir: fileStats.isDirectory()
|
||||
isDir: fileStats.isDirectory(),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -743,11 +773,10 @@ app.get('/api/fs/browse', (req, res) => {
|
|||
if (a.isDir && !b.isDir) return -1;
|
||||
if (!a.isDir && b.isDir) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
}),
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error listing directory:', error);
|
||||
} catch (_error) {
|
||||
console.error('Error listing directory:', _error);
|
||||
res.status(500).json({ error: 'Failed to list directory' });
|
||||
}
|
||||
});
|
||||
|
|
@ -773,7 +802,7 @@ app.post('/api/mkdir', (req, res) => {
|
|||
|
||||
// Security check: ensure we're not trying to access outside allowed areas
|
||||
const allowedBasePaths = [os.homedir(), process.cwd()];
|
||||
const isAllowed = allowedBasePaths.some(basePath =>
|
||||
const isAllowed = allowedBasePaths.some((basePath) =>
|
||||
expandedPath.startsWith(path.resolve(basePath))
|
||||
);
|
||||
|
||||
|
|
@ -804,11 +833,10 @@ app.post('/api/mkdir', (req, res) => {
|
|||
res.json({
|
||||
success: true,
|
||||
path: newDirPath,
|
||||
message: `Directory '${name}' created successfully`
|
||||
message: `Directory '${name}' created successfully`,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating directory:', error);
|
||||
} catch (_error) {
|
||||
console.error('Error creating directory:', _error);
|
||||
res.status(500).json({ error: 'Failed to create directory' });
|
||||
}
|
||||
});
|
||||
|
|
@ -836,7 +864,7 @@ if (process.env.NODE_ENV !== 'production') {
|
|||
const chokidar = require('chokidar');
|
||||
const watcher = chokidar.watch(['public/**/*', 'src/**/*'], {
|
||||
ignored: /node_modules/,
|
||||
persistent: true
|
||||
persistent: true,
|
||||
});
|
||||
|
||||
watcher.on('change', (path: string) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue