mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-08-11 09:07:13 +01:00
Sync CloudShell backend PTY size on terminal resize
Resizing the browser window only re-fit the local xterm; the remote shell kept its original column count, so typed input wrapped/broke at the wrong column. Add resizeTerminal() client call to the CloudShell terminals/{id}/size endpoint and register a debounced terminal.onResize handler that pushes new dimensions to the backend.
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
provisionConsole,
|
||||
putEphemeralUserSettings,
|
||||
registerCloudShellProvider,
|
||||
resizeTerminal,
|
||||
verifyCloudShellProviderRegistration,
|
||||
} from "./Data/CloudShellClient";
|
||||
import { CloudShellProviderInfo, ProvisionConsoleResponse } from "./Models/DataModels";
|
||||
@@ -26,6 +27,7 @@ const DEFAULT_FAIRFAX_CLOUDSHELL_REGION = "usgovvirginia";
|
||||
const POLLING_INTERVAL_MS = 2000;
|
||||
const MAX_RETRY_COUNT = 10;
|
||||
const MAX_PING_COUNT = 120 * 60; // 120 minutes (60 seconds/minute)
|
||||
const TERMINAL_RESIZE_DEBOUNCE_MS = 300;
|
||||
|
||||
let pingCount = 0;
|
||||
let keepAliveID: NodeJS.Timeout = null;
|
||||
@@ -96,6 +98,7 @@ export const startCloudShellTerminal = async (terminal: Terminal, shellType: Ter
|
||||
socketUri?: string;
|
||||
provisionConsoleResponse?: ProvisionConsoleResponse;
|
||||
targetUri?: string;
|
||||
terminalId?: string;
|
||||
} = await provisionCloudShellSession(resolvedRegion, terminal);
|
||||
|
||||
if (!sessionDetails.socketUri) {
|
||||
@@ -108,6 +111,14 @@ export const startCloudShellTerminal = async (terminal: Terminal, shellType: Ter
|
||||
// Configure WebSocket connection with shell-specific commands
|
||||
const socket = await establishTerminalConnection(terminal, shellHandler, sessionDetails.socketUri);
|
||||
|
||||
// Keep the backend PTY size in sync with the frontend terminal. Without this, resizing the
|
||||
// browser window only re-fits the local xterm while the remote shell keeps its original
|
||||
// column count, causing typed input to wrap/break at the wrong column.
|
||||
const consoleUri = sessionDetails.provisionConsoleResponse?.properties?.uri;
|
||||
if (consoleUri && sessionDetails.terminalId) {
|
||||
registerTerminalResizeHandler(terminal, consoleUri, sessionDetails.terminalId);
|
||||
}
|
||||
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.CloudShellTerminalSession,
|
||||
{
|
||||
@@ -159,13 +170,46 @@ export const determineCloudShellRegion = (): string => {
|
||||
return getNormalizedRegion(userContext.databaseAccount?.location, defaultRegion);
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers a debounced handler that notifies the CloudShell backend whenever the frontend
|
||||
* terminal is resized (e.g. after a browser window resize triggers FitAddon.fit()), keeping the
|
||||
* remote PTY column/row count in sync with what the user sees.
|
||||
*/
|
||||
export const registerTerminalResizeHandler = (terminal: Terminal, consoleUri: string, terminalId: string): void => {
|
||||
let debounceTimer: NodeJS.Timeout | null = null;
|
||||
let lastCols = terminal.cols;
|
||||
let lastRows = terminal.rows;
|
||||
|
||||
terminal.onResize(({ cols, rows }) => {
|
||||
if (cols === lastCols && rows === lastRows) {
|
||||
return;
|
||||
}
|
||||
lastCols = cols;
|
||||
lastRows = rows;
|
||||
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
debounceTimer = setTimeout(() => {
|
||||
resizeTerminal(consoleUri, terminalId, { cols, rows }).catch((err) => {
|
||||
console.warn("CloudShell: failed to resize backend terminal", err);
|
||||
});
|
||||
}, TERMINAL_RESIZE_DEBOUNCE_MS);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Provisions a CloudShell session
|
||||
*/
|
||||
export const provisionCloudShellSession = async (
|
||||
resolvedRegion: string,
|
||||
terminal: Terminal,
|
||||
): Promise<{ socketUri?: string; provisionConsoleResponse?: ProvisionConsoleResponse; targetUri?: string }> => {
|
||||
): Promise<{
|
||||
socketUri?: string;
|
||||
provisionConsoleResponse?: ProvisionConsoleResponse;
|
||||
targetUri?: string;
|
||||
terminalId?: string;
|
||||
}> => {
|
||||
// Apply user settings
|
||||
await putEphemeralUserSettings(userContext.subscriptionId, resolvedRegion);
|
||||
|
||||
@@ -217,7 +261,7 @@ export const provisionCloudShellSession = async (
|
||||
socketUri = `wss://${targetUriBodyArr[0]}/$hc/${targetUriBodyArr[1]}/terminals/${termId}`;
|
||||
}
|
||||
|
||||
return { socketUri, provisionConsoleResponse, targetUri };
|
||||
return { socketUri, provisionConsoleResponse, targetUri, terminalId: termId };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
provisionConsole,
|
||||
putEphemeralUserSettings,
|
||||
registerCloudShellProvider,
|
||||
resizeTerminal,
|
||||
verifyCloudShellProviderRegistration,
|
||||
} from "./CloudShellClient";
|
||||
|
||||
@@ -340,4 +341,56 @@ describe("CloudShellClient", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resizeTerminal", () => {
|
||||
it("should call fetch with correct parameters", async () => {
|
||||
const consoleUri = "https://shell.azure.com/console123";
|
||||
const terminalId = "terminal-id";
|
||||
const size = { rows: 40, cols: 120 };
|
||||
|
||||
await resizeTerminal(consoleUri, terminalId, size);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://shell.azure.com/console123/terminals/terminal-id/size?cols=120&rows=40&version=2019-01-01",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": "2",
|
||||
Authorization: "Bearer mock-token",
|
||||
"x-ms-client-request-id": "mocked-uuid",
|
||||
"Accept-Language": "en-US",
|
||||
},
|
||||
body: "{}",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle errors when terminal resize fails", async () => {
|
||||
const consoleUri = "https://shell.azure.com/console123";
|
||||
const terminalId = "terminal-id";
|
||||
const size = { rows: 40, cols: 120 };
|
||||
|
||||
global.fetch = jest.fn().mockImplementationOnce(() => {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
json: jest.fn().mockRejectedValue(new Error("Failed to parse JSON")),
|
||||
text: jest.fn().mockResolvedValue("Server Error"),
|
||||
headers: new Headers(),
|
||||
} as unknown as Promise<Response>;
|
||||
});
|
||||
|
||||
await expect(resizeTerminal(consoleUri, terminalId, size)).rejects.toThrow(
|
||||
"Failed to resize terminal: 500 Internal Server Error",
|
||||
);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://shell.azure.com/console123/terminals/terminal-id/size?cols=120&rows=40&version=2019-01-01",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,3 +125,27 @@ export const connectTerminal = async (
|
||||
|
||||
return resp.json();
|
||||
};
|
||||
|
||||
export const resizeTerminal = async (
|
||||
consoleUri: string,
|
||||
terminalId: string,
|
||||
size: { rows: number; cols: number },
|
||||
): Promise<void> => {
|
||||
const targetUri = consoleUri + `/terminals/${terminalId}/size?cols=${size.cols}&rows=${size.rows}&version=2019-01-01`;
|
||||
const resp = await fetch(targetUri, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": "2",
|
||||
Authorization: userContext.authorizationToken,
|
||||
"x-ms-client-request-id": uuidv4(),
|
||||
"Accept-Language": getLocale(),
|
||||
},
|
||||
body: "{}", // empty body is necessary
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to resize terminal: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user