diff --git a/docker/terminal/server.js b/docker/terminal/server.js index 42fd62b..fd24bd6 100644 --- a/docker/terminal/server.js +++ b/docker/terminal/server.js @@ -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(); } }); diff --git a/resources/js/app.js b/resources/js/app.js index 171384c..7c81f77 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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'); } };