Skip to content

Commit ff1fdd9

Browse files
authored
fix(shell): Take a bigger margin for terminal size (#2811)
When openning the shell websocket, the frontend compute the width and height of the terminal to forward it to the backend. Today, the width and height are too big causing line wrapping Use the shell container size instead of the global one increase the cell height to avoid line wrapping
1 parent 87e33c3 commit ff1fdd9

5 files changed

Lines changed: 223 additions & 15 deletions

File tree

libs/domains/clusters/feature/src/lib/cluster-terminal/cluster-terminal.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { memo, useCallback, useEffect, useMemo, useState } from 'react'
99
import { XTerm } from 'react-xtermjs'
1010
import { match } from 'ts-pattern'
1111
import { LoaderSpinner, toast } from '@qovery/shared/ui'
12-
import { useTerminalReadiness } from '@qovery/shared/util-hooks'
12+
import { useTerminalDimensions, useTerminalReadiness } from '@qovery/shared/util-hooks'
1313
import { QOVERY_WS } from '@qovery/shared/util-node-env'
1414
import { useReactQueryWsSubscription } from '@qovery/state/util-queries'
1515

@@ -76,12 +76,18 @@ export function ClusterTerminal({ organizationId, clusterId, cloudProvider }: Cl
7676
[detachWebSocket]
7777
)
7878

79-
// Necesssary to calculate the number of rows and columns (tty) for the terminal
80-
// https://github.com/xtermjs/xterm.js/issues/1412#issuecomment-724421101
81-
// 14 is the font height for better k9s compatibility
82-
const rows = Math.ceil(document.body.clientHeight / 14)
83-
// 9 is the font width for better k9s compatibility, with a minimum width of 80 columns
84-
const cols = Math.max(80, Math.ceil(document.body.clientWidth / 9))
79+
// Measure the actual terminal container (not document.body) so the TTY size sent to the
80+
// backend matches what is rendered. https://github.com/xtermjs/xterm.js/issues/1412#issuecomment-724421101
81+
// 9x17 is the font cell size for better k9s compatibility, with a minimum width of 80 columns.
82+
const {
83+
ref: terminalContainerRef,
84+
cols,
85+
rows,
86+
} = useTerminalDimensions({
87+
cellWidth: 9,
88+
cellHeight: 17,
89+
minCols: 80,
90+
})
8591

8692
useReactQueryWsSubscription({
8793
url: QOVERY_WS + '/shell/debug',
@@ -104,7 +110,7 @@ export function ClusterTerminal({ organizationId, clusterId, cloudProvider }: Cl
104110

105111
return (
106112
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden rounded-none border-0 bg-background">
107-
<div className="relative h-full flex-1 border-neutral bg-background px-4 py-2">
113+
<div ref={terminalContainerRef} className="relative h-full flex-1 border-neutral bg-background px-4 py-2">
108114
{isTerminalLoading ? (
109115
<div className="flex h-40 items-start justify-center p-5">
110116
<LoaderSpinner />

libs/domains/services/feature/src/lib/service-terminal/service-terminal.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
toast,
2020
useModal,
2121
} from '@qovery/shared/ui'
22-
import { useTerminalReadiness } from '@qovery/shared/util-hooks'
22+
import { useTerminalDimensions, useTerminalReadiness } from '@qovery/shared/util-hooks'
2323
import { QOVERY_WS } from '@qovery/shared/util-node-env'
2424
import { useReactQueryWsSubscription } from '@qovery/state/util-queries'
2525
import { useRunningStatus, useService } from '../..'
@@ -236,11 +236,17 @@ export function ServiceTerminal({
236236
[runningStatuses?.state]
237237
)
238238

239-
// Necesssary to calculate the number of rows and columns (tty) for the terminal
240-
// https://github.com/xtermjs/xterm.js/issues/1412#issuecomment-724421101
241-
// 16 is the font height
242-
const rows = Math.ceil(document.body.clientHeight / 16)
243-
const cols = Math.ceil(document.body.clientWidth / 8)
239+
// Measure the actual terminal container (not document.body) so the TTY size sent to the
240+
// backend matches what is rendered. https://github.com/xtermjs/xterm.js/issues/1412#issuecomment-724421101
241+
// 9x17 is the font cell size.
242+
const {
243+
ref: terminalContainerRef,
244+
cols,
245+
rows,
246+
} = useTerminalDimensions({
247+
cellWidth: 9,
248+
cellHeight: 17,
249+
})
244250

245251
useReactQueryWsSubscription({
246252
url: QOVERY_WS + (ephemeralConfig ? '/shell/ephemeral' : '/shell/exec'),
@@ -321,7 +327,7 @@ export function ServiceTerminal({
321327
</ExternalLink>
322328
</div>
323329
<div className="flex h-full flex-1 flex-col bg-background px-3 pt-5">
324-
<div className="relative min-h-0 flex-1">
330+
<div ref={terminalContainerRef} className="relative min-h-0 flex-1">
325331
{terminalLaunchError ? (
326332
<EmptyState icon="terminal" title="Unable to launch CLI" description={terminalUnavailableDescription}>
327333
<p className="text-xs text-neutral-subtle">Request ID: {requestId}</p>

libs/shared/util-hooks/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export * from './lib/use-local-storage/use-local-storage'
99
export * from './lib/use-support-chat/use-support-chat'
1010
export * from './lib/use-pod-color/use-pod-color'
1111
export * from './lib/use-terminal-readiness/use-terminal-readiness'
12+
export * from './lib/use-terminal-dimensions/use-terminal-dimensions'
1213
export * from './lib/use-interval-tick/use-interval-tick'
1314
export * from './lib/use-utm-params/use-utm-params'
1415
export * from './lib/use-os/use-os'
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { render, screen } from '@qovery/shared/util-tests'
2+
import { useTerminalDimensions } from './use-terminal-dimensions'
3+
4+
// jsdom does not lay out elements, so we stub the geometry the hook reads. The
5+
// returned box lets a test mutate the size after mount to simulate a resize.
6+
function mockElementBox(width: number, height: number, padding = 0) {
7+
const box = { width, height }
8+
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => box.width)
9+
jest.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => box.height)
10+
jest.spyOn(window, 'getComputedStyle').mockReturnValue({
11+
paddingLeft: `${padding}px`,
12+
paddingRight: `${padding}px`,
13+
paddingTop: `${padding}px`,
14+
paddingBottom: `${padding}px`,
15+
} as CSSStyleDeclaration)
16+
return box
17+
}
18+
19+
function TestComponent(props: Parameters<typeof useTerminalDimensions>[0]) {
20+
const { ref, cols, rows } = useTerminalDimensions(props)
21+
return (
22+
<div ref={ref} data-testid="terminal-container">
23+
<span data-testid="cols">{cols}</span>
24+
<span data-testid="rows">{rows}</span>
25+
</div>
26+
)
27+
}
28+
29+
function readDimensions() {
30+
return {
31+
cols: Number(screen.getByTestId('cols').textContent),
32+
rows: Number(screen.getByTestId('rows').textContent),
33+
}
34+
}
35+
36+
function renderDimensions(props: Parameters<typeof useTerminalDimensions>[0]) {
37+
const { rerender } = render(<TestComponent {...props} />)
38+
return { ...readDimensions(), rerender: () => rerender(<TestComponent {...props} />) }
39+
}
40+
41+
describe('useTerminalDimensions', () => {
42+
afterEach(() => {
43+
jest.restoreAllMocks()
44+
})
45+
46+
it('computes cols and rows from the measured container box', () => {
47+
mockElementBox(800, 400)
48+
49+
// 800 / 8 = 100 cols, 400 / 16 = 25 rows
50+
const { cols, rows } = renderDimensions({ cellWidth: 8, cellHeight: 16 })
51+
expect({ cols, rows }).toEqual({ cols: 100, rows: 25 })
52+
})
53+
54+
it('subtracts container padding before dividing by the cell size', () => {
55+
mockElementBox(800, 400, 20)
56+
57+
// (800 - 40) / 8 = 95 cols, (400 - 40) / 16 = 22.5 -> 22 rows
58+
const { cols, rows } = renderDimensions({ cellWidth: 8, cellHeight: 16 })
59+
expect({ cols, rows }).toEqual({ cols: 95, rows: 22 })
60+
})
61+
62+
it('respects the configured minimum columns and rows', () => {
63+
mockElementBox(100, 8)
64+
65+
// 100 / 9 = 11 cols but min is 80; 8 / 14 = 0 rows but min is 1
66+
const { cols, rows } = renderDimensions({ cellWidth: 9, cellHeight: 14, minCols: 80, minRows: 1 })
67+
expect({ cols, rows }).toEqual({ cols: 80, rows: 1 })
68+
})
69+
70+
it('does not re-measure after the initial capture, so a resize keeps the socket dimensions stable', () => {
71+
// The returned cols/rows feed the websocket connect params; the subscription
72+
// reconnects when they change. Latching on capture is what keeps a running
73+
// shell alive across window/panel resizes.
74+
const box = mockElementBox(800, 400)
75+
76+
const { cols, rows, rerender } = renderDimensions({ cellWidth: 8, cellHeight: 16 })
77+
expect({ cols, rows }).toEqual({ cols: 100, rows: 25 })
78+
79+
// Simulate a resize + re-render: the reported dimensions must not change.
80+
box.width = 400
81+
box.height = 200
82+
rerender()
83+
84+
expect(readDimensions()).toEqual({ cols: 100, rows: 25 })
85+
})
86+
87+
it('keeps the min defaults when the container is not laid out yet (0x0)', () => {
88+
// A 0x0 box means layout has not resolved; we must not latch a bogus size.
89+
mockElementBox(0, 0)
90+
91+
const { cols, rows } = renderDimensions({ cellWidth: 8, cellHeight: 16, minCols: 80, minRows: 1 })
92+
expect({ cols, rows }).toEqual({ cols: 80, rows: 1 })
93+
})
94+
})
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { useLayoutEffect, useRef, useState } from 'react'
2+
3+
export interface UseTerminalDimensionsOptions {
4+
/** Width of a single character cell in pixels (matches the terminal font). */
5+
cellWidth: number
6+
/** Height of a single character cell in pixels (matches the terminal font). */
7+
cellHeight: number
8+
/** Minimum number of columns, useful to keep TUIs (e.g. k9s) usable on narrow layouts. */
9+
minCols?: number
10+
/** Minimum number of rows. */
11+
minRows?: number
12+
}
13+
14+
export interface TerminalDimensions {
15+
cols: number
16+
rows: number
17+
}
18+
19+
function computeDimensions(
20+
element: HTMLElement,
21+
{ cellWidth, cellHeight, minCols = 2, minRows = 1 }: UseTerminalDimensionsOptions
22+
): TerminalDimensions {
23+
const styles = window.getComputedStyle(element)
24+
const paddingX = parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight)
25+
const paddingY = parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom)
26+
const availableWidth = Math.max(0, element.clientWidth - paddingX)
27+
const availableHeight = Math.max(0, element.clientHeight - paddingY)
28+
29+
return {
30+
cols: Math.max(minCols, Math.floor(availableWidth / cellWidth)),
31+
rows: Math.max(minRows, Math.floor(availableHeight / cellHeight)),
32+
}
33+
}
34+
35+
/**
36+
* Measures the terminal container element to derive the TTY `cols`/`rows` that
37+
* actually fit, instead of guessing from `document.body`. The value is sent to
38+
* the backend at websocket connect time as `tty_width`/`tty_height`.
39+
*
40+
* The dimensions are latched from the first non-zero measurement and then stay
41+
* stable for the lifetime of the mount. This is intentional: the returned
42+
* `cols`/`rows` feed the websocket connect params, and the subscription hook
43+
* reconnects whenever those params change. If we kept re-measuring on every
44+
* resize we would tear down the live socket — and the user's running shell —
45+
* every time the window or a panel crossed a cell boundary. So we measure once
46+
* the layout has settled and let subsequent resizes be handled client-side by
47+
* the xterm fit addon. A genuinely new session (remount) re-measures.
48+
*
49+
* Returns a `ref` to attach to the element wrapping the terminal, and the
50+
* captured dimensions.
51+
*/
52+
export function useTerminalDimensions(options: UseTerminalDimensionsOptions) {
53+
const ref = useRef<HTMLDivElement | null>(null)
54+
const [dimensions, setDimensions] = useState<TerminalDimensions>({
55+
cols: options.minCols ?? 2,
56+
rows: options.minRows ?? 1,
57+
})
58+
// Keep the latest options without re-subscribing the observer on every render.
59+
const optionsRef = useRef(options)
60+
optionsRef.current = options
61+
62+
useLayoutEffect(() => {
63+
const element = ref.current
64+
if (!element) {
65+
return
66+
}
67+
68+
// Latch the first measurement that reflects a laid-out container. The box can
69+
// start at 0x0 before flex/layout resolves, which would otherwise freeze a
70+
// bogus (min) size for the whole session.
71+
const capture = () => {
72+
if (element.clientWidth === 0 && element.clientHeight === 0) {
73+
return false
74+
}
75+
setDimensions(computeDimensions(element, optionsRef.current))
76+
return true
77+
}
78+
79+
if (capture()) {
80+
return
81+
}
82+
83+
// Not laid out yet: wait for the first resize that gives it a real size,
84+
// capture it, then stop observing so later resizes don't churn the socket.
85+
if (typeof ResizeObserver === 'undefined') {
86+
return
87+
}
88+
const resizeObserver = new ResizeObserver(() => {
89+
if (capture()) {
90+
resizeObserver.disconnect()
91+
}
92+
})
93+
resizeObserver.observe(element)
94+
95+
return () => resizeObserver.disconnect()
96+
}, [])
97+
98+
return { ref, ...dimensions }
99+
}
100+
101+
export default useTerminalDimensions

0 commit comments

Comments
 (0)