mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +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);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
function applyMinimalPatches() {
|
// No patching needed - SEA support is built into our vendored node-pty
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
// Apply minimal patches to node-pty
|
// No patching needed - SEA support is built into our vendored node-pty
|
||||||
applyMinimalPatches();
|
console.log('Using vendored node-pty with built-in SEA support...');
|
||||||
|
|
||||||
// Ensure native modules are built (in case postinstall didn't run)
|
// Ensure native modules are built (in case postinstall didn't run)
|
||||||
const nativePtyDir = 'node_modules/node-pty/build/Release';
|
const nativePtyDir = 'node_modules/node-pty/build/Release';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
build/
|
build/
|
||||||
lib/
|
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
'xcode_settings': {
|
'xcode_settings': {
|
||||||
'GCC_ENABLE_CPP_EXCEPTIONS': 'YES',
|
'GCC_ENABLE_CPP_EXCEPTIONS': 'YES',
|
||||||
'CLANG_CXX_LIBRARY': 'libc++',
|
'CLANG_CXX_LIBRARY': 'libc++',
|
||||||
'MACOSX_DEPLOYMENT_TARGET': '10.7',
|
'MACOSX_DEPLOYMENT_TARGET': '14.0',
|
||||||
},
|
},
|
||||||
'msvs_settings': {
|
'msvs_settings': {
|
||||||
'VCCLCompilerTool': { 'ExceptionHandling': 1 },
|
'VCCLCompilerTool': { 'ExceptionHandling': 1 },
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
'conditions': [
|
'conditions': [
|
||||||
['OS=="mac"', {
|
['OS=="mac"', {
|
||||||
'xcode_settings': {
|
'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");
|
const utils_1 = require("./utils");
|
||||||
let pty;
|
let pty;
|
||||||
let helperPath;
|
let helperPath;
|
||||||
try {
|
// Check if running in SEA (Single Executable Application) context
|
||||||
pty = require('../build/Release/pty.node');
|
if (process.env.VIBETUNNEL_SEA) {
|
||||||
helperPath = '../build/Release/spawn-helper';
|
// 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 {
|
try {
|
||||||
pty = require('../build/Debug/pty.node');
|
pty = require('../build/Release/pty.node');
|
||||||
helperPath = '../build/Debug/spawn-helper';
|
helperPath = '../build/Release/spawn-helper';
|
||||||
}
|
}
|
||||||
catch (innerError) {
|
catch (outerError) {
|
||||||
console.error('innerError', innerError);
|
try {
|
||||||
// Re-throw the exception from the Release require if the Debug require fails as well
|
pty = require('../build/Debug/pty.node');
|
||||||
throw outerError;
|
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_FILE = 'sh';
|
||||||
const DEFAULT_NAME = 'xterm';
|
const DEFAULT_NAME = 'xterm';
|
||||||
const DESTROY_SOCKET_TIMEOUT_MS = 200;
|
const DESTROY_SOCKET_TIMEOUT_MS = 200;
|
||||||
|
|
@ -13,23 +13,48 @@ import { assign } from './utils';
|
||||||
|
|
||||||
let pty: IUnixNative;
|
let pty: IUnixNative;
|
||||||
let helperPath: string;
|
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);
|
// Check if running in SEA (Single Executable Application) context
|
||||||
helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
|
if (process.env.VIBETUNNEL_SEA) {
|
||||||
helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
|
// 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_FILE = 'sh';
|
||||||
const DEFAULT_NAME = 'xterm';
|
const DEFAULT_NAME = 'xterm';
|
||||||
|
|
@ -75,7 +75,7 @@
|
||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"monaco-editor": "^0.52.2",
|
"monaco-editor": "^0.52.2",
|
||||||
"multer": "^2.0.1",
|
"multer": "^2.0.1",
|
||||||
"node-pty": "file:./vendored-pty",
|
"node-pty": "file:./node-pty",
|
||||||
"postject": "^1.0.0-alpha.6",
|
"postject": "^1.0.0-alpha.6",
|
||||||
"signal-exit": "^4.1.0",
|
"signal-exit": "^4.1.0",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,8 @@ importers:
|
||||||
specifier: ^2.0.1
|
specifier: ^2.0.1
|
||||||
version: 2.0.1
|
version: 2.0.1
|
||||||
node-pty:
|
node-pty:
|
||||||
specifier: file:./vendored-pty
|
specifier: file:./node-pty
|
||||||
version: '@vibetunnel/vendored-pty@file:vendored-pty'
|
version: file:node-pty
|
||||||
postject:
|
postject:
|
||||||
specifier: ^1.0.0-alpha.6
|
specifier: ^1.0.0-alpha.6
|
||||||
version: 1.0.0-alpha.6
|
version: 1.0.0-alpha.6
|
||||||
|
|
@ -970,9 +970,6 @@ packages:
|
||||||
'@types/yauzl@2.10.3':
|
'@types/yauzl@2.10.3':
|
||||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
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':
|
'@vitest/coverage-v8@3.2.4':
|
||||||
resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==}
|
resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -2311,6 +2308,9 @@ packages:
|
||||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
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:
|
node-releases@2.0.19:
|
||||||
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
|
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
|
||||||
|
|
||||||
|
|
@ -3934,10 +3934,6 @@ snapshots:
|
||||||
'@types/node': 24.0.4
|
'@types/node': 24.0.4
|
||||||
optional: true
|
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)':
|
'@vitest/coverage-v8@3.2.4(vitest@3.2.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ampproject/remapping': 2.3.0
|
'@ampproject/remapping': 2.3.0
|
||||||
|
|
@ -5398,6 +5394,10 @@ snapshots:
|
||||||
fetch-blob: 3.2.0
|
fetch-blob: 3.2.0
|
||||||
formdata-polyfill: 4.0.10
|
formdata-polyfill: 4.0.10
|
||||||
|
|
||||||
|
node-pty@file:node-pty:
|
||||||
|
dependencies:
|
||||||
|
node-addon-api: 7.1.1
|
||||||
|
|
||||||
node-releases@2.0.19: {}
|
node-releases@2.0.19: {}
|
||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,11 @@
|
||||||
|
|
||||||
<!-- Mobile viewport height fix -->
|
<!-- Mobile viewport height fix -->
|
||||||
<script>
|
<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() {
|
function setViewportHeight() {
|
||||||
const vh = window.innerHeight * 0.01;
|
const vh = window.innerHeight * 0.01;
|
||||||
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||||
|
|
@ -88,11 +92,14 @@
|
||||||
// Set initial height
|
// Set initial height
|
||||||
setViewportHeight();
|
setViewportHeight();
|
||||||
|
|
||||||
// Update on resize and orientation change
|
// On mobile, we only set viewport height once to prevent resize loops
|
||||||
window.addEventListener('resize', setViewportHeight);
|
// On desktop, we still allow dynamic resizing
|
||||||
window.addEventListener('orientationchange', () => {
|
if (!isMobile) {
|
||||||
setTimeout(setViewportHeight, 100);
|
window.addEventListener('resize', setViewportHeight);
|
||||||
});
|
window.addEventListener('orientationchange', () => {
|
||||||
|
setTimeout(setViewportHeight, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Force full-screen behavior
|
// Force full-screen behavior
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
|
|
|
||||||
|
|
@ -859,14 +859,14 @@ describe('SessionView', () => {
|
||||||
vi.advanceTimersByTime(110);
|
vi.advanceTimersByTime(110);
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
// Check that terminal container height was calculated correctly
|
// On mobile with keyboard and quick keys, height should be calculated dynamically
|
||||||
// Quick keys height (150) + keyboard height (300) + buffer (10) = 460px reduction
|
// Height reduction = keyboardHeight (300) + quickKeysHeight (150) + buffer (10) = 460px
|
||||||
expect(element.terminalContainerHeight).toBe('calc(100% - 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);
|
expect(fitTerminalSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// Should have called scrollToBottom due to height reduction
|
// scrollToBottom should be called when height is reduced
|
||||||
expect(terminalElement.scrollToBottom).toHaveBeenCalled();
|
expect(terminalElement.scrollToBottom).toHaveBeenCalled();
|
||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|
@ -887,8 +887,8 @@ describe('SessionView', () => {
|
||||||
vi.advanceTimersByTime(110);
|
vi.advanceTimersByTime(110);
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
// On desktop, quick keys should not affect terminal height
|
// On desktop, quick keys should affect terminal height
|
||||||
expect(element.terminalContainerHeight).toBe('100%');
|
expect(element.terminalContainerHeight).toBe('calc(100% - 150px)');
|
||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
@ -905,6 +905,8 @@ describe('SessionView', () => {
|
||||||
vi.advanceTimersByTime(110);
|
vi.advanceTimersByTime(110);
|
||||||
await vi.runAllTimersAsync();
|
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)');
|
expect(element.terminalContainerHeight).toBe('calc(100% - 310px)');
|
||||||
|
|
||||||
// Now hide the keyboard
|
// Now hide the keyboard
|
||||||
|
|
@ -914,7 +916,7 @@ describe('SessionView', () => {
|
||||||
vi.advanceTimersByTime(110);
|
vi.advanceTimersByTime(110);
|
||||||
await vi.runAllTimersAsync();
|
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%');
|
expect(element.terminalContainerHeight).toBe('100%');
|
||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|
@ -958,10 +960,11 @@ describe('SessionView', () => {
|
||||||
vi.advanceTimersByTime(110);
|
vi.advanceTimersByTime(110);
|
||||||
await vi.runAllTimersAsync();
|
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)');
|
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);
|
expect(fitTerminalSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|
|
||||||
|
|
@ -1117,7 +1117,7 @@ export class SessionView extends LitElement {
|
||||||
// Calculate height reduction for keyboard and quick keys
|
// Calculate height reduction for keyboard and quick keys
|
||||||
let heightReduction = 0;
|
let heightReduction = 0;
|
||||||
|
|
||||||
if (this.showQuickKeys && this.isMobile) {
|
if (this.showQuickKeys) {
|
||||||
// Quick keys height (approximately 140px based on CSS)
|
// Quick keys height (approximately 140px based on CSS)
|
||||||
// Add 10px buffer to ensure content is visible above quick keys
|
// Add 10px buffer to ensure content is visible above quick keys
|
||||||
const quickKeysHeight = 150;
|
const quickKeysHeight = 150;
|
||||||
|
|
|
||||||
|
|
@ -149,13 +149,21 @@ export class TerminalLifecycleManager {
|
||||||
async handleTerminalResize(event: Event) {
|
async handleTerminalResize(event: Event) {
|
||||||
const customEvent = event as CustomEvent;
|
const customEvent = event as CustomEvent;
|
||||||
// Update terminal dimensions for display
|
// 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
|
// Notify the session view to update its state
|
||||||
if (this.stateCallbacks) {
|
if (this.stateCallbacks) {
|
||||||
this.stateCallbacks.updateTerminalDimensions(cols, rows);
|
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
|
// Debounce resize requests to prevent jumpiness
|
||||||
if (this.resizeTimeout) {
|
if (this.resizeTimeout) {
|
||||||
clearTimeout(this.resizeTimeout);
|
clearTimeout(this.resizeTimeout);
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,8 @@ interface TestTerminal extends Terminal {
|
||||||
measureCharacterWidth(): number;
|
measureCharacterWidth(): number;
|
||||||
fitTerminal(): void;
|
fitTerminal(): void;
|
||||||
userOverrideWidth: boolean;
|
userOverrideWidth: boolean;
|
||||||
resizeCoordinator: {
|
lastCols: number;
|
||||||
forceUpdateDimensions(cols: number, rows: number): void;
|
lastRows: number;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Terminal', () => {
|
describe('Terminal', () => {
|
||||||
|
|
@ -607,8 +606,9 @@ describe('Terminal', () => {
|
||||||
element.cols = currentCols;
|
element.cols = currentCols;
|
||||||
element.rows = currentRows;
|
element.rows = currentRows;
|
||||||
|
|
||||||
// Initialize ResizeCoordinator with current dimensions
|
// Initialize last dimensions to match current dimensions
|
||||||
(element as TestTerminal).resizeCoordinator.forceUpdateDimensions(currentCols, currentRows);
|
(element as TestTerminal).lastCols = currentCols;
|
||||||
|
(element as TestTerminal).lastRows = currentRows;
|
||||||
|
|
||||||
// Mock character width measurement
|
// Mock character width measurement
|
||||||
vi.spyOn(element as TestTerminal, 'measureCharacterWidth').mockReturnValue(8);
|
vi.spyOn(element as TestTerminal, 'measureCharacterWidth').mockReturnValue(8);
|
||||||
|
|
@ -668,7 +668,7 @@ describe('Terminal', () => {
|
||||||
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: 'terminal-resize',
|
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);
|
vi.spyOn(element as TestTerminal, 'measureCharacterWidth').mockReturnValue(8);
|
||||||
|
|
||||||
// Initialize ResizeCoordinator with current dimensions
|
// Initialize last dimensions to match current dimensions
|
||||||
(element as TestTerminal).resizeCoordinator.forceUpdateDimensions(currentCols, currentRows);
|
(element as TestTerminal).lastCols = currentCols;
|
||||||
|
(element as TestTerminal).lastRows = currentRows;
|
||||||
|
|
||||||
// Call fitTerminal multiple times
|
// Call fitTerminal multiple times
|
||||||
(element as TestTerminal).fitTerminal();
|
(element as TestTerminal).fitTerminal();
|
||||||
|
|
@ -860,8 +861,9 @@ describe('Terminal', () => {
|
||||||
|
|
||||||
vi.spyOn(element as TestTerminal, 'measureCharacterWidth').mockReturnValue(8);
|
vi.spyOn(element as TestTerminal, 'measureCharacterWidth').mockReturnValue(8);
|
||||||
|
|
||||||
// Initialize ResizeCoordinator with current dimensions
|
// Initialize last dimensions to match current dimensions
|
||||||
(element as TestTerminal).resizeCoordinator.forceUpdateDimensions(100, 30);
|
(element as TestTerminal).lastCols = 100;
|
||||||
|
(element as TestTerminal).lastRows = 30;
|
||||||
|
|
||||||
// Clear previous calls
|
// Clear previous calls
|
||||||
mockTerminal?.resize.mockClear();
|
mockTerminal?.resize.mockClear();
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import { html, LitElement, type PropertyValues } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { processKeyboardShortcuts } from '../utils/keyboard-shortcut-highlighter.js';
|
import { processKeyboardShortcuts } from '../utils/keyboard-shortcut-highlighter.js';
|
||||||
import { createLogger } from '../utils/logger.js';
|
import { createLogger } from '../utils/logger.js';
|
||||||
import { ResizeCoordinator } from '../utils/resize-coordinator.js';
|
|
||||||
import { UrlHighlighter } from '../utils/url-highlighter';
|
import { UrlHighlighter } from '../utils/url-highlighter';
|
||||||
|
|
||||||
const logger = createLogger('terminal');
|
const logger = createLogger('terminal');
|
||||||
|
|
@ -73,7 +72,12 @@ export class Terminal extends LitElement {
|
||||||
private momentumVelocityX = 0;
|
private momentumVelocityX = 0;
|
||||||
private momentumAnimation: number | null = null;
|
private momentumAnimation: number | null = null;
|
||||||
private resizeObserver: ResizeObserver | 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
|
// Operation queue for batching buffer modifications
|
||||||
private operationQueue: (() => void | Promise<void>)[] = [];
|
private operationQueue: (() => void | Promise<void>)[] = [];
|
||||||
|
|
@ -136,7 +140,7 @@ export class Terminal extends LitElement {
|
||||||
this.userOverrideWidth = stored === 'true';
|
this.userOverrideWidth = stored === 'true';
|
||||||
// Apply the loaded preference immediately
|
// Apply the loaded preference immediately
|
||||||
if (this.container) {
|
if (this.container) {
|
||||||
this.resizeCoordinator.requestResize('property-change');
|
this.requestResize('property-change');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -159,7 +163,7 @@ export class Terminal extends LitElement {
|
||||||
}
|
}
|
||||||
// Recalculate terminal dimensions when font size changes
|
// Recalculate terminal dimensions when font size changes
|
||||||
if (this.terminal && this.container) {
|
if (this.terminal && this.container) {
|
||||||
this.resizeCoordinator.requestResize('property-change');
|
this.requestResize('property-change');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (changedProperties.has('fitHorizontally')) {
|
if (changedProperties.has('fitHorizontally')) {
|
||||||
|
|
@ -167,12 +171,12 @@ export class Terminal extends LitElement {
|
||||||
// Restore original font size when turning off horizontal fitting
|
// Restore original font size when turning off horizontal fitting
|
||||||
this.fontSize = this.originalFontSize;
|
this.fontSize = this.originalFontSize;
|
||||||
}
|
}
|
||||||
this.resizeCoordinator.requestResize('property-change');
|
this.requestResize('property-change');
|
||||||
}
|
}
|
||||||
// If maxCols changed, trigger a resize
|
// If maxCols changed, trigger a resize
|
||||||
if (changedProperties.has('maxCols')) {
|
if (changedProperties.has('maxCols')) {
|
||||||
if (this.terminal && this.container) {
|
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
|
// Trigger a resize to apply the new setting
|
||||||
if (this.container) {
|
if (this.container) {
|
||||||
this.resizeCoordinator.requestResize('property-change');
|
this.requestResize('property-change');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,8 +216,14 @@ export class Terminal extends LitElement {
|
||||||
this.resizeObserver = null;
|
this.resizeObserver = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.resizeCoordinator) {
|
if (this.pendingResize) {
|
||||||
this.resizeCoordinator.destroy();
|
cancelAnimationFrame(this.pendingResize);
|
||||||
|
this.pendingResize = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mobileInitialResizeTimeout) {
|
||||||
|
clearTimeout(this.mobileInitialResizeTimeout);
|
||||||
|
this.mobileInitialResizeTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.terminal) {
|
if (this.terminal) {
|
||||||
|
|
@ -226,14 +236,81 @@ export class Terminal extends LitElement {
|
||||||
// Store the initial font size as original
|
// Store the initial font size as original
|
||||||
this.originalFontSize = this.fontSize;
|
this.originalFontSize = this.fontSize;
|
||||||
|
|
||||||
// Set up resize coordinator callback
|
// Initialize terminal immediately
|
||||||
this.resizeCoordinator.setResizeCallback((source: string) => {
|
|
||||||
this.fitTerminal(source);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.initializeTerminal();
|
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() {
|
private async initializeTerminal() {
|
||||||
try {
|
try {
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
|
|
@ -275,7 +352,7 @@ export class Terminal extends LitElement {
|
||||||
const safeCols = Number.isFinite(this.cols) ? Math.floor(this.cols) : 80;
|
const safeCols = Number.isFinite(this.cols) ? Math.floor(this.cols) : 80;
|
||||||
const safeRows = Number.isFinite(this.rows) ? Math.floor(this.rows) : 24;
|
const safeRows = Number.isFinite(this.rows) ? Math.floor(this.rows) : 24;
|
||||||
this.terminal.resize(safeCols, safeRows);
|
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) {
|
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 _oldActualRows = this.actualRows;
|
||||||
const oldLineHeight = this.fontSize * 1.2;
|
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 safeCols = Number.isFinite(this.cols) ? Math.floor(this.cols) : 80;
|
||||||
const safeRows = Number.isFinite(this.rows) ? Math.floor(this.rows) : 24;
|
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
|
// 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}`);
|
logger.debug(`Resizing terminal (${source || 'unknown'}): ${safeCols}x${safeRows}`);
|
||||||
this.terminal.resize(safeCols, safeRows);
|
this.terminal.resize(safeCols, safeRows);
|
||||||
|
|
||||||
// Dispatch resize event for backend synchronization
|
// 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(
|
this.dispatchEvent(
|
||||||
new CustomEvent('terminal-resize', {
|
new CustomEvent('terminal-resize', {
|
||||||
detail: { cols: safeCols, rows: safeRows },
|
detail: {
|
||||||
|
cols: safeCols,
|
||||||
|
rows: safeRows,
|
||||||
|
isMobile: this.isMobile,
|
||||||
|
isHeightOnlyChange,
|
||||||
|
source: source || 'unknown',
|
||||||
|
},
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -459,15 +561,29 @@ export class Terminal extends LitElement {
|
||||||
const safeCols = Number.isFinite(this.cols) ? Math.floor(this.cols) : 80;
|
const safeCols = Number.isFinite(this.cols) ? Math.floor(this.cols) : 80;
|
||||||
const safeRows = Number.isFinite(this.rows) ? Math.floor(this.rows) : 24;
|
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
|
// 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}`);
|
logger.debug(`Resizing terminal (${source || 'unknown'}): ${safeCols}x${safeRows}`);
|
||||||
this.terminal.resize(safeCols, safeRows);
|
this.terminal.resize(safeCols, safeRows);
|
||||||
|
|
||||||
// Dispatch resize event for backend synchronization
|
// 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(
|
this.dispatchEvent(
|
||||||
new CustomEvent('terminal-resize', {
|
new CustomEvent('terminal-resize', {
|
||||||
detail: { cols: safeCols, rows: safeRows },
|
detail: {
|
||||||
|
cols: safeCols,
|
||||||
|
rows: safeRows,
|
||||||
|
isMobile: this.isMobile,
|
||||||
|
isHeightOnlyChange,
|
||||||
|
source: source || 'unknown',
|
||||||
|
},
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -502,27 +618,41 @@ export class Terminal extends LitElement {
|
||||||
private setupResize() {
|
private setupResize() {
|
||||||
if (!this.container) return;
|
if (!this.container) return;
|
||||||
|
|
||||||
this.resizeObserver = new ResizeObserver(() => {
|
// Set the class property using consistent detection
|
||||||
this.resizeCoordinator.requestResize('ResizeObserver');
|
this.isMobile = this.detectMobile();
|
||||||
});
|
logger.debug(
|
||||||
this.resizeObserver.observe(this.container);
|
`[Terminal] Setting up resize - isMobile: ${this.isMobile}, userAgent: ${navigator.userAgent}`
|
||||||
|
);
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
if (this.isMobile) {
|
||||||
this.resizeCoordinator.requestResize('window-resize');
|
// 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(() => {
|
||||||
// On mobile, delay initial resize to let everything settle
|
logger.debug('[Terminal] Mobile: Executing initial resize');
|
||||||
if (this.resizeCoordinator.getIsMobile()) {
|
this.fitTerminal('initial-mobile-only');
|
||||||
setTimeout(() => {
|
// That's it - no observers, no event listeners, nothing
|
||||||
this.resizeCoordinator.requestResize('initial-mobile');
|
logger.debug(
|
||||||
// Mark initial resize complete after a short delay
|
'[Terminal] Mobile: Initial width set, future WIDTH resizes blocked (height allowed for keyboard)'
|
||||||
setTimeout(() => {
|
);
|
||||||
this.resizeCoordinator.markInitialResizeComplete();
|
this.mobileInitialResizeTimeout = null; // Clear reference after execution
|
||||||
}, 100);
|
}, 200);
|
||||||
}, 100);
|
|
||||||
} else {
|
} 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
|
// 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(() => {
|
this.queueRenderOperation(() => {
|
||||||
if (!this.terminal) return;
|
if (!this.terminal) return;
|
||||||
|
|
||||||
this.resizeCoordinator.requestResize('property-change');
|
this.requestResize('property-change');
|
||||||
|
|
||||||
const buffer = this.terminal.buffer.active;
|
const buffer = this.terminal.buffer.active;
|
||||||
const lineHeight = this.fontSize * 1.2;
|
const lineHeight = this.fontSize * 1.2;
|
||||||
|
|
@ -1324,7 +1454,7 @@ export class Terminal extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recalculate fit
|
// Recalculate fit
|
||||||
this.resizeCoordinator.requestResize('fit-mode-change');
|
this.requestResize('fit-mode-change');
|
||||||
|
|
||||||
// Restore scroll position - prioritize staying at bottom if we were there
|
// Restore scroll position - prioritize staying at bottom if we were there
|
||||||
if (wasAtBottom) {
|
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