mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
Fix mobile terminal resize loop (#305)
This commit is contained in:
parent
b16035b9f8
commit
0c617aed8d
50 changed files with 674 additions and 366 deletions
365
docs/architecture-mario.md
Normal file
365
docs/architecture-mario.md
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
# VibeTunnel Architecture Analysis - Mario's Technical Deep Dive
|
||||
|
||||
This document contains comprehensive technical insights from Mario's debugging session about VibeTunnel's architecture, critical performance issues, and detailed solutions.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Mario identified two critical issues causing performance problems in VibeTunnel:
|
||||
|
||||
1. **850MB Session Bug**: External terminal sessions (via `fwd.ts`) bypass the clear sequence truncation in `stream-watcher.ts`, sending entire gigabyte files instead of the last 2MB
|
||||
2. **Resize Loop**: Claude terminal app issues full clear sequence (`\x1b[2J`) and re-renders entire scroll buffer on every resize event, creating exponential data growth
|
||||
|
||||
Note: A third issue with Node-PTY's shared pipe architecture causing Electron crashes has already been resolved with a custom PTY implementation.
|
||||
|
||||
## System Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||
│ Client │────▶│ Web Server │────▶│ PTY Process │
|
||||
└─────────────┘ └──────────────┘ └─────────────┘
|
||||
│ │ │
|
||||
│ ▼ ▼
|
||||
│ ┌──────────┐ ┌──────────┐
|
||||
└─────────────▶│ Terminal │ │ Ascinema │
|
||||
│ Manager │ │ Files │
|
||||
└──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
### Detailed Sequence Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as Browser/Electron
|
||||
participant WS as WebSocket
|
||||
participant Server as Node Server
|
||||
participant PTY as PTYManager
|
||||
participant FWD as fwd.ts
|
||||
participant Proc as User Process
|
||||
|
||||
UI->>WS: keystrokes
|
||||
WS->>Server: /api/sessions/:id/input
|
||||
Server->>PTY: IPC Socket message
|
||||
PTY->>Proc: write to stdin
|
||||
Proc-->>PTY: stdout (ANSI sequences)
|
||||
PTY-->>Server: write to ascinema file
|
||||
alt External Terminal
|
||||
FWD-->>TTY: mirror stdout to terminal
|
||||
end
|
||||
Server-->>UI: SSE stream (truncated)
|
||||
```
|
||||
|
||||
### Key Files and Their Roles
|
||||
|
||||
| File | Purpose | Critical Functions |
|
||||
|------|---------|-------------------|
|
||||
| `server.ts` | Main web server | HTTP endpoints, WebSocket handling |
|
||||
| `pty-manager.ts` | PTY lifecycle management | `createSession()`, `setupPtyHandlers()` |
|
||||
| `stream-watcher.ts` | Monitors ascinema files | `sendExistingContent()` - implements clear truncation |
|
||||
| `fwd.ts` | External terminal forwarding | Process spawning, **BYPASSES TRUNCATION** |
|
||||
| `terminal-manager.ts` | Binary buffer rendering | Converts ANSI to binary cells format |
|
||||
|
||||
### Data Flow Paths
|
||||
|
||||
#### Input Path (Keystroke → Terminal)
|
||||
1. Browser captures key press
|
||||
2. WebSocket sends to `/api/sessions/:id/input`
|
||||
3. Server writes to IPC socket
|
||||
4. PTY Manager writes to process stdin
|
||||
5. Process executes command
|
||||
|
||||
#### Output Path (Terminal → Browser)
|
||||
1. Process writes to stdout
|
||||
2. PTY Manager captures via `onData` handler
|
||||
3. Writes to ascinema file (with write queue for backpressure)
|
||||
4. Stream watcher monitors file changes
|
||||
5. For existing content: **scans for last clear sequence**
|
||||
6. Client receives via:
|
||||
- SSE: `/api/sessions/:id/stream` (text/ascinema format)
|
||||
- WebSocket: `/buffers` (binary cell format)
|
||||
|
||||
### Binary Cell Buffer Format
|
||||
|
||||
The terminal manager pre-renders terminal output into a binary format for efficient transmission:
|
||||
|
||||
```
|
||||
For each cell at (row, column):
|
||||
- Character (UTF-8 encoded)
|
||||
- Foreground color (RGB values)
|
||||
- Background color (RGB values)
|
||||
- Attributes (bold, italic, underline, etc.)
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- Server-side ANSI parsing eliminates client CPU usage
|
||||
- Efficient binary transmission reduces bandwidth
|
||||
- Only last 10,000 lines kept in memory
|
||||
- Client simply renders pre-computed cells
|
||||
|
||||
## Critical Bugs Analysis
|
||||
|
||||
### 1. The 850MB Session Loading Bug
|
||||
|
||||
**Symptom**: Sessions with large output (850MB+) cause infinite loading and browser unresponsiveness.
|
||||
|
||||
**Root Cause**: External terminal sessions via `fwd.ts` bypass the clear sequence truncation logic.
|
||||
|
||||
**Technical Details**:
|
||||
```javascript
|
||||
// In stream-watcher.ts - WORKING CORRECTLY
|
||||
sendExistingContent() {
|
||||
// Scans backwards for last clear sequence
|
||||
const lastClear = content.lastIndexOf('\x1b[2J');
|
||||
// Sends only content after clear
|
||||
return content.slice(lastClear); // 2MB instead of 850MB
|
||||
}
|
||||
```
|
||||
|
||||
**Evidence from Testing**:
|
||||
- Test file: 980MB containing 2,400 clear sequences
|
||||
- Server-created sessions: Correctly send only last 2MB
|
||||
- External terminal sessions: Send entire 980MB file
|
||||
- Processing time: 2-3 seconds to scan 1GB file
|
||||
- Client receives instant replay for 2MB truncated content
|
||||
|
||||
**The Issue**: External terminal path doesn't trigger `sendExistingContent()`, sending gigabyte files to clients.
|
||||
|
||||
### 2. Resize Event Performance Catastrophe
|
||||
|
||||
**Problem**: Each resize event causes Claude to re-render the entire terminal history.
|
||||
|
||||
**Claude's Behavior**:
|
||||
```
|
||||
1. Resize event received
|
||||
2. Claude issues clear sequence: \x1b[2J
|
||||
3. Re-renders ENTIRE scroll buffer from line 1
|
||||
4. Rendering causes viewport changes
|
||||
5. Viewport changes trigger resize event
|
||||
6. GOTO step 1 (infinite loop)
|
||||
```
|
||||
|
||||
**Technical Evidence**:
|
||||
- In 850MB session: each resize → full buffer re-render
|
||||
- Claude renders from "Welcome to Claude" message every time
|
||||
- Mobile UI particularly problematic (frequent resize events)
|
||||
- Header button position shifts during rendering indicate viewport instability
|
||||
- Session with 39 resize events can generate 850MB+ files
|
||||
|
||||
**Contributing Factors**:
|
||||
- React Ink (TUI framework) unnecessarily re-renders entire components
|
||||
- Session-detail-view has buggy resize observer
|
||||
- Mobile Safari behaves differently than desktop at same viewport size
|
||||
- Touch events vs mouse events complicate scrolling behavior
|
||||
|
||||
### 3. Node-PTY Architecture Flaw (ALREADY FIXED)
|
||||
|
||||
This issue has been resolved by implementing a custom PTY solution without the shared pipe architecture.
|
||||
|
||||
## Ascinema Format Details
|
||||
|
||||
VibeTunnel uses the ascinema format for recording terminal sessions:
|
||||
|
||||
```javascript
|
||||
// Format: [timestamp, event_type, data]
|
||||
[1.234, "o", "Hello World\n"] // Output event
|
||||
[1.235, "i", "k"] // Input event (keypress)
|
||||
[1.236, "r", "80x24"] // Resize event
|
||||
```
|
||||
|
||||
Clear sequence detection:
|
||||
```javascript
|
||||
const CLEAR_SEQUENCE = '\x1b[2J'; // ANSI clear screen
|
||||
const CLEAR_WITH_HOME = '\x1b[H\x1b[2J'; // Home + clear
|
||||
|
||||
function findLastClearSequence(buffer) {
|
||||
// Search from end for efficiency
|
||||
let lastClear = buffer.lastIndexOf(CLEAR_SEQUENCE);
|
||||
return lastClear === -1 ? 0 : lastClear;
|
||||
}
|
||||
```
|
||||
|
||||
## Proposed Solutions
|
||||
|
||||
### Priority 1: Fix External Terminal Clear Truncation (IMMEDIATE)
|
||||
|
||||
**Problem**: External terminal sessions don't use `sendExistingContent()` truncation.
|
||||
|
||||
**Investigation Needed**:
|
||||
1. Trace how `fwd.ts` connects to client streams
|
||||
2. Determine why it bypasses stream-watcher's truncation
|
||||
3. Ensure external terminals use same code path as server sessions
|
||||
4. Test with 980MB file to verify fix
|
||||
|
||||
**Expected Impact**: Immediate fix for users experiencing infinite loading with large sessions.
|
||||
|
||||
### Priority 2: Fix Resize Handling (INVESTIGATION)
|
||||
|
||||
**Debugging Approach**:
|
||||
1. Instrument session-detail-view with resize observer logging
|
||||
2. Identify what causes viewport expansion
|
||||
3. Implement resize event debouncing
|
||||
4. Fix mobile-specific issues:
|
||||
- Keyboard state affects scrolling
|
||||
- Touch vs mouse event handling
|
||||
- Scrollbar visibility problems
|
||||
|
||||
**Code to Add**:
|
||||
```javascript
|
||||
// Add to session-detail-view
|
||||
let resizeCount = 0;
|
||||
new ResizeObserver((entries) => {
|
||||
console.log(`Resize ${++resizeCount}:`, entries[0].contentRect);
|
||||
// Debounce resize events
|
||||
clearTimeout(this.resizeTimeout);
|
||||
this.resizeTimeout = setTimeout(() => {
|
||||
this.handleResize();
|
||||
}, 100);
|
||||
}).observe(this.terminalElement);
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Write Queue Implementation
|
||||
|
||||
The PTY manager implements backpressure handling:
|
||||
|
||||
```javascript
|
||||
class WriteQueue {
|
||||
constructor(writer) {
|
||||
this.queue = [];
|
||||
this.writing = false;
|
||||
this.writer = writer;
|
||||
}
|
||||
|
||||
async write(data) {
|
||||
this.queue.push(data);
|
||||
if (!this.writing) {
|
||||
await this.flush();
|
||||
}
|
||||
}
|
||||
|
||||
async flush() {
|
||||
this.writing = true;
|
||||
while (this.queue.length > 0) {
|
||||
const chunk = this.queue.shift();
|
||||
await this.writer.write(chunk);
|
||||
}
|
||||
this.writing = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Platform-Specific Considerations
|
||||
|
||||
**macOS**:
|
||||
- Screen Recording permission required for terminal access
|
||||
- Terminal.app specific behaviors and quirks
|
||||
|
||||
**Mobile Safari**:
|
||||
- Different behavior than desktop Safari at same viewport
|
||||
- Touch events complicate scrolling
|
||||
- Keyboard state affects scroll behavior
|
||||
- Missing/hidden scrollbars
|
||||
- Viewport meta tag issues
|
||||
|
||||
**Windows (Future)**:
|
||||
- ConPTY vs WinPTY support
|
||||
- Different ANSI sequence handling
|
||||
- Path normalization requirements
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
| Metric | Current | After Fix |
|
||||
|--------|---------|-----------|
|
||||
| 980MB session initial load | Infinite/Crash | 2-3 seconds |
|
||||
| Data sent to client | 980MB | 2MB |
|
||||
| Memory per terminal | 50-100MB | Target: 10MB |
|
||||
| Clear sequence scan time | N/A | ~2 seconds for 1GB |
|
||||
| Resize event storms | Exponential growth | Debounced |
|
||||
|
||||
## Testing and Debugging
|
||||
|
||||
### Test Large Session Handling
|
||||
```bash
|
||||
# Create large session file
|
||||
cd web
|
||||
npm run dev
|
||||
|
||||
# In another terminal, create session
|
||||
SESSION_ID=$(curl -X POST localhost:3000/api/sessions | jq -r .id)
|
||||
|
||||
# Stop server, inject large file
|
||||
cp /path/to/850mb-test-file ~/.vibetunnel/sessions/$SESSION_ID/stdout
|
||||
|
||||
# Restart and verify truncation works
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Debug Resize Events
|
||||
```javascript
|
||||
// Add to any component to detect resize loops
|
||||
window.addEventListener('resize', () => {
|
||||
console.count('resize');
|
||||
console.trace('Resize triggered from:');
|
||||
});
|
||||
```
|
||||
|
||||
### Monitor Network Traffic
|
||||
- Check `/api/sessions/:id/stream` response size
|
||||
- Verify only sends data after last clear
|
||||
- Monitor WebSocket `/buffers` for binary updates
|
||||
|
||||
## Architectural Insights
|
||||
|
||||
### Why Current Architecture Works (When Not Bugged)
|
||||
|
||||
1. **Simplicity**: "Es ist die todeleinfachste Variante" - It's the simplest possible approach
|
||||
2. **Efficiency**: 2MB instead of 980MB transmission after clear sequence truncation
|
||||
3. **Server-side rendering**: Binary cell format eliminates client ANSI parsing
|
||||
|
||||
### Community Contribution Challenges
|
||||
|
||||
- High development velocity makes contribution difficult
|
||||
- "Velocity kills" - rapid changes discourage contributors
|
||||
- LitElement/Web Components unfamiliar to most developers
|
||||
- Large file sizes cause AI tools to refuse processing
|
||||
|
||||
### Future Architecture Considerations
|
||||
|
||||
**Go Migration Benefits**:
|
||||
- Automatic test dependency tracking
|
||||
- Only runs tests that changed
|
||||
- Pre-allocated buffers minimize GC
|
||||
- Better suited for AI-assisted development
|
||||
|
||||
**Rust Benefits**:
|
||||
- 2MB static binary
|
||||
- 10MB RAM usage
|
||||
- Direct C interop for PTY code
|
||||
- No garbage collection overhead
|
||||
|
||||
## Action Plan Summary
|
||||
|
||||
1. **Immediate (End of Week)**: Fix external terminal truncation bug
|
||||
- Debug why `fwd.ts` bypasses `sendExistingContent()`
|
||||
- Deploy fix for immediate user relief
|
||||
|
||||
2. **Short Term**: Comprehensive resize fix
|
||||
- Debug session-detail-view triggers
|
||||
- Implement proper debouncing
|
||||
- Fix mobile-specific issues
|
||||
|
||||
3. **Long Term**: Consider architecture migration
|
||||
- Evaluate Rust forward binary
|
||||
- Consider Go for web server
|
||||
- Maintain backwards compatibility
|
||||
|
||||
## Key Technical Quotes
|
||||
|
||||
- "Wir schicken 2MB statt 980MB" - We send 2MB instead of 980MB
|
||||
- "Die haben einen Shared Pipe, wo alle reinschreiben" - They have a shared pipe where everyone writes
|
||||
- "Es gibt keinen Grund, warum ich von da weg alles neu rendern muss" - There's no reason to re-render everything from the beginning
|
||||
- "Das ist known good" - Referring to battle-tested implementations
|
||||
|
||||
This architecture analysis provides the technical foundation for fixing VibeTunnel's critical performance issues while maintaining its elegant simplicity.
|
||||
|
|
@ -79,148 +79,12 @@ process.on('SIGTERM', () => {
|
|||
process.exit(1);
|
||||
});
|
||||
|
||||
function applyMinimalPatches() {
|
||||
console.log('Applying minimal SEA patches to node-pty...');
|
||||
|
||||
// Create sea-loader.js
|
||||
const seaLoaderPath = path.join(__dirname, 'node_modules/node-pty/lib/sea-loader.js');
|
||||
if (!fs.existsSync(seaLoaderPath)) {
|
||||
const seaLoaderContent = `"use strict";
|
||||
/* VIBETUNNEL_SEA_LOADER */
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
var path = require("path");
|
||||
var fs = require("fs");
|
||||
|
||||
// Custom loader for SEA that uses process.dlopen
|
||||
var pty;
|
||||
|
||||
// Helper function to load native module using dlopen
|
||||
function loadNativeModule(modulePath) {
|
||||
const module = { exports: {} };
|
||||
process.dlopen(module, modulePath);
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
// Determine the path to pty.node
|
||||
function getPtyPath() {
|
||||
const execDir = path.dirname(process.execPath);
|
||||
// Look for pty.node next to the executable first
|
||||
const ptyPath = path.join(execDir, 'pty.node');
|
||||
|
||||
if (fs.existsSync(ptyPath)) {
|
||||
// Add path validation for security
|
||||
const resolvedPath = path.resolve(ptyPath);
|
||||
const resolvedExecDir = path.resolve(execDir);
|
||||
if (!resolvedPath.startsWith(resolvedExecDir)) {
|
||||
throw new Error('Invalid pty.node path detected');
|
||||
}
|
||||
return ptyPath;
|
||||
}
|
||||
|
||||
// If not found, throw error with helpful message
|
||||
throw new Error('Could not find pty.node next to executable at: ' + ptyPath);
|
||||
}
|
||||
|
||||
try {
|
||||
const ptyPath = getPtyPath();
|
||||
|
||||
// Set spawn-helper path for macOS only
|
||||
// Linux uses forkpty() directly and doesn't need spawn-helper
|
||||
if (process.platform === 'darwin') {
|
||||
const execDir = path.dirname(process.execPath);
|
||||
const spawnHelperPath = path.join(execDir, 'spawn-helper');
|
||||
if (fs.existsSync(spawnHelperPath)) {
|
||||
process.env.NODE_PTY_SPAWN_HELPER_PATH = spawnHelperPath;
|
||||
}
|
||||
}
|
||||
|
||||
pty = loadNativeModule(ptyPath);
|
||||
} catch (error) {
|
||||
console.error('Failed to load pty.node:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
exports.default = pty;
|
||||
`;
|
||||
fs.writeFileSync(seaLoaderPath, seaLoaderContent);
|
||||
}
|
||||
|
||||
// Patch index.js
|
||||
const indexPath = path.join(__dirname, 'node_modules/node-pty/lib/index.js');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
let content = fs.readFileSync(indexPath, 'utf8');
|
||||
if (!content.includes('VIBETUNNEL_SEA')) {
|
||||
content = content.replace(
|
||||
"exports.native = (process.platform !== 'win32' ? require('../build/Release/pty.node') : null);",
|
||||
"exports.native = (process.platform !== 'win32' ? (process.env.VIBETUNNEL_SEA ? require('./sea-loader').default : require('../build/Release/pty.node')) : null);"
|
||||
);
|
||||
fs.writeFileSync(indexPath, content);
|
||||
}
|
||||
}
|
||||
|
||||
// Patch unixTerminal.js
|
||||
const unixPath = path.join(__dirname, 'node_modules/node-pty/lib/unixTerminal.js');
|
||||
if (fs.existsSync(unixPath)) {
|
||||
let content = fs.readFileSync(unixPath, 'utf8');
|
||||
if (!content.includes('VIBETUNNEL_SEA')) {
|
||||
// Find and replace the pty loading section
|
||||
const startMarker = 'var pty;\nvar helperPath;';
|
||||
const endMarker = 'var DEFAULT_FILE = \'sh\';';
|
||||
const startIdx = content.indexOf(startMarker);
|
||||
const endIdx = content.indexOf(endMarker);
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1) {
|
||||
const newSection = `var pty;
|
||||
var helperPath;
|
||||
// For SEA, check environment variables
|
||||
if (process.env.VIBETUNNEL_SEA) {
|
||||
pty = require('./sea-loader').default;
|
||||
// In SEA context, look for spawn-helper on macOS only (Linux doesn't use it)
|
||||
if (process.platform === 'darwin') {
|
||||
const execDir = path.dirname(process.execPath);
|
||||
const spawnHelperPath = path.join(execDir, 'spawn-helper');
|
||||
if (require('fs').existsSync(spawnHelperPath)) {
|
||||
helperPath = spawnHelperPath;
|
||||
} else if (process.env.NODE_PTY_SPAWN_HELPER_PATH) {
|
||||
helperPath = process.env.NODE_PTY_SPAWN_HELPER_PATH;
|
||||
}
|
||||
}
|
||||
// On Linux, helperPath remains undefined which is fine
|
||||
} else {
|
||||
// Original loading logic
|
||||
try {
|
||||
pty = require('../build/Release/pty.node');
|
||||
helperPath = '../build/Release/spawn-helper';
|
||||
}
|
||||
catch (outerError) {
|
||||
try {
|
||||
pty = require('../build/Debug/pty.node');
|
||||
helperPath = '../build/Debug/spawn-helper';
|
||||
}
|
||||
catch (innerError) {
|
||||
console.error('innerError', innerError);
|
||||
// Re-throw the exception from the Release require if the Debug require fails as well
|
||||
throw outerError;
|
||||
}
|
||||
}
|
||||
helperPath = path.resolve(__dirname, helperPath);
|
||||
helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
|
||||
helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
|
||||
}
|
||||
`;
|
||||
content = content.substring(0, startIdx) + newSection + content.substring(endIdx);
|
||||
fs.writeFileSync(unixPath, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('SEA patches applied successfully');
|
||||
}
|
||||
// No patching needed - SEA support is built into our vendored node-pty
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// Apply minimal patches to node-pty
|
||||
applyMinimalPatches();
|
||||
// No patching needed - SEA support is built into our vendored node-pty
|
||||
console.log('Using vendored node-pty with built-in SEA support...');
|
||||
|
||||
// Ensure native modules are built (in case postinstall didn't run)
|
||||
const nativePtyDir = 'node_modules/node-pty/build/Release';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
node_modules/
|
||||
build/
|
||||
lib/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
'xcode_settings': {
|
||||
'GCC_ENABLE_CPP_EXCEPTIONS': 'YES',
|
||||
'CLANG_CXX_LIBRARY': 'libc++',
|
||||
'MACOSX_DEPLOYMENT_TARGET': '10.7',
|
||||
'MACOSX_DEPLOYMENT_TARGET': '14.0',
|
||||
},
|
||||
'msvs_settings': {
|
||||
'VCCLCompilerTool': { 'ExceptionHandling': 1 },
|
||||
|
|
@ -43,7 +43,7 @@
|
|||
'conditions': [
|
||||
['OS=="mac"', {
|
||||
'xcode_settings': {
|
||||
'MACOSX_DEPLOYMENT_TARGET': '10.12'
|
||||
'MACOSX_DEPLOYMENT_TARGET': '14.0'
|
||||
}
|
||||
}]
|
||||
]
|
||||
|
|
@ -40,24 +40,48 @@ const terminal_1 = require("./terminal");
|
|||
const utils_1 = require("./utils");
|
||||
let pty;
|
||||
let helperPath;
|
||||
try {
|
||||
pty = require('../build/Release/pty.node');
|
||||
helperPath = '../build/Release/spawn-helper';
|
||||
// Check if running in SEA (Single Executable Application) context
|
||||
if (process.env.VIBETUNNEL_SEA) {
|
||||
// In SEA mode, load native module using process.dlopen
|
||||
const fs = require('fs');
|
||||
const execDir = path.dirname(process.execPath);
|
||||
const ptyPath = path.join(execDir, 'pty.node');
|
||||
if (fs.existsSync(ptyPath)) {
|
||||
const module = { exports: {} };
|
||||
process.dlopen(module, ptyPath);
|
||||
pty = module.exports;
|
||||
}
|
||||
else {
|
||||
throw new Error(`Could not find pty.node next to executable at: ${ptyPath}`);
|
||||
}
|
||||
// Set spawn-helper path for macOS only (Linux doesn't use it)
|
||||
if (process.platform === 'darwin') {
|
||||
helperPath = path.join(execDir, 'spawn-helper');
|
||||
if (!fs.existsSync(helperPath)) {
|
||||
console.warn(`spawn-helper not found at ${helperPath}, PTY operations may fail`);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (outerError) {
|
||||
else {
|
||||
// Standard Node.js loading
|
||||
try {
|
||||
pty = require('../build/Debug/pty.node');
|
||||
helperPath = '../build/Debug/spawn-helper';
|
||||
pty = require('../build/Release/pty.node');
|
||||
helperPath = '../build/Release/spawn-helper';
|
||||
}
|
||||
catch (innerError) {
|
||||
console.error('innerError', innerError);
|
||||
// Re-throw the exception from the Release require if the Debug require fails as well
|
||||
throw outerError;
|
||||
catch (outerError) {
|
||||
try {
|
||||
pty = require('../build/Debug/pty.node');
|
||||
helperPath = '../build/Debug/spawn-helper';
|
||||
}
|
||||
catch (innerError) {
|
||||
console.error('innerError', innerError);
|
||||
throw outerError;
|
||||
}
|
||||
}
|
||||
helperPath = path.resolve(__dirname, helperPath);
|
||||
helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
|
||||
helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
|
||||
}
|
||||
helperPath = path.resolve(__dirname, helperPath);
|
||||
helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
|
||||
helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
|
||||
const DEFAULT_FILE = 'sh';
|
||||
const DEFAULT_NAME = 'xterm';
|
||||
const DESTROY_SOCKET_TIMEOUT_MS = 200;
|
||||
|
|
@ -13,23 +13,48 @@ import { assign } from './utils';
|
|||
|
||||
let pty: IUnixNative;
|
||||
let helperPath: string;
|
||||
try {
|
||||
pty = require('../build/Release/pty.node');
|
||||
helperPath = '../build/Release/spawn-helper';
|
||||
} catch (outerError) {
|
||||
try {
|
||||
pty = require('../build/Debug/pty.node');
|
||||
helperPath = '../build/Debug/spawn-helper';
|
||||
} catch (innerError) {
|
||||
console.error('innerError', innerError);
|
||||
// Re-throw the exception from the Release require if the Debug require fails as well
|
||||
throw outerError;
|
||||
}
|
||||
}
|
||||
|
||||
helperPath = path.resolve(__dirname, helperPath);
|
||||
helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
|
||||
helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
|
||||
// Check if running in SEA (Single Executable Application) context
|
||||
if (process.env.VIBETUNNEL_SEA) {
|
||||
// In SEA mode, load native module using process.dlopen
|
||||
const fs = require('fs');
|
||||
const execDir = path.dirname(process.execPath);
|
||||
const ptyPath = path.join(execDir, 'pty.node');
|
||||
|
||||
if (fs.existsSync(ptyPath)) {
|
||||
const module = { exports: {} };
|
||||
process.dlopen(module, ptyPath);
|
||||
pty = module.exports as IUnixNative;
|
||||
} else {
|
||||
throw new Error(`Could not find pty.node next to executable at: ${ptyPath}`);
|
||||
}
|
||||
|
||||
// Set spawn-helper path for macOS only (Linux doesn't use it)
|
||||
if (process.platform === 'darwin') {
|
||||
helperPath = path.join(execDir, 'spawn-helper');
|
||||
if (!fs.existsSync(helperPath)) {
|
||||
console.warn(`spawn-helper not found at ${helperPath}, PTY operations may fail`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Standard Node.js loading
|
||||
try {
|
||||
pty = require('../build/Release/pty.node');
|
||||
helperPath = '../build/Release/spawn-helper';
|
||||
} catch (outerError) {
|
||||
try {
|
||||
pty = require('../build/Debug/pty.node');
|
||||
helperPath = '../build/Debug/spawn-helper';
|
||||
} catch (innerError) {
|
||||
console.error('innerError', innerError);
|
||||
throw outerError;
|
||||
}
|
||||
}
|
||||
|
||||
helperPath = path.resolve(__dirname, helperPath);
|
||||
helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
|
||||
helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
|
||||
}
|
||||
|
||||
const DEFAULT_FILE = 'sh';
|
||||
const DEFAULT_NAME = 'xterm';
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
"mime-types": "^3.0.1",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"multer": "^2.0.1",
|
||||
"node-pty": "file:./vendored-pty",
|
||||
"node-pty": "file:./node-pty",
|
||||
"postject": "^1.0.0-alpha.6",
|
||||
"signal-exit": "^4.1.0",
|
||||
"web-push": "^3.6.7",
|
||||
|
|
|
|||
|
|
@ -72,8 +72,8 @@ importers:
|
|||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
node-pty:
|
||||
specifier: file:./vendored-pty
|
||||
version: '@vibetunnel/vendored-pty@file:vendored-pty'
|
||||
specifier: file:./node-pty
|
||||
version: file:node-pty
|
||||
postject:
|
||||
specifier: ^1.0.0-alpha.6
|
||||
version: 1.0.0-alpha.6
|
||||
|
|
@ -970,9 +970,6 @@ packages:
|
|||
'@types/yauzl@2.10.3':
|
||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||
|
||||
'@vibetunnel/vendored-pty@file:vendored-pty':
|
||||
resolution: {directory: vendored-pty, type: directory}
|
||||
|
||||
'@vitest/coverage-v8@3.2.4':
|
||||
resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -2311,6 +2308,9 @@ packages:
|
|||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
node-pty@file:node-pty:
|
||||
resolution: {directory: node-pty, type: directory}
|
||||
|
||||
node-releases@2.0.19:
|
||||
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
|
||||
|
||||
|
|
@ -3934,10 +3934,6 @@ snapshots:
|
|||
'@types/node': 24.0.4
|
||||
optional: true
|
||||
|
||||
'@vibetunnel/vendored-pty@file:vendored-pty':
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
|
||||
'@vitest/coverage-v8@3.2.4(vitest@3.2.4)':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
|
|
@ -5398,6 +5394,10 @@ snapshots:
|
|||
fetch-blob: 3.2.0
|
||||
formdata-polyfill: 4.0.10
|
||||
|
||||
node-pty@file:node-pty:
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
|
||||
node-releases@2.0.19: {}
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,11 @@
|
|||
|
||||
<!-- Mobile viewport height fix -->
|
||||
<script>
|
||||
// Handle dynamic viewport height for mobile browsers
|
||||
// Check if mobile device
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) ||
|
||||
(navigator.maxTouchPoints && navigator.maxTouchPoints > 1);
|
||||
|
||||
// Handle viewport height - set only once on mobile to prevent resize loops
|
||||
function setViewportHeight() {
|
||||
const vh = window.innerHeight * 0.01;
|
||||
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||
|
|
@ -88,11 +92,14 @@
|
|||
// Set initial height
|
||||
setViewportHeight();
|
||||
|
||||
// Update on resize and orientation change
|
||||
window.addEventListener('resize', setViewportHeight);
|
||||
window.addEventListener('orientationchange', () => {
|
||||
setTimeout(setViewportHeight, 100);
|
||||
});
|
||||
// On mobile, we only set viewport height once to prevent resize loops
|
||||
// On desktop, we still allow dynamic resizing
|
||||
if (!isMobile) {
|
||||
window.addEventListener('resize', setViewportHeight);
|
||||
window.addEventListener('orientationchange', () => {
|
||||
setTimeout(setViewportHeight, 100);
|
||||
});
|
||||
}
|
||||
|
||||
// Force full-screen behavior
|
||||
window.addEventListener('load', () => {
|
||||
|
|
|
|||
|
|
@ -859,14 +859,14 @@ describe('SessionView', () => {
|
|||
vi.advanceTimersByTime(110);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Check that terminal container height was calculated correctly
|
||||
// Quick keys height (150) + keyboard height (300) + buffer (10) = 460px reduction
|
||||
// On mobile with keyboard and quick keys, height should be calculated dynamically
|
||||
// Height reduction = keyboardHeight (300) + quickKeysHeight (150) + buffer (10) = 460px
|
||||
expect(element.terminalContainerHeight).toBe('calc(100% - 460px)');
|
||||
|
||||
// Should have called fitTerminal
|
||||
// fitTerminal should be called even on mobile now (height changes allowed)
|
||||
expect(fitTerminalSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Should have called scrollToBottom due to height reduction
|
||||
// scrollToBottom should be called when height is reduced
|
||||
expect(terminalElement.scrollToBottom).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
|
|
@ -887,8 +887,8 @@ describe('SessionView', () => {
|
|||
vi.advanceTimersByTime(110);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// On desktop, quick keys should not affect terminal height
|
||||
expect(element.terminalContainerHeight).toBe('100%');
|
||||
// On desktop, quick keys should affect terminal height
|
||||
expect(element.terminalContainerHeight).toBe('calc(100% - 150px)');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
|
@ -905,6 +905,8 @@ describe('SessionView', () => {
|
|||
vi.advanceTimersByTime(110);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// On mobile with keyboard only, height should be calculated dynamically
|
||||
// Height reduction = keyboardHeight (300) + buffer (10) = 310px
|
||||
expect(element.terminalContainerHeight).toBe('calc(100% - 310px)');
|
||||
|
||||
// Now hide the keyboard
|
||||
|
|
@ -914,7 +916,7 @@ describe('SessionView', () => {
|
|||
vi.advanceTimersByTime(110);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Height should be reset to 100%
|
||||
// On mobile with keyboard hidden, height should be back to 100%
|
||||
expect(element.terminalContainerHeight).toBe('100%');
|
||||
|
||||
vi.useRealTimers();
|
||||
|
|
@ -958,10 +960,11 @@ describe('SessionView', () => {
|
|||
vi.advanceTimersByTime(110);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Should use the latest values: keyboard 300 + quick keys 150 + buffer 10 = 460px
|
||||
// On mobile with quick keys and keyboard, height should be calculated
|
||||
// Height reduction = keyboardHeight (300) + quickKeysHeight (150) + buffer (10) = 460px
|
||||
expect(element.terminalContainerHeight).toBe('calc(100% - 460px)');
|
||||
|
||||
// Should have called fitTerminal only once due to debounce
|
||||
// fitTerminal should be called on mobile for height changes
|
||||
expect(fitTerminalSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.useRealTimers();
|
||||
|
|
|
|||
|
|
@ -1117,7 +1117,7 @@ export class SessionView extends LitElement {
|
|||
// Calculate height reduction for keyboard and quick keys
|
||||
let heightReduction = 0;
|
||||
|
||||
if (this.showQuickKeys && this.isMobile) {
|
||||
if (this.showQuickKeys) {
|
||||
// Quick keys height (approximately 140px based on CSS)
|
||||
// Add 10px buffer to ensure content is visible above quick keys
|
||||
const quickKeysHeight = 150;
|
||||
|
|
|
|||
|
|
@ -149,13 +149,21 @@ export class TerminalLifecycleManager {
|
|||
async handleTerminalResize(event: Event) {
|
||||
const customEvent = event as CustomEvent;
|
||||
// Update terminal dimensions for display
|
||||
const { cols, rows } = customEvent.detail;
|
||||
const { cols, rows, isMobile, isHeightOnlyChange, source } = customEvent.detail;
|
||||
|
||||
// Notify the session view to update its state
|
||||
if (this.stateCallbacks) {
|
||||
this.stateCallbacks.updateTerminalDimensions(cols, rows);
|
||||
}
|
||||
|
||||
// On mobile, skip sending height-only changes to the server (keyboard events)
|
||||
if (isMobile && isHeightOnlyChange) {
|
||||
logger.debug(
|
||||
`skipping mobile height-only resize to server: ${cols}x${rows} (source: ${source})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce resize requests to prevent jumpiness
|
||||
if (this.resizeTimeout) {
|
||||
clearTimeout(this.resizeTimeout);
|
||||
|
|
|
|||
|
|
@ -21,9 +21,8 @@ interface TestTerminal extends Terminal {
|
|||
measureCharacterWidth(): number;
|
||||
fitTerminal(): void;
|
||||
userOverrideWidth: boolean;
|
||||
resizeCoordinator: {
|
||||
forceUpdateDimensions(cols: number, rows: number): void;
|
||||
};
|
||||
lastCols: number;
|
||||
lastRows: number;
|
||||
}
|
||||
|
||||
describe('Terminal', () => {
|
||||
|
|
@ -607,8 +606,9 @@ describe('Terminal', () => {
|
|||
element.cols = currentCols;
|
||||
element.rows = currentRows;
|
||||
|
||||
// Initialize ResizeCoordinator with current dimensions
|
||||
(element as TestTerminal).resizeCoordinator.forceUpdateDimensions(currentCols, currentRows);
|
||||
// Initialize last dimensions to match current dimensions
|
||||
(element as TestTerminal).lastCols = currentCols;
|
||||
(element as TestTerminal).lastRows = currentRows;
|
||||
|
||||
// Mock character width measurement
|
||||
vi.spyOn(element as TestTerminal, 'measureCharacterWidth').mockReturnValue(8);
|
||||
|
|
@ -668,7 +668,7 @@ describe('Terminal', () => {
|
|||
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'terminal-resize',
|
||||
detail: { cols, rows },
|
||||
detail: expect.objectContaining({ cols, rows }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
@ -696,8 +696,9 @@ describe('Terminal', () => {
|
|||
|
||||
vi.spyOn(element as TestTerminal, 'measureCharacterWidth').mockReturnValue(8);
|
||||
|
||||
// Initialize ResizeCoordinator with current dimensions
|
||||
(element as TestTerminal).resizeCoordinator.forceUpdateDimensions(currentCols, currentRows);
|
||||
// Initialize last dimensions to match current dimensions
|
||||
(element as TestTerminal).lastCols = currentCols;
|
||||
(element as TestTerminal).lastRows = currentRows;
|
||||
|
||||
// Call fitTerminal multiple times
|
||||
(element as TestTerminal).fitTerminal();
|
||||
|
|
@ -860,8 +861,9 @@ describe('Terminal', () => {
|
|||
|
||||
vi.spyOn(element as TestTerminal, 'measureCharacterWidth').mockReturnValue(8);
|
||||
|
||||
// Initialize ResizeCoordinator with current dimensions
|
||||
(element as TestTerminal).resizeCoordinator.forceUpdateDimensions(100, 30);
|
||||
// Initialize last dimensions to match current dimensions
|
||||
(element as TestTerminal).lastCols = 100;
|
||||
(element as TestTerminal).lastRows = 30;
|
||||
|
||||
// Clear previous calls
|
||||
mockTerminal?.resize.mockClear();
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import { html, LitElement, type PropertyValues } from 'lit';
|
|||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { processKeyboardShortcuts } from '../utils/keyboard-shortcut-highlighter.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
import { ResizeCoordinator } from '../utils/resize-coordinator.js';
|
||||
import { UrlHighlighter } from '../utils/url-highlighter';
|
||||
|
||||
const logger = createLogger('terminal');
|
||||
|
|
@ -73,7 +72,12 @@ export class Terminal extends LitElement {
|
|||
private momentumVelocityX = 0;
|
||||
private momentumAnimation: number | null = null;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private resizeCoordinator = new ResizeCoordinator();
|
||||
private mobileWidthResizeComplete = false;
|
||||
private pendingResize: number | null = null;
|
||||
private lastCols = 0;
|
||||
private lastRows = 0;
|
||||
private isMobile = false;
|
||||
private mobileInitialResizeTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
// Operation queue for batching buffer modifications
|
||||
private operationQueue: (() => void | Promise<void>)[] = [];
|
||||
|
|
@ -136,7 +140,7 @@ export class Terminal extends LitElement {
|
|||
this.userOverrideWidth = stored === 'true';
|
||||
// Apply the loaded preference immediately
|
||||
if (this.container) {
|
||||
this.resizeCoordinator.requestResize('property-change');
|
||||
this.requestResize('property-change');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -159,7 +163,7 @@ export class Terminal extends LitElement {
|
|||
}
|
||||
// Recalculate terminal dimensions when font size changes
|
||||
if (this.terminal && this.container) {
|
||||
this.resizeCoordinator.requestResize('property-change');
|
||||
this.requestResize('property-change');
|
||||
}
|
||||
}
|
||||
if (changedProperties.has('fitHorizontally')) {
|
||||
|
|
@ -167,12 +171,12 @@ export class Terminal extends LitElement {
|
|||
// Restore original font size when turning off horizontal fitting
|
||||
this.fontSize = this.originalFontSize;
|
||||
}
|
||||
this.resizeCoordinator.requestResize('property-change');
|
||||
this.requestResize('property-change');
|
||||
}
|
||||
// If maxCols changed, trigger a resize
|
||||
if (changedProperties.has('maxCols')) {
|
||||
if (this.terminal && this.container) {
|
||||
this.resizeCoordinator.requestResize('property-change');
|
||||
this.requestResize('property-change');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -196,7 +200,7 @@ export class Terminal extends LitElement {
|
|||
}
|
||||
// Trigger a resize to apply the new setting
|
||||
if (this.container) {
|
||||
this.resizeCoordinator.requestResize('property-change');
|
||||
this.requestResize('property-change');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -212,8 +216,14 @@ export class Terminal extends LitElement {
|
|||
this.resizeObserver = null;
|
||||
}
|
||||
|
||||
if (this.resizeCoordinator) {
|
||||
this.resizeCoordinator.destroy();
|
||||
if (this.pendingResize) {
|
||||
cancelAnimationFrame(this.pendingResize);
|
||||
this.pendingResize = null;
|
||||
}
|
||||
|
||||
if (this.mobileInitialResizeTimeout) {
|
||||
clearTimeout(this.mobileInitialResizeTimeout);
|
||||
this.mobileInitialResizeTimeout = null;
|
||||
}
|
||||
|
||||
if (this.terminal) {
|
||||
|
|
@ -226,14 +236,81 @@ export class Terminal extends LitElement {
|
|||
// Store the initial font size as original
|
||||
this.originalFontSize = this.fontSize;
|
||||
|
||||
// Set up resize coordinator callback
|
||||
this.resizeCoordinator.setResizeCallback((source: string) => {
|
||||
this.fitTerminal(source);
|
||||
});
|
||||
// Initialize terminal immediately
|
||||
|
||||
this.initializeTerminal();
|
||||
}
|
||||
|
||||
// Consistent mobile detection across the app
|
||||
private detectMobile(): boolean {
|
||||
// Use the same logic as index.html for consistency
|
||||
return (
|
||||
/iPhone|iPad|iPod|Android/i.test(navigator.userAgent) ||
|
||||
(navigator.maxTouchPoints !== undefined && navigator.maxTouchPoints > 1)
|
||||
);
|
||||
}
|
||||
|
||||
private requestResize(source: string) {
|
||||
// Update mobile state using consistent detection
|
||||
this.isMobile = this.detectMobile();
|
||||
|
||||
logger.debug(`[Terminal] Resize requested from ${source} (mobile: ${this.isMobile})`);
|
||||
|
||||
// Cancel any pending resize
|
||||
if (this.pendingResize) {
|
||||
cancelAnimationFrame(this.pendingResize);
|
||||
}
|
||||
|
||||
// Schedule resize for next animation frame
|
||||
this.pendingResize = requestAnimationFrame(() => {
|
||||
this.fitTerminal(source);
|
||||
this.pendingResize = null;
|
||||
});
|
||||
}
|
||||
|
||||
private shouldResize(cols: number, rows: number): boolean {
|
||||
// On mobile, prevent WIDTH changes after initial setup, but allow HEIGHT changes
|
||||
if (this.isMobile && this.mobileWidthResizeComplete) {
|
||||
// Check if only height changed (allow keyboard resizes)
|
||||
const widthChanged = this.lastCols !== cols;
|
||||
const heightChanged = this.lastRows !== rows;
|
||||
|
||||
if (widthChanged) {
|
||||
logger.debug(`[Terminal] Preventing WIDTH resize on mobile (width already set)`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (heightChanged) {
|
||||
logger.debug(
|
||||
`[Terminal] Allowing HEIGHT resize on mobile: ${this.lastRows} → ${rows} rows`
|
||||
);
|
||||
this.lastRows = rows;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if dimensions actually changed
|
||||
const changed = this.lastCols !== cols || this.lastRows !== rows;
|
||||
|
||||
if (changed) {
|
||||
logger.debug(
|
||||
`[Terminal] Dimensions changed: ${this.lastCols}x${this.lastRows} → ${cols}x${rows}`
|
||||
);
|
||||
this.lastCols = cols;
|
||||
this.lastRows = rows;
|
||||
|
||||
// Mark mobile WIDTH resize as complete after first resize
|
||||
if (this.isMobile && !this.mobileWidthResizeComplete) {
|
||||
this.mobileWidthResizeComplete = true;
|
||||
logger.debug(`[Terminal] Mobile WIDTH resize complete - blocking future width changes`);
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private async initializeTerminal() {
|
||||
try {
|
||||
this.requestUpdate();
|
||||
|
|
@ -275,7 +352,7 @@ export class Terminal extends LitElement {
|
|||
const safeCols = Number.isFinite(this.cols) ? Math.floor(this.cols) : 80;
|
||||
const safeRows = Number.isFinite(this.rows) ? Math.floor(this.rows) : 24;
|
||||
this.terminal.resize(safeCols, safeRows);
|
||||
this.resizeCoordinator.requestResize('property-change');
|
||||
this.requestResize('property-change');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -362,7 +439,18 @@ export class Terminal extends LitElement {
|
|||
}
|
||||
|
||||
private fitTerminal(source?: string) {
|
||||
if (!this.terminal || !this.container) return;
|
||||
if (!this.terminal || !this.container) {
|
||||
logger.warn('[Terminal] Cannot fit terminal: terminal or container not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`[Terminal] fitTerminal called from source: ${source || 'unknown'}`);
|
||||
// Use the class property instead of rechecking
|
||||
if (this.isMobile) {
|
||||
logger.debug(
|
||||
`[Terminal] Mobile detected in fitTerminal - source: ${source}, userAgent: ${navigator.userAgent}`
|
||||
);
|
||||
}
|
||||
|
||||
const _oldActualRows = this.actualRows;
|
||||
const oldLineHeight = this.fontSize * 1.2;
|
||||
|
|
@ -400,15 +488,29 @@ export class Terminal extends LitElement {
|
|||
const safeCols = Number.isFinite(this.cols) ? Math.floor(this.cols) : 80;
|
||||
const safeRows = Number.isFinite(this.rows) ? Math.floor(this.rows) : 24;
|
||||
|
||||
// Save old dimensions before shouldResize updates them
|
||||
const oldCols = this.lastCols;
|
||||
const oldRows = this.lastRows;
|
||||
|
||||
// Use resize coordinator to check if we should actually resize
|
||||
if (this.resizeCoordinator.shouldResize(safeCols, safeRows)) {
|
||||
if (this.shouldResize(safeCols, safeRows)) {
|
||||
logger.debug(`Resizing terminal (${source || 'unknown'}): ${safeCols}x${safeRows}`);
|
||||
this.terminal.resize(safeCols, safeRows);
|
||||
|
||||
// Dispatch resize event for backend synchronization
|
||||
// Include mobile flag and whether this is height-only change
|
||||
const isWidthChange = safeCols !== oldCols;
|
||||
const isHeightOnlyChange = !isWidthChange && safeRows !== oldRows;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('terminal-resize', {
|
||||
detail: { cols: safeCols, rows: safeRows },
|
||||
detail: {
|
||||
cols: safeCols,
|
||||
rows: safeRows,
|
||||
isMobile: this.isMobile,
|
||||
isHeightOnlyChange,
|
||||
source: source || 'unknown',
|
||||
},
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
|
|
@ -459,15 +561,29 @@ export class Terminal extends LitElement {
|
|||
const safeCols = Number.isFinite(this.cols) ? Math.floor(this.cols) : 80;
|
||||
const safeRows = Number.isFinite(this.rows) ? Math.floor(this.rows) : 24;
|
||||
|
||||
// Save old dimensions before shouldResize updates them
|
||||
const oldCols = this.lastCols;
|
||||
const oldRows = this.lastRows;
|
||||
|
||||
// Use resize coordinator to check if we should actually resize
|
||||
if (this.resizeCoordinator.shouldResize(safeCols, safeRows)) {
|
||||
if (this.shouldResize(safeCols, safeRows)) {
|
||||
logger.debug(`Resizing terminal (${source || 'unknown'}): ${safeCols}x${safeRows}`);
|
||||
this.terminal.resize(safeCols, safeRows);
|
||||
|
||||
// Dispatch resize event for backend synchronization
|
||||
// Include mobile flag and whether this is height-only change
|
||||
const isWidthChange = safeCols !== oldCols;
|
||||
const isHeightOnlyChange = !isWidthChange && safeRows !== oldRows;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('terminal-resize', {
|
||||
detail: { cols: safeCols, rows: safeRows },
|
||||
detail: {
|
||||
cols: safeCols,
|
||||
rows: safeRows,
|
||||
isMobile: this.isMobile,
|
||||
isHeightOnlyChange,
|
||||
source: source || 'unknown',
|
||||
},
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
|
|
@ -502,27 +618,41 @@ export class Terminal extends LitElement {
|
|||
private setupResize() {
|
||||
if (!this.container) return;
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.resizeCoordinator.requestResize('ResizeObserver');
|
||||
});
|
||||
this.resizeObserver.observe(this.container);
|
||||
// Set the class property using consistent detection
|
||||
this.isMobile = this.detectMobile();
|
||||
logger.debug(
|
||||
`[Terminal] Setting up resize - isMobile: ${this.isMobile}, userAgent: ${navigator.userAgent}`
|
||||
);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
this.resizeCoordinator.requestResize('window-resize');
|
||||
});
|
||||
|
||||
// On mobile, delay initial resize to let everything settle
|
||||
if (this.resizeCoordinator.getIsMobile()) {
|
||||
setTimeout(() => {
|
||||
this.resizeCoordinator.requestResize('initial-mobile');
|
||||
// Mark initial resize complete after a short delay
|
||||
setTimeout(() => {
|
||||
this.resizeCoordinator.markInitialResizeComplete();
|
||||
}, 100);
|
||||
}, 100);
|
||||
if (this.isMobile) {
|
||||
// On mobile: Do initial resize to set width, then allow HEIGHT changes only (for keyboard)
|
||||
logger.debug('[Terminal] Mobile detected - scheduling initial resize in 200ms');
|
||||
this.mobileInitialResizeTimeout = setTimeout(() => {
|
||||
logger.debug('[Terminal] Mobile: Executing initial resize');
|
||||
this.fitTerminal('initial-mobile-only');
|
||||
// That's it - no observers, no event listeners, nothing
|
||||
logger.debug(
|
||||
'[Terminal] Mobile: Initial width set, future WIDTH resizes blocked (height allowed for keyboard)'
|
||||
);
|
||||
this.mobileInitialResizeTimeout = null; // Clear reference after execution
|
||||
}, 200);
|
||||
} else {
|
||||
// Desktop: Normal resize handling with observers
|
||||
logger.debug('[Terminal] Desktop detected - setting up resize observers');
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
logger.debug('[Terminal] ResizeObserver triggered');
|
||||
this.requestResize('ResizeObserver');
|
||||
});
|
||||
this.resizeObserver.observe(this.container);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
logger.debug('[Terminal] Window resize event triggered');
|
||||
this.requestResize('window-resize');
|
||||
});
|
||||
|
||||
// Desktop: immediate initial resize
|
||||
this.resizeCoordinator.requestResize('initial-desktop');
|
||||
logger.debug('[Terminal] Desktop: Requesting initial resize');
|
||||
this.requestResize('initial-desktop');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1110,7 +1240,7 @@ export class Terminal extends LitElement {
|
|||
this.queueRenderOperation(() => {
|
||||
if (!this.terminal) return;
|
||||
|
||||
this.resizeCoordinator.requestResize('property-change');
|
||||
this.requestResize('property-change');
|
||||
|
||||
const buffer = this.terminal.buffer.active;
|
||||
const lineHeight = this.fontSize * 1.2;
|
||||
|
|
@ -1324,7 +1454,7 @@ export class Terminal extends LitElement {
|
|||
}
|
||||
|
||||
// Recalculate fit
|
||||
this.resizeCoordinator.requestResize('fit-mode-change');
|
||||
this.requestResize('fit-mode-change');
|
||||
|
||||
// Restore scroll position - prioritize staying at bottom if we were there
|
||||
if (wasAtBottom) {
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
/**
|
||||
* Resize Coordinator
|
||||
*
|
||||
* Centralizes and coordinates all terminal resize requests to prevent multiple
|
||||
* resize events from causing terminal reflows. Essential for mobile stability.
|
||||
*/
|
||||
|
||||
export interface ResizeDimensions {
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export class ResizeCoordinator {
|
||||
private pendingResize: number | null = null;
|
||||
private lastDimensions: ResizeDimensions | null = null;
|
||||
private resizeCallback: ((source: string) => void) | null = null;
|
||||
private initialResizeComplete = false;
|
||||
private resizeSources = new Set<string>();
|
||||
|
||||
/**
|
||||
* Set the callback function to be called when resize should happen
|
||||
*/
|
||||
setResizeCallback(callback: (source: string) => void) {
|
||||
this.resizeCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a resize from a specific source
|
||||
* All resize requests are coalesced into a single animation frame
|
||||
*/
|
||||
requestResize(source: string) {
|
||||
this.resizeSources.add(source);
|
||||
|
||||
// Cancel any pending resize
|
||||
if (this.pendingResize) {
|
||||
cancelAnimationFrame(this.pendingResize);
|
||||
}
|
||||
|
||||
// Schedule resize for next animation frame
|
||||
this.pendingResize = requestAnimationFrame(() => {
|
||||
const sources = Array.from(this.resizeSources).join(', ');
|
||||
this.resizeSources.clear();
|
||||
|
||||
if (this.resizeCallback) {
|
||||
this.resizeCallback(sources);
|
||||
}
|
||||
|
||||
this.pendingResize = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if dimensions have actually changed
|
||||
*/
|
||||
shouldResize(cols: number, rows: number): boolean {
|
||||
const isMobile = this.getIsMobile();
|
||||
|
||||
// On mobile, after initial resize, never resize again
|
||||
// This prevents rotation and keyboard events from causing reflows
|
||||
if (isMobile && this.initialResizeComplete) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.lastDimensions) {
|
||||
this.lastDimensions = { cols, rows };
|
||||
return true;
|
||||
}
|
||||
|
||||
const changed = this.lastDimensions.cols !== cols || this.lastDimensions.rows !== rows;
|
||||
|
||||
if (changed) {
|
||||
this.lastDimensions = { cols, rows };
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark initial resize as complete
|
||||
* After this, mobile will only resize on width changes
|
||||
*/
|
||||
markInitialResizeComplete() {
|
||||
this.initialResizeComplete = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last known dimensions
|
||||
*/
|
||||
getLastDimensions(): ResizeDimensions | null {
|
||||
return this.lastDimensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force update dimensions (for explicit user actions)
|
||||
*/
|
||||
forceUpdateDimensions(cols: number, rows: number) {
|
||||
this.lastDimensions = { cols, rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running on mobile (dynamically checks on each call)
|
||||
*/
|
||||
getIsMobile(): boolean {
|
||||
// Check viewport width and touch capability dynamically
|
||||
// This handles orientation changes and window resizing
|
||||
return window.innerWidth < 768 && 'ontouchstart' in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup
|
||||
*/
|
||||
destroy() {
|
||||
if (this.pendingResize) {
|
||||
cancelAnimationFrame(this.pendingResize);
|
||||
}
|
||||
this.resizeCallback = null;
|
||||
this.resizeSources.clear();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue