fix(v0.4): full-screen per device — latest-writer-wins PTY sizing

A shared PTY can only be one size; min-sizing clamped the shared session to the
SMALLEST viewer, so a wide desktop got letterboxed when an iPad was also attached
(iPad looked full-screen, desktop did not).

Switch to latest-writer-wins: the device that most recently fit/focused drives the
PTY size, so whichever device you're actively using is full-screen.
- session.ts: setClientDims resizes the PTY directly (drop min across clients);
  attach/detach/blur never resize (the active device keeps its size)
- frontend: TerminalSession.refit() force-resends dims; window 'focus' /
  visibilitychange re-assert the active tab's size, so switching devices reclaims
  full-screen
- tests updated for latest-wins (incl. join/hidden/blur don't resize)

Verified (two ws clients): 200x50 → iPad 100x40 → desktop refit 200x50 → iPad blur
keeps 200x50. 225 tests green.
This commit is contained in:
Yaojia Wang
2026-06-19 11:16:59 +02:00
parent d782ec8488
commit edbfc62f7f
5 changed files with 87 additions and 70 deletions

View File

@@ -208,54 +208,51 @@ describe('attachWs', () => {
expect(received(b)).toEqual([{ type: 'output', data: 'live' }]);
});
it('resizes the PTY to the MIN dims across active viewers (tmux-style)', () => {
it('attaching alone does NOT change the PTY size (the active device keeps it)', () => {
const s = newSession(); // spawn dims 80x24
const wide = createMockWs();
const narrow = createMockWs();
// Attach alone does NOT vote on size (a join could be a hidden mirror).
attachWs(s, wide);
expect(s.pty.cols).toBe(80);
// A client only constrains the PTY once it reports dims (it's viewing).
setClientDims(s, wide, 200, 50);
expect(s.pty.cols).toBe(200);
const hidden = createMockWs();
attachWs(s, hidden); // joins but never reports dims (background mirror)
// The join must not change the PTY — the active viewer keeps 200x50.
expect(s.pty.cols).toBe(200);
expect(s.pty.rows).toBe(50);
});
it('latest-writer-wins: the most recent client dims drive the PTY size', () => {
const s = newSession();
const desktop = createMockWs();
const ipad = createMockWs();
setClientDims(s, desktop, 200, 50);
expect(s.pty.cols).toBe(200);
expect(s.pty.rows).toBe(50);
attachWs(s, narrow);
setClientDims(s, narrow, 90, 30);
// min(200,90)=90, min(50,30)=30
// The device used next (smaller iPad) takes over — it is now full-screen.
setClientDims(s, ipad, 100, 40);
expect(s.pty.cols).toBe(100);
expect(s.pty.rows).toBe(40);
// Switching back to the desktop (re-fit on focus) reclaims its size.
setClientDims(s, desktop, 200, 50);
expect(s.pty.cols).toBe(200);
expect(s.pty.rows).toBe(50);
});
it('clearClientDims (tab hidden) does NOT resize — the active device keeps its size', () => {
const s = newSession();
const a = createMockWs();
const b = createMockWs();
setClientDims(s, a, 200, 50);
setClientDims(s, b, 90, 30); // b is using it now → 90x30
clearClientDims(s, a); // a's tab hidden elsewhere → just forget a's dims
// PTY unchanged: b is still the active viewer at 90x30.
expect(s.pty.cols).toBe(90);
expect(s.pty.rows).toBe(30);
});
it('a hidden mirror (no dims reported) does NOT clamp the shared PTY', () => {
const s = newSession();
const viewer = createMockWs();
const hidden = createMockWs();
attachWs(s, viewer);
setClientDims(s, viewer, 200, 50);
attachWs(s, hidden); // joins but never reports dims (background tab)
// The hidden mirror must not drag the PTY down to the 80x24 default.
expect(s.pty.cols).toBe(200);
expect(s.pty.rows).toBe(50);
});
it('clearClientDims withdraws a vote (tab hidden) and lets the PTY grow', () => {
const s = newSession();
const wide = createMockWs();
const narrow = createMockWs();
attachWs(s, wide);
setClientDims(s, wide, 200, 50);
attachWs(s, narrow);
setClientDims(s, narrow, 90, 30); // clamps to 90x30
clearClientDims(s, narrow); // narrow's tab hidden → withdraw vote
expect(s.pty.cols).toBe(200);
expect(s.pty.rows).toBe(50);
});
});
// ── detachWs (remove one client) ──────────────────────────────────────────────
@@ -280,19 +277,19 @@ describe('detachWs', () => {
expect((s.pty as MockIPty).killed).toBe(false);
});
it('lets the PTY grow back when a small client leaves', () => {
it('does NOT resize when a client leaves (the remaining device keeps its size)', () => {
const s = newSession();
const wide = createMockWs();
const narrow = createMockWs();
attachWs(s, wide);
setClientDims(s, wide, 200, 50);
attachWs(s, narrow);
setClientDims(s, narrow, 90, 30); // clamps to 90x30
setClientDims(s, narrow, 90, 30); // narrow is the latest writer → 90x30
detachWs(s, narrow, 5_000);
// only the wide client remains → PTY expands back to 200x50
expect(s.pty.cols).toBe(200);
expect(s.pty.rows).toBe(50);
// detach does not resize; PTY stays at the last set size until a client re-fits.
expect(s.pty.cols).toBe(90);
expect(s.pty.rows).toBe(30);
});
it('keeps the PTY alive after the last detach: further onData still fills the buffer', () => {