fix(frontend): restore terminal tab scroll position
preserve xterm viewport intent when switching terminal tabs so bottom-pinned sessions stay pinned and manually scrolled sessions keep their history position unify viewport restoration across activation, fit, and resize paths to avoid losing scroll state after terminal reflow
This commit is contained in:
@@ -10,3 +10,5 @@
|
||||
### 修复
|
||||
- **[frontend]**: 统一 `QuickCommandsView.vue` 的按钮主题适配,移除残留硬编码 hover 色值并切回主题变量体系 — by yinjianm
|
||||
- 方案: [202603250532_quickcommands-theme-alignment](archive/2026-03/202603250532_quickcommands-theme-alignment/)
|
||||
- **[frontend]**: 修复终端标签切换后的视口恢复逻辑,贴底终端重新激活后自动贴底,上翻终端保留历史位置 — by yinjianm
|
||||
- 方案: [202603250547_terminal-tab-scroll-restore](archive/2026-03/202603250547_terminal-tab-scroll-restore/)
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
```yaml
|
||||
kb_version: 2.3.7
|
||||
最后更新: 2026-03-25 05:39
|
||||
最后更新: 2026-03-25 05:52
|
||||
模块数量: 4
|
||||
待执行方案: 0
|
||||
```
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"status":"completed","completed":5,"failed":0,"pending":0,"total":5,"done":5,"percent":100,"current":"已完成 Terminal.vue 视口恢复修复与验证,等待归档","updated_at":"2026-03-25 05:52:11"}
|
||||
@@ -0,0 +1,65 @@
|
||||
# 变更提案: terminal-tab-scroll-restore
|
||||
|
||||
## 元信息
|
||||
```yaml
|
||||
类型: 缺陷修复
|
||||
方案类型: implementation
|
||||
优先级: P1
|
||||
状态: 已完成
|
||||
状态说明: 已完成 Terminal.vue 视口恢复修复与构建验证,待归档
|
||||
创建: 2026-03-25
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求
|
||||
|
||||
### 背景
|
||||
在 `/workspace` 切换不同终端标签时,xterm 视口偶发没有停留在底部,导致最新输出不在可见区域底部。用户期望保留“看历史”和“追最新输出”两种不同滚动意图。
|
||||
|
||||
### 目标
|
||||
- 修复终端标签切换后的视口恢复行为。
|
||||
- 如果终端切换前原本就在底部附近,则重新激活后自动贴底。
|
||||
- 如果用户切换前主动上翻查看历史,则重新激活后保留原滚动位置。
|
||||
|
||||
### 约束条件
|
||||
```yaml
|
||||
范围约束: 优先限制在前端终端组件与现有会话终端实例管理链路
|
||||
实现约束: 不改动 SSH 数据流、标签切换语义和后端协议
|
||||
体验约束: 不得把所有终端切换都强制滚到底部
|
||||
兼容约束: 兼容现有 keep-alive + v-show 的终端挂载方式
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
- [ ] 切换终端标签时,原本贴底的终端重新显示后仍贴底
|
||||
- [ ] 切换终端标签时,原本已上翻查看历史的终端重新显示后保留原滚动位置
|
||||
- [ ] 终端 `fit`、重激活与滚动恢复逻辑不引入明显回归
|
||||
- [ ] 前端构建通过
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 技术方案
|
||||
在 `Terminal.vue` 内为每个终端实例记录当前视口行号和“是否应贴底”的本地状态。使用 xterm 的 `buffer.active.ydisp` / `baseY` 判断当前距离底部的行数,并定义一个“小于等于阈值即视为贴底”的规则。终端重新激活或执行 `fit` 时,不再只做尺寸重算,而是按切换前快照恢复:贴底终端调用 `scrollToBottom()`,上翻终端调用 `scrollToLine(savedYdisp)` 恢复到原位置。
|
||||
|
||||
### 影响范围
|
||||
```yaml
|
||||
涉及模块:
|
||||
- frontend: Terminal.vue 终端视口恢复逻辑
|
||||
预计变更文件: 1
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
| 风险 | 等级 | 应对 |
|
||||
|------|------|------|
|
||||
| `fit()` 后再次滚动导致闪动 | 低 | 将滚动恢复整合到同一恢复函数中,减少多次跳转 |
|
||||
| 输出写入与隐藏标签状态交织时覆盖用户历史位置 | 低 | 切换前冻结快照,重新激活后按快照恢复 |
|
||||
| 阈值过大导致“已上翻少量行”也被当成贴底 | 低 | 使用保守的小阈值,仅认定底部附近少量行差 |
|
||||
|
||||
### 实施结果
|
||||
- `Terminal.vue` 新增终端视口快照逻辑,使用 `buffer.active.viewportY` 与 `baseY` 判断当前是否处于底部附近。
|
||||
- 终端在失活时会冻结“视口行号 + 是否贴底”状态,重新激活时按该状态恢复,而不是无条件停在任意位置。
|
||||
- `fit()` 和 `ResizeObserver` 路径都纳入了同一套恢复逻辑,避免尺寸重算后把视口意图覆盖掉。
|
||||
- `npm run build --workspace @nexus-terminal/frontend` 通过。
|
||||
- 受本地登录态与后端 SSH 会话现场限制,本轮未在浏览器里真实完成“多终端切换 + 输出滚动”交互验收。
|
||||
@@ -0,0 +1,51 @@
|
||||
# 任务清单: terminal-tab-scroll-restore
|
||||
|
||||
```yaml
|
||||
@feature: terminal-tab-scroll-restore
|
||||
@created: 2026-03-25
|
||||
@status: completed
|
||||
@mode: R2
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 5 | 0 | 0 | 5 |
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
### 1. 方案与范围确认
|
||||
|
||||
- [√] 1.1 创建终端切换滚动恢复方案包并锁定前端终端组件范围 | depends_on: []
|
||||
|
||||
### 2. 终端滚动恢复修复
|
||||
|
||||
- [√] 2.1 盘点终端切换、fit 与 xterm 视口状态的现有实现 | depends_on: [1.1]
|
||||
- [√] 2.2 在 `Terminal.vue` 中实现“贴底优先、上翻保留”的视口恢复逻辑 | depends_on: [2.1]
|
||||
|
||||
### 3. 验证与同步
|
||||
|
||||
- [√] 3.1 运行前端最小验证并记录结果 | depends_on: [2.2]
|
||||
- [√] 3.2 更新 `.helloagents` 文档与变更记录 | depends_on: [3.1]
|
||||
|
||||
---
|
||||
|
||||
## 执行日志
|
||||
|
||||
| 时间 | 任务 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 2026-03-25 05:47 | 1.1 | 完成 | 创建 implementation 方案包,范围锁定为终端标签切换后的 xterm 视口恢复 |
|
||||
| 2026-03-25 05:49 | 2.1 / 2.2 | 完成 | 在 Terminal.vue 中加入视口快照、贴底判断与重激活恢复逻辑,并将 fit/ResizeObserver 路径统一纳入恢复流程 |
|
||||
| 2026-03-25 05:51 | 3.1 | 完成 | `npm run build --workspace @nexus-terminal/frontend` 通过 |
|
||||
| 2026-03-25 05:52 | 3.2 | 完成 | 更新 frontend 模块文档并准备归档 |
|
||||
|
||||
---
|
||||
|
||||
## 执行备注
|
||||
|
||||
- 本次修复目标是恢复滚动意图,不是简单强制滚到底部。
|
||||
- 当前最可能的落点是 `Terminal.vue` 的激活与 `fit()` 逻辑,而非 `TerminalTabBar.vue` 本身。
|
||||
- 运行态真实验收仍依赖本地可用的多 SSH 会话现场;本轮以构建验证和代码路径审查为主。
|
||||
@@ -9,6 +9,7 @@
|
||||
|--------|------|------|---------|------|------|
|
||||
| 202603250317 | ghcr-docker-publish | implementation | workspace-root | ghcr-docker-publish#D001 | ✅完成 |
|
||||
| 202603250532 | quickcommands-theme-alignment | implementation | frontend | - | ✅完成 |
|
||||
| 202603250547 | terminal-tab-scroll-restore | implementation | frontend | - | ✅完成 |
|
||||
| 202603251200 | workspace-workbench-monitor | implementation | frontend, backend | workspace-workbench-monitor#D001 | ✅完成 |
|
||||
|
||||
## 按月归档
|
||||
@@ -16,6 +17,7 @@
|
||||
### 2026-03
|
||||
- [202603250317_ghcr-docker-publish](./2026-03/202603250317_ghcr-docker-publish/) - 新增 GHCR 镜像发布 workflow 并切换 compose 镜像来源
|
||||
- [202603250532_quickcommands-theme-alignment](./2026-03/202603250532_quickcommands-theme-alignment/) - 统一快捷指令视图按钮主题适配,移除残留硬编码 hover 色值
|
||||
- [202603250547_terminal-tab-scroll-restore](./2026-03/202603250547_terminal-tab-scroll-restore/) - 修复终端标签切换后的贴底/历史滚动恢复逻辑
|
||||
- [202603251200_workspace-workbench-monitor](./2026-03/202603251200_workspace-workbench-monitor/) - `/workspace` 改为三栏 Workbench 布局,并新增开机累计流量监控
|
||||
|
||||
## 结果状态说明
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
### 工作区交互
|
||||
**条件**: 用户进入 `/workspace` 或相关管理页面。
|
||||
**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 以 tab 容器整合快捷指令、命令历史、文件管理和编辑器,默认激活快捷指令。`QuickCommandsView.vue` 内的新增按钮、空状态按钮和列表操作按钮统一复用 `bg-button`、`text-button-text`、`hover:bg-button-hover`、`hover:bg-border` 等主题变量类,避免写死黑白 hover 色值。
|
||||
**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 以 tab 容器整合快捷指令、命令历史、文件管理和编辑器,默认激活快捷指令。`QuickCommandsView.vue` 内的新增按钮、空状态按钮和列表操作按钮统一复用 `bg-button`、`text-button-text`、`hover:bg-button-hover`、`hover:bg-border` 等主题变量类,避免写死黑白 hover 色值;`Terminal.vue` 会跟踪 xterm 的视口行号与贴底状态,在终端标签切换、重新激活和 `fit()` 后按原滚动意图恢复。
|
||||
**结果**: 页面逻辑分散在 `views/`、`components/`、`stores/` 与 `composables/`,其中布局与交互微调优先落在 `layout.store.ts`、`LayoutRenderer.vue`、`WorkspaceWorkbench.vue`、`QuickCommandsView.vue` 和 `Terminal.vue`。
|
||||
|
||||
## 依赖关系
|
||||
|
||||
@@ -36,9 +36,13 @@ let resizeObserver: ResizeObserver | null = null;
|
||||
let observedElement: HTMLElement | null = null; // +++ Store the observed element +++
|
||||
let debounceTimer: number | null = null; // 用于防抖的计时器 ID
|
||||
let selectionListenerDisposable: IDisposable | null = null; // +++ 提升声明并添加类型 +++
|
||||
let scrollListenerDisposable: IDisposable | null = null;
|
||||
let lastResizeObserverWidth = 0;
|
||||
let lastResizeObserverHeight = 0;
|
||||
const RESIZE_THRESHOLD = 0.5; // px
|
||||
const BOTTOM_STICK_THRESHOLD = 2;
|
||||
let lastKnownViewportLine = 0;
|
||||
let lastKnownShouldStickToBottom = true;
|
||||
|
||||
|
||||
const { isMobile } = useDeviceDetection(); // 设备检测
|
||||
@@ -89,6 +93,45 @@ const debounce = (func: Function, delay: number) => {
|
||||
};
|
||||
};
|
||||
|
||||
type TerminalViewportSnapshot = {
|
||||
viewportLine: number;
|
||||
shouldStickToBottom: boolean;
|
||||
};
|
||||
|
||||
const getViewportSnapshot = (term: Terminal): TerminalViewportSnapshot => {
|
||||
const buffer = term.buffer.active;
|
||||
const maxScrollLine = Math.max(0, buffer.baseY);
|
||||
const viewportLine = Math.max(0, Math.min(buffer.viewportY, maxScrollLine));
|
||||
|
||||
return {
|
||||
viewportLine,
|
||||
shouldStickToBottom: maxScrollLine - viewportLine <= BOTTOM_STICK_THRESHOLD,
|
||||
};
|
||||
};
|
||||
|
||||
const syncViewportTracking = (term: Terminal): TerminalViewportSnapshot => {
|
||||
const snapshot = getViewportSnapshot(term);
|
||||
lastKnownViewportLine = snapshot.viewportLine;
|
||||
lastKnownShouldStickToBottom = snapshot.shouldStickToBottom;
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
const restoreViewportSnapshot = (term: Terminal, snapshot?: TerminalViewportSnapshot) => {
|
||||
const effectiveSnapshot = snapshot ?? {
|
||||
viewportLine: lastKnownViewportLine,
|
||||
shouldStickToBottom: lastKnownShouldStickToBottom,
|
||||
};
|
||||
|
||||
if (effectiveSnapshot.shouldStickToBottom) {
|
||||
term.scrollToBottom();
|
||||
} else {
|
||||
const targetLine = Math.min(effectiveSnapshot.viewportLine, Math.max(0, term.buffer.active.baseY));
|
||||
term.scrollToLine(targetLine);
|
||||
}
|
||||
|
||||
syncViewportTracking(term);
|
||||
};
|
||||
|
||||
// 防抖处理由 ResizeObserver 触发的 resize 事件
|
||||
const debouncedEmitResize = debounce((term: Terminal) => {
|
||||
if (term && props.isActive) { // 仅当标签仍处于活动状态时才发送防抖后的 resize
|
||||
@@ -105,13 +148,15 @@ const debouncedEmitResize = debounce((term: Terminal) => {
|
||||
}, 150); // 150ms 防抖延迟
|
||||
|
||||
// 立即执行 Fit 并发送 Resize 的函数
|
||||
const fitAndEmitResizeNow = (term: Terminal) => {
|
||||
const fitAndEmitResizeNow = (term: Terminal, snapshotOverride?: TerminalViewportSnapshot) => {
|
||||
// terminalRef 现在指向内部容器,检查它即可
|
||||
if (!term || !terminalRef.value) return;
|
||||
try {
|
||||
// 确保容器可见且有尺寸
|
||||
if (terminalRef.value.offsetHeight > 0 && terminalRef.value.offsetWidth > 0) {
|
||||
const viewportSnapshot = snapshotOverride ?? syncViewportTracking(term);
|
||||
fitAddon?.fit();
|
||||
restoreViewportSnapshot(term, viewportSnapshot);
|
||||
const dimensions = { cols: term.cols, rows: term.rows };
|
||||
emitWorkspaceEvent('terminal:resize', { sessionId: props.sessionId, dims: dimensions });
|
||||
// 发出稳定尺寸事件
|
||||
@@ -268,14 +313,19 @@ onMounted(() => {
|
||||
console.log(`[Terminal ${props.sessionId}] Xterm open() called, considering DOM ready for initial style checks.`);
|
||||
|
||||
// 适应容器大小
|
||||
fitAddon.fit();
|
||||
emitWorkspaceEvent('terminal:resize', { sessionId: props.sessionId, dims: { cols: terminal.cols, rows: terminal.rows } }); // 触发初始 resize 事件
|
||||
fitAndEmitResizeNow(terminal);
|
||||
|
||||
// 监听用户输入
|
||||
terminal.onData((data) => {
|
||||
emitWorkspaceEvent('terminal:input', { sessionId: props.sessionId, data });
|
||||
});
|
||||
|
||||
scrollListenerDisposable = terminal.onScroll(() => {
|
||||
if (terminal && props.isActive) {
|
||||
syncViewportTracking(terminal);
|
||||
}
|
||||
});
|
||||
|
||||
// 监听终端大小变化 (通过 ResizeObserver) - 主要处理浏览器窗口大小变化等
|
||||
// ResizeObserver 观察内部容器 terminalRef
|
||||
if (terminalRef.value) {
|
||||
@@ -317,7 +367,9 @@ onMounted(() => {
|
||||
|
||||
if (rectHeight > 0 && rectWidth > 0) {
|
||||
try {
|
||||
const viewportSnapshot = syncViewportTracking(terminal);
|
||||
fitAddon?.fit();
|
||||
restoreViewportSnapshot(terminal, viewportSnapshot);
|
||||
debouncedEmitResize(terminal); // This will log the cols/rows after debouncing
|
||||
emitWorkspaceEvent('terminal:stabilizedResize', { sessionId: props.sessionId, width: roundedWidth, height: roundedHeight });
|
||||
} catch (e) {
|
||||
@@ -340,6 +392,10 @@ onMounted(() => {
|
||||
if (newValue) {
|
||||
// --- Become Active ---
|
||||
console.log(`[Terminal ${props.sessionId}] Becoming active. Observing element and fitting.`);
|
||||
const activationViewportSnapshot = {
|
||||
viewportLine: lastKnownViewportLine,
|
||||
shouldStickToBottom: lastKnownShouldStickToBottom,
|
||||
};
|
||||
// Start observing
|
||||
try {
|
||||
resizeObserver.observe(observedElement);
|
||||
@@ -351,7 +407,7 @@ onMounted(() => {
|
||||
setTimeout(() => {
|
||||
// 检查内部容器 terminalRef
|
||||
if (props.isActive && terminal && terminalRef.value && terminalRef.value.offsetHeight > 0) {
|
||||
fitAndEmitResizeNow(terminal);
|
||||
fitAndEmitResizeNow(terminal, activationViewportSnapshot);
|
||||
// Also ensure focus when becoming active
|
||||
terminal.focus();
|
||||
} else {
|
||||
@@ -362,6 +418,9 @@ onMounted(() => {
|
||||
} else {
|
||||
// --- Become Inactive ---
|
||||
console.log(`[Terminal ${props.sessionId}] Becoming inactive. Unobserving element.`);
|
||||
if (terminal) {
|
||||
syncViewportTracking(terminal);
|
||||
}
|
||||
// Stop observing
|
||||
try {
|
||||
resizeObserver.unobserve(observedElement);
|
||||
@@ -602,6 +661,10 @@ onBeforeUnmount(() => {
|
||||
selectionListenerDisposable.dispose();
|
||||
}
|
||||
|
||||
if (scrollListenerDisposable) {
|
||||
scrollListenerDisposable.dispose();
|
||||
}
|
||||
|
||||
|
||||
// 确保在卸载时移除右键监听器
|
||||
removeContextMenuListener();
|
||||
|
||||
Reference in New Issue
Block a user