mirror of
https://github.com/Buriburizaem0n/admin-frontend-domain.git
synced 2026-09-19 09:40:13 +00:00
feat(terminal): improve mobile controls and paste
This commit is contained in:
Generated
-5
@@ -27,7 +27,6 @@
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/luxon": "^3.7.3",
|
||||
"@xterm/addon-attach": "^0.12.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -3257,10 +3256,6 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-attach": {
|
||||
"version": "0.12.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"license": "MIT"
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/luxon": "^3.7.3",
|
||||
"@xterm/addon-attach": "^0.12.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
+262
-75
@@ -8,8 +8,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import useTerminal from "@/hooks/useTerminal"
|
||||
import { sleep } from "@/lib/utils"
|
||||
import { AttachAddon } from "@xterm/addon-attach"
|
||||
import { type TerminalKey, controlSequenceForInput, terminalKeySequence } from "@/lib/terminal-keys"
|
||||
import { FitAddon } from "@xterm/addon-fit"
|
||||
import { Terminal } from "@xterm/xterm"
|
||||
import "@xterm/xterm/css/xterm.css"
|
||||
@@ -36,111 +35,299 @@ interface XtermProps {
|
||||
setClose: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
type ConnectionState = "connecting" | "connected" | "disconnected"
|
||||
|
||||
const terminalKeys: Array<{ key: TerminalKey; label: string; ariaLabel: string }> = [
|
||||
{ key: "escape", label: "Esc", ariaLabel: "Escape" },
|
||||
{ key: "tab", label: "Tab", ariaLabel: "Tab" },
|
||||
{ key: "arrowLeft", label: "←", ariaLabel: "Left arrow" },
|
||||
{ key: "arrowUp", label: "↑", ariaLabel: "Up arrow" },
|
||||
{ key: "arrowDown", label: "↓", ariaLabel: "Down arrow" },
|
||||
{ key: "arrowRight", label: "→", ariaLabel: "Right arrow" },
|
||||
{ key: "home", label: "Home", ariaLabel: "Home" },
|
||||
{ key: "end", label: "End", ariaLabel: "End" },
|
||||
{ key: "pageUp", label: "PgUp", ariaLabel: "Page up" },
|
||||
{ key: "pageDown", label: "PgDn", ariaLabel: "Page down" },
|
||||
]
|
||||
|
||||
const maxClipboardBytes = 512 * 1024
|
||||
|
||||
export const XtermComponent = forwardRef<HTMLDivElement, XtermProps & JSX.IntrinsicElements["div"]>(
|
||||
({ wsUrl, setClose, ...props }, ref) => {
|
||||
const terminalIdRef = useRef<HTMLDivElement>(null)
|
||||
({ wsUrl, setClose, className = "", ...props }, ref) => {
|
||||
const shellRef = useRef<HTMLDivElement>(null)
|
||||
const screenRef = useRef<HTMLDivElement>(null)
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||
const fitFrameRef = useRef(0)
|
||||
const lastSizeRef = useRef("")
|
||||
const controlActiveRef = useRef(false)
|
||||
const pasteInProgressRef = useRef(false)
|
||||
const pasteRequestRef = useRef(0)
|
||||
const [controlActive, setControlActive] = useState(false)
|
||||
const [pasteBusy, setPasteBusy] = useState(false)
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>("connecting")
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
...terminalIdRef.current!,
|
||||
async requestFullscreen() {
|
||||
await terminalIdRef.current?.requestFullscreen()
|
||||
},
|
||||
}
|
||||
useImperativeHandle(ref, () => shellRef.current!, [])
|
||||
|
||||
const updateControl = useCallback((active: boolean) => {
|
||||
controlActiveRef.current = active
|
||||
setControlActive(active)
|
||||
}, [])
|
||||
|
||||
const fitAddon = useRef(new FitAddon()).current
|
||||
const sendResize = useRef(false)
|
||||
const sendResize = useCallback(() => {
|
||||
const terminal = terminalRef.current
|
||||
const ws = wsRef.current
|
||||
if (!terminal || !ws || ws.readyState !== WebSocket.OPEN) return
|
||||
if (terminal.cols < 2 || terminal.rows < 2) return
|
||||
|
||||
const doResize = useCallback(() => {
|
||||
if (!terminalIdRef.current) return
|
||||
const sizeKey = `${terminal.cols}x${terminal.rows}`
|
||||
if (lastSizeRef.current === sizeKey) return
|
||||
lastSizeRef.current = sizeKey
|
||||
const resizeMessage = new TextEncoder().encode(
|
||||
JSON.stringify({ Rows: terminal.rows, Cols: terminal.cols }),
|
||||
)
|
||||
const message = new Uint8Array(resizeMessage.length + 1)
|
||||
message[0] = 1
|
||||
message.set(resizeMessage, 1)
|
||||
ws.send(message)
|
||||
}, [])
|
||||
|
||||
fitAddon.fit()
|
||||
|
||||
const dimensions = fitAddon.proposeDimensions()
|
||||
|
||||
if (dimensions) {
|
||||
const prefix = new Int8Array([1])
|
||||
const resizeMessage = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
Rows: dimensions.rows,
|
||||
Cols: dimensions.cols,
|
||||
}),
|
||||
)
|
||||
|
||||
const msg = new Int8Array(prefix.length + resizeMessage.length)
|
||||
msg.set(prefix)
|
||||
msg.set(resizeMessage, prefix.length)
|
||||
|
||||
wsRef.current?.send(msg)
|
||||
}
|
||||
}, [fitAddon])
|
||||
|
||||
const onResize = useCallback(async () => {
|
||||
if (sendResize.current) return
|
||||
|
||||
sendResize.current = true
|
||||
try {
|
||||
await sleep(1500)
|
||||
doResize()
|
||||
} catch (error) {
|
||||
console.error("resize error", error)
|
||||
} finally {
|
||||
sendResize.current = false
|
||||
}
|
||||
}, [doResize])
|
||||
const fit = useCallback(() => {
|
||||
window.cancelAnimationFrame(fitFrameRef.current)
|
||||
fitFrameRef.current = window.requestAnimationFrame(() => {
|
||||
const screen = screenRef.current
|
||||
if (!screen || screen.clientWidth === 0 || screen.clientHeight === 0) return
|
||||
try {
|
||||
fitAddonRef.current?.fit()
|
||||
sendResize()
|
||||
} catch (error) {
|
||||
console.error("resize error", error)
|
||||
}
|
||||
})
|
||||
}, [sendResize])
|
||||
|
||||
useEffect(() => {
|
||||
const container = terminalIdRef.current
|
||||
if (!container) return
|
||||
const shell = shellRef.current
|
||||
const screen = screenRef.current
|
||||
if (!shell || !screen) return
|
||||
|
||||
let active = true
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 16,
|
||||
fontSize: window.innerWidth <= 640 ? 13 : 16,
|
||||
scrollback: 10000,
|
||||
scrollOnUserInput: true,
|
||||
})
|
||||
const fitAddon = new FitAddon()
|
||||
fitAddonRef.current = fitAddon
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.open(screen)
|
||||
terminal.element?.setAttribute("aria-label", "Interactive terminal")
|
||||
terminalRef.current = terminal
|
||||
|
||||
const url = new URL(wsUrl, window.location.origin)
|
||||
url.protocol = url.protocol.replace("http", "ws")
|
||||
const ws = new WebSocket(url)
|
||||
ws.binaryType = "arraybuffer"
|
||||
|
||||
terminalRef.current = terminal
|
||||
wsRef.current = ws
|
||||
pasteInProgressRef.current = false
|
||||
setPasteBusy(false)
|
||||
updateControl(false)
|
||||
setConnectionState("connecting")
|
||||
|
||||
const attachAddon = new AttachAddon(ws)
|
||||
terminal.loadAddon(attachAddon)
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.open(container)
|
||||
window.addEventListener("resize", onResize)
|
||||
const sendText = (data: string, applyControl: boolean) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return
|
||||
let outgoing = data
|
||||
if (applyControl && controlActiveRef.current) {
|
||||
outgoing = controlSequenceForInput(data) ?? data
|
||||
updateControl(false)
|
||||
}
|
||||
ws.send(outgoing)
|
||||
terminal.focus()
|
||||
}
|
||||
|
||||
const dataSubscription = terminal.onData((data) => {
|
||||
sendText(data, !pasteInProgressRef.current)
|
||||
})
|
||||
const binarySubscription = terminal.onBinary((data) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return
|
||||
const message = new Uint8Array(data.length + 1)
|
||||
for (let index = 0; index < data.length; index += 1) {
|
||||
message[index + 1] = data.charCodeAt(index) & 0xff
|
||||
}
|
||||
ws.send(message)
|
||||
})
|
||||
|
||||
ws.onopen = () => {
|
||||
onResize()
|
||||
if (!active) return
|
||||
lastSizeRef.current = ""
|
||||
setConnectionState("connected")
|
||||
fit()
|
||||
terminal.focus()
|
||||
}
|
||||
ws.onmessage = (event) => {
|
||||
if (!active) return
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
terminal.write(new Uint8Array(event.data))
|
||||
} else if (typeof event.data === "string") {
|
||||
terminal.write(event.data)
|
||||
}
|
||||
}
|
||||
ws.onclose = () => {
|
||||
terminal.dispose()
|
||||
if (!active) return
|
||||
pasteRequestRef.current += 1
|
||||
setPasteBusy(false)
|
||||
setConnectionState("disconnected")
|
||||
setClose(true)
|
||||
}
|
||||
ws.onerror = (e) => {
|
||||
console.error(e)
|
||||
toast("Websocket error", {
|
||||
description: "View console for details.",
|
||||
})
|
||||
ws.onerror = (event) => {
|
||||
console.error(event)
|
||||
toast("Websocket error", { description: "View console for details." })
|
||||
}
|
||||
|
||||
const updateViewport = () => {
|
||||
const viewport = window.visualViewport
|
||||
const viewportTop = viewport?.offsetTop ?? 0
|
||||
const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight)
|
||||
const shellTop = Math.max(shell.getBoundingClientRect().top, viewportTop)
|
||||
shell.style.setProperty(
|
||||
"--terminal-available-height",
|
||||
`${Math.max(220, Math.floor(viewportBottom - shellTop - 8))}px`,
|
||||
)
|
||||
fit()
|
||||
}
|
||||
const observer = new ResizeObserver(fit)
|
||||
observer.observe(screen)
|
||||
window.addEventListener("resize", updateViewport)
|
||||
window.addEventListener("orientationchange", updateViewport)
|
||||
window.visualViewport?.addEventListener("resize", updateViewport)
|
||||
window.visualViewport?.addEventListener("scroll", updateViewport)
|
||||
updateViewport()
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize)
|
||||
active = false
|
||||
pasteRequestRef.current += 1
|
||||
window.cancelAnimationFrame(fitFrameRef.current)
|
||||
observer.disconnect()
|
||||
window.removeEventListener("resize", updateViewport)
|
||||
window.removeEventListener("orientationchange", updateViewport)
|
||||
window.visualViewport?.removeEventListener("resize", updateViewport)
|
||||
window.visualViewport?.removeEventListener("scroll", updateViewport)
|
||||
dataSubscription.dispose()
|
||||
binarySubscription.dispose()
|
||||
ws.onopen = null
|
||||
ws.onmessage = null
|
||||
ws.onclose = null
|
||||
ws.onerror = null
|
||||
ws.close()
|
||||
terminal.dispose()
|
||||
if (wsRef.current === ws) wsRef.current = null
|
||||
if (terminalRef.current === terminal) terminalRef.current = null
|
||||
if (fitAddonRef.current === fitAddon) fitAddonRef.current = null
|
||||
}
|
||||
}, [fitAddon, onResize, setClose, wsUrl])
|
||||
}, [fit, setClose, updateControl, wsUrl])
|
||||
|
||||
return <div ref={terminalIdRef} {...props} />
|
||||
const pasteClipboard = async () => {
|
||||
if (pasteBusy || connectionState !== "connected") return
|
||||
if (!navigator.clipboard?.readText) {
|
||||
toast("Clipboard unavailable", {
|
||||
description: "Use HTTPS and allow clipboard permission in your browser.",
|
||||
})
|
||||
terminalRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
setPasteBusy(true)
|
||||
const requestID = ++pasteRequestRef.current
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (pasteRequestRef.current !== requestID) return
|
||||
if (!text) {
|
||||
toast("Clipboard is empty")
|
||||
return
|
||||
}
|
||||
if (new TextEncoder().encode(text).length > maxClipboardBytes) {
|
||||
toast("Clipboard is too large", {
|
||||
description: "Terminal paste is limited to 512 KiB per action.",
|
||||
})
|
||||
return
|
||||
}
|
||||
const terminal = terminalRef.current
|
||||
if (!terminal) return
|
||||
pasteInProgressRef.current = true
|
||||
try {
|
||||
terminal.paste(text)
|
||||
} finally {
|
||||
pasteInProgressRef.current = false
|
||||
}
|
||||
terminal.focus()
|
||||
} catch {
|
||||
if (pasteRequestRef.current === requestID) {
|
||||
toast("Could not read clipboard", {
|
||||
description: "Allow clipboard access in your browser and try again.",
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
if (pasteRequestRef.current === requestID) setPasteBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const sendKey = (key: TerminalKey) => {
|
||||
const ws = wsRef.current
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
const withControl = controlActiveRef.current
|
||||
updateControl(false)
|
||||
ws.send(terminalKeySequence(key, withControl))
|
||||
terminalRef.current?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={shellRef}
|
||||
className={`terminal-shell ${className}`}
|
||||
data-connection-state={connectionState}
|
||||
{...props}
|
||||
>
|
||||
<div ref={screenRef} className="terminal-screen" />
|
||||
<div className="terminal-keyboard" role="toolbar" aria-label="Terminal controls">
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-key terminal-key-paste"
|
||||
disabled={connectionState !== "connected" || pasteBusy}
|
||||
aria-label="Paste clipboard"
|
||||
onClick={() => void pasteClipboard()}
|
||||
>
|
||||
{pasteBusy ? "Pasting…" : "Paste"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`terminal-key ${controlActive ? "is-active" : ""}`}
|
||||
aria-label="Control modifier for next key"
|
||||
aria-pressed={controlActive}
|
||||
disabled={connectionState !== "connected"}
|
||||
onClick={() => {
|
||||
updateControl(!controlActiveRef.current)
|
||||
terminalRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
Ctrl
|
||||
</button>
|
||||
{terminalKeys.map(({ key, label, ariaLabel }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`terminal-key ${key.startsWith("arrow") ? "terminal-key-arrow" : ""}`}
|
||||
aria-label={ariaLabel}
|
||||
disabled={connectionState !== "connected"}
|
||||
onClick={() => sendKey(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -150,10 +337,10 @@ export const TerminalPage = () => {
|
||||
const terminal = useTerminal(id ? parseInt(id) : undefined)
|
||||
const terminalIdRef = useRef<HTMLDivElement>(null)
|
||||
return (
|
||||
<div className="px-8">
|
||||
<div className="flex mt-6 mb-4">
|
||||
<h1 className="flex-1 text-3xl font-bold tracking-tight">{`Terminal (${id})`}</h1>
|
||||
<div className="flex ml-auto self-end sm:self-auto gap-2 flex-wrap shrink-0">
|
||||
<div className="terminal-page px-3 sm:px-8">
|
||||
<div className="flex mt-3 sm:mt-6 mb-3 sm:mb-4 items-center gap-2">
|
||||
<h1 className="flex-1 text-xl sm:text-3xl font-bold tracking-tight">{`Terminal (${id})`}</h1>
|
||||
<div className="flex ml-auto gap-2 shrink-0">
|
||||
<IconButton
|
||||
icon="expand"
|
||||
onClick={async () => {
|
||||
@@ -166,8 +353,8 @@ export const TerminalPage = () => {
|
||||
{terminal?.session_id ? (
|
||||
<XtermComponent
|
||||
ref={terminalIdRef}
|
||||
className="max-h-[60%] mb-5 overflow-auto"
|
||||
wsUrl={`/api/v1/ws/terminal/${terminal?.session_id}`}
|
||||
className="mb-3 sm:mb-5"
|
||||
wsUrl={`/api/v1/ws/terminal/${terminal.session_id}`}
|
||||
setClose={setOpen}
|
||||
/>
|
||||
) : (
|
||||
|
||||
+116
@@ -119,3 +119,119 @@ body,
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-border rounded-full border-[1px] border-solid border-transparent bg-clip-padding;
|
||||
}
|
||||
|
||||
.terminal-shell {
|
||||
--terminal-available-height: 70vh;
|
||||
display: flex;
|
||||
height: min(70vh, var(--terminal-available-height));
|
||||
min-height: 360px;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
.terminal-screen {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
padding: 6px 3px 2px 6px;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.terminal-screen .xterm {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-screen .xterm-viewport {
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.terminal-keyboard {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
padding: 6px 6px calc(6px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--background));
|
||||
touch-action: pan-x;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.terminal-key {
|
||||
display: inline-flex;
|
||||
min-width: 46px;
|
||||
height: 42px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
background: hsl(var(--secondary));
|
||||
padding: 0 9px;
|
||||
color: hsl(var(--secondary-foreground));
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.terminal-key:hover:not(:disabled),
|
||||
.terminal-key:focus-visible,
|
||||
.terminal-key.is-active {
|
||||
border-color: hsl(var(--ring));
|
||||
background: hsl(var(--accent));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.terminal-key:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.terminal-key-arrow {
|
||||
min-width: 40px;
|
||||
padding-inline: 5px;
|
||||
}
|
||||
|
||||
.terminal-key-paste {
|
||||
min-width: 58px;
|
||||
}
|
||||
|
||||
.terminal-shell:fullscreen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.terminal-shell {
|
||||
height: var(--terminal-available-height);
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.terminal-keyboard {
|
||||
gap: 2px;
|
||||
padding-inline: 4px;
|
||||
}
|
||||
|
||||
.terminal-key {
|
||||
min-width: 43px;
|
||||
height: 44px;
|
||||
padding-inline: 5px;
|
||||
}
|
||||
|
||||
.terminal-key-arrow {
|
||||
min-width: 36px;
|
||||
padding-inline: 2px;
|
||||
}
|
||||
|
||||
.terminal-key-paste {
|
||||
min-width: 49px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
export type TerminalKey =
|
||||
| "escape"
|
||||
| "tab"
|
||||
| "arrowUp"
|
||||
| "arrowDown"
|
||||
| "arrowLeft"
|
||||
| "arrowRight"
|
||||
| "home"
|
||||
| "end"
|
||||
| "pageUp"
|
||||
| "pageDown"
|
||||
|
||||
const keySequences: Record<TerminalKey, string> = {
|
||||
escape: "\x1b",
|
||||
tab: "\t",
|
||||
arrowUp: "\x1b[A",
|
||||
arrowDown: "\x1b[B",
|
||||
arrowRight: "\x1b[C",
|
||||
arrowLeft: "\x1b[D",
|
||||
home: "\x1b[H",
|
||||
end: "\x1b[F",
|
||||
pageUp: "\x1b[5~",
|
||||
pageDown: "\x1b[6~",
|
||||
}
|
||||
|
||||
const controlKeySequences: Partial<Record<TerminalKey, string>> = {
|
||||
arrowUp: "\x1b[1;5A",
|
||||
arrowDown: "\x1b[1;5B",
|
||||
arrowRight: "\x1b[1;5C",
|
||||
arrowLeft: "\x1b[1;5D",
|
||||
home: "\x1b[1;5H",
|
||||
end: "\x1b[1;5F",
|
||||
pageUp: "\x1b[5;5~",
|
||||
pageDown: "\x1b[6;5~",
|
||||
}
|
||||
|
||||
export function terminalKeySequence(key: TerminalKey, control = false): string {
|
||||
return (control && controlKeySequences[key]) || keySequences[key]
|
||||
}
|
||||
|
||||
export function controlSequenceForInput(data: string): string | null {
|
||||
if (data.length !== 1) return null
|
||||
|
||||
const code = data.toUpperCase().charCodeAt(0)
|
||||
if (code >= 65 && code <= 90) return String.fromCharCode(code - 64)
|
||||
|
||||
const punctuation: Record<string, number> = {
|
||||
" ": 0,
|
||||
"@": 0,
|
||||
"[": 27,
|
||||
"\\": 28,
|
||||
"]": 29,
|
||||
"^": 30,
|
||||
_: 31,
|
||||
"?": 127,
|
||||
"2": 0,
|
||||
"3": 27,
|
||||
"4": 28,
|
||||
"5": 29,
|
||||
"6": 30,
|
||||
"7": 31,
|
||||
"8": 127,
|
||||
}
|
||||
const mapped = punctuation[data]
|
||||
return mapped === undefined ? null : String.fromCharCode(mapped)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { controlSequenceForInput, terminalKeySequence } from "@/lib/terminal-keys"
|
||||
import { expect, test } from "vitest"
|
||||
|
||||
test("terminal mobile keys use xterm-compatible control sequences", () => {
|
||||
expect(terminalKeySequence("escape")).toBe("\x1b")
|
||||
expect(terminalKeySequence("tab")).toBe("\t")
|
||||
expect(terminalKeySequence("arrowUp")).toBe("\x1b[A")
|
||||
expect(terminalKeySequence("arrowLeft", true)).toBe("\x1b[1;5D")
|
||||
expect(terminalKeySequence("pageDown", true)).toBe("\x1b[6;5~")
|
||||
})
|
||||
|
||||
test("one-shot Ctrl converts printable input without corrupting paste chunks", () => {
|
||||
expect(controlSequenceForInput("c")).toBe("\x03")
|
||||
expect(controlSequenceForInput("D")).toBe("\x04")
|
||||
expect(controlSequenceForInput("[")).toBe("\x1b")
|
||||
expect(controlSequenceForInput("codex")).toBeNull()
|
||||
expect(controlSequenceForInput("中文")).toBeNull()
|
||||
})
|
||||
+97
-36
@@ -1,46 +1,61 @@
|
||||
import { render } from "@testing-library/react"
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest"
|
||||
|
||||
vi.mock("sonner", () => ({ toast: () => undefined }))
|
||||
|
||||
vi.mock("@/lib/utils", () => ({
|
||||
sleep: () => Promise.resolve(),
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(" "),
|
||||
const terminalMocks = vi.hoisted(() => ({
|
||||
instances: [] as Array<{
|
||||
write: ReturnType<typeof vi.fn>
|
||||
paste: ReturnType<typeof vi.fn>
|
||||
focus: ReturnType<typeof vi.fn>
|
||||
dataHandler?: (data: string) => void
|
||||
binaryHandler?: (data: string) => void
|
||||
}>,
|
||||
}))
|
||||
|
||||
const attachAddonInstances: { ws: WebSocket }[] = []
|
||||
vi.mock("@xterm/addon-attach", () => ({
|
||||
AttachAddon: class {
|
||||
ws: WebSocket
|
||||
constructor(ws: WebSocket) {
|
||||
this.ws = ws
|
||||
attachAddonInstances.push(this)
|
||||
}
|
||||
activate() {}
|
||||
dispose() {}
|
||||
},
|
||||
}))
|
||||
const toastMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock("sonner", () => ({ toast: toastMock }))
|
||||
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
FitAddon: class {
|
||||
activate() {}
|
||||
dispose() {}
|
||||
fit() {}
|
||||
proposeDimensions() {
|
||||
return { rows: 24, cols: 80 }
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@xterm/xterm", () => ({
|
||||
Terminal: class {
|
||||
cols = 80
|
||||
rows = 24
|
||||
element: HTMLElement | null = null
|
||||
write = vi.fn()
|
||||
focus = vi.fn()
|
||||
dataHandler?: (data: string) => void
|
||||
binaryHandler?: (data: string) => void
|
||||
paste = vi.fn((data: string) => this.dataHandler?.(data))
|
||||
|
||||
constructor() {
|
||||
terminalMocks.instances.push(this)
|
||||
}
|
||||
|
||||
loadAddon() {}
|
||||
open() {}
|
||||
open(container: HTMLElement) {
|
||||
this.element = document.createElement("div")
|
||||
container.appendChild(this.element)
|
||||
}
|
||||
dispose() {}
|
||||
onData(handler: (data: string) => void) {
|
||||
this.dataHandler = handler
|
||||
return { dispose() {} }
|
||||
}
|
||||
onBinary(handler: (data: string) => void) {
|
||||
this.binaryHandler = handler
|
||||
return { dispose() {} }
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
class FakeWebSocket {
|
||||
static readonly OPEN = 1
|
||||
static instances: FakeWebSocket[] = []
|
||||
url: string
|
||||
binaryType = "arraybuffer"
|
||||
@@ -50,37 +65,42 @@ class FakeWebSocket {
|
||||
onmessage: ((ev: MessageEvent) => unknown) | null = null
|
||||
readyState = 0
|
||||
closeCalls = 0
|
||||
send = vi.fn()
|
||||
|
||||
constructor(url: string | URL) {
|
||||
this.url = url.toString()
|
||||
FakeWebSocket.instances.push(this)
|
||||
}
|
||||
|
||||
open() {
|
||||
this.readyState = FakeWebSocket.OPEN
|
||||
this.onopen?.(new Event("open"))
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closeCalls += 1
|
||||
this.readyState = 3
|
||||
}
|
||||
|
||||
send() {}
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
dispatchEvent() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeWebSocket.instances = []
|
||||
attachAddonInstances.length = 0
|
||||
terminalMocks.instances = []
|
||||
toastMock.mockReset()
|
||||
;(globalThis as { WebSocket: typeof WebSocket }).WebSocket =
|
||||
FakeWebSocket as unknown as typeof WebSocket
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn(() => 1),
|
||||
)
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test("XtermComponent closes the previous WebSocket and re-attaches xterm when wsUrl changes", async () => {
|
||||
test("XtermComponent closes the previous WebSocket and recreates xterm when wsUrl changes", async () => {
|
||||
const { XtermComponent } = await import("../components/terminal")
|
||||
const noop = () => undefined
|
||||
|
||||
@@ -90,14 +110,55 @@ test("XtermComponent closes the previous WebSocket and re-attaches xterm when ws
|
||||
|
||||
expect(FakeWebSocket.instances).toHaveLength(1)
|
||||
const firstSocket = FakeWebSocket.instances[0]
|
||||
expect(attachAddonInstances).toHaveLength(1)
|
||||
expect(attachAddonInstances[0].ws).toBe(firstSocket as unknown as WebSocket)
|
||||
expect(terminalMocks.instances).toHaveLength(1)
|
||||
|
||||
rerender(<XtermComponent wsUrl="/api/v1/ws/terminal/session-2" setClose={noop} />)
|
||||
|
||||
expect(FakeWebSocket.instances).toHaveLength(2)
|
||||
const secondSocket = FakeWebSocket.instances[1]
|
||||
expect(firstSocket.closeCalls).toBeGreaterThanOrEqual(1)
|
||||
expect(attachAddonInstances).toHaveLength(2)
|
||||
expect(attachAddonInstances[1].ws).toBe(secondSocket as unknown as WebSocket)
|
||||
expect(terminalMocks.instances).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("mobile controls send text keys and preserve one-shot Ctrl across xterm paste", async () => {
|
||||
const { XtermComponent } = await import("../components/terminal")
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { readText: vi.fn().mockResolvedValue("first line\n第二行") },
|
||||
})
|
||||
render(<XtermComponent wsUrl="/api/v1/ws/terminal/mobile" setClose={() => undefined} />)
|
||||
const socket = FakeWebSocket.instances[0]
|
||||
act(() => socket.open())
|
||||
|
||||
const control = screen.getByRole("button", { name: "Control modifier for next key" })
|
||||
fireEvent.click(control)
|
||||
fireEvent.click(screen.getByRole("button", { name: "Paste clipboard" }))
|
||||
|
||||
const terminal = terminalMocks.instances[0]
|
||||
await waitFor(() => expect(terminal.paste).toHaveBeenCalledWith("first line\n第二行"))
|
||||
expect(control.getAttribute("aria-pressed")).toBe("true")
|
||||
expect(socket.send).toHaveBeenCalledWith("first line\n第二行")
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Left arrow" }))
|
||||
expect(socket.send).toHaveBeenLastCalledWith("\x1b[1;5D")
|
||||
expect(control.getAttribute("aria-pressed")).toBe("false")
|
||||
})
|
||||
|
||||
test("xterm output and binary input remain byte-safe without AttachAddon", async () => {
|
||||
const { XtermComponent } = await import("../components/terminal")
|
||||
render(<XtermComponent wsUrl="/api/v1/ws/terminal/bytes" setClose={() => undefined} />)
|
||||
const socket = FakeWebSocket.instances[0]
|
||||
act(() => socket.open())
|
||||
const terminal = terminalMocks.instances[0]
|
||||
|
||||
const output = new window.Uint8Array(new window.ArrayBuffer(16))
|
||||
output.set(new TextEncoder().encode("stream 中文"))
|
||||
act(() => socket.onmessage?.({ data: output.buffer } as MessageEvent))
|
||||
expect(terminal.write).toHaveBeenCalledWith(expect.any(Uint8Array))
|
||||
|
||||
terminal.binaryHandler?.("\x00\xff")
|
||||
const binaryCall = socket.send.mock.calls.find(([data]) => ArrayBuffer.isView(data))?.[0]
|
||||
expect(Array.from(binaryCall as Uint8Array)).toEqual([0, 0, 255])
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Escape" }))
|
||||
expect(socket.send).toHaveBeenLastCalledWith("\x1b")
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user