彻底解决 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 终端后执行 vimhtopnano 等全屏 TUI 程序
  • 程序底部出现空白区域,内容没有填满终端
  • 触发一次窗口 resize(最大化/还原/拖动边缘)后立即恢复正常

这个”resize 能修复”的特征是破案的关键线索。


技术背景

xterm.js 的 FitAddon 工作原理

xterm.js 通过 FitAddon.fit() 来自适应容器尺寸:

  1. 读取 DOM 容器的 clientWidth / clientHeight
  2. 除以单个字符的像素尺寸,得到 colsrows
  3. 调用 terminal.resize(cols, rows)
  4. 通过 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 程序(htoptmuxnano)或其他使用 xterm.js 的终端项目中,这个”延迟补发 resize”的技巧可以作为通用解法参考。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

 桂ICP备15001694号-3