mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Fix Create Session button visibility by removing duplicate form
Removed the duplicate session-create-form component from the main app since the session list now manages its own modal. Added session-created event handler to the session-list component. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
77706ab1f5
commit
a868752e40
18 changed files with 175 additions and 119 deletions
|
|
@ -117,15 +117,12 @@ let VibeTunnelAppNew = class VibeTunnelAppNew extends LitElement {
|
||||||
return html `
|
return html `
|
||||||
<div class="max-w-4xl mx-auto">
|
<div class="max-w-4xl mx-auto">
|
||||||
<app-header></app-header>
|
<app-header></app-header>
|
||||||
<session-create-form
|
|
||||||
@session-created=${this.handleSessionCreated}
|
|
||||||
@error=${this.handleError}
|
|
||||||
></session-create-form>
|
|
||||||
<session-list
|
<session-list
|
||||||
.sessions=${this.sessions}
|
.sessions=${this.sessions}
|
||||||
.loading=${this.loading}
|
.loading=${this.loading}
|
||||||
@session-select=${this.handleSessionSelect}
|
@session-select=${this.handleSessionSelect}
|
||||||
@session-killed=${this.handleSessionKilled}
|
@session-killed=${this.handleSessionKilled}
|
||||||
|
@session-created=${this.handleSessionCreated}
|
||||||
@refresh=${this.handleRefresh}
|
@refresh=${this.handleRefresh}
|
||||||
@error=${this.handleError}
|
@error=${this.handleError}
|
||||||
></session-list>
|
></session-list>
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -13,6 +13,7 @@ let SessionCreateForm = class SessionCreateForm extends LitElement {
|
||||||
this.workingDir = '~/';
|
this.workingDir = '~/';
|
||||||
this.command = '';
|
this.command = '';
|
||||||
this.disabled = false;
|
this.disabled = false;
|
||||||
|
this.visible = false;
|
||||||
this.isCreating = false;
|
this.isCreating = false;
|
||||||
this.showFileBrowser = false;
|
this.showFileBrowser = false;
|
||||||
}
|
}
|
||||||
|
|
@ -114,10 +115,25 @@ let SessionCreateForm = class SessionCreateForm extends LitElement {
|
||||||
}
|
}
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
handleCancel() {
|
||||||
|
this.dispatchEvent(new CustomEvent('cancel'));
|
||||||
|
}
|
||||||
render() {
|
render() {
|
||||||
|
if (!this.visible) {
|
||||||
|
return html ``;
|
||||||
|
}
|
||||||
return html `
|
return html `
|
||||||
<div class="border border-vs-accent font-mono text-sm p-4 m-4 rounded">
|
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style="z-index: 9999;">
|
||||||
<div class="text-vs-assistant text-sm mb-4">Create New Session</div>
|
<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="mb-4">
|
||||||
<div class="text-vs-muted mb-2">Working Directory:</div>
|
<div class="text-vs-muted mb-2">Working Directory:</div>
|
||||||
|
|
@ -153,13 +169,24 @@ let SessionCreateForm = class SessionCreateForm extends LitElement {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div class="flex gap-4 justify-end">
|
||||||
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none"
|
<button
|
||||||
@click=${this.handleCreate}
|
class="bg-vs-muted text-vs-bg hover:bg-vs-text font-mono px-4 py-2 border-none"
|
||||||
?disabled=${this.disabled || this.isCreating || !this.workingDir.trim() || !this.command.trim()}
|
@click=${this.handleCancel}
|
||||||
>
|
?disabled=${this.isCreating}
|
||||||
${this.isCreating ? 'creating...' : 'create'}
|
>
|
||||||
</button>
|
cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none"
|
||||||
|
@click=${this.handleCreate}
|
||||||
|
?disabled=${this.disabled || this.isCreating || !this.workingDir.trim() || !this.command.trim()}
|
||||||
|
>
|
||||||
|
${this.isCreating ? 'creating...' : 'create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<file-browser
|
<file-browser
|
||||||
|
|
@ -180,6 +207,9 @@ __decorate([
|
||||||
__decorate([
|
__decorate([
|
||||||
property({ type: Boolean })
|
property({ type: Boolean })
|
||||||
], SessionCreateForm.prototype, "disabled", void 0);
|
], SessionCreateForm.prototype, "disabled", void 0);
|
||||||
|
__decorate([
|
||||||
|
property({ type: Boolean })
|
||||||
|
], SessionCreateForm.prototype, "visible", void 0);
|
||||||
__decorate([
|
__decorate([
|
||||||
state()
|
state()
|
||||||
], SessionCreateForm.prototype, "isCreating", void 0);
|
], SessionCreateForm.prototype, "isCreating", void 0);
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -126,15 +126,12 @@ export class VibeTunnelAppNew extends LitElement {
|
||||||
return html`
|
return html`
|
||||||
<div class="max-w-4xl mx-auto">
|
<div class="max-w-4xl mx-auto">
|
||||||
<app-header></app-header>
|
<app-header></app-header>
|
||||||
<session-create-form
|
|
||||||
@session-created=${this.handleSessionCreated}
|
|
||||||
@error=${this.handleError}
|
|
||||||
></session-create-form>
|
|
||||||
<session-list
|
<session-list
|
||||||
.sessions=${this.sessions}
|
.sessions=${this.sessions}
|
||||||
.loading=${this.loading}
|
.loading=${this.loading}
|
||||||
@session-select=${this.handleSessionSelect}
|
@session-select=${this.handleSessionSelect}
|
||||||
@session-killed=${this.handleSessionKilled}
|
@session-killed=${this.handleSessionKilled}
|
||||||
|
@session-created=${this.handleSessionCreated}
|
||||||
@refresh=${this.handleRefresh}
|
@refresh=${this.handleRefresh}
|
||||||
@error=${this.handleError}
|
@error=${this.handleError}
|
||||||
></session-list>
|
></session-list>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export class SessionCreateForm extends LitElement {
|
||||||
@property({ type: String }) workingDir = '~/';
|
@property({ type: String }) workingDir = '~/';
|
||||||
@property({ type: String }) command = '';
|
@property({ type: String }) command = '';
|
||||||
@property({ type: Boolean }) disabled = false;
|
@property({ type: Boolean }) disabled = false;
|
||||||
|
@property({ type: Boolean }) visible = false;
|
||||||
|
|
||||||
@state() private isCreating = false;
|
@state() private isCreating = false;
|
||||||
@state() private showFileBrowser = false;
|
@state() private showFileBrowser = false;
|
||||||
|
|
@ -124,10 +125,27 @@ export class SessionCreateForm extends LitElement {
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleCancel() {
|
||||||
|
this.dispatchEvent(new CustomEvent('cancel'));
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
if (!this.visible) {
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="border border-vs-accent font-mono text-sm p-4 m-4 rounded">
|
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style="z-index: 9999;">
|
||||||
<div class="text-vs-assistant text-sm mb-4">Create New Session</div>
|
<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="mb-4">
|
||||||
<div class="text-vs-muted mb-2">Working Directory:</div>
|
<div class="text-vs-muted mb-2">Working Directory:</div>
|
||||||
|
|
@ -163,13 +181,24 @@ export class SessionCreateForm extends LitElement {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div class="flex gap-4 justify-end">
|
||||||
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none"
|
<button
|
||||||
@click=${this.handleCreate}
|
class="bg-vs-muted text-vs-bg hover:bg-vs-text font-mono px-4 py-2 border-none"
|
||||||
?disabled=${this.disabled || this.isCreating || !this.workingDir.trim() || !this.command.trim()}
|
@click=${this.handleCancel}
|
||||||
>
|
?disabled=${this.isCreating}
|
||||||
${this.isCreating ? 'creating...' : 'create'}
|
>
|
||||||
</button>
|
cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none"
|
||||||
|
@click=${this.handleCreate}
|
||||||
|
?disabled=${this.disabled || this.isCreating || !this.workingDir.trim() || !this.command.trim()}
|
||||||
|
>
|
||||||
|
${this.isCreating ? 'creating...' : 'create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<file-browser
|
<file-browser
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { LitElement, html } from 'lit';
|
import { LitElement, html } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
|
import './session-create-form.js';
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -25,7 +26,8 @@ export class SessionList extends LitElement {
|
||||||
@state() private killingSessionIds = new Set<string>();
|
@state() private killingSessionIds = new Set<string>();
|
||||||
@state() private loadedSnapshots = new Map<string, string>();
|
@state() private loadedSnapshots = new Map<string, string>();
|
||||||
@state() private loadingSnapshots = new Set<string>();
|
@state() private loadingSnapshots = new Set<string>();
|
||||||
@state() private hideExited = false;
|
@state() private hideExited = true;
|
||||||
|
@state() private showCreateModal = false;
|
||||||
|
|
||||||
private handleRefresh() {
|
private handleRefresh() {
|
||||||
this.dispatchEvent(new CustomEvent('refresh'));
|
this.dispatchEvent(new CustomEvent('refresh'));
|
||||||
|
|
@ -165,6 +167,19 @@ export class SessionList extends LitElement {
|
||||||
return id.length > 8 ? `${id.substring(0, 8)}...` : id;
|
return id.length > 8 ? `${id.substring(0, 8)}...` : id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleSessionCreated(e: CustomEvent) {
|
||||||
|
this.showCreateModal = false;
|
||||||
|
this.dispatchEvent(new CustomEvent('session-created', {
|
||||||
|
detail: e.detail
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleCreateError(e: CustomEvent) {
|
||||||
|
this.dispatchEvent(new CustomEvent('error', {
|
||||||
|
detail: e.detail
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private get filteredSessions() {
|
private get filteredSessions() {
|
||||||
return this.hideExited
|
return this.hideExited
|
||||||
? this.sessions.filter(session => session.status === 'running')
|
? this.sessions.filter(session => session.status === 'running')
|
||||||
|
|
@ -176,8 +191,15 @@ export class SessionList extends LitElement {
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="font-mono text-sm p-4">
|
<div class="font-mono text-sm p-4">
|
||||||
<!-- Filter Controls -->
|
<!-- Controls -->
|
||||||
<div class="mb-4 flex items-center justify-end">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none rounded"
|
||||||
|
@click=${() => this.showCreateModal = true}
|
||||||
|
>
|
||||||
|
Create Session
|
||||||
|
</button>
|
||||||
|
|
||||||
<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">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
|
|
@ -248,6 +270,13 @@ export class SessionList extends LitElement {
|
||||||
`)}
|
`)}
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
|
|
||||||
|
<session-create-form
|
||||||
|
.visible=${this.showCreateModal}
|
||||||
|
@session-created=${this.handleSessionCreated}
|
||||||
|
@cancel=${() => this.showCreateModal = false}
|
||||||
|
@error=${this.handleCreateError}
|
||||||
|
></session-create-form>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,11 +65,11 @@ async function executeTtyFwd(args: string[]): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(TTY_FWD_PATH, args);
|
const child = spawn(TTY_FWD_PATH, args);
|
||||||
let output = '';
|
let output = '';
|
||||||
|
|
||||||
child.stdout.on('data', (data) => {
|
child.stdout.on('data', (data) => {
|
||||||
output += data.toString();
|
output += data.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve(output);
|
resolve(output);
|
||||||
|
|
@ -77,7 +77,7 @@ async function executeTtyFwd(args: string[]): Promise<string> {
|
||||||
reject(new Error(`tty-fwd failed with code ${code}`));
|
reject(new Error(`tty-fwd failed with code ${code}`));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('error', (error) => {
|
child.on('error', (error) => {
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
|
|
@ -89,11 +89,11 @@ function resolvePath(inputPath: string, fallback?: string): string {
|
||||||
if (!inputPath) {
|
if (!inputPath) {
|
||||||
return fallback || process.cwd();
|
return fallback || process.cwd();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inputPath.startsWith('~')) {
|
if (inputPath.startsWith('~')) {
|
||||||
return path.join(os.homedir(), inputPath.slice(1));
|
return path.join(os.homedir(), inputPath.slice(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
return path.resolve(inputPath);
|
return path.resolve(inputPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,7 +111,7 @@ app.get('/api/sessions', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const output = await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
|
const output = await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
|
||||||
const sessions: TtyFwdListResponse = JSON.parse(output || '{}');
|
const sessions: TtyFwdListResponse = JSON.parse(output || '{}');
|
||||||
|
|
||||||
const sessionData = Object.entries(sessions).map(([sessionId, sessionInfo]) => {
|
const sessionData = Object.entries(sessions).map(([sessionId, sessionInfo]) => {
|
||||||
// Get actual last modified time from stream-out file
|
// Get actual last modified time from stream-out file
|
||||||
let lastModified = sessionInfo.started_at;
|
let lastModified = sessionInfo.started_at;
|
||||||
|
|
@ -123,7 +123,7 @@ app.get('/api/sessions', async (req, res) => {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Use started_at as fallback
|
// Use started_at as fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
command: sessionInfo.cmdline.join(' '),
|
command: sessionInfo.cmdline.join(' '),
|
||||||
|
|
@ -135,7 +135,7 @@ app.get('/api/sessions', async (req, res) => {
|
||||||
pid: sessionInfo.pid
|
pid: sessionInfo.pid
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort by lastModified, most recent first
|
// 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);
|
res.json(sessionData);
|
||||||
|
|
@ -149,44 +149,44 @@ app.get('/api/sessions', async (req, res) => {
|
||||||
app.post('/api/sessions', async (req, res) => {
|
app.post('/api/sessions', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { command, workingDir } = req.body;
|
const { command, workingDir } = req.body;
|
||||||
|
|
||||||
if (!command || !Array.isArray(command) || command.length === 0) {
|
if (!command || !Array.isArray(command) || command.length === 0) {
|
||||||
return res.status(400).json({ error: 'Command array is required and cannot be empty' });
|
return res.status(400).json({ error: 'Command array is required and cannot be empty' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionName = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
const sessionName = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
const cwd = resolvePath(workingDir, process.cwd());
|
const cwd = resolvePath(workingDir, process.cwd());
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||||
'--session-name', sessionName,
|
'--session-name', sessionName,
|
||||||
'--'
|
'--'
|
||||||
].concat(command);
|
].concat(command);
|
||||||
|
|
||||||
console.log(`Creating session: ${TTY_FWD_PATH} ${args.join(' ')}`);
|
console.log(`Creating session: ${TTY_FWD_PATH} ${args.join(' ')}`);
|
||||||
|
|
||||||
const child = spawn(TTY_FWD_PATH, args, {
|
const child = spawn(TTY_FWD_PATH, args, {
|
||||||
cwd: cwd,
|
cwd: cwd,
|
||||||
detached: false,
|
detached: false,
|
||||||
stdio: 'pipe'
|
stdio: 'pipe'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log output for debugging
|
// Log output for debugging
|
||||||
child.stdout.on('data', (data) => {
|
child.stdout.on('data', (data) => {
|
||||||
console.log(`Session ${sessionName} stdout:`, data.toString());
|
console.log(`Session ${sessionName} stdout:`, data.toString());
|
||||||
});
|
});
|
||||||
|
|
||||||
child.stderr.on('data', (data) => {
|
child.stderr.on('data', (data) => {
|
||||||
console.log(`Session ${sessionName} stderr:`, data.toString());
|
console.log(`Session ${sessionName} stderr:`, data.toString());
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
console.log(`Session ${sessionName} exited with code: ${code}`);
|
console.log(`Session ${sessionName} exited with code: ${code}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Respond immediately - don't wait for completion
|
// Respond immediately - don't wait for completion
|
||||||
res.json({ sessionId: sessionName });
|
res.json({ sessionId: sessionName });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating session:', error);
|
console.error('Error creating session:', error);
|
||||||
res.status(500).json({ error: 'Failed to create session' });
|
res.status(500).json({ error: 'Failed to create session' });
|
||||||
|
|
@ -196,16 +196,16 @@ app.post('/api/sessions', async (req, res) => {
|
||||||
// Kill session (just kill the process)
|
// Kill session (just kill the process)
|
||||||
app.delete('/api/sessions/:sessionId', async (req, res) => {
|
app.delete('/api/sessions/:sessionId', async (req, res) => {
|
||||||
const sessionId = req.params.sessionId;
|
const sessionId = req.params.sessionId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const output = await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
|
const output = await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
|
||||||
const sessions: TtyFwdListResponse = JSON.parse(output || '{}');
|
const sessions: TtyFwdListResponse = JSON.parse(output || '{}');
|
||||||
const session = sessions[sessionId];
|
const session = sessions[sessionId];
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return res.status(404).json({ error: 'Session not found' });
|
return res.status(404).json({ error: 'Session not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.pid) {
|
if (session.pid) {
|
||||||
try {
|
try {
|
||||||
process.kill(session.pid, 'SIGTERM');
|
process.kill(session.pid, 'SIGTERM');
|
||||||
|
|
@ -221,9 +221,9 @@ app.delete('/api/sessions/:sessionId', async (req, res) => {
|
||||||
// Process already dead
|
// Process already dead
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, message: 'Session killed' });
|
res.json({ success: true, message: 'Session killed' });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error killing session:', error);
|
console.error('Error killing session:', error);
|
||||||
res.status(500).json({ error: 'Failed to kill session' });
|
res.status(500).json({ error: 'Failed to kill session' });
|
||||||
|
|
@ -233,16 +233,16 @@ app.delete('/api/sessions/:sessionId', async (req, res) => {
|
||||||
// Cleanup session files
|
// Cleanup session files
|
||||||
app.delete('/api/sessions/:sessionId/cleanup', async (req, res) => {
|
app.delete('/api/sessions/:sessionId/cleanup', async (req, res) => {
|
||||||
const sessionId = req.params.sessionId;
|
const sessionId = req.params.sessionId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await executeTtyFwd([
|
await executeTtyFwd([
|
||||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||||
'--session', sessionId,
|
'--session', sessionId,
|
||||||
'--cleanup'
|
'--cleanup'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
res.json({ success: true, message: 'Session cleaned up' });
|
res.json({ success: true, message: 'Session cleaned up' });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If tty-fwd cleanup fails, force remove directory
|
// If tty-fwd cleanup fails, force remove directory
|
||||||
console.log('tty-fwd cleanup failed, force removing directory');
|
console.log('tty-fwd cleanup failed, force removing directory');
|
||||||
|
|
@ -265,11 +265,11 @@ app.delete('/api/sessions/:sessionId/cleanup', async (req, res) => {
|
||||||
app.get('/api/sessions/:sessionId/stream', (req, res) => {
|
app.get('/api/sessions/:sessionId/stream', (req, res) => {
|
||||||
const sessionId = req.params.sessionId;
|
const sessionId = req.params.sessionId;
|
||||||
const streamOutPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out');
|
const streamOutPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out');
|
||||||
|
|
||||||
if (!fs.existsSync(streamOutPath)) {
|
if (!fs.existsSync(streamOutPath)) {
|
||||||
return res.status(404).json({ error: 'Session not found' });
|
return res.status(404).json({ error: 'Session not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
|
|
@ -277,16 +277,16 @@ app.get('/api/sessions/:sessionId/stream', (req, res) => {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Headers': 'Cache-Control'
|
'Access-Control-Allow-Headers': 'Cache-Control'
|
||||||
});
|
});
|
||||||
|
|
||||||
const startTime = Date.now() / 1000;
|
const startTime = Date.now() / 1000;
|
||||||
let headerSent = false;
|
let headerSent = false;
|
||||||
|
|
||||||
// Send existing content first
|
// Send existing content first
|
||||||
// NOTE: Small race condition possible between reading file and starting tail
|
// NOTE: Small race condition possible between reading file and starting tail
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(streamOutPath, 'utf8');
|
const content = fs.readFileSync(streamOutPath, 'utf8');
|
||||||
const lines = content.trim().split('\n');
|
const lines = content.trim().split('\n');
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.trim()) {
|
if (line.trim()) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -306,7 +306,7 @@ app.get('/api/sessions/:sessionId/stream', (req, res) => {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error reading existing content:', error);
|
console.error('Error reading existing content:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send default header if none found
|
// Send default header if none found
|
||||||
if (!headerSent) {
|
if (!headerSent) {
|
||||||
const defaultHeader = {
|
const defaultHeader = {
|
||||||
|
|
@ -318,16 +318,16 @@ app.get('/api/sessions/:sessionId/stream', (req, res) => {
|
||||||
};
|
};
|
||||||
res.write(`data: ${JSON.stringify(defaultHeader)}\n\n`);
|
res.write(`data: ${JSON.stringify(defaultHeader)}\n\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream new content
|
// Stream new content
|
||||||
const tailProcess = spawn('tail', ['-f', streamOutPath]);
|
const tailProcess = spawn('tail', ['-f', streamOutPath]);
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
|
|
||||||
tailProcess.stdout.on('data', (chunk) => {
|
tailProcess.stdout.on('data', (chunk) => {
|
||||||
buffer += chunk.toString();
|
buffer += chunk.toString();
|
||||||
const lines = buffer.split('\n');
|
const lines = buffer.split('\n');
|
||||||
buffer = lines.pop() || '';
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.trim()) {
|
if (line.trim()) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -349,7 +349,7 @@ app.get('/api/sessions/:sessionId/stream', (req, res) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup on disconnect
|
// Cleanup on disconnect
|
||||||
req.on('close', () => tailProcess.kill('SIGTERM'));
|
req.on('close', () => tailProcess.kill('SIGTERM'));
|
||||||
req.on('aborted', () => tailProcess.kill('SIGTERM'));
|
req.on('aborted', () => tailProcess.kill('SIGTERM'));
|
||||||
|
|
@ -359,24 +359,24 @@ app.get('/api/sessions/:sessionId/stream', (req, res) => {
|
||||||
app.get('/api/sessions/:sessionId/snapshot', (req, res) => {
|
app.get('/api/sessions/:sessionId/snapshot', (req, res) => {
|
||||||
const sessionId = req.params.sessionId;
|
const sessionId = req.params.sessionId;
|
||||||
const streamOutPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out');
|
const streamOutPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out');
|
||||||
|
|
||||||
if (!fs.existsSync(streamOutPath)) {
|
if (!fs.existsSync(streamOutPath)) {
|
||||||
return res.status(404).json({ error: 'Session not found' });
|
return res.status(404).json({ error: 'Session not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(streamOutPath, 'utf8');
|
const content = fs.readFileSync(streamOutPath, 'utf8');
|
||||||
const lines = content.trim().split('\n');
|
const lines = content.trim().split('\n');
|
||||||
|
|
||||||
let header = null;
|
let header = null;
|
||||||
const events = [];
|
const events = [];
|
||||||
let startTime = null;
|
let startTime = null;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.trim()) {
|
if (line.trim()) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(line);
|
const parsed = JSON.parse(line);
|
||||||
|
|
||||||
// Header line
|
// Header line
|
||||||
if (parsed.version && parsed.width && parsed.height) {
|
if (parsed.version && parsed.width && parsed.height) {
|
||||||
header = parsed;
|
header = parsed;
|
||||||
|
|
@ -386,19 +386,17 @@ app.get('/api/sessions/:sessionId/snapshot', (req, res) => {
|
||||||
if (startTime === null) {
|
if (startTime === null) {
|
||||||
startTime = parsed[0];
|
startTime = parsed[0];
|
||||||
}
|
}
|
||||||
// Adjust timestamp to start from 0 and compress time
|
events.push([0, parsed[1], parsed[2]]);
|
||||||
const adjustedTime = (parsed[0] - startTime) * 0.1; // 10x speed
|
|
||||||
events.push([adjustedTime, parsed[1], parsed[2]]);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Skip invalid lines
|
// Skip invalid lines
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the complete asciinema cast
|
// Build the complete asciinema cast
|
||||||
const cast = [];
|
const cast = [];
|
||||||
|
|
||||||
// Add header if found, otherwise use default
|
// Add header if found, otherwise use default
|
||||||
if (header) {
|
if (header) {
|
||||||
cast.push(JSON.stringify(header));
|
cast.push(JSON.stringify(header));
|
||||||
|
|
@ -411,15 +409,15 @@ app.get('/api/sessions/:sessionId/snapshot', (req, res) => {
|
||||||
env: { TERM: "xterm-256color" }
|
env: { TERM: "xterm-256color" }
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add all events
|
// Add all events
|
||||||
events.forEach(event => {
|
events.forEach(event => {
|
||||||
cast.push(JSON.stringify(event));
|
cast.push(JSON.stringify(event));
|
||||||
});
|
});
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/plain');
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
res.send(cast.join('\n'));
|
res.send(cast.join('\n'));
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error reading session snapshot:', error);
|
console.error('Error reading session snapshot:', error);
|
||||||
res.status(500).json({ error: 'Failed to read session snapshot' });
|
res.status(500).json({ error: 'Failed to read session snapshot' });
|
||||||
|
|
@ -430,18 +428,18 @@ app.get('/api/sessions/:sessionId/snapshot', (req, res) => {
|
||||||
app.post('/api/sessions/:sessionId/input', async (req, res) => {
|
app.post('/api/sessions/:sessionId/input', async (req, res) => {
|
||||||
const sessionId = req.params.sessionId;
|
const sessionId = req.params.sessionId;
|
||||||
const { text } = req.body;
|
const { text } = req.body;
|
||||||
|
|
||||||
if (text === undefined || text === null) {
|
if (text === undefined || text === null) {
|
||||||
return res.status(400).json({ error: 'Text is required' });
|
return res.status(400).json({ error: 'Text is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Sending input to session ${sessionId}:`, JSON.stringify(text));
|
console.log(`Sending input to session ${sessionId}:`, JSON.stringify(text));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if this is a special key that should use --send-key
|
// 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'];
|
||||||
const isSpecialKey = specialKeys.includes(text);
|
const isSpecialKey = specialKeys.includes(text);
|
||||||
|
|
||||||
if (isSpecialKey) {
|
if (isSpecialKey) {
|
||||||
await executeTtyFwd([
|
await executeTtyFwd([
|
||||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||||
|
|
@ -457,9 +455,9 @@ app.post('/api/sessions/:sessionId/input', async (req, res) => {
|
||||||
]);
|
]);
|
||||||
console.log(`Successfully sent text: ${text}`);
|
console.log(`Successfully sent text: ${text}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sending input via tty-fwd:', error);
|
console.error('Error sending input via tty-fwd:', error);
|
||||||
res.status(500).json({ error: 'Failed to send input' });
|
res.status(500).json({ error: 'Failed to send input' });
|
||||||
|
|
@ -471,23 +469,23 @@ app.post('/api/sessions/:sessionId/input', async (req, res) => {
|
||||||
// Directory listing for file browser
|
// Directory listing for file browser
|
||||||
app.get('/api/fs/browse', (req, res) => {
|
app.get('/api/fs/browse', (req, res) => {
|
||||||
const dirPath = req.query.path as string || '~';
|
const dirPath = req.query.path as string || '~';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const expandedPath = resolvePath(dirPath, '~');
|
const expandedPath = resolvePath(dirPath, '~');
|
||||||
|
|
||||||
if (!fs.existsSync(expandedPath)) {
|
if (!fs.existsSync(expandedPath)) {
|
||||||
return res.status(404).json({ error: 'Directory not found' });
|
return res.status(404).json({ error: 'Directory not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = fs.statSync(expandedPath);
|
const stats = fs.statSync(expandedPath);
|
||||||
if (!stats.isDirectory()) {
|
if (!stats.isDirectory()) {
|
||||||
return res.status(400).json({ error: 'Path is not a directory' });
|
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 filePath = path.join(expandedPath, name);
|
||||||
const fileStats = fs.statSync(filePath);
|
const fileStats = fs.statSync(filePath);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
created: fileStats.birthtime.toISOString(),
|
created: fileStats.birthtime.toISOString(),
|
||||||
|
|
@ -496,7 +494,7 @@ app.get('/api/fs/browse', (req, res) => {
|
||||||
isDir: fileStats.isDirectory()
|
isDir: fileStats.isDirectory()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
absolutePath: expandedPath,
|
absolutePath: expandedPath,
|
||||||
files: files.sort((a, b) => {
|
files: files.sort((a, b) => {
|
||||||
|
|
@ -506,7 +504,7 @@ app.get('/api/fs/browse', (req, res) => {
|
||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error listing directory:', error);
|
console.error('Error listing directory:', error);
|
||||||
res.status(500).json({ error: 'Failed to list directory' });
|
res.status(500).json({ error: 'Failed to list directory' });
|
||||||
|
|
@ -519,7 +517,7 @@ app.get('/api/fs/browse', (req, res) => {
|
||||||
wss.on('connection', (ws, req) => {
|
wss.on('connection', (ws, req) => {
|
||||||
const url = new URL(req.url!, `http://${req.headers.host}`);
|
const url = new URL(req.url!, `http://${req.headers.host}`);
|
||||||
const isHotReload = url.searchParams.get('hotReload') === 'true';
|
const isHotReload = url.searchParams.get('hotReload') === 'true';
|
||||||
|
|
||||||
if (isHotReload) {
|
if (isHotReload) {
|
||||||
hotReloadClients.add(ws);
|
hotReloadClients.add(ws);
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
|
|
@ -527,7 +525,7 @@ wss.on('connection', (ws, req) => {
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.close(1008, 'Only hot reload connections supported');
|
ws.close(1008, 'Only hot reload connections supported');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"cmdline":["bash"],"name":"test-debug","cwd":"/Users/badlogic/workspaces/vibetunnel/web"}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{"version":2,"width":80,"height":24}
|
|
||||||
[0.000356917,"o","^D\b\b"]
|
|
||||||
[0.00358725,"o","\r\nThe default interactive shell is now zsh.\r\nTo update your account to use zsh, please run `chsh -s /bin/zsh`.\r\nFor more details, please visit https://support.apple.com/kb/HT208050.\r\n"]
|
|
||||||
[0.003765042,"o","\u001b[?1034h"]
|
|
||||||
[0.003780584,"o","bash-3.2$ "]
|
|
||||||
[0.00379075,"o","exit\r\n"]
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"cmdline":["sleep","30"],"name":"session_test_manual","cwd":"/Users/badlogic/workspaces/vibetunnel/web"}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
{"version":2,"width":80,"height":24}
|
|
||||||
[0.000313666,"o","^D\b\b"]
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"cmdline":["sleep","10"],"name":"test-sleep","cwd":"/Users/badlogic/workspaces/vibetunnel/web"}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
{"version":2,"width":80,"height":24}
|
|
||||||
[0.000764375,"o","^D\b\b"]
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"cmdline":["bash"],"name":"test-manual","cwd":"/Users/badlogic/workspaces/vibetunnel/web"}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{"version":2,"width":80,"height":24}
|
|
||||||
[0.000316791,"o","^D\b\b"]
|
|
||||||
[0.003504166,"o","\r\nThe default interactive shell is now zsh.\r\nTo update your account to use zsh, please run `chsh -s /bin/zsh`.\r\nFor more details, please visit https://support.apple.com/kb/HT208050.\r\n"]
|
|
||||||
[0.003675083,"o","\u001b[?1034h"]
|
|
||||||
[0.00369025,"o","bash-3.2$ "]
|
|
||||||
[0.003700875,"o","exit\r\n"]
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"cmdline":["echo","hello"],"name":"test-session","cwd":"/Users/badlogic/workspaces/vibetunnel/web"}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
{"version":2,"width":80,"height":24}
|
|
||||||
[0.000257042,"o","^D\b\b"]
|
|
||||||
[0.001932042,"o","hello\r\n"]
|
|
||||||
Loading…
Reference in a new issue