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:
@@ -45,6 +45,14 @@ app.applySettings(settings)
|
|||||||
// Key bar (mobile + desktop) sends to whichever tab is active.
|
// Key bar (mobile + desktop) sends to whichever tab is active.
|
||||||
mountKeybar((data) => app.sendToActive(data))
|
mountKeybar((data) => app.sendToActive(data))
|
||||||
|
|
||||||
|
// Multi-device: when this device regains focus, re-assert the active tab's size
|
||||||
|
// so a shared session snaps to THIS screen (latest-writer-wins) — full-screen on
|
||||||
|
// whichever device you're currently using.
|
||||||
|
window.addEventListener('focus', () => app.refitActive())
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (!document.hidden) app.refitActive()
|
||||||
|
})
|
||||||
|
|
||||||
// Toolbar utilities.
|
// Toolbar utilities.
|
||||||
mountSearch(toolbar, {
|
mountSearch(toolbar, {
|
||||||
find: (query, dir) => app.findInActive(query, dir),
|
find: (query, dir) => app.findInActive(query, dir),
|
||||||
|
|||||||
@@ -337,6 +337,12 @@ export class TabApp {
|
|||||||
this.tabs[this.activeIndex]?.session.send(data)
|
this.tabs[this.activeIndex]?.session.send(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Re-assert the active tab's size (latest-writer-wins) when this device
|
||||||
|
* regains focus, so a shared session snaps back to this screen's size. */
|
||||||
|
refitActive(): void {
|
||||||
|
this.tabs[this.activeIndex]?.session.refit()
|
||||||
|
}
|
||||||
|
|
||||||
/** Apply theme + font settings to every terminal (M3). */
|
/** Apply theme + font settings to every terminal (M3). */
|
||||||
applySettings(s: Settings): void {
|
applySettings(s: Settings): void {
|
||||||
this.settings = s
|
this.settings = s
|
||||||
|
|||||||
@@ -349,6 +349,19 @@ export class TerminalSession {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-fit and FORCE-resend dims (latest-writer-wins): when this device regains
|
||||||
|
* focus, re-assert its size so the shared PTY snaps to this screen even if
|
||||||
|
* another device had resized it. No-op while hidden.
|
||||||
|
*/
|
||||||
|
refit(): void {
|
||||||
|
if (this.el.style.display === 'none') return
|
||||||
|
this.lastCols = -1 // force sendResize to fire even if our dims are unchanged
|
||||||
|
this.lastRows = -1
|
||||||
|
const dims = this.safefit()
|
||||||
|
if (dims !== null) this.sendResize(dims.cols, dims.rows)
|
||||||
|
}
|
||||||
|
|
||||||
hide(): void {
|
hide(): void {
|
||||||
this.el.style.display = 'none'
|
this.el.style.display = 'none'
|
||||||
// v0.4: withdraw our size vote so a backgrounded mirror doesn't clamp the
|
// v0.4: withdraw our size vote so a backgrounded mirror doesn't clamp the
|
||||||
|
|||||||
@@ -52,21 +52,9 @@ export function broadcast(session: Session, msg: ServerMessage): void {
|
|||||||
for (const ws of session.clients) sendIfOpen(ws, msg);
|
for (const ws of session.clients) sendIfOpen(ws, msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Resize the PTY if the dims actually changed and it's still alive (L4). */
|
||||||
* Resize the PTY to the MIN cols/rows across all attached clients (tmux-style),
|
function applyDims(session: Session, cols: number, rows: number): void {
|
||||||
* so a small phone and a wide laptop sharing the session both see content
|
if (session.exitedAt !== null) return;
|
||||||
* without overflow. No-op after exit (L4), when no client is attached, or when
|
|
||||||
* the computed dims are unchanged (idempotent).
|
|
||||||
*/
|
|
||||||
function applyMinDims(session: Session): void {
|
|
||||||
if (session.exitedAt !== null || session.clientDims.size === 0) return;
|
|
||||||
let cols = Infinity;
|
|
||||||
let rows = Infinity;
|
|
||||||
for (const d of session.clientDims.values()) {
|
|
||||||
cols = Math.min(cols, d.cols);
|
|
||||||
rows = Math.min(rows, d.rows);
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
|
|
||||||
if (session.pty.cols === cols && session.pty.rows === rows) return;
|
if (session.pty.cols === cols && session.pty.rows === rows) return;
|
||||||
session.pty.resize(cols, rows);
|
session.pty.resize(cols, rows);
|
||||||
}
|
}
|
||||||
@@ -154,10 +142,9 @@ export function createSession(
|
|||||||
* replay the scrollback to THIS client only so it sees the current screen.
|
* replay the scrollback to THIS client only so it sees the current screen.
|
||||||
* No kicking — devices share the session (invariant #5 relaxed for v0.4).
|
* No kicking — devices share the session (invariant #5 relaxed for v0.4).
|
||||||
*
|
*
|
||||||
* The client does NOT get a size vote here: only a client that is actively
|
* Attaching does NOT change the PTY size — the device actively using the
|
||||||
* viewing the session sends a `resize` (the frontend skips hidden panes), so a
|
* session keeps its size. A client only sets the size once it sends a `resize`
|
||||||
* background mirror never clamps the shared PTY. The vote is added on the first
|
* (the frontend does so when its pane is visible / regains focus).
|
||||||
* `setClientDims` and removed on `clearClientDims`/`detachWs`.
|
|
||||||
*/
|
*/
|
||||||
export function attachWs(session: Session, ws: WebSocketLike): void {
|
export function attachWs(session: Session, ws: WebSocketLike): void {
|
||||||
session.clients.add(ws);
|
session.clients.add(ws);
|
||||||
@@ -175,10 +162,10 @@ export function attachWs(session: Session, ws: WebSocketLike): void {
|
|||||||
export function detachWs(session: Session, ws: WebSocketLike, now: number): void {
|
export function detachWs(session: Session, ws: WebSocketLike, now: number): void {
|
||||||
session.clients.delete(ws);
|
session.clients.delete(ws);
|
||||||
session.clientDims.delete(ws);
|
session.clientDims.delete(ws);
|
||||||
|
// A client leaving does NOT resize the PTY — whatever device is still actively
|
||||||
|
// viewing keeps its size. Start the idle clock only when the last client goes.
|
||||||
if (session.clients.size === 0) {
|
if (session.clients.size === 0) {
|
||||||
session.detachedAt = now;
|
session.detachedAt = now;
|
||||||
} else {
|
|
||||||
applyMinDims(session);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +179,13 @@ export function writeInput(session: Session, data: string): void {
|
|||||||
* Record one client's requested dims and resize the PTY to the new min across
|
* Record one client's requested dims and resize the PTY to the new min across
|
||||||
* all clients (tmux-style). No-op after exit (L4); idempotent when unchanged.
|
* all clients (tmux-style). No-op after exit (L4); idempotent when unchanged.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Latest-writer-wins: the device that most recently fit/focused drives the PTY
|
||||||
|
* size, so whichever device you are actively using is full-screen. (A shared
|
||||||
|
* PTY can only be one size; min-sizing letterboxed the bigger screen.) The
|
||||||
|
* frontend re-sends dims when a pane is shown or its window regains focus, so
|
||||||
|
* switching devices reclaims full size. No-op after exit (L4) / when unchanged.
|
||||||
|
*/
|
||||||
export function setClientDims(
|
export function setClientDims(
|
||||||
session: Session,
|
session: Session,
|
||||||
ws: WebSocketLike,
|
ws: WebSocketLike,
|
||||||
@@ -200,16 +194,15 @@ export function setClientDims(
|
|||||||
): void {
|
): void {
|
||||||
if (session.exitedAt !== null) return;
|
if (session.exitedAt !== null) return;
|
||||||
session.clientDims.set(ws, { cols, rows });
|
session.clientDims.set(ws, { cols, rows });
|
||||||
applyMinDims(session);
|
applyDims(session, cols, rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Withdraw a client's size vote without detaching it (its tab was hidden). The
|
* Forget a client's size when its tab goes hidden — but do NOT resize: the
|
||||||
* client still receives output (it's a background mirror); it just stops
|
* device still actively viewing keeps its size (no letterboxing of a mirror).
|
||||||
* constraining the shared PTY size. Re-votes on its next `setClientDims`.
|
|
||||||
*/
|
*/
|
||||||
export function clearClientDims(session: Session, ws: WebSocketLike): void {
|
export function clearClientDims(session: Session, ws: WebSocketLike): void {
|
||||||
if (session.clientDims.delete(ws)) applyMinDims(session);
|
session.clientDims.delete(ws);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -208,54 +208,51 @@ describe('attachWs', () => {
|
|||||||
expect(received(b)).toEqual([{ type: 'output', data: 'live' }]);
|
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 s = newSession(); // spawn dims 80x24
|
||||||
const wide = createMockWs();
|
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);
|
setClientDims(s, wide, 200, 50);
|
||||||
expect(s.pty.cols).toBe(200);
|
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);
|
expect(s.pty.rows).toBe(50);
|
||||||
|
|
||||||
attachWs(s, narrow);
|
// The device used next (smaller iPad) takes over — it is now full-screen.
|
||||||
setClientDims(s, narrow, 90, 30);
|
setClientDims(s, ipad, 100, 40);
|
||||||
// min(200,90)=90, min(50,30)=30
|
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.cols).toBe(90);
|
||||||
expect(s.pty.rows).toBe(30);
|
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) ──────────────────────────────────────────────
|
// ── detachWs (remove one client) ──────────────────────────────────────────────
|
||||||
@@ -280,19 +277,19 @@ describe('detachWs', () => {
|
|||||||
expect((s.pty as MockIPty).killed).toBe(false);
|
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 s = newSession();
|
||||||
const wide = createMockWs();
|
const wide = createMockWs();
|
||||||
const narrow = createMockWs();
|
const narrow = createMockWs();
|
||||||
attachWs(s, wide);
|
attachWs(s, wide);
|
||||||
setClientDims(s, wide, 200, 50);
|
setClientDims(s, wide, 200, 50);
|
||||||
attachWs(s, narrow);
|
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);
|
detachWs(s, narrow, 5_000);
|
||||||
// only the wide client remains → PTY expands back to 200x50
|
// detach does not resize; PTY stays at the last set size until a client re-fits.
|
||||||
expect(s.pty.cols).toBe(200);
|
expect(s.pty.cols).toBe(90);
|
||||||
expect(s.pty.rows).toBe(50);
|
expect(s.pty.rows).toBe(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the PTY alive after the last detach: further onData still fills the buffer', () => {
|
it('keeps the PTY alive after the last detach: further onData still fills the buffer', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user