fix: 终端 PTY 尺寸同步

前端在 xterm fit/resize 后通过 WebSocket 发送 resize 控制消息,后端收到后调用 ChannelShell.setPtySize 触发远端重绘,修复 less/vim/top 等全屏程序只显示部分区域的问题。

同时补齐控制消息解析与 PTY resize 的单测,并修正失配的 SftpControllerTest 断言。
This commit is contained in:
liumangmang
2026-03-24 12:03:03 +08:00
parent aced2871b2
commit acac45b692
8 changed files with 317 additions and 44 deletions

View File

@@ -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)