fix: 终端 PTY 尺寸同步
前端在 xterm fit/resize 后通过 WebSocket 发送 resize 控制消息,后端收到后调用 ChannelShell.setPtySize 触发远端重绘,修复 less/vim/top 等全屏程序只显示部分区域的问题。 同时补齐控制消息解析与 PTY resize 的单测,并修正失配的 SftpControllerTest 断言。
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Terminal } from 'xterm'
|
||||
import { AttachAddon } from '@xterm/addon-attach'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import 'xterm/css/xterm.css'
|
||||
|
||||
const CONTROL_PREFIX = '__SSHMANAGER__:'
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: number
|
||||
active?: boolean
|
||||
@@ -15,10 +16,15 @@ const containerRef = ref<HTMLElement | null>(null)
|
||||
const status = ref<'connecting' | 'connected' | 'error'>('connecting')
|
||||
const errorMessage = ref('')
|
||||
|
||||
let term: Terminal | null = null
|
||||
let fitAddon: FitAddon | null = null
|
||||
let term: Terminal | null = null
|
||||
let fitAddon: FitAddon | null = null
|
||||
let ws: WebSocket | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let onDataDisposable: { dispose: () => void } | null = null
|
||||
let onResizeDisposable: { dispose: () => void } | null = null
|
||||
let resizeDebounceTimer = 0
|
||||
let lastSentCols = 0
|
||||
let lastSentRows = 0
|
||||
|
||||
function fitTerminal() {
|
||||
fitAddon?.fit()
|
||||
@@ -46,21 +52,67 @@ function getWsUrl(): string {
|
||||
return `${protocol}//${host}/ws/terminal?connectionId=${props.connectionId}&token=${encodeURIComponent(token)}`
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (resizeObserver && containerRef.value) {
|
||||
resizeObserver.unobserve(containerRef.value)
|
||||
}
|
||||
resizeObserver = null
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
if (term) {
|
||||
term.dispose()
|
||||
term = null
|
||||
}
|
||||
fitAddon = null
|
||||
}
|
||||
function cleanup() {
|
||||
if (resizeObserver && containerRef.value) {
|
||||
resizeObserver.unobserve(containerRef.value)
|
||||
}
|
||||
resizeObserver = null
|
||||
|
||||
if (onDataDisposable) {
|
||||
onDataDisposable.dispose()
|
||||
onDataDisposable = null
|
||||
}
|
||||
if (onResizeDisposable) {
|
||||
onResizeDisposable.dispose()
|
||||
onResizeDisposable = null
|
||||
}
|
||||
clearTimeout(resizeDebounceTimer)
|
||||
resizeDebounceTimer = 0
|
||||
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
if (term) {
|
||||
term.dispose()
|
||||
term = null
|
||||
}
|
||||
fitAddon = null
|
||||
}
|
||||
|
||||
function sendResize(cols: number, rows: number) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
if (!cols || !rows) return
|
||||
if (cols === lastSentCols && rows === lastSentRows) return
|
||||
lastSentCols = cols
|
||||
lastSentRows = rows
|
||||
ws.send(CONTROL_PREFIX + JSON.stringify({ type: 'resize', cols, rows }))
|
||||
}
|
||||
|
||||
function scheduleResize(cols: number, rows: number) {
|
||||
clearTimeout(resizeDebounceTimer)
|
||||
resizeDebounceTimer = window.setTimeout(() => {
|
||||
sendResize(cols, rows)
|
||||
}, 80)
|
||||
}
|
||||
|
||||
async function handleWsMessage(data: unknown) {
|
||||
if (!term) return
|
||||
|
||||
if (typeof data === 'string') {
|
||||
term.write(data)
|
||||
return
|
||||
}
|
||||
if (data instanceof ArrayBuffer) {
|
||||
const text = new TextDecoder('utf-8').decode(new Uint8Array(data))
|
||||
term.write(text)
|
||||
return
|
||||
}
|
||||
if (data instanceof Blob) {
|
||||
const text = await data.text()
|
||||
term.write(text)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const authStore = useAuthStore()
|
||||
@@ -72,7 +124,7 @@ onMounted(() => {
|
||||
|
||||
if (!containerRef.value) return
|
||||
|
||||
term = new Terminal({
|
||||
term = new Terminal({
|
||||
cursorBlink: true,
|
||||
theme: {
|
||||
background: '#0f172a',
|
||||
@@ -85,10 +137,14 @@ onMounted(() => {
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
})
|
||||
|
||||
fitAddon = new FitAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
fitAddon = new FitAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
term.open(containerRef.value)
|
||||
fitTerminal()
|
||||
|
||||
onResizeDisposable = term.onResize(({ cols, rows }) => {
|
||||
scheduleResize(cols, rows)
|
||||
})
|
||||
|
||||
try {
|
||||
ws = new WebSocket(getWsUrl())
|
||||
@@ -98,11 +154,19 @@ onMounted(() => {
|
||||
return
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.onopen = () => {
|
||||
status.value = 'connected'
|
||||
const attachAddon = new AttachAddon(ws!)
|
||||
term!.loadAddon(attachAddon)
|
||||
refreshTerminalLayout()
|
||||
scheduleResize(term!.cols, term!.rows)
|
||||
}
|
||||
|
||||
onDataDisposable = term.onData((data) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(data)
|
||||
})
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
void handleWsMessage(ev.data)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
@@ -120,6 +184,9 @@ onMounted(() => {
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
fitTerminal()
|
||||
if (term) {
|
||||
scheduleResize(term.cols, term.rows)
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(containerRef.value)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user