Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { XTerm } from 'react-xtermjs'
import { match } from 'ts-pattern'
import { LoaderSpinner, toast } from '@qovery/shared/ui'
import { useTerminalReadiness } from '@qovery/shared/util-hooks'
import { useTerminalDimensions, useTerminalReadiness } from '@qovery/shared/util-hooks'
import { QOVERY_WS } from '@qovery/shared/util-node-env'
import { useReactQueryWsSubscription } from '@qovery/state/util-queries'

Expand Down Expand Up @@ -76,12 +76,18 @@ export function ClusterTerminal({ organizationId, clusterId, cloudProvider }: Cl
[detachWebSocket]
)

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

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

return (
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden rounded-none border-0 bg-background">
<div className="relative h-full flex-1 border-neutral bg-background px-4 py-2">
<div ref={terminalContainerRef} className="relative h-full flex-1 border-neutral bg-background px-4 py-2">
{isTerminalLoading ? (
<div className="flex h-40 items-start justify-center p-5">
<LoaderSpinner />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
toast,
useModal,
} from '@qovery/shared/ui'
import { useTerminalReadiness } from '@qovery/shared/util-hooks'
import { useTerminalDimensions, useTerminalReadiness } from '@qovery/shared/util-hooks'
import { QOVERY_WS } from '@qovery/shared/util-node-env'
import { useReactQueryWsSubscription } from '@qovery/state/util-queries'
import { useRunningStatus, useService } from '../..'
Expand Down Expand Up @@ -236,11 +236,17 @@ export function ServiceTerminal({
[runningStatuses?.state]
)

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

useReactQueryWsSubscription({
url: QOVERY_WS + (ephemeralConfig ? '/shell/ephemeral' : '/shell/exec'),
Expand Down Expand Up @@ -321,7 +327,7 @@ export function ServiceTerminal({
</ExternalLink>
</div>
<div className="flex h-full flex-1 flex-col bg-background px-3 pt-5">
<div className="relative min-h-0 flex-1">
<div ref={terminalContainerRef} className="relative min-h-0 flex-1">
{terminalLaunchError ? (
<EmptyState icon="terminal" title="Unable to launch CLI" description={terminalUnavailableDescription}>
<p className="text-xs text-neutral-subtle">Request ID: {requestId}</p>
Expand Down
1 change: 1 addition & 0 deletions libs/shared/util-hooks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './lib/use-local-storage/use-local-storage'
export * from './lib/use-support-chat/use-support-chat'
export * from './lib/use-pod-color/use-pod-color'
export * from './lib/use-terminal-readiness/use-terminal-readiness'
export * from './lib/use-terminal-dimensions/use-terminal-dimensions'
export * from './lib/use-interval-tick/use-interval-tick'
export * from './lib/use-utm-params/use-utm-params'
export * from './lib/use-os/use-os'
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { render, screen } from '@qovery/shared/util-tests'
import { useTerminalDimensions } from './use-terminal-dimensions'

function mockElementBox(width: number, height: number, padding = 0) {
// jsdom does not lay out elements, so we stub the geometry the hook reads.
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(width)
jest.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(height)
jest.spyOn(window, 'getComputedStyle').mockReturnValue({
paddingLeft: `${padding}px`,
paddingRight: `${padding}px`,
paddingTop: `${padding}px`,
paddingBottom: `${padding}px`,
} as CSSStyleDeclaration)
}

function TestComponent(props: Parameters<typeof useTerminalDimensions>[0]) {
const { ref, cols, rows } = useTerminalDimensions(props)
return (
<div ref={ref} data-testid="terminal-container">
<span data-testid="cols">{cols}</span>
<span data-testid="rows">{rows}</span>
</div>
)
}

function renderDimensions(props: Parameters<typeof useTerminalDimensions>[0]) {
render(<TestComponent {...props} />)
return {
cols: Number(screen.getByTestId('cols').textContent),
rows: Number(screen.getByTestId('rows').textContent),
}
}

describe('useTerminalDimensions', () => {
afterEach(() => {
jest.restoreAllMocks()
})

it('computes cols and rows from the measured container box', () => {
mockElementBox(800, 400)

// 800 / 8 = 100 cols, 400 / 16 = 25 rows
expect(renderDimensions({ cellWidth: 8, cellHeight: 16 })).toEqual({ cols: 100, rows: 25 })
})

it('subtracts container padding before dividing by the cell size', () => {
mockElementBox(800, 400, 20)

// (800 - 40) / 8 = 95 cols, (400 - 40) / 16 = 22.5 -> 22 rows
expect(renderDimensions({ cellWidth: 8, cellHeight: 16 })).toEqual({ cols: 95, rows: 22 })
})

it('respects the configured minimum columns and rows', () => {
mockElementBox(100, 8)

// 100 / 9 = 11 cols but min is 80; 8 / 14 = 0 rows but min is 1
expect(renderDimensions({ cellWidth: 9, cellHeight: 14, minCols: 80, minRows: 1 })).toEqual({ cols: 80, rows: 1 })
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useLayoutEffect, useRef, useState } from 'react'

export interface UseTerminalDimensionsOptions {
/** Width of a single character cell in pixels (matches the terminal font). */
cellWidth: number
/** Height of a single character cell in pixels (matches the terminal font). */
cellHeight: number
/** Minimum number of columns, useful to keep TUIs (e.g. k9s) usable on narrow layouts. */
minCols?: number
/** Minimum number of rows. */
minRows?: number
}

export interface TerminalDimensions {
cols: number
rows: number
}

function computeDimensions(
element: HTMLElement,
{ cellWidth, cellHeight, minCols = 2, minRows = 1 }: UseTerminalDimensionsOptions
): TerminalDimensions {
const styles = window.getComputedStyle(element)
const paddingX = parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight)
const paddingY = parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom)
const availableWidth = Math.max(0, element.clientWidth - paddingX)
const availableHeight = Math.max(0, element.clientHeight - paddingY)

return {
cols: Math.max(minCols, Math.floor(availableWidth / cellWidth)),
rows: Math.max(minRows, Math.floor(availableHeight / cellHeight)),
}
}

/**
* Measures the terminal container element to derive the TTY `cols`/`rows` that
* actually fit, instead of guessing from `document.body`. The value is sent to
* the backend at websocket connect time as `tty_width`/`tty_height`.
*
* Returns a `ref` to attach to the element wrapping the terminal, and the
* current dimensions. Dimensions recompute on element resize so a connection
* opened after layout settles gets the real size.
*/
export function useTerminalDimensions(options: UseTerminalDimensionsOptions) {
const ref = useRef<HTMLDivElement | null>(null)
const [dimensions, setDimensions] = useState<TerminalDimensions>({
cols: options.minCols ?? 2,
rows: options.minRows ?? 1,
})
// Keep the latest options without re-subscribing the observer on every render.
const optionsRef = useRef(options)
optionsRef.current = options

useLayoutEffect(() => {
const element = ref.current
if (!element) {
return
}

const update = () => {
setDimensions((previous) => {
const next = computeDimensions(element, optionsRef.current)
return previous.cols === next.cols && previous.rows === next.rows ? previous : next
})
}

update()

const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update)
resizeObserver?.observe(element)

return () => resizeObserver?.disconnect()
}, [])

return { ref, ...dimensions }
}

export default useTerminalDimensions
Loading