From 0f6e48a95c49a62e2f99bdc5a3bc4bed6aa4f7da Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Wed, 30 Sep 2026 22:46:35 +0800 Subject: [PATCH] fix(session-sharing): synchronize shared SSH terminal dimensions (#1502) --- .../src/frontend/SharedSessionView.tsx | 21 +++- .../frontend/guest-terminal-size.test.tsx | 110 ++++++++++++++++++ .../src/backend/session-manager.ts | 18 +++ .../src/backend/terminal-socket.ts | 12 +- .../src/frontend/terminal/Terminal.tsx | 46 ++++++-- .../tests/backend/session-manager.test.ts | 40 +++++++ .../terminal/shared-terminal-size.test.ts | 109 +++++++++++++++++ 7 files changed, 341 insertions(+), 15 deletions(-) create mode 100644 plugins/session-sharing/tests/frontend/guest-terminal-size.test.tsx create mode 100644 plugins/ssh-terminal/tests/frontend/terminal/shared-terminal-size.test.ts diff --git a/plugins/session-sharing/src/frontend/SharedSessionView.tsx b/plugins/session-sharing/src/frontend/SharedSessionView.tsx index 3bf85eff9..b8164e64c 100644 --- a/plugins/session-sharing/src/frontend/SharedSessionView.tsx +++ b/plugins/session-sharing/src/frontend/SharedSessionView.tsx @@ -135,7 +135,11 @@ export function GuestTerminalView({ terminal.open(xtermRef.current); fitAddon.fit(); - const resizeObserver = new ResizeObserver(() => fitAddon.fit()); + let sharedSize: { cols: number; rows: number } | null = null; + const resizeObserver = new ResizeObserver(() => { + if (sharedSize) terminal.resize(sharedSize.cols, sharedSize.rows); + else fitAddon.fit(); + }); resizeObserver.observe(xtermRef.current); let cancelled = false; @@ -163,6 +167,21 @@ export function GuestTerminalView({ } switch (msg.type) { + case "resized": { + const { cols, rows } = msg; + if ( + typeof cols === "number" && + typeof rows === "number" && + Number.isInteger(cols) && + Number.isInteger(rows) && + cols > 0 && + rows > 0 + ) { + sharedSize = { cols, rows }; + terminal.resize(cols, rows); + } + break; + } case "data": if (typeof msg.data === "string") terminal.write(msg.data); break; diff --git a/plugins/session-sharing/tests/frontend/guest-terminal-size.test.tsx b/plugins/session-sharing/tests/frontend/guest-terminal-size.test.tsx new file mode 100644 index 000000000..5adfede78 --- /dev/null +++ b/plugins/session-sharing/tests/frontend/guest-terminal-size.test.tsx @@ -0,0 +1,110 @@ +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GuestTerminalView } from "../../src/frontend/SharedSessionView"; + +const f = vi.hoisted(() => ({ + terminal: { + cols: 80, + rows: 24, + options: {}, + loadAddon: vi.fn(), + open: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + }, + fit: vi.fn(), + onResize: null as (() => void) | null, +})); +vi.mock("@termix/plugin-sdk/frontend", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("react-xtermjs", () => ({ + useXTerm: () => ({ instance: f.terminal, ref: { current: null } }), +})); +vi.mock("@xterm/addon-fit", () => ({ + FitAddon: class { + fit() { + f.fit(); + } + }, +})); +vi.mock("../../src/frontend/shared", () => ({ + wsUrlForPath: async () => "ws://termix.test/terminal", + RemoteDisplay: () => null, + Loader: () => null, +})); +class Socket { + static OPEN = 1; + static latest: Socket; + readyState = 1; + onopen?: () => void; + onmessage?: (event: { data: string }) => void; + onclose?: () => void; + onerror?: () => void; + constructor() { + Socket.latest = this; + } + send() {} + close() {} + message(value: object) { + this.onmessage?.({ data: JSON.stringify(value) }); + } +} +beforeEach(() => { + vi.clearAllMocks(); + f.terminal.cols = 80; + f.terminal.rows = 24; + f.terminal.resize.mockImplementation((cols: number, rows: number) => { + f.terminal.cols = cols; + f.terminal.rows = rows; + }); + f.fit.mockImplementation(() => { + f.terminal.cols = 80; + f.terminal.rows = 24; + }); + vi.stubGlobal("WebSocket", Socket); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(fn: () => void) { + f.onResize = fn; + } + observe() {} + disconnect() {} + }, + ); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +it("keeps the presenter's grid while the guest window changes size", async () => { + render( + , + ); + await waitFor(() => expect(Socket.latest.onmessage).toBeTypeOf("function")); + act(() => Socket.latest.message({ type: "resized", cols: 132, rows: 40 })); + expect([f.terminal.cols, f.terminal.rows]).toEqual([132, 40]); + act(() => f.onResize?.()); + expect([f.terminal.cols, f.terminal.rows]).toEqual([132, 40]); + act(() => Socket.latest.message({ type: "resized", cols: 100, rows: 30 })); + expect([f.terminal.cols, f.terminal.rows]).toEqual([100, 30]); +}); + +describe("invalid shared terminal dimensions", () => { + it.each([0, -1, 1.5, "132"])("ignores cols=%s", async (cols) => { + render( + , + ); + await waitFor(() => expect(Socket.latest.onmessage).toBeTypeOf("function")); + act(() => Socket.latest.message({ type: "resized", cols, rows: 24 })); + expect(f.terminal.resize).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/ssh-terminal/src/backend/session-manager.ts b/plugins/ssh-terminal/src/backend/session-manager.ts index 06fcf5e20..3fcc7d21d 100644 --- a/plugins/ssh-terminal/src/backend/session-manager.ts +++ b/plugins/ssh-terminal/src/backend/session-manager.ts @@ -444,6 +444,15 @@ export class TerminalSessionManager { tabInstanceId: opts.tabInstanceId, joinedViaShareId: opts.shareId, }); + if (ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + type: "resized", + cols: session.cols, + rows: session.rows, + }), + ); + } this.broadcastParticipants(sessionId); this.log.info("Participant joined shared session", { @@ -812,6 +821,15 @@ export class TerminalSessionManager { this.recordSessionEvent(session, "i", data); } + resizeSession(sessionId: string, cols: number, rows: number): void { + const session = this.sessions.get(sessionId); + if (!session) return; + session.cols = cols; + session.rows = rows; + this.broadcast(sessionId, { type: "resized", cols, rows }); + this.bufferResize(sessionId, cols, rows); + } + bufferResize(sessionId: string, cols: number, rows: number): void { const session = this.sessions.get(sessionId); if (!session) return; diff --git a/plugins/ssh-terminal/src/backend/terminal-socket.ts b/plugins/ssh-terminal/src/backend/terminal-socket.ts index 849e4e228..472479ac9 100644 --- a/plugins/ssh-terminal/src/backend/terminal-socket.ts +++ b/plugins/ssh-terminal/src/backend/terminal-socket.ts @@ -568,8 +568,11 @@ export function createTerminalSocket(deps: TerminalSocketDeps) { attachRows, attachCols, ); - session.cols = attachCols; - session.rows = attachRows; + sessionManager.resizeSession( + session.id, + attachCols, + attachRows, + ); } ws.send( @@ -2871,11 +2874,8 @@ export function createTerminalSocket(deps: TerminalSocketDeps) { resizeStream.setWindow(rows, cols, rows, cols); const session = sessionManager.getSession(currentSessionId); if (session) { - session.cols = cols; - session.rows = rows; - sessionManager.bufferResize(session.id, cols, rows); + sessionManager.resizeSession(session.id, cols, rows); } - ws.send(JSON.stringify({ type: "resized", cols, rows })); } } diff --git a/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx b/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx index d8123dcf7..5ff36c487 100644 --- a/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx +++ b/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx @@ -910,6 +910,33 @@ const TerminalInner = forwardRef( } } + const sharedSizeRef = useRef<{ cols: number; rows: number } | null>(null); + + function applySharedSize(msg: { cols?: unknown; rows?: unknown }) { + if (!hostConfig.joinShareId || !terminal) return; + const { cols, rows } = msg; + if ( + typeof cols !== "number" || + typeof rows !== "number" || + !Number.isInteger(cols) || + !Number.isInteger(rows) || + cols < 1 || + rows < 1 + ) + return; + sharedSizeRef.current = { cols, rows }; + terminal.resize(cols, rows); + } + + function fitTerminal() { + const size = sharedSizeRef.current; + if (hostConfig.joinShareId && size) { + terminal?.resize(size.cols, size.rows); + } else { + fitAddonRef.current?.fit(); + } + } + function performFit() { if ( !fitAddonRef.current || @@ -923,7 +950,7 @@ const TerminalInner = forwardRef( isFittingRef.current = true; try { - fitAddonRef.current.fit(); + fitTerminal(); if (terminal && terminal.cols > 0 && terminal.rows > 0) { const lastSize = lastFittedSizeRef.current; if ( @@ -1180,6 +1207,7 @@ const TerminalInner = forwardRef( } function scheduleNotify(cols: number, rows: number) { + if (hostConfig.joinShareId) return; if (!(cols > 0 && rows > 0)) return; pendingSizeRef.current = { cols, rows }; if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); @@ -1390,7 +1418,7 @@ const TerminalInner = forwardRef( if (!fitAddonRef.current || !terminal || isFittingRef.current) return; isFittingRef.current = true; try { - fitAddonRef.current.fit(); + fitTerminal(); if (terminal.cols > 0 && terminal.rows > 0) { const lastSize = lastFittedSizeRef.current; if ( @@ -1839,7 +1867,9 @@ const TerminalInner = forwardRef( pongReceivedRef.current = true; return; } - if (msg.type === "data") { + if (msg.type === "resized") { + applySharedSize(msg); + } else if (msg.type === "data") { if (typeof msg.data === "string") { outputListenersRef.current.forEach((listener) => listener(msg.data), @@ -2674,7 +2704,7 @@ const TerminalInner = forwardRef( }); document.fonts.ready.then(() => { terminal.refresh(0, terminal.rows - 1); - fitAddon.fit(); + fitTerminal(); }); terminal.attachCustomWheelEventHandler((ev) => { @@ -2699,12 +2729,12 @@ const TerminalInner = forwardRef( return true; }); - fitAddonRef.current?.fit(); + fitTerminal(); // Double-rAF ensures layout is fully settled (fonts, flexbox, etc.) before // committing the fitted size, preventing the "terminal too short" glitch. requestAnimationFrame(() => { requestAnimationFrame(() => { - fitAddonRef.current?.fit(); + fitTerminal(); setIsFitted(true); }); }); @@ -3383,10 +3413,10 @@ const TerminalInner = forwardRef( } setIsConnecting(true); - fitAddonRef.current?.fit(); + fitTerminal(); requestAnimationFrame(() => { requestAnimationFrame(() => { - fitAddonRef.current?.fit(); + fitTerminal(); if (terminal.cols > 0 && terminal.rows > 0) { scheduleNotify(terminal.cols, terminal.rows); connectToHost(terminal.cols, terminal.rows); diff --git a/plugins/ssh-terminal/tests/backend/session-manager.test.ts b/plugins/ssh-terminal/tests/backend/session-manager.test.ts index f15be1579..eb35d7216 100644 --- a/plugins/ssh-terminal/tests/backend/session-manager.test.ts +++ b/plugins/ssh-terminal/tests/backend/session-manager.test.ts @@ -182,6 +182,46 @@ describe("TerminalSessionManager - multiplayer participants", () => { return id; } + it("sends the presenter dimensions before a participant replays output", () => { + const id = createConnectedSession(); + const session = sessionManager.getSession(id)!; + session.cols = 132; + session.rows = 40; + const ws = makeFakeWs(); + sessionManager.joinAsParticipant(id, ws, { + userId: "viewer", + permissionLevel: "read-only", + }); + expect(JSON.parse(vi.mocked(ws.send).mock.calls[0][0] as string)).toEqual({ + type: "resized", + cols: 132, + rows: 40, + }); + sessionManager.destroySession(id); + }); + + it("broadcasts later size changes and retains them for new participants", () => { + const id = createConnectedSession(); + const owner = makeFakeWs(); + const viewer = makeFakeWs(); + sessionManager.attachWs(id, "owner-1", owner); + sessionManager.joinAsParticipant(id, viewer, { + userId: "viewer", + permissionLevel: "read-only", + }); + sessionManager.resizeSession(id, 100, 35); + const message = JSON.stringify({ type: "resized", cols: 100, rows: 35 }); + expect(owner.send).toHaveBeenCalledWith(message); + expect(viewer.send).toHaveBeenCalledWith(message); + const newcomer = makeFakeWs(); + sessionManager.joinAsParticipant(id, newcomer, { + userId: null, + permissionLevel: "read-only", + }); + expect(vi.mocked(newcomer.send).mock.calls[0][0]).toBe(message); + sessionManager.destroySession(id); + }); + it("joinAsParticipant adds a participant without evicting the owner", () => { const id = createConnectedSession(); const ownerWs = makeFakeWs(); diff --git a/plugins/ssh-terminal/tests/frontend/terminal/shared-terminal-size.test.ts b/plugins/ssh-terminal/tests/frontend/terminal/shared-terminal-size.test.ts new file mode 100644 index 000000000..ec95d0462 --- /dev/null +++ b/plugins/ssh-terminal/tests/frontend/terminal/shared-terminal-size.test.ts @@ -0,0 +1,109 @@ +import ts from "typescript"; +import { describe, expect, it, vi } from "vitest"; +import sourceText from "../../../src/frontend/terminal/Terminal.tsx?raw"; + +const source = ts.createSourceFile( + "Terminal.tsx", + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, +); +const names = [ + "setupWebSocketListeners", + "applySharedSize", + "fitTerminal", + "scheduleNotify", +]; +const functions: string[] = []; +function visit(node: ts.Node) { + if ( + ts.isFunctionDeclaration(node) && + node.name && + names.includes(node.name.text) + ) + functions.push(node.getText(source)); + ts.forEachChild(node, visit); +} +visit(source); +if (functions.length !== names.length) + throw new Error("Missing terminal size handlers"); +const create = new Function( + "state", + ts.transpileModule( + ` + const { terminal, hostConfig, sharedSizeRef, fitAddonRef, webSocketRef, emitSessionMessage } = state; + const connectionAttemptIdRef = { current: 1 }; + ${functions.join("\n")} + setupWebSocketListeners(webSocketRef.current, 80, 24, hostConfig); + return { fit: fitTerminal, notify: scheduleNotify }; +`, + { compilerOptions: { target: ts.ScriptTarget.ES2022 } }, + ).outputText, +) as (state: Record) => { + fit: () => void; + notify: (cols: number, rows: number) => void; +}; + +function fixture(shared = true) { + const events = new Map void>(); + const terminal = { + cols: 80, + rows: 24, + resize: vi.fn((cols: number, rows: number) => { + terminal.cols = cols; + terminal.rows = rows; + }), + }; + const fit = vi.fn(() => { + terminal.cols = 80; + terminal.rows = 24; + }); + const socket = { + addEventListener: (name: string, fn: (event: { data: string }) => void) => + events.set(name, fn), + send: vi.fn(), + }; + const handlers = create({ + terminal, + hostConfig: shared ? { joinShareId: "share" } : {}, + sharedSizeRef: { current: null }, + fitAddonRef: { current: { fit } }, + webSocketRef: { current: socket }, + emitSessionMessage: vi.fn(), + }); + const resize = (cols: unknown, rows: unknown) => + events.get("message")!({ + data: JSON.stringify({ type: "resized", cols, rows }), + }); + return { terminal, fit, handlers, socket, resize }; +} + +describe("in-app shared terminal sizing", () => { + it("uses presenter sizes and does not refit or resize the remote shell from the viewer", () => { + const f = fixture(); + f.resize(132, 40); + expect([f.terminal.cols, f.terminal.rows]).toEqual([132, 40]); + f.handlers.fit(); + expect(f.fit).not.toHaveBeenCalled(); + expect([f.terminal.cols, f.terminal.rows]).toEqual([132, 40]); + f.handlers.notify(80, 24); + expect(f.socket.send).not.toHaveBeenCalled(); + f.resize(100, 30); + expect([f.terminal.cols, f.terminal.rows]).toEqual([100, 30]); + }); + + it("continues fitting the owner's terminal normally", () => { + const f = fixture(false); + f.resize(132, 40); + expect(f.terminal.resize).not.toHaveBeenCalled(); + f.handlers.fit(); + expect(f.fit).toHaveBeenCalledOnce(); + }); + + it.each([0, -1, 1.5, "132"])("rejects an invalid column count %s", (cols) => { + const f = fixture(); + f.resize(cols, 24); + expect(f.terminal.resize).not.toHaveBeenCalled(); + }); +});