mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-20 13:45:54 +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",
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
// Entry point for the app
|
||||
import './app.js';
|
||||
import './app.js';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -232,14 +230,14 @@ export class VibeTunnelApp extends LitElement {
|
|||
private setupHotReload(): void {
|
||||
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
try {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}?hotReload=true`;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}?hotReload=true`;
|
||||
|
||||
this.hotReloadWs = new WebSocket(wsUrl);
|
||||
this.hotReloadWs.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'reload') {
|
||||
window.location.reload();
|
||||
this.hotReloadWs = new WebSocket(wsUrl);
|
||||
this.hotReloadWs.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'reload') {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
|
|
@ -251,45 +249,53 @@ export class VibeTunnelApp extends LitElement {
|
|||
render() {
|
||||
return html`
|
||||
<!-- Error notification overlay -->
|
||||
${this.errorMessage ? html`
|
||||
<div class="fixed top-4 right-4 z-50">
|
||||
<div class="bg-vs-warning text-vs-bg px-4 py-2 rounded shadow-lg font-mono text-sm">
|
||||
${this.errorMessage}
|
||||
<button @click=${this.clearError} class="ml-2 text-vs-bg hover:text-vs-muted">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${this.errorMessage
|
||||
? html`
|
||||
<div class="fixed top-4 right-4 z-50">
|
||||
<div class="bg-vs-warning text-vs-bg px-4 py-2 rounded shadow-lg font-mono text-sm">
|
||||
${this.errorMessage}
|
||||
<button @click=${this.clearError} class="ml-2 text-vs-bg hover:text-vs-muted">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<!-- Main content -->
|
||||
${this.currentView === 'session' && this.selectedSessionId ?
|
||||
keyed(this.selectedSessionId, html`
|
||||
<session-view
|
||||
.session=${this.sessions.find(s => s.id === this.selectedSessionId)}
|
||||
></session-view>
|
||||
`) : html`
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<app-header
|
||||
.sessions=${this.sessions}
|
||||
.hideExited=${this.hideExited}
|
||||
@create-session=${this.handleCreateSession}
|
||||
@hide-exited-change=${this.handleHideExitedChange}
|
||||
@kill-all-sessions=${this.handleKillAll}
|
||||
></app-header>
|
||||
<session-list
|
||||
.sessions=${this.sessions}
|
||||
.loading=${this.loading}
|
||||
.hideExited=${this.hideExited}
|
||||
.showCreateModal=${this.showCreateModal}
|
||||
@session-killed=${this.handleSessionKilled}
|
||||
@session-created=${this.handleSessionCreated}
|
||||
@create-modal-close=${this.handleCreateModalClose}
|
||||
@refresh=${this.handleRefresh}
|
||||
@error=${this.handleError}
|
||||
@hide-exited-change=${this.handleHideExitedChange}
|
||||
@kill-all-sessions=${this.handleKillAll}
|
||||
></session-list>
|
||||
</div>
|
||||
`}
|
||||
${this.currentView === 'session' && this.selectedSessionId
|
||||
? keyed(
|
||||
this.selectedSessionId,
|
||||
html`
|
||||
<session-view
|
||||
.session=${this.sessions.find((s) => s.id === this.selectedSessionId)}
|
||||
></session-view>
|
||||
`
|
||||
)
|
||||
: html`
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<app-header
|
||||
.sessions=${this.sessions}
|
||||
.hideExited=${this.hideExited}
|
||||
@create-session=${this.handleCreateSession}
|
||||
@hide-exited-change=${this.handleHideExitedChange}
|
||||
@kill-all-sessions=${this.handleKillAll}
|
||||
></app-header>
|
||||
<session-list
|
||||
.sessions=${this.sessions}
|
||||
.loading=${this.loading}
|
||||
.hideExited=${this.hideExited}
|
||||
.showCreateModal=${this.showCreateModal}
|
||||
@session-killed=${this.handleSessionKilled}
|
||||
@session-created=${this.handleSessionCreated}
|
||||
@create-modal-close=${this.handleCreateModalClose}
|
||||
@refresh=${this.handleRefresh}
|
||||
@error=${this.handleError}
|
||||
@hide-exited-change=${this.handleHideExitedChange}
|
||||
@kill-all-sessions=${this.handleKillAll}
|
||||
></session-list>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,12 +18,12 @@ export class AppHeader extends LitElement {
|
|||
|
||||
private handleKillAll() {
|
||||
if (this.killingAll) return;
|
||||
|
||||
|
||||
this.killingAll = true;
|
||||
this.requestUpdate();
|
||||
|
||||
|
||||
this.dispatchEvent(new CustomEvent('kill-all-sessions'));
|
||||
|
||||
|
||||
// Reset the state after a delay to allow for the kill operations to complete
|
||||
setTimeout(() => {
|
||||
this.killingAll = false;
|
||||
|
|
@ -32,8 +32,8 @@ 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) {
|
||||
this.killingAll = false;
|
||||
|
|
@ -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`
|
||||
<svg class="w-3 h-3 text-vs-bg" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
` : ''}
|
||||
${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>
|
||||
</svg>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
hide exited
|
||||
</label>
|
||||
${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>
|
||||
` : ''}
|
||||
${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}
|
||||
|
|
@ -83,4 +101,4 @@ export class AppHeader extends LitElement {
|
|||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,12 +145,17 @@ 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>
|
||||
<button
|
||||
<button
|
||||
class="bg-vs-user text-vs-bg hover:bg-vs-accent font-mono px-2 py-1 text-xs border-none rounded"
|
||||
@click=${this.handleCreateFolder}
|
||||
?disabled=${this.loading}
|
||||
|
|
@ -159,80 +166,90 @@ export class FileBrowser extends LitElement {
|
|||
</div>
|
||||
<div class="text-vs-muted text-sm break-all">${this.currentPath}</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="p-4 flex-1 overflow-y-auto">
|
||||
${this.loading ? html`
|
||||
<div class="text-vs-muted">Loading...</div>
|
||||
` : html`
|
||||
${this.currentPath !== '/' ? html`
|
||||
<div
|
||||
class="flex items-center gap-2 p-2 hover:bg-vs-nav-hover cursor-pointer text-vs-accent"
|
||||
@click=${this.handleParentClick}
|
||||
>
|
||||
<span>📁</span>
|
||||
<span>.. (parent directory)</span>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this.files.filter(f => f.isDir).map(file => html`
|
||||
<div
|
||||
class="flex items-center gap-2 p-2 hover:bg-vs-nav-hover cursor-pointer text-vs-accent"
|
||||
@click=${() => this.handleDirectoryClick(file.name)}
|
||||
>
|
||||
<span>📁</span>
|
||||
<span>${file.name}</span>
|
||||
</div>
|
||||
`)}
|
||||
|
||||
${this.files.filter(f => !f.isDir).map(file => html`
|
||||
<div class="flex items-center gap-2 p-2 text-vs-muted">
|
||||
<span>📄</span>
|
||||
<span>${file.name}</span>
|
||||
</div>
|
||||
`)}
|
||||
`}
|
||||
${this.loading
|
||||
? html` <div class="text-vs-muted">Loading...</div> `
|
||||
: html`
|
||||
${this.currentPath !== '/'
|
||||
? html`
|
||||
<div
|
||||
class="flex items-center gap-2 p-2 hover:bg-vs-nav-hover cursor-pointer text-vs-accent"
|
||||
@click=${this.handleParentClick}
|
||||
>
|
||||
<span>📁</span>
|
||||
<span>.. (parent directory)</span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${this.files
|
||||
.filter((f) => f.isDir)
|
||||
.map(
|
||||
(file) => html`
|
||||
<div
|
||||
class="flex items-center gap-2 p-2 hover:bg-vs-nav-hover cursor-pointer text-vs-accent"
|
||||
@click=${() => this.handleDirectoryClick(file.name)}
|
||||
>
|
||||
<span>📁</span>
|
||||
<span>${file.name}</span>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
${this.files
|
||||
.filter((f) => !f.isDir)
|
||||
.map(
|
||||
(file) => html`
|
||||
<div class="flex items-center gap-2 p-2 text-vs-muted">
|
||||
<span>📄</span>
|
||||
<span>${file.name}</span>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Create folder dialog -->
|
||||
${this.showCreateFolder ? html`
|
||||
<div class="p-4 border-t border-vs-border flex-shrink-0">
|
||||
<div class="text-vs-assistant text-sm mb-2">Create New Folder</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 bg-vs-bg border border-vs-border text-vs-text px-2 py-1 text-sm font-mono"
|
||||
placeholder="Folder name"
|
||||
.value=${this.newFolderName}
|
||||
@input=${this.handleFolderNameInput}
|
||||
@keydown=${this.handleFolderNameKeydown}
|
||||
?disabled=${this.creating}
|
||||
/>
|
||||
<button
|
||||
class="bg-vs-user text-vs-bg hover:bg-vs-accent font-mono px-2 py-1 text-xs border-none"
|
||||
@click=${this.createFolder}
|
||||
?disabled=${this.creating || !this.newFolderName.trim()}
|
||||
>
|
||||
${this.creating ? '...' : 'create'}
|
||||
</button>
|
||||
<button
|
||||
class="bg-vs-muted text-vs-bg hover:bg-vs-text font-mono px-2 py-1 text-xs border-none"
|
||||
@click=${this.handleCancelCreateFolder}
|
||||
?disabled=${this.creating}
|
||||
>
|
||||
cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this.showCreateFolder
|
||||
? html`
|
||||
<div class="p-4 border-t border-vs-border flex-shrink-0">
|
||||
<div class="text-vs-assistant text-sm mb-2">Create New Folder</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 bg-vs-bg border border-vs-border text-vs-text px-2 py-1 text-sm font-mono"
|
||||
placeholder="Folder name"
|
||||
.value=${this.newFolderName}
|
||||
@input=${this.handleFolderNameInput}
|
||||
@keydown=${this.handleFolderNameKeydown}
|
||||
?disabled=${this.creating}
|
||||
/>
|
||||
<button
|
||||
class="bg-vs-user text-vs-bg hover:bg-vs-accent font-mono px-2 py-1 text-xs border-none"
|
||||
@click=${this.createFolder}
|
||||
?disabled=${this.creating || !this.newFolderName.trim()}
|
||||
>
|
||||
${this.creating ? '...' : 'create'}
|
||||
</button>
|
||||
<button
|
||||
class="bg-vs-muted text-vs-bg hover:bg-vs-text font-mono px-2 py-1 text-xs border-none"
|
||||
@click=${this.handleCancelCreateFolder}
|
||||
?disabled=${this.creating}
|
||||
>
|
||||
cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<div class="p-4 border-t border-vs-border flex gap-4 justify-end flex-shrink-0">
|
||||
<button
|
||||
<button
|
||||
class="bg-vs-muted text-vs-bg hover:bg-vs-text font-mono px-4 py-2 border-none"
|
||||
@click=${this.handleCancel}
|
||||
>
|
||||
cancel
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
class="bg-vs-user text-vs-bg hover:bg-vs-accent font-mono px-4 py-2 border-none"
|
||||
@click=${this.handleSelect}
|
||||
>
|
||||
|
|
@ -243,4 +260,4 @@ export class FileBrowser extends LitElement {
|
|||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
detail: this.session,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('session-select', {
|
||||
detail: this.session,
|
||||
bubbles: 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,37 +181,46 @@ 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`
|
||||
<button
|
||||
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-2 py-0.5 border-none text-xs disabled:opacity-50 flex-shrink-0 rounded"
|
||||
@click=${this.handleKillClick}
|
||||
?disabled=${this.killing}
|
||||
>
|
||||
${this.killing ? 'killing...' : 'kill'}
|
||||
</button>
|
||||
` : ''}
|
||||
<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}
|
||||
?disabled=${this.killing}
|
||||
>
|
||||
${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`
|
||||
<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>
|
||||
`}
|
||||
${this.killing
|
||||
? html`
|
||||
<div class="w-full h-full flex items-center justify-center text-vs-warning">
|
||||
<div class="text-center font-mono">
|
||||
<div class="text-4xl mb-2">${this.getKillingText()}</div>
|
||||
<div class="text-sm">Killing session...</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html` <div id="player" class="w-full h-full"></div> `}
|
||||
</div>
|
||||
|
||||
<!-- Compact Footer -->
|
||||
|
|
@ -220,17 +230,21 @@ export class SessionCard extends LitElement {
|
|||
<div class="w-2 h-2 rounded-full ${this.getStatusDotColor()}"></div>
|
||||
${this.getStatusText()}
|
||||
</span>
|
||||
${this.session.pid ? html`
|
||||
<span
|
||||
class="cursor-pointer hover:text-vs-accent transition-colors"
|
||||
@click=${this.handlePidClick}
|
||||
title="Click to copy PID"
|
||||
>
|
||||
PID: ${this.session.pid} <span class="opacity-50">(click to copy)</span>
|
||||
</span>
|
||||
` : ''}
|
||||
${this.session.pid
|
||||
? html`
|
||||
<span
|
||||
class="cursor-pointer hover:text-vs-accent transition-colors"
|
||||
@click=${this.handlePidClick}
|
||||
title="Click to copy PID"
|
||||
>
|
||||
PID: ${this.session.pid} <span class="opacity-50">(click to copy)</span>
|
||||
</span>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
<div class="truncate text-xs opacity-75" title="${this.session.workingDir}">
|
||||
${this.session.workingDir}
|
||||
</div>
|
||||
<div 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';
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,16 +35,16 @@ export class SessionCreateForm extends LitElement {
|
|||
try {
|
||||
const savedWorkingDir = localStorage.getItem(this.STORAGE_KEY_WORKING_DIR);
|
||||
const savedCommand = localStorage.getItem(this.STORAGE_KEY_COMMAND);
|
||||
|
||||
|
||||
console.log('Loading from localStorage:', { savedWorkingDir, savedCommand });
|
||||
|
||||
|
||||
if (savedWorkingDir) {
|
||||
this.workingDir = savedWorkingDir;
|
||||
}
|
||||
if (savedCommand) {
|
||||
this.command = savedCommand;
|
||||
}
|
||||
|
||||
|
||||
// Force re-render to update the input values
|
||||
this.requestUpdate();
|
||||
} catch (error) {
|
||||
|
|
@ -56,9 +56,9 @@ export class SessionCreateForm extends LitElement {
|
|||
try {
|
||||
const workingDir = this.workingDir.trim();
|
||||
const command = this.command.trim();
|
||||
|
||||
|
||||
console.log('Saving to localStorage:', { workingDir, command });
|
||||
|
||||
|
||||
// Only save non-empty values
|
||||
if (workingDir) {
|
||||
localStorage.setItem(this.STORAGE_KEY_WORKING_DIR, workingDir);
|
||||
|
|
@ -73,7 +73,7 @@ export class SessionCreateForm extends LitElement {
|
|||
|
||||
updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
|
||||
// Load from localStorage when form becomes visible
|
||||
if (changedProperties.has('visible') && this.visible) {
|
||||
this.loadFromLocalStorage();
|
||||
|
|
@ -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,37 +122,43 @@ 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) {
|
||||
const result = await response.json();
|
||||
|
||||
|
||||
// Save to localStorage before clearing the command
|
||||
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;
|
||||
}
|
||||
|
|
@ -163,7 +173,7 @@ export class SessionCreateForm extends LitElement {
|
|||
|
||||
for (let i = 0; i < commandStr.length; i++) {
|
||||
const char = commandStr[i];
|
||||
|
||||
|
||||
if ((char === '"' || char === "'") && !inQuotes) {
|
||||
inQuotes = true;
|
||||
quoteChar = char;
|
||||
|
|
@ -179,11 +189,11 @@ export class SessionCreateForm extends LitElement {
|
|||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (current) {
|
||||
args.push(current);
|
||||
}
|
||||
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
|
|
@ -197,64 +207,73 @@ 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
|
||||
<button
|
||||
class="text-vs-muted hover:text-vs-text text-lg leading-none border-none bg-transparent cursor-pointer"
|
||||
@click=${this.handleCancel}
|
||||
>×</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-vs-text mb-2">Working Directory:</div>
|
||||
<div class="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 bg-vs-bg text-vs-text border border-vs-border outline-none font-mono px-4 py-2"
|
||||
.value=${this.workingDir}
|
||||
@input=${this.handleWorkingDirChange}
|
||||
placeholder="~/"
|
||||
?disabled=${this.disabled || this.isCreating}
|
||||
/>
|
||||
<button
|
||||
class="bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-4 py-2 border-none"
|
||||
@click=${this.handleBrowse}
|
||||
?disabled=${this.disabled || this.isCreating}
|
||||
>
|
||||
browse
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-vs-text mb-2">Command:</div>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-vs-bg text-vs-text border border-vs-border outline-none font-mono px-4 py-2"
|
||||
.value=${this.command}
|
||||
@input=${this.handleCommandChange}
|
||||
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleCreate()}
|
||||
placeholder="zsh"
|
||||
?disabled=${this.disabled || this.isCreating}
|
||||
/>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="mb-4">
|
||||
<div class="text-vs-text mb-2">Working Directory:</div>
|
||||
<div class="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 bg-vs-bg text-vs-text border border-vs-border outline-none font-mono px-4 py-2"
|
||||
.value=${this.workingDir}
|
||||
@input=${this.handleWorkingDirChange}
|
||||
placeholder="~/"
|
||||
?disabled=${this.disabled || this.isCreating}
|
||||
/>
|
||||
<button
|
||||
class="bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-4 py-2 border-none"
|
||||
@click=${this.handleBrowse}
|
||||
?disabled=${this.disabled || this.isCreating}
|
||||
>
|
||||
browse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-vs-text mb-2">Command:</div>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-vs-bg text-vs-text border border-vs-border outline-none font-mono px-4 py-2"
|
||||
.value=${this.command}
|
||||
@input=${this.handleCommandChange}
|
||||
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleCreate()}
|
||||
placeholder="zsh"
|
||||
?disabled=${this.disabled || this.isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 justify-end">
|
||||
<button
|
||||
<button
|
||||
class="bg-vs-muted text-vs-bg hover:bg-vs-text font-mono px-4 py-2 border-none"
|
||||
@click=${this.handleCancel}
|
||||
?disabled=${this.isCreating}
|
||||
>
|
||||
cancel
|
||||
</button>
|
||||
<button
|
||||
<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>
|
||||
|
|
@ -262,7 +281,7 @@ export class SessionCreateForm extends LitElement {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<file-browser
|
||||
.visible=${this.showFileBrowser}
|
||||
.currentPath=${this.workingDir}
|
||||
|
|
@ -271,4 +290,4 @@ export class SessionCreateForm extends LitElement {
|
|||
></file-browser>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,22 +39,23 @@ export class SessionList extends LitElement {
|
|||
window.location.search = `?session=${session.id}`;
|
||||
}
|
||||
|
||||
|
||||
private async handleCleanupExited() {
|
||||
if (this.cleaningExited) return;
|
||||
|
||||
|
||||
this.cleaningExited = true;
|
||||
this.requestUpdate();
|
||||
|
||||
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,48 +66,59 @@ export class SessionList extends LitElement {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
const filteredSessions = this.hideExited
|
||||
? this.sessions.filter(session => session.status !== 'exited')
|
||||
const filteredSessions = this.hideExited
|
||||
? 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`
|
||||
<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"
|
||||
@click=${this.handleCleanupExited}
|
||||
?disabled=${this.cleaningExited}
|
||||
>
|
||||
${this.cleaningExited ? '[~] CLEANING...' : 'CLEAN EXITED'}
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
${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')}
|
||||
</div>
|
||||
` : 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}>
|
||||
</session-card>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
${!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"
|
||||
@click=${this.handleCleanupExited}
|
||||
?disabled=${this.cleaningExited}
|
||||
>
|
||||
${this.cleaningExited ? '[~] CLEANING...' : 'CLEAN EXITED'}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${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'}
|
||||
</div>
|
||||
`
|
||||
: 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}>
|
||||
</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,8 +78,9 @@ export class SessionView extends LitElement {
|
|||
}
|
||||
|
||||
// Detect mobile device
|
||||
this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
||||
window.innerWidth <= 768;
|
||||
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
|
||||
if (!this.isMobile && !this.keyboardListenerAdded) {
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
@ -158,7 +157,7 @@ export class SessionView extends LitElement {
|
|||
// Create the interactive terminal div inside the container
|
||||
const container = this.querySelector('#terminal-container') as HTMLElement;
|
||||
if (!container) return;
|
||||
|
||||
|
||||
terminalElement = document.createElement('div');
|
||||
terminalElement.id = 'interactive-terminal';
|
||||
terminalElement.className = 'w-full h-full';
|
||||
|
|
@ -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) {
|
||||
|
|
@ -200,10 +202,10 @@ export class SessionView extends LitElement {
|
|||
|
||||
// Handle clipboard shortcuts: Cmd+C/V on macOS, Shift+Ctrl+C/V on Linux/Windows
|
||||
const isMacOS = navigator.platform.toLowerCase().includes('mac');
|
||||
const isPasteShortcut =
|
||||
const isPasteShortcut =
|
||||
(isMacOS && e.metaKey && e.key === 'v' && !e.ctrlKey && !e.shiftKey) ||
|
||||
(!isMacOS && e.ctrlKey && e.shiftKey && e.key === 'V');
|
||||
const isCopyShortcut =
|
||||
const isCopyShortcut =
|
||||
(isMacOS && e.metaKey && e.key === 'c' && !e.ctrlKey && !e.shiftKey) ||
|
||||
(!isMacOS && e.ctrlKey && e.shiftKey && e.key === 'C');
|
||||
|
||||
|
|
@ -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,14 +505,13 @@ export class SessionView extends LitElement {
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
private async handlePaste() {
|
||||
if (!this.session) return;
|
||||
|
||||
try {
|
||||
// Try clipboard API first (requires user activation)
|
||||
const clipboardText = await navigator.clipboard.readText();
|
||||
|
||||
|
||||
if (clipboardText) {
|
||||
// Send the clipboard text to the terminal
|
||||
await this.sendInputText(clipboardText);
|
||||
|
|
@ -517,7 +520,7 @@ export class SessionView extends LitElement {
|
|||
console.error('Failed to read from clipboard:', error);
|
||||
// Show user a message about using Ctrl+V instead
|
||||
console.log('Tip: Try using Ctrl+V (Cmd+V on Mac) to paste instead');
|
||||
|
||||
|
||||
// Fallback: try to use the older document.execCommand method
|
||||
try {
|
||||
const textArea = document.createElement('textarea');
|
||||
|
|
@ -527,14 +530,14 @@ export class SessionView extends LitElement {
|
|||
textArea.style.top = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
|
||||
|
||||
if (document.execCommand('paste')) {
|
||||
const pastedText = textArea.value;
|
||||
if (pastedText) {
|
||||
await this.sendInputText(pastedText);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
} catch (fallbackError) {
|
||||
console.error('Fallback paste method also failed:', fallbackError);
|
||||
|
|
@ -549,14 +552,17 @@ export class SessionView extends LitElement {
|
|||
try {
|
||||
// Get the terminal instance from the renderer
|
||||
const terminal = this.renderer.getTerminal();
|
||||
|
||||
|
||||
// Get the selected text from the terminal
|
||||
const selectedText = terminal.getSelection();
|
||||
|
||||
|
||||
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');
|
||||
}
|
||||
|
|
@ -567,7 +573,7 @@ export class SessionView extends LitElement {
|
|||
if (this.renderer) {
|
||||
const terminal = this.renderer.getTerminal();
|
||||
const selectedText = terminal.getSelection();
|
||||
|
||||
|
||||
if (selectedText) {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = selectedText;
|
||||
|
|
@ -575,11 +581,14 @@ export class SessionView extends LitElement {
|
|||
textArea.style.opacity = '0';
|
||||
document.body.appendChild(textArea);
|
||||
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,147 +691,168 @@ 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`
|
||||
<!-- Loading overlay -->
|
||||
<div class="absolute inset-0 bg-black bg-opacity-80 flex items-center justify-center">
|
||||
<div class="text-vs-text font-mono text-center">
|
||||
<div class="text-2xl mb-2">${this.getLoadingText()}</div>
|
||||
<div class="text-sm text-vs-muted">Connecting to session...</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div
|
||||
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="text-vs-text font-mono text-center">
|
||||
<div class="text-2xl mb-2">${this.getLoadingText()}</div>
|
||||
<div class="text-sm text-vs-muted">Connecting to session...</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
|
||||
<!-- Mobile Input Controls -->
|
||||
${this.isMobile && !this.showMobileInput ? html`
|
||||
<div class="flex-shrink-0 p-4 bg-vs-bg">
|
||||
<!-- First row: Arrow keys -->
|
||||
<div class="flex gap-2 mb-2">
|
||||
<button
|
||||
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('arrow_up')}
|
||||
>
|
||||
<span class="text-xl">↑</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('arrow_down')}
|
||||
>
|
||||
<span class="text-xl">↓</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('arrow_left')}
|
||||
>
|
||||
<span class="text-xl">←</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('arrow_right')}
|
||||
>
|
||||
<span class="text-xl">→</span>
|
||||
</button>
|
||||
</div>
|
||||
${this.isMobile && !this.showMobileInput
|
||||
? html`
|
||||
<div class="flex-shrink-0 p-4 bg-vs-bg">
|
||||
<!-- First row: Arrow keys -->
|
||||
<div class="flex gap-2 mb-2">
|
||||
<button
|
||||
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('arrow_up')}
|
||||
>
|
||||
<span class="text-xl">↑</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('arrow_down')}
|
||||
>
|
||||
<span class="text-xl">↓</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('arrow_left')}
|
||||
>
|
||||
<span class="text-xl">←</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('arrow_right')}
|
||||
>
|
||||
<span class="text-xl">→</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Second row: Special keys -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('\t')}
|
||||
>
|
||||
<span class="text-xl">⇥</span>
|
||||
</button>
|
||||
<button
|
||||
class="bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('enter')}
|
||||
>
|
||||
<span class="text-xl">⏎</span>
|
||||
</button>
|
||||
<button
|
||||
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('escape')}
|
||||
>
|
||||
ESC
|
||||
</button>
|
||||
<button
|
||||
class="bg-vs-error text-vs-text hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('\x03')}
|
||||
>
|
||||
^C
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${this.handleMobileInputToggle}
|
||||
>
|
||||
TYPE
|
||||
</button>
|
||||
<!-- Second row: Special keys -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('\t')}
|
||||
>
|
||||
<span class="text-xl">⇥</span>
|
||||
</button>
|
||||
<button
|
||||
class="bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('enter')}
|
||||
>
|
||||
<span class="text-xl">⏎</span>
|
||||
</button>
|
||||
<button
|
||||
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('escape')}
|
||||
>
|
||||
ESC
|
||||
</button>
|
||||
<button
|
||||
class="bg-vs-error text-vs-text hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${() => this.handleSpecialKey('\x03')}
|
||||
>
|
||||
^C
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||
@click=${this.handleMobileInputToggle}
|
||||
>
|
||||
TYPE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`
|
||||
: ''}
|
||||
|
||||
<!-- Full-Screen Input Overlay (only when opened) -->
|
||||
${this.isMobile && this.showMobileInput ? html`
|
||||
<div class="fixed inset-0 bg-vs-bg-secondary bg-opacity-95 z-50 flex flex-col" style="height: 100vh; height: 100dvh;">
|
||||
<!-- Input Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-vs-border flex-shrink-0">
|
||||
<div class="text-vs-text font-mono text-sm">Terminal Input</div>
|
||||
<button
|
||||
class="text-vs-muted hover:text-vs-text text-lg leading-none border-none bg-transparent cursor-pointer"
|
||||
@click=${this.handleMobileInputToggle}
|
||||
${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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Input Area with dynamic height -->
|
||||
<div class="flex-1 p-4 flex flex-col min-h-0">
|
||||
<div class="text-vs-muted text-sm mb-2 flex-shrink-0">
|
||||
Type your command(s) below. Supports multiline input.
|
||||
</div>
|
||||
<textarea
|
||||
id="mobile-input-textarea"
|
||||
class="flex-1 bg-vs-bg text-vs-text border border-vs-border font-mono text-sm p-4 resize-none outline-none"
|
||||
placeholder="Enter your command here..."
|
||||
.value=${this.mobileInputText}
|
||||
@input=${this.handleMobileInputChange}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.handleMobileInputSend();
|
||||
}
|
||||
}}
|
||||
style="min-height: 120px; margin-bottom: 16px;"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Controls - Fixed above keyboard -->
|
||||
<div id="mobile-controls" class="fixed bottom-0 left-0 right-0 p-4 border-t border-vs-border bg-vs-bg-secondary z-60" style="padding-bottom: max(1rem, env(safe-area-inset-bottom)); transform: translateY(0px);">
|
||||
<!-- Send Buttons Row -->
|
||||
<div class="flex gap-2 mb-3">
|
||||
<div class="text-vs-text font-mono text-sm">Terminal Input</div>
|
||||
<button
|
||||
class="flex-1 bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-3 border-none rounded transition-colors text-sm font-bold"
|
||||
@click=${this.handleMobileInputSendOnly}
|
||||
?disabled=${!this.mobileInputText.trim()}
|
||||
class="text-vs-muted hover:text-vs-text text-lg leading-none border-none bg-transparent cursor-pointer"
|
||||
@click=${this.handleMobileInputToggle}
|
||||
>
|
||||
SEND
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-4 py-3 border-none rounded transition-colors text-sm font-bold"
|
||||
@click=${this.handleMobileInputSend}
|
||||
?disabled=${!this.mobileInputText.trim()}
|
||||
>
|
||||
SEND + ENTER
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-vs-muted text-xs text-center">
|
||||
SEND: text only • SEND + ENTER: text with enter key
|
||||
<!-- Input Area with dynamic height -->
|
||||
<div class="flex-1 p-4 flex flex-col min-h-0">
|
||||
<div class="text-vs-muted text-sm mb-2 flex-shrink-0">
|
||||
Type your command(s) below. Supports multiline input.
|
||||
</div>
|
||||
<textarea
|
||||
id="mobile-input-textarea"
|
||||
class="flex-1 bg-vs-bg text-vs-text border border-vs-border font-mono text-sm p-4 resize-none outline-none"
|
||||
placeholder="Enter your command here..."
|
||||
.value=${this.mobileInputText}
|
||||
@input=${this.handleMobileInputChange}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.handleMobileInputSend();
|
||||
}
|
||||
}}
|
||||
style="min-height: 120px; margin-bottom: 16px;"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Controls - Fixed above keyboard -->
|
||||
<div
|
||||
id="mobile-controls"
|
||||
class="fixed bottom-0 left-0 right-0 p-4 border-t border-vs-border bg-vs-bg-secondary z-60"
|
||||
style="padding-bottom: max(1rem, env(safe-area-inset-bottom)); transform: translateY(0px);"
|
||||
>
|
||||
<!-- Send Buttons Row -->
|
||||
<div class="flex gap-2 mb-3">
|
||||
<button
|
||||
class="flex-1 bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-3 border-none rounded transition-colors text-sm font-bold"
|
||||
@click=${this.handleMobileInputSendOnly}
|
||||
?disabled=${!this.mobileInputText.trim()}
|
||||
>
|
||||
SEND
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-4 py-3 border-none rounded transition-colors text-sm font-bold"
|
||||
@click=${this.handleMobileInputSend}
|
||||
?disabled=${!this.mobileInputText.trim()}
|
||||
>
|
||||
SEND + ENTER
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-vs-muted text-xs text-center">
|
||||
SEND: text only • SEND + ENTER: text with enter key
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
// Entry point for renderer bundle - exports XTerm-based renderer
|
||||
export { Renderer } from './renderer';
|
||||
export { Renderer } from './renderer';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -379,4 +386,4 @@ export class Renderer {
|
|||
terminalElement.style.pointerEvents = enabled ? 'auto' : 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -27,19 +26,19 @@ export class ScaleFitAddon implements ITerminalAddon {
|
|||
public dispose(): void {}
|
||||
|
||||
public fit(): void {
|
||||
// For full terminals, resize both font and dimensions
|
||||
const dims = this.proposeDimensions();
|
||||
if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) {
|
||||
return;
|
||||
}
|
||||
// For full terminals, resize both font and dimensions
|
||||
const dims = this.proposeDimensions();
|
||||
if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only resize rows, keep cols the same (font scaling handles width)
|
||||
if (this._terminal.rows !== dims.rows) {
|
||||
this._terminal.resize(this._terminal.cols, dims.rows);
|
||||
}
|
||||
// Only resize rows, keep cols the same (font scaling handles width)
|
||||
if (this._terminal.rows !== dims.rows) {
|
||||
this._terminal.resize(this._terminal.cols, dims.rows);
|
||||
}
|
||||
|
||||
// Force responsive sizing by overriding XTerm's fixed dimensions
|
||||
this.forceResponsiveSizing();
|
||||
// Force responsive sizing by overriding XTerm's fixed dimensions
|
||||
this.forceResponsiveSizing();
|
||||
}
|
||||
|
||||
public proposeDimensions(): ITerminalDimensions | undefined {
|
||||
|
|
@ -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,8 +199,9 @@ 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')) +
|
||||
parseInt(elementStyle.getPropertyValue('padding-right'));
|
||||
const paddingHor =
|
||||
parseInt(elementStyle.getPropertyValue('padding-left')) +
|
||||
parseInt(elementStyle.getPropertyValue('padding-right'));
|
||||
|
||||
const availableWidth = parentWidth - paddingHor;
|
||||
const charWidthRatio = 0.63;
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -285,4 +291,4 @@ export class ScaleFitAddon implements ITerminalAddon {
|
|||
xtermViewport.style.maxWidth = '100%';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1384
web/src/server.ts
1384
web/src/server.ts
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue