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",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@types/luxon": "^3.7.3",
|
"@types/luxon": "^3.7.3",
|
||||||
"@xterm/addon-attach": "^0.12.0",
|
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -3257,10 +3256,6 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/addon-attach": {
|
|
||||||
"version": "0.12.0",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@xterm/addon-fit": {
|
"node_modules/@xterm/addon-fit": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@types/luxon": "^3.7.3",
|
"@types/luxon": "^3.7.3",
|
||||||
"@xterm/addon-attach": "^0.12.0",
|
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
+256
-69
@@ -8,8 +8,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
import useTerminal from "@/hooks/useTerminal"
|
import useTerminal from "@/hooks/useTerminal"
|
||||||
import { sleep } from "@/lib/utils"
|
import { type TerminalKey, controlSequenceForInput, terminalKeySequence } from "@/lib/terminal-keys"
|
||||||
import { AttachAddon } from "@xterm/addon-attach"
|
|
||||||
import { FitAddon } from "@xterm/addon-fit"
|
import { FitAddon } from "@xterm/addon-fit"
|
||||||
import { Terminal } from "@xterm/xterm"
|
import { Terminal } from "@xterm/xterm"
|
||||||
import "@xterm/xterm/css/xterm.css"
|
import "@xterm/xterm/css/xterm.css"
|
||||||
@@ -36,111 +35,299 @@ interface XtermProps {
|
|||||||
setClose: React.Dispatch<React.SetStateAction<boolean>>
|
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"]>(
|
export const XtermComponent = forwardRef<HTMLDivElement, XtermProps & JSX.IntrinsicElements["div"]>(
|
||||||
({ wsUrl, setClose, ...props }, ref) => {
|
({ wsUrl, setClose, className = "", ...props }, ref) => {
|
||||||
const terminalIdRef = useRef<HTMLDivElement>(null)
|
const shellRef = useRef<HTMLDivElement>(null)
|
||||||
|
const screenRef = useRef<HTMLDivElement>(null)
|
||||||
const terminalRef = useRef<Terminal | null>(null)
|
const terminalRef = useRef<Terminal | null>(null)
|
||||||
const wsRef = useRef<WebSocket | 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, () => {
|
useImperativeHandle(ref, () => shellRef.current!, [])
|
||||||
return {
|
|
||||||
...terminalIdRef.current!,
|
const updateControl = useCallback((active: boolean) => {
|
||||||
async requestFullscreen() {
|
controlActiveRef.current = active
|
||||||
await terminalIdRef.current?.requestFullscreen()
|
setControlActive(active)
|
||||||
},
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const fitAddon = useRef(new FitAddon()).current
|
const sendResize = useCallback(() => {
|
||||||
const sendResize = useRef(false)
|
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(() => {
|
const sizeKey = `${terminal.cols}x${terminal.rows}`
|
||||||
if (!terminalIdRef.current) return
|
if (lastSizeRef.current === sizeKey) return
|
||||||
|
lastSizeRef.current = sizeKey
|
||||||
fitAddon.fit()
|
|
||||||
|
|
||||||
const dimensions = fitAddon.proposeDimensions()
|
|
||||||
|
|
||||||
if (dimensions) {
|
|
||||||
const prefix = new Int8Array([1])
|
|
||||||
const resizeMessage = new TextEncoder().encode(
|
const resizeMessage = new TextEncoder().encode(
|
||||||
JSON.stringify({
|
JSON.stringify({ Rows: terminal.rows, Cols: terminal.cols }),
|
||||||
Rows: dimensions.rows,
|
|
||||||
Cols: dimensions.cols,
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
const message = new Uint8Array(resizeMessage.length + 1)
|
||||||
|
message[0] = 1
|
||||||
|
message.set(resizeMessage, 1)
|
||||||
|
ws.send(message)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const msg = new Int8Array(prefix.length + resizeMessage.length)
|
const fit = useCallback(() => {
|
||||||
msg.set(prefix)
|
window.cancelAnimationFrame(fitFrameRef.current)
|
||||||
msg.set(resizeMessage, prefix.length)
|
fitFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
|
const screen = screenRef.current
|
||||||
wsRef.current?.send(msg)
|
if (!screen || screen.clientWidth === 0 || screen.clientHeight === 0) return
|
||||||
}
|
|
||||||
}, [fitAddon])
|
|
||||||
|
|
||||||
const onResize = useCallback(async () => {
|
|
||||||
if (sendResize.current) return
|
|
||||||
|
|
||||||
sendResize.current = true
|
|
||||||
try {
|
try {
|
||||||
await sleep(1500)
|
fitAddonRef.current?.fit()
|
||||||
doResize()
|
sendResize()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("resize error", error)
|
console.error("resize error", error)
|
||||||
} finally {
|
|
||||||
sendResize.current = false
|
|
||||||
}
|
}
|
||||||
}, [doResize])
|
})
|
||||||
|
}, [sendResize])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = terminalIdRef.current
|
const shell = shellRef.current
|
||||||
if (!container) return
|
const screen = screenRef.current
|
||||||
|
if (!shell || !screen) return
|
||||||
|
|
||||||
|
let active = true
|
||||||
const terminal = new Terminal({
|
const terminal = new Terminal({
|
||||||
cursorBlink: true,
|
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)
|
const url = new URL(wsUrl, window.location.origin)
|
||||||
url.protocol = url.protocol.replace("http", "ws")
|
url.protocol = url.protocol.replace("http", "ws")
|
||||||
const ws = new WebSocket(url)
|
const ws = new WebSocket(url)
|
||||||
ws.binaryType = "arraybuffer"
|
ws.binaryType = "arraybuffer"
|
||||||
|
|
||||||
terminalRef.current = terminal
|
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
|
pasteInProgressRef.current = false
|
||||||
|
setPasteBusy(false)
|
||||||
|
updateControl(false)
|
||||||
|
setConnectionState("connecting")
|
||||||
|
|
||||||
const attachAddon = new AttachAddon(ws)
|
const sendText = (data: string, applyControl: boolean) => {
|
||||||
terminal.loadAddon(attachAddon)
|
if (ws.readyState !== WebSocket.OPEN) return
|
||||||
terminal.loadAddon(fitAddon)
|
let outgoing = data
|
||||||
terminal.open(container)
|
if (applyControl && controlActiveRef.current) {
|
||||||
window.addEventListener("resize", onResize)
|
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 = () => {
|
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 = () => {
|
ws.onclose = () => {
|
||||||
terminal.dispose()
|
if (!active) return
|
||||||
|
pasteRequestRef.current += 1
|
||||||
|
setPasteBusy(false)
|
||||||
|
setConnectionState("disconnected")
|
||||||
setClose(true)
|
setClose(true)
|
||||||
}
|
}
|
||||||
ws.onerror = (e) => {
|
ws.onerror = (event) => {
|
||||||
console.error(e)
|
console.error(event)
|
||||||
toast("Websocket error", {
|
toast("Websocket error", { description: "View console for details." })
|
||||||
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 () => {
|
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.onopen = null
|
||||||
|
ws.onmessage = null
|
||||||
ws.onclose = null
|
ws.onclose = null
|
||||||
ws.onerror = null
|
ws.onerror = null
|
||||||
ws.close()
|
ws.close()
|
||||||
terminal.dispose()
|
terminal.dispose()
|
||||||
if (wsRef.current === ws) wsRef.current = null
|
if (wsRef.current === ws) wsRef.current = null
|
||||||
if (terminalRef.current === terminal) terminalRef.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 terminal = useTerminal(id ? parseInt(id) : undefined)
|
||||||
const terminalIdRef = useRef<HTMLDivElement>(null)
|
const terminalIdRef = useRef<HTMLDivElement>(null)
|
||||||
return (
|
return (
|
||||||
<div className="px-8">
|
<div className="terminal-page px-3 sm:px-8">
|
||||||
<div className="flex mt-6 mb-4">
|
<div className="flex mt-3 sm:mt-6 mb-3 sm:mb-4 items-center gap-2">
|
||||||
<h1 className="flex-1 text-3xl font-bold tracking-tight">{`Terminal (${id})`}</h1>
|
<h1 className="flex-1 text-xl sm: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="flex ml-auto gap-2 shrink-0">
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="expand"
|
icon="expand"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -166,8 +353,8 @@ export const TerminalPage = () => {
|
|||||||
{terminal?.session_id ? (
|
{terminal?.session_id ? (
|
||||||
<XtermComponent
|
<XtermComponent
|
||||||
ref={terminalIdRef}
|
ref={terminalIdRef}
|
||||||
className="max-h-[60%] mb-5 overflow-auto"
|
className="mb-3 sm:mb-5"
|
||||||
wsUrl={`/api/v1/ws/terminal/${terminal?.session_id}`}
|
wsUrl={`/api/v1/ws/terminal/${terminal.session_id}`}
|
||||||
setClose={setOpen}
|
setClose={setOpen}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+116
@@ -119,3 +119,119 @@ body,
|
|||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
@apply bg-border rounded-full border-[1px] border-solid border-transparent bg-clip-padding;
|
@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"
|
import { afterEach, beforeEach, expect, test, vi } from "vitest"
|
||||||
|
|
||||||
vi.mock("sonner", () => ({ toast: () => undefined }))
|
const terminalMocks = vi.hoisted(() => ({
|
||||||
|
instances: [] as Array<{
|
||||||
vi.mock("@/lib/utils", () => ({
|
write: ReturnType<typeof vi.fn>
|
||||||
sleep: () => Promise.resolve(),
|
paste: ReturnType<typeof vi.fn>
|
||||||
cn: (...args: unknown[]) => args.filter(Boolean).join(" "),
|
focus: ReturnType<typeof vi.fn>
|
||||||
|
dataHandler?: (data: string) => void
|
||||||
|
binaryHandler?: (data: string) => void
|
||||||
|
}>,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const attachAddonInstances: { ws: WebSocket }[] = []
|
const toastMock = vi.hoisted(() => vi.fn())
|
||||||
vi.mock("@xterm/addon-attach", () => ({
|
vi.mock("sonner", () => ({ toast: toastMock }))
|
||||||
AttachAddon: class {
|
|
||||||
ws: WebSocket
|
|
||||||
constructor(ws: WebSocket) {
|
|
||||||
this.ws = ws
|
|
||||||
attachAddonInstances.push(this)
|
|
||||||
}
|
|
||||||
activate() {}
|
|
||||||
dispose() {}
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock("@xterm/addon-fit", () => ({
|
vi.mock("@xterm/addon-fit", () => ({
|
||||||
FitAddon: class {
|
FitAddon: class {
|
||||||
activate() {}
|
activate() {}
|
||||||
dispose() {}
|
dispose() {}
|
||||||
fit() {}
|
fit() {}
|
||||||
proposeDimensions() {
|
|
||||||
return { rows: 24, cols: 80 }
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock("@xterm/xterm", () => ({
|
vi.mock("@xterm/xterm", () => ({
|
||||||
Terminal: class {
|
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() {}
|
loadAddon() {}
|
||||||
open() {}
|
open(container: HTMLElement) {
|
||||||
|
this.element = document.createElement("div")
|
||||||
|
container.appendChild(this.element)
|
||||||
|
}
|
||||||
dispose() {}
|
dispose() {}
|
||||||
|
onData(handler: (data: string) => void) {
|
||||||
|
this.dataHandler = handler
|
||||||
|
return { dispose() {} }
|
||||||
|
}
|
||||||
|
onBinary(handler: (data: string) => void) {
|
||||||
|
this.binaryHandler = handler
|
||||||
|
return { dispose() {} }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
class FakeWebSocket {
|
class FakeWebSocket {
|
||||||
|
static readonly OPEN = 1
|
||||||
static instances: FakeWebSocket[] = []
|
static instances: FakeWebSocket[] = []
|
||||||
url: string
|
url: string
|
||||||
binaryType = "arraybuffer"
|
binaryType = "arraybuffer"
|
||||||
@@ -50,37 +65,42 @@ class FakeWebSocket {
|
|||||||
onmessage: ((ev: MessageEvent) => unknown) | null = null
|
onmessage: ((ev: MessageEvent) => unknown) | null = null
|
||||||
readyState = 0
|
readyState = 0
|
||||||
closeCalls = 0
|
closeCalls = 0
|
||||||
|
send = vi.fn()
|
||||||
|
|
||||||
constructor(url: string | URL) {
|
constructor(url: string | URL) {
|
||||||
this.url = url.toString()
|
this.url = url.toString()
|
||||||
FakeWebSocket.instances.push(this)
|
FakeWebSocket.instances.push(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
this.readyState = FakeWebSocket.OPEN
|
||||||
|
this.onopen?.(new Event("open"))
|
||||||
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.closeCalls += 1
|
this.closeCalls += 1
|
||||||
this.readyState = 3
|
this.readyState = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
send() {}
|
|
||||||
addEventListener() {}
|
|
||||||
removeEventListener() {}
|
|
||||||
dispatchEvent() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
FakeWebSocket.instances = []
|
FakeWebSocket.instances = []
|
||||||
attachAddonInstances.length = 0
|
terminalMocks.instances = []
|
||||||
|
toastMock.mockReset()
|
||||||
;(globalThis as { WebSocket: typeof WebSocket }).WebSocket =
|
;(globalThis as { WebSocket: typeof WebSocket }).WebSocket =
|
||||||
FakeWebSocket as unknown as typeof WebSocket
|
FakeWebSocket as unknown as typeof WebSocket
|
||||||
|
vi.stubGlobal(
|
||||||
|
"requestAnimationFrame",
|
||||||
|
vi.fn(() => 1),
|
||||||
|
)
|
||||||
|
vi.stubGlobal("cancelAnimationFrame", vi.fn())
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks()
|
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 { XtermComponent } = await import("../components/terminal")
|
||||||
const noop = () => undefined
|
const noop = () => undefined
|
||||||
|
|
||||||
@@ -90,14 +110,55 @@ test("XtermComponent closes the previous WebSocket and re-attaches xterm when ws
|
|||||||
|
|
||||||
expect(FakeWebSocket.instances).toHaveLength(1)
|
expect(FakeWebSocket.instances).toHaveLength(1)
|
||||||
const firstSocket = FakeWebSocket.instances[0]
|
const firstSocket = FakeWebSocket.instances[0]
|
||||||
expect(attachAddonInstances).toHaveLength(1)
|
expect(terminalMocks.instances).toHaveLength(1)
|
||||||
expect(attachAddonInstances[0].ws).toBe(firstSocket as unknown as WebSocket)
|
|
||||||
|
|
||||||
rerender(<XtermComponent wsUrl="/api/v1/ws/terminal/session-2" setClose={noop} />)
|
rerender(<XtermComponent wsUrl="/api/v1/ws/terminal/session-2" setClose={noop} />)
|
||||||
|
|
||||||
expect(FakeWebSocket.instances).toHaveLength(2)
|
expect(FakeWebSocket.instances).toHaveLength(2)
|
||||||
const secondSocket = FakeWebSocket.instances[1]
|
|
||||||
expect(firstSocket.closeCalls).toBeGreaterThanOrEqual(1)
|
expect(firstSocket.closeCalls).toBeGreaterThanOrEqual(1)
|
||||||
expect(attachAddonInstances).toHaveLength(2)
|
expect(terminalMocks.instances).toHaveLength(2)
|
||||||
expect(attachAddonInstances[1].ws).toBe(secondSocket as unknown as WebSocket)
|
})
|
||||||
|
|
||||||
|
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