彻底解决 xterm.js + Tauri 中 vim 底部空白问题
彻底解决 xterm.js + Tauri 中 vim 底部空白问题
发布日期:2026-06-16
标签:Tauri、xterm.js、Rust、SSH、vim、终端
在开发 CocoTerminal(一款基于 Tauri 2.x + xterm.js 的 SSH 终端工具)时,我们遇到了一个让人抓狂的 bug:
连接 SSH 后打开 vim,底部始终有一段空白,vim 状态栏没有贴在终端底部。但只要最大化窗口再还原,空白立刻消失。
这个问题折腾了很久,期间尝试了多种方案均无效,本文记录完整的排查和修复过程。
现象
- 进入 SSH 终端后执行
vim、htop、nano等全屏 TUI 程序 - 程序底部出现空白区域,内容没有填满终端
- 触发一次窗口 resize(最大化/还原/拖动边缘)后立即恢复正常
这个”resize 能修复”的特征是破案的关键线索。
技术背景
xterm.js 的 FitAddon 工作原理
xterm.js 通过 FitAddon.fit() 来自适应容器尺寸:
- 读取 DOM 容器的
clientWidth/clientHeight - 除以单个字符的像素尺寸,得到
cols和rows - 调用
terminal.resize(cols, rows) - 通过 SSH channel 的
window_change通知后端 pty
如果 fit() 执行时容器高度尚未确定(偏小),计算出的 rows 就会偏少,vim 就只绘制对应行数的内容,底部留白。
我们的布局是深层嵌套 Flex
html/body/root (height:100%)
└── .app-body (flex)
└── .app-main (flex-direction:column)
└── .app-terminal-area (flex:1)
└── <div style="display:block/none"> ← 面板切换
└── SplitTerminal
└── .terminal-pane-root
└── <div class="xterm">
整条链是 8 层嵌套 flex 布局,加上 display:none/block 面板切换,容器高度的确定存在异步延迟。
失败的尝试
方案一:固定延迟二次 fit
setTimeout(() => fit.fit(), 200);
200ms 是经验值,在不同硬件/负载下不可靠,仍有空白。
方案二:监听 visible prop 变化
useEffect(() => {
if (!visible) return;
setTimeout(() => fit.fit(), 50);
}, [visible]);
解决了从 SFTP 面板切回终端的问题,但不能解决初次打开 vim 就有空白。
方案三:渐进式 fitUntilStable(rAF 循环)
function fitUntilStable(fit, term, sessionId, onResizeFn) {
let prevCols = -1, prevRows = -1, stableCount = 0, round = 0;
function doFit() {
if (round++ >= 15) return;
void rootContainer.offsetHeight; // 强制 reflow
fit.fit();
const { cols, rows } = term;
if (cols !== prevCols || rows !== prevRows) {
prevCols = cols; prevRows = rows; stableCount = 0;
onResizeFn(sessionId, cols, rows);
} else if (++stableCount >= 3) return;
requestAnimationFrame(doFit);
}
doFit();
}
结合强制 reflow 和清除 xterm-viewport 固定高度,大幅改善——但用户反馈仍然偶发空白。
真正的根本原因
在前三个方案失败后,我重新审视了整个调用链:
fitUntilStable 实际上是在正确工作的。
它最终确实计算出了正确的 cols/rows,也通过 window_change 把正确尺寸发给了后端 pty。问题出在更后面:
前端 fit() → 正确的 rows=35 → SSH window_change → 后端 pty → SIGWINCH → vim
vim 在收到这个 SIGWINCH 时,正处于启动初始化阶段(约 100–300ms),还没有进入稳定的事件等待循环。 此时 SIGWINCH 被操作系统传递给了 vim 进程,vim 的信号处理函数也执行了,但由于 vim 内部状态尚未完成初始化,重绘被跳过或推迟。
这完美解释了所有现象:
| 现象 | 原因 |
|---|---|
| 初次打开 vim 有空白 | vim 启动期间忽略了首次 SIGWINCH |
| 最大化/还原后恢复 | 触发第二次 SIGWINCH,此时 vim 已就绪 |
| 按任意键后恢复 | 用户输入触发 vim 重绘(检测到尺寸变化) |
| resize 窗口后恢复 | 同上,第二次 SIGWINCH |
最终修复
思路很简单:在 rAF 循环稳定后,延迟 500ms 补发一次 resize。
此时 vim 已完成启动,处于正常的事件等待状态,第二次 SIGWINCH 会被立即处理并触发完整重绘。
function fitUntilStable(fit, term, sessionId, onResizeFn) {
const MAX_ROUNDS = 15;
let round = 0, prevCols = -1, prevRows = -1, stableCount = 0;
function doFit() {
if (round >= MAX_ROUNDS) { scheduleDelayedResize(); return; }
// 强制 reflow,清除 viewport 固定高度
void rootContainer?.offsetHeight;
const viewport = termElement?.querySelector('.xterm-viewport');
if (viewport?.style.height) viewport.style.height = '';
try { fit.fit(); } catch { return; }
const { cols, rows } = term;
round++;
if (cols !== prevCols || rows !== prevRows) {
prevCols = cols; prevRows = rows; stableCount = 0;
onResizeFn?.(sessionId, cols, rows)?.catch(() => {});
} else if (++stableCount >= 3) {
scheduleDelayedResize(); // ← 关键:稳定后延迟补发
return;
}
requestAnimationFrame(doFit);
}
// vim 启动完成前可能忽略首次 SIGWINCH,500ms 后补发一次确保重绘
function scheduleDelayedResize() {
if (!sessionId || !onResizeFn) return;
setTimeout(() => {
try { fit.fit(); } catch { return; }
const { cols, rows } = term;
if (cols > 0 && rows > 0) onResizeFn(sessionId, cols, rows)?.catch(() => {});
}, 500);
}
doFit();
}
500ms 的选取依据:
– 需要 > vim 启动时间(本地快速机器 ~50ms,通过 SSH 连接的远程机器可能 ~200–400ms)
– 需要 < 用户察觉到”闪烁重绘”的阈值(~800ms 以上才明显)
– 500ms 是一个保守且合理的中间值
总结
这个 bug 的排查过程有一个重要教训:
不要只盯着”信号有没有发出”,也要考虑”接收方是否已经准备好接收”。
前端把正确的尺寸发出去了,后端也正确转发了 SIGWINCH,但 vim 的接收时机不对。修复只需要在信号接收方就绪后补发一次——4 行代码,解决了一个反复折腾的问题。
类似的问题也可能出现在其他 TUI 程序(htop、tmux、nano)或其他使用 xterm.js 的终端项目中,这个”延迟补发 resize”的技巧可以作为通用解法参考。