fix(terminal): correct mobile sizing — fit after web-font load + open PTY at the visible grid
On a phone the terminal clipped on the right: FitAddon measured the column count before JetBrains Mono finished loading (narrower fallback metrics → too many columns → the wider real font then overflowed and the right edge was cut). And the PTY opened at a fixed 80x24, so the remote shell's first screenful (MOTD/prompt) was generated at 80 columns and wrapped awkwardly on a ~43-column viewport. - refit immediately, on the next animation frame, and again on document.fonts.ready (font race) - refit on the 'ready' frame before sending the size - pass the browser's cols/rows on the WS URL; the sidecar opens the host PTY / SSH shell at that size (clamped 1..1000), so output is born at the visible width Verified in-browser at 390px: PTY reports 43x23 (matches the box), no horizontal overflow, clean wrapping, zero console errors; desktop server+host terminals and cross-origin rejection still pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
05042c3582
commit
e3b5aa5902
|
|
@ -66,11 +66,11 @@ function bindInput(ws, io) {
|
|||
});
|
||||
}
|
||||
|
||||
function startHost(ws) {
|
||||
function startHost(ws, size) {
|
||||
let p;
|
||||
try {
|
||||
p = pty.spawn(HOST_SHELL, ['-l'], {
|
||||
name: 'xterm-256color', cwd: HOST_CWD, cols: 80, rows: 24,
|
||||
name: 'xterm-256color', cwd: HOST_CWD, cols: size.cols, rows: size.rows,
|
||||
// HOME = the landing dir so the shell opens at ~ (not a bare absolute path) and the prompt
|
||||
// reads cleanly; the container's `hostname: clusev` makes \h show "clusev", not the hash.
|
||||
env: { ...process.env, TERM: 'xterm-256color', HOME: HOST_CWD },
|
||||
|
|
@ -87,12 +87,12 @@ function startHost(ws) {
|
|||
ws.on('close', () => { try { p.kill(); } catch (_) {} });
|
||||
}
|
||||
|
||||
function startSsh(ws, spec) {
|
||||
function startSsh(ws, spec, size) {
|
||||
const conn = new Client();
|
||||
let stream = null;
|
||||
|
||||
conn.on('ready', () => {
|
||||
conn.shell({ term: 'xterm-256color', cols: 80, rows: 24 }, (err, s) => {
|
||||
conn.shell({ term: 'xterm-256color', cols: size.cols, rows: size.rows }, (err, s) => {
|
||||
if (err) { send(ws, { type: 'error', data: 'PTY: ' + err.message }); conn.end(); return; }
|
||||
stream = s;
|
||||
send(ws, { type: 'ready' });
|
||||
|
|
@ -139,7 +139,17 @@ wss.on('connection', async (ws, req) => {
|
|||
}
|
||||
|
||||
let token = '';
|
||||
try { token = new URL(req.url, 'http://x').searchParams.get('token') || ''; } catch (_) {}
|
||||
const size = { cols: 80, rows: 24 };
|
||||
try {
|
||||
const q = new URL(req.url, 'http://x').searchParams;
|
||||
token = q.get('token') || '';
|
||||
// Open the PTY at the browser's reported grid so the first screenful matches the viewport. Clamp
|
||||
// to sane bounds (a bad/huge value must never reach pty/ssh); fall back to 80×24 when absent.
|
||||
const c = parseInt(q.get('cols'), 10);
|
||||
const r = parseInt(q.get('rows'), 10);
|
||||
if (c >= 1 && c <= 1000) size.cols = c;
|
||||
if (r >= 1 && r <= 1000) size.rows = r;
|
||||
} catch (_) {}
|
||||
if (!token || !SECRET) { send(ws, { type: 'error', data: 'Sitzung ungültig.' }); ws.close(); return; }
|
||||
|
||||
let spec;
|
||||
|
|
@ -151,8 +161,8 @@ wss.on('connection', async (ws, req) => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (spec && spec.kind === 'host') startHost(ws);
|
||||
else if (spec && spec.kind === 'server' && spec.host && spec.username) startSsh(ws, spec);
|
||||
if (spec && spec.kind === 'host') startHost(ws, size);
|
||||
else if (spec && spec.kind === 'server' && spec.host && spec.username) startSsh(ws, spec, size);
|
||||
else { send(ws, { type: 'error', data: 'Kein gültiges Ziel.' }); ws.close(); }
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -380,7 +380,11 @@ document.addEventListener('alpine:init', () => {
|
|||
this.fit = new FitAddon();
|
||||
this.term.loadAddon(this.fit);
|
||||
this.term.open(this.$refs.screen);
|
||||
try { this.fit.fit(); } catch (_) {}
|
||||
// Fit now, next frame (after layout), and again once the web font finishes loading.
|
||||
// The last one is critical: FitAddon measures the cell width to pick the column count;
|
||||
// if it runs while JetBrains Mono is still loading it measures the (narrower) fallback,
|
||||
// over-counts columns, then the wider real font overflows and the right edge gets clipped.
|
||||
this._refit();
|
||||
|
||||
this.term.onData((d) => { if (this.ws && this.ws.readyState === 1) this.ws.send(JSON.stringify({ type: 'data', data: d })); });
|
||||
this._ro = new ResizeObserver(() => { try { this.fit.fit(); this._sendResize(); } catch (_) {} });
|
||||
|
|
@ -390,6 +394,15 @@ document.addEventListener('alpine:init', () => {
|
|||
if (this._pending) { const t = this._pending; this._pending = null; this._connect(t); }
|
||||
},
|
||||
|
||||
// Re-measure + resize the grid to the container: immediately, after the next layout frame, and
|
||||
// once web fonts settle (so column math uses the real monospace metrics, not a fallback).
|
||||
_refit() {
|
||||
const f = () => { try { this.fit.fit(); this._sendResize(); } catch (_) {} };
|
||||
f();
|
||||
requestAnimationFrame(f);
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(f).catch(() => {});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this._close();
|
||||
if (this._ro) this._ro.disconnect();
|
||||
|
|
@ -408,7 +421,12 @@ document.addEventListener('alpine:init', () => {
|
|||
this.status = 'connecting';
|
||||
if (this.term) this.term.reset();
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${proto}://${window.location.host}/terminal/ws?token=${encodeURIComponent(token)}`);
|
||||
// Hand the current grid size to the sidecar so the PTY (and the remote shell's MOTD/prompt)
|
||||
// is born at the visible width — otherwise it starts at 80×24 and the first screenful wraps
|
||||
// awkwardly on a narrow (mobile) viewport until the first resize lands.
|
||||
const cols = (this.term && this.term.cols) || 80;
|
||||
const rows = (this.term && this.term.rows) || 24;
|
||||
const ws = new WebSocket(`${proto}://${window.location.host}/terminal/ws?token=${encodeURIComponent(token)}&cols=${cols}&rows=${rows}`);
|
||||
this.ws = ws;
|
||||
// Every handler is bound to THIS socket: a superseded connection (the user picked another
|
||||
// target) still fires onclose/onerror asynchronously, which would otherwise clobber the
|
||||
|
|
@ -418,7 +436,7 @@ document.addEventListener('alpine:init', () => {
|
|||
let m;
|
||||
try { m = JSON.parse(ev.data); } catch (_) { return; }
|
||||
if (m.type === 'data') this.term.write(m.data);
|
||||
else if (m.type === 'ready') { this.status = 'connected'; this._sendResize(); setTimeout(() => this.term.focus(), 0); }
|
||||
else if (m.type === 'ready') { this.status = 'connected'; this._refit(); setTimeout(() => this.term.focus(), 0); }
|
||||
else if (m.type === 'error') { this.status = 'error'; this.term.write(`\r\n\x1b[31m${m.data}\x1b[0m\r\n`); }
|
||||
else if (m.type === 'exit') { this.status = 'closed'; this.term.write('\r\n\x1b[90m— Sitzung beendet —\x1b[0m\r\n'); }
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue