fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-09-30 09:46:35 -05:00
1 parent c265758cdd
commit 0f6e48a95c
7 files changed
+341 -15

No files matched your search

@@ -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;
@@ -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(
<GuestTerminalView
share={{ permissionLevel: "read-only" }}
wsPath="/plugin-ws/ssh-terminal/terminal?shareToken=test"
/>,
);
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(
<GuestTerminalView
share={{ permissionLevel: "read-only" }}
wsPath="/plugin-ws/ssh-terminal/terminal?shareToken=test"
/>,
);
await waitFor(() => expect(Socket.latest.onmessage).toBeTypeOf("function"));
act(() => Socket.latest.message({ type: "resized", cols, rows: 24 }));
expect(f.terminal.resize).not.toHaveBeenCalled();
});
});