From 12ad23a061e70e327fd4129dfc14665bf005deb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Mon, 17 Aug 2026 10:15:33 +0200 Subject: [PATCH 1/4] Sync CloudShell backend PTY size on terminal resize (#2553) * 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. * Add unit tests for registerTerminalResizeHandler (debounce, no-op skip, error handling) * Fix resizeTerminal success test: stub a successful fetch response --- .../CloudShellTerminalCore.test.tsx | 119 ++++++++++++++++++ .../CloudShellTab/CloudShellTerminalCore.tsx | 48 ++++++- .../Data/CloudShellClient.test.tsx | 63 ++++++++++ .../CloudShellTab/Data/CloudShellClient.tsx | 24 ++++ 4 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 src/Explorer/Tabs/CloudShellTab/CloudShellTerminalCore.test.tsx diff --git a/src/Explorer/Tabs/CloudShellTab/CloudShellTerminalCore.test.tsx b/src/Explorer/Tabs/CloudShellTab/CloudShellTerminalCore.test.tsx new file mode 100644 index 000000000..f5f67c3de --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/CloudShellTerminalCore.test.tsx @@ -0,0 +1,119 @@ +import { Terminal } from "xterm"; +import { registerTerminalResizeHandler } from "./CloudShellTerminalCore"; +import { resizeTerminal } from "./Data/CloudShellClient"; + +// Mock the CloudShell client so we can assert on backend resize calls without any network access. +jest.mock("./Data/CloudShellClient"); + +const mockResizeTerminal = resizeTerminal as jest.Mock; + +const CONSOLE_URI = "https://shell.azure.com/console123"; +const TERMINAL_ID = "terminal-id"; +const DEBOUNCE_MS = 300; + +type ResizeListener = (size: { cols: number; rows: number }) => void; + +interface MockTerminal { + cols: number; + rows: number; + onResize: (listener: ResizeListener) => void; + emitResize: (size: { cols: number; rows: number }) => void; +} + +const createMockTerminal = (cols: number, rows: number): MockTerminal => { + let listener: ResizeListener | undefined; + return { + cols, + rows, + onResize: (cb: ResizeListener) => { + listener = cb; + }, + emitResize: (size: { cols: number; rows: number }) => listener?.(size), + }; +}; + +const registerHandler = (terminal: MockTerminal): void => + registerTerminalResizeHandler(terminal as unknown as Terminal, CONSOLE_URI, TERMINAL_ID); + +describe("registerTerminalResizeHandler", () => { + beforeEach(() => { + jest.useFakeTimers(); + mockResizeTerminal.mockReset(); + mockResizeTerminal.mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it("does not notify the backend when the dimensions are unchanged", () => { + const terminal = createMockTerminal(80, 24); + registerHandler(terminal); + + terminal.emitResize({ cols: 80, rows: 24 }); + jest.advanceTimersByTime(DEBOUNCE_MS); + + expect(mockResizeTerminal).not.toHaveBeenCalled(); + }); + + it("notifies the backend with the new dimensions after the debounce interval", () => { + const terminal = createMockTerminal(80, 24); + registerHandler(terminal); + + terminal.emitResize({ cols: 120, rows: 40 }); + // Nothing should be sent until the debounce interval elapses. + expect(mockResizeTerminal).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(DEBOUNCE_MS); + + expect(mockResizeTerminal).toHaveBeenCalledTimes(1); + expect(mockResizeTerminal).toHaveBeenCalledWith(CONSOLE_URI, TERMINAL_ID, { cols: 120, rows: 40 }); + }); + + it("debounces rapid resizes into a single call with the latest dimensions", () => { + const terminal = createMockTerminal(80, 24); + registerHandler(terminal); + + terminal.emitResize({ cols: 100, rows: 30 }); + terminal.emitResize({ cols: 110, rows: 35 }); + terminal.emitResize({ cols: 120, rows: 40 }); + + jest.advanceTimersByTime(DEBOUNCE_MS); + + expect(mockResizeTerminal).toHaveBeenCalledTimes(1); + expect(mockResizeTerminal).toHaveBeenCalledWith(CONSOLE_URI, TERMINAL_ID, { cols: 120, rows: 40 }); + }); + + it("sends a separate call for each resize that is spaced beyond the debounce interval", () => { + const terminal = createMockTerminal(80, 24); + registerHandler(terminal); + + terminal.emitResize({ cols: 100, rows: 30 }); + jest.advanceTimersByTime(DEBOUNCE_MS); + + terminal.emitResize({ cols: 120, rows: 40 }); + jest.advanceTimersByTime(DEBOUNCE_MS); + + expect(mockResizeTerminal).toHaveBeenCalledTimes(2); + expect(mockResizeTerminal).toHaveBeenNthCalledWith(1, CONSOLE_URI, TERMINAL_ID, { cols: 100, rows: 30 }); + expect(mockResizeTerminal).toHaveBeenNthCalledWith(2, CONSOLE_URI, TERMINAL_ID, { cols: 120, rows: 40 }); + }); + + it("swallows backend resize errors and logs a warning", async () => { + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined); + mockResizeTerminal.mockRejectedValueOnce(new Error("boom")); + const terminal = createMockTerminal(80, 24); + registerHandler(terminal); + + terminal.emitResize({ cols: 120, rows: 40 }); + jest.advanceTimersByTime(DEBOUNCE_MS); + // Flush the microtask queue so the rejected promise's .catch handler runs. + await Promise.resolve(); + + expect(mockResizeTerminal).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith("CloudShell: failed to resize backend terminal", expect.any(Error)); + + warnSpy.mockRestore(); + }); +}); diff --git a/src/Explorer/Tabs/CloudShellTab/CloudShellTerminalCore.tsx b/src/Explorer/Tabs/CloudShellTab/CloudShellTerminalCore.tsx index a706e0691..c69d9016e 100644 --- a/src/Explorer/Tabs/CloudShellTab/CloudShellTerminalCore.tsx +++ b/src/Explorer/Tabs/CloudShellTab/CloudShellTerminalCore.tsx @@ -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 }; }; /** diff --git a/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.test.tsx b/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.test.tsx index cc89cb34f..0a38e4aee 100644 --- a/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.test.tsx +++ b/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.test.tsx @@ -6,6 +6,7 @@ import { provisionConsole, putEphemeralUserSettings, registerCloudShellProvider, + resizeTerminal, verifyCloudShellProviderRegistration, } from "./CloudShellClient"; @@ -340,4 +341,66 @@ 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 }; + + global.fetch = jest.fn().mockImplementationOnce(() => { + return { + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue(""), + headers: new Headers(), + } as unknown as Promise; + }); + + 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; + }); + + 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), + ); + }); + }); }); diff --git a/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.tsx b/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.tsx index 2f7926cae..38cf1b03c 100644 --- a/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.tsx +++ b/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.tsx @@ -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 => { + 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}`); + } +}; From 0b38a6be4d1b42b0bc07ccfd5ed2ab9e2dbb5968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Mon, 17 Aug 2026 11:13:21 +0200 Subject: [PATCH 2/4] Fix casing of Cosmos DB Shell command bar button label (#2552) * Fix casing of Cosmos DB Shell command bar button label * Capitalize Shell in all terminal button labels for consistency --- .../CommandBarComponentButtonFactory.test.ts | 12 ++++++------ .../CommandBar/CommandBarComponentButtonFactory.tsx | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts index 12831e3d7..c0d97bb02 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts @@ -74,8 +74,8 @@ describe("CommandBarComponentButtonFactory tests", () => { }); }); - describe("Open Cassandra shell button", () => { - const openCassandraShellBtnLabel = "Open Cassandra shell"; + describe("Open Cassandra Shell button", () => { + const openCassandraShellBtnLabel = "Open Cassandra Shell"; const selectedNodeState = useSelectedNode.getState(); beforeAll(() => { @@ -135,14 +135,14 @@ describe("CommandBarComponentButtonFactory tests", () => { }); describe("Open Postgres and vCore Mongo buttons", () => { - const openPostgresShellButtonLabel = "Open PSQL shell"; - const openVCoreMongoShellButtonLabel = "Open MongoDB (DocumentDB) shell"; + const openPostgresShellButtonLabel = "Open PSQL Shell"; + const openVCoreMongoShellButtonLabel = "Open MongoDB (DocumentDB) Shell"; beforeAll(() => { mockExplorer = {} as Explorer; }); - it("creates Postgres shell button", () => { + it("creates Postgres Shell button", () => { const buttons = CommandBarComponentButtonFactory.createPostgreButtons(mockExplorer); const openPostgresShellButton = buttons.find( (button) => button.commandButtonLabel === openPostgresShellButtonLabel, @@ -150,7 +150,7 @@ describe("CommandBarComponentButtonFactory tests", () => { expect(openPostgresShellButton).toBeDefined(); }); - it("creates vCore Mongo shell button", () => { + it("creates vCore Mongo Shell button", () => { const buttons = CommandBarComponentButtonFactory.createVCoreMongoButtons(mockExplorer); const openVCoreMongoShellButton = buttons.find( (button) => button.commandButtonLabel === openVCoreMongoShellButtonLabel, diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx index 911191e0d..ada89d807 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx @@ -513,7 +513,7 @@ function createOpenTerminalButtonByKind( return ""; } }; - const label = `Open ${terminalFriendlyName()} shell`; + const label = `Open ${terminalFriendlyName()} Shell`; const tooltip = "This feature is not yet available in your account's region. View supported regions here: https://aka.ms/cosmos-enable-notebooks."; const isNativeAuthDisabled = terminalKind === ViewModels.TerminalKind.VCoreMongo && isVCoreMongoNativeAuthDisabled(); From 61ac2189f0a96f3f1c34a6d03e51e596d7a58943 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:45:22 -0700 Subject: [PATCH 3/4] Bump fast-uri from 3.1.2 to 3.1.5 (#2551) Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.5. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.5) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a19ed8c72..fa61b633b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13942,9 +13942,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", From fb8a85b84cda7948ad15c9a0bdbe7c96b9926ad9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:04:09 -0700 Subject: [PATCH 4/4] Bump postcss from 8.5.15 to 8.5.26 (#2550) Bumps [postcss](https://github.com/postcss/postcss) from 8.5.15 to 8.5.26. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.15...8.5.26) --- updated-dependencies: - dependency-name: postcss dependency-version: 8.5.25 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index fa61b633b..d8e3e2c66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22533,9 +22533,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -22553,7 +22553,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" },