mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)
This commit is contained in:
1 parent
c265758cdd
commit
0f6e48a95c
7 files changed
+341
-15
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -910,6 +910,33 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
}
|
||||
|
||||
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<TerminalHandle, SSHTerminalProps>(
|
||||
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<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
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<TerminalHandle, SSHTerminalProps>(
|
||||
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<TerminalHandle, SSHTerminalProps>(
|
||||
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<TerminalHandle, SSHTerminalProps>(
|
||||
});
|
||||
document.fonts.ready.then(() => {
|
||||
terminal.refresh(0, terminal.rows - 1);
|
||||
fitAddon.fit();
|
||||
fitTerminal();
|
||||
});
|
||||
|
||||
terminal.attachCustomWheelEventHandler((ev) => {
|
||||
@@ -2699,12 +2729,12 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
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<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
fit: () => void;
|
||||
notify: (cols: number, rows: number) => void;
|
||||
};
|
||||
|
||||
function fixture(shared = true) {
|
||||
const events = new Map<string, (event: { data: string }) => 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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user