CI / test (push) Successful in 9m56s
Production-grade xterm FitAddon resize, binary-safe I/O, TTY-safe server streams, demuxed log viewer with line buffering, and auto-dismiss deploy job panel without the full-screen spinner.
308 lines
8.9 KiB
JavaScript
308 lines
8.9 KiB
JavaScript
/**
|
|
* Shared xterm.js helpers: FitAddon resolution, binary-safe base64, safe fit/resize.
|
|
*/
|
|
|
|
export function getTerminalCtor() {
|
|
if (typeof window === 'undefined' || !window.Terminal) {
|
|
throw new Error('xterm.js not loaded')
|
|
}
|
|
return window.Terminal
|
|
}
|
|
|
|
export function getFitAddonCtor() {
|
|
if (typeof window === 'undefined' || !window.FitAddon) {
|
|
throw new Error('xterm-addon-fit not loaded')
|
|
}
|
|
// UMD may expose { FitAddon: fn } or the constructor itself
|
|
if (typeof window.FitAddon.FitAddon === 'function') return window.FitAddon.FitAddon
|
|
if (typeof window.FitAddon === 'function') return window.FitAddon
|
|
throw new Error('FitAddon constructor not found on window.FitAddon')
|
|
}
|
|
|
|
/** Default production xterm options */
|
|
export function defaultXtermOptions(overrides = {}) {
|
|
return {
|
|
cursorBlink: true,
|
|
cursorStyle: 'block',
|
|
cursorWidth: 1,
|
|
fontSize: 14,
|
|
fontFamily:
|
|
'"JetBrains Mono", Menlo, Monaco, "Cascadia Code", "Courier New", monospace',
|
|
fontWeight: 400,
|
|
fontWeightBold: 700,
|
|
lineHeight: 1.2,
|
|
letterSpacing: 0,
|
|
scrollback: 10000,
|
|
allowProposedApi: true,
|
|
convertEol: false,
|
|
disableStdin: false,
|
|
macOptionIsMeta: true,
|
|
rightClickSelectsWord: true,
|
|
screenReaderMode: false,
|
|
windowsMode: false,
|
|
allowTransparency: false,
|
|
theme: {
|
|
background: '#0b0f14',
|
|
foreground: '#e6edf3',
|
|
cursor: '#34d399',
|
|
cursorAccent: '#0b0f14',
|
|
selectionBackground: 'rgba(52, 211, 153, 0.35)',
|
|
selectionForeground: '#f4f7fb',
|
|
black: '#0b0f14',
|
|
red: '#f87171',
|
|
green: '#4ade80',
|
|
yellow: '#fbbf24',
|
|
blue: '#38bdf8',
|
|
magenta: '#c084fc',
|
|
cyan: '#2dd4bf',
|
|
white: '#e6edf3',
|
|
brightBlack: '#64748b',
|
|
brightRed: '#fca5a5',
|
|
brightGreen: '#86efac',
|
|
brightYellow: '#fde68a',
|
|
brightBlue: '#7dd3fc',
|
|
brightMagenta: '#d8b4fe',
|
|
brightCyan: '#5eead4',
|
|
brightWhite: '#f8fafc',
|
|
},
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Binary-safe UTF-8 → base64 (handles multi-byte + binary)
|
|
* @param {string} str
|
|
*/
|
|
export function encodeBase64Utf8(str) {
|
|
const bytes = new TextEncoder().encode(str)
|
|
let binary = ''
|
|
const chunk = 0x8000
|
|
for (let i = 0; i < bytes.length; i += chunk) {
|
|
binary += String.fromCharCode(...bytes.subarray(i, i + chunk))
|
|
}
|
|
return btoa(binary)
|
|
}
|
|
|
|
/**
|
|
* Binary-safe base64 → UTF-8 string
|
|
* @param {string} b64
|
|
*/
|
|
export function decodeBase64Utf8(b64) {
|
|
if (b64 == null || b64 === '') return ''
|
|
try {
|
|
const binary = atob(b64)
|
|
const bytes = new Uint8Array(binary.length)
|
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
|
return new TextDecoder('utf-8', { fatal: false }).decode(bytes)
|
|
} catch {
|
|
// Fallback: latin1
|
|
try {
|
|
return atob(b64)
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decode terminal/log payload with encoding field.
|
|
*/
|
|
export function decodePayload(data, encoding = 'base64') {
|
|
if (data == null) return ''
|
|
if (encoding === 'base64' || encoding === 'b64') return decodeBase64Utf8(String(data))
|
|
return String(data)
|
|
}
|
|
|
|
/**
|
|
* Safely fit xterm when container has measurable size.
|
|
* @param {{ fit: () => void, proposeDimensions?: () => {cols:number,rows:number}|undefined }} fitAddon
|
|
* @param {{ cols: number, rows: number }|null} [term]
|
|
* @returns {{ cols: number, rows: number }|null}
|
|
*/
|
|
export function safeFit(fitAddon, term = null) {
|
|
if (!fitAddon) return null
|
|
try {
|
|
const dims = typeof fitAddon.proposeDimensions === 'function' ? fitAddon.proposeDimensions() : null
|
|
if (dims && (dims.cols < 2 || dims.rows < 1)) {
|
|
return term ? { cols: term.cols, rows: term.rows } : null
|
|
}
|
|
fitAddon.fit()
|
|
if (term) return { cols: term.cols, rows: term.rows }
|
|
return dims || null
|
|
} catch (err) {
|
|
console.debug('[xterm] fit skipped:', err?.message || err)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Debounced fit + callback with dimensions.
|
|
*/
|
|
export function createFitController(fitAddon, term, onDims, { debounceMs = 50 } = {}) {
|
|
let timer = null
|
|
let raf = null
|
|
|
|
const run = () => {
|
|
timer = null
|
|
raf = null
|
|
const dims = safeFit(fitAddon, term)
|
|
if (dims && onDims) onDims(dims.cols, dims.rows)
|
|
}
|
|
|
|
const schedule = () => {
|
|
if (timer) clearTimeout(timer)
|
|
if (raf) cancelAnimationFrame(raf)
|
|
raf = requestAnimationFrame(() => {
|
|
timer = setTimeout(run, debounceMs)
|
|
})
|
|
}
|
|
|
|
/** Immediate fit (e.g. after open) */
|
|
const fitNow = () => {
|
|
if (timer) clearTimeout(timer)
|
|
if (raf) cancelAnimationFrame(raf)
|
|
run()
|
|
}
|
|
|
|
const ro =
|
|
typeof ResizeObserver !== 'undefined'
|
|
? new ResizeObserver(() => schedule())
|
|
: null
|
|
|
|
const onWin = () => schedule()
|
|
window.addEventListener('resize', onWin)
|
|
|
|
return {
|
|
schedule,
|
|
fitNow,
|
|
observe(el) {
|
|
if (ro && el) ro.observe(el)
|
|
},
|
|
disconnect() {
|
|
if (timer) clearTimeout(timer)
|
|
if (raf) cancelAnimationFrame(raf)
|
|
window.removeEventListener('resize', onWin)
|
|
if (ro) ro.disconnect()
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Strip Docker multiplex headers from a Buffer/Uint8Array chunk.
|
|
* Frame: [stream_type:1][0:3][size:4 BE][payload]
|
|
* @param {Uint8Array|ArrayBuffer|string} input
|
|
* @returns {string}
|
|
*/
|
|
export function demuxDockerLogs(input) {
|
|
let buf
|
|
if (typeof input === 'string') {
|
|
// binary string from atob
|
|
buf = new Uint8Array(input.length)
|
|
for (let i = 0; i < input.length; i++) buf[i] = input.charCodeAt(i)
|
|
} else if (input instanceof ArrayBuffer) {
|
|
buf = new Uint8Array(input)
|
|
} else if (input?.buffer) {
|
|
buf = new Uint8Array(input.buffer, input.byteOffset || 0, input.byteLength || input.length)
|
|
} else {
|
|
return String(input || '')
|
|
}
|
|
|
|
// Heuristic: if not muxed, decode as utf8
|
|
if (buf.length < 8) {
|
|
return new TextDecoder('utf-8', { fatal: false }).decode(buf)
|
|
}
|
|
|
|
const chunks = []
|
|
let offset = 0
|
|
let sawFrame = false
|
|
while (offset + 8 <= buf.length) {
|
|
const streamType = buf[offset]
|
|
// stream types 0-2 are stdin/stdout/stderr; higher is suspicious
|
|
if (streamType > 2) {
|
|
// Not multiplexed — whole buffer is payload
|
|
return new TextDecoder('utf-8', { fatal: false }).decode(buf)
|
|
}
|
|
const size = (buf[offset + 4] << 24) | (buf[offset + 5] << 16) | (buf[offset + 6] << 8) | buf[offset + 7]
|
|
// size as unsigned
|
|
const payloadSize = size >>> 0
|
|
if (payloadSize < 0 || offset + 8 + payloadSize > buf.length) {
|
|
// Incomplete or invalid — if we already took frames, return them + rest raw
|
|
if (sawFrame) {
|
|
const rest = new TextDecoder('utf-8', { fatal: false }).decode(buf.subarray(offset))
|
|
if (rest) chunks.push(rest)
|
|
return chunks.join('')
|
|
}
|
|
return new TextDecoder('utf-8', { fatal: false }).decode(buf)
|
|
}
|
|
sawFrame = true
|
|
const payload = buf.subarray(offset + 8, offset + 8 + payloadSize)
|
|
chunks.push(new TextDecoder('utf-8', { fatal: false }).decode(payload))
|
|
offset += 8 + payloadSize
|
|
}
|
|
if (sawFrame && offset < buf.length) {
|
|
chunks.push(new TextDecoder('utf-8', { fatal: false }).decode(buf.subarray(offset)))
|
|
}
|
|
return sawFrame ? chunks.join('') : new TextDecoder('utf-8', { fatal: false }).decode(buf)
|
|
}
|
|
|
|
/**
|
|
* Incremental demuxer for streaming log chunks that may split mid-frame.
|
|
*/
|
|
export function createLogDemuxer() {
|
|
let pending = new Uint8Array(0)
|
|
|
|
const concat = (a, b) => {
|
|
const out = new Uint8Array(a.length + b.length)
|
|
out.set(a, 0)
|
|
out.set(b, a.length)
|
|
return out
|
|
}
|
|
|
|
return {
|
|
/**
|
|
* @param {string} b64 base64 chunk from server (raw docker stream)
|
|
* @returns {string} decoded text ready for display
|
|
*/
|
|
pushBase64(b64) {
|
|
try {
|
|
const binary = atob(b64)
|
|
const bytes = new Uint8Array(binary.length)
|
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
|
pending = concat(pending, bytes)
|
|
} catch {
|
|
return decodeBase64Utf8(b64)
|
|
}
|
|
|
|
// If not looking like mux frames, flush as utf8
|
|
if (pending.length >= 1 && pending[0] > 2) {
|
|
const text = new TextDecoder('utf-8', { fatal: false }).decode(pending)
|
|
pending = new Uint8Array(0)
|
|
return text
|
|
}
|
|
|
|
const parts = []
|
|
let offset = 0
|
|
while (offset + 8 <= pending.length) {
|
|
const size =
|
|
((pending[offset + 4] << 24) |
|
|
(pending[offset + 5] << 16) |
|
|
(pending[offset + 6] << 8) |
|
|
pending[offset + 7]) >>>
|
|
0
|
|
if (offset + 8 + size > pending.length) break
|
|
const payload = pending.subarray(offset + 8, offset + 8 + size)
|
|
parts.push(new TextDecoder('utf-8', { fatal: false }).decode(payload))
|
|
offset += 8 + size
|
|
}
|
|
pending = pending.subarray(offset)
|
|
return parts.join('')
|
|
},
|
|
flush() {
|
|
if (!pending.length) return ''
|
|
const text = new TextDecoder('utf-8', { fatal: false }).decode(pending)
|
|
pending = new Uint8Array(0)
|
|
return text
|
|
},
|
|
}
|
|
}
|