外观
标签栏右键菜单开发计划
description: 标签栏右键菜单设计方案的分阶段实现计划,覆盖 store 基建、批量关闭、复制会话、按键拦截、菜单与 inline 重命名
created: 2026-10-07 14:23:03
updated: 2026-10-07 14:23:03
author: sjzhao
1. 概述
本计划是 标签栏右键菜单设计方案 的实现分解,按依赖关系拆分为 8 个阶段,每阶段可独立提交。阶段 1 是后续所有阶段的前置依赖(数据格式变更),其余阶段尽量并行。
关联代码:
- 前端:
frontend/src/stores/session.ts、frontend/src/components/wb/WbTabs.vue、frontend/src/components/wb/ContextMenu.vue、frontend/src/style.css - 后端:
src-tauri/src/applog/persisted.rs(UiStateSection)、src-tauri/src/config_io.rs(remap_sessions) - 文档:
docs/.vitepress/config.mts(登记本计划)
前置约束:
- 所有批量关闭必须复用
closeSession,禁止另写清理路径 openSession通用打开逻辑不得修改(同主机复用),复制会话走独立路径- 会话/标签不进入配置迁移与导入导出
2. 阶段总览与依赖
text
阶段 1 数据层(Session.alias + 持久化格式 + 后端类型)
│
├─→ 阶段 2 批量关闭函数(依赖 1)
├─→ 阶段 3 复制会话(依赖 1)
├─→ 阶段 4 ContextMenu 按键拦截(无依赖,可并行)
└─→ 阶段 5 WbTabs 右键菜单 + 中键关闭(依赖 2、3、4)
│
├─→ 阶段 6 inline 重命名编辑(依赖 5)
├─→ 阶段 7 会话属性弹窗 + SFTP(依赖 5)
└─→ 阶段 8 联调与验证(依赖全部)3. 阶段 1:数据层改造(Session.alias + 持久化格式)
目标:Session 增加 alias 字段;ui_state.sessions 由 string[] 改为 SessionEntry[];后端类型同步;不进入迁移/导入导出。
3.1 前端 Session 接口
文件:frontend/src/stores/session.ts
在
Session接口(第 13-28 行)增加字段:ts/** 标签自定义别名;空字符串表示无别名,UI 回落展示 host.id */ alias: string所有创建
Session的地方补alias: '':restoreSessions()(第 890-898 行):从持久化SessionEntry.alias读取openSession()(第 917-920 行):alias: ''
3.2 persistSessions 格式变更
文件:frontend/src/stores/session.ts 第 874-877 行
ts
function persistSessions() {
if (!isTauri) return
patchUiState({
sessions: sessions.value.map(s => ({
sessionId: s.id,
hostId: s.host.id,
alias: s.alias,
})),
})
}3.3 restoreSessions 格式变更
文件:frontend/src/stores/session.ts 第 880-903 行
ts
export function restoreSessions() {
const raw = getSnapshot()?.uiState.sessions
// 新格式:SessionEntry[] = [{ sessionId, hostId, alias }]
// 旧格式 string[] 读取失败直接清空(不迁移)
const entries: Array<{ sessionId: string; hostId: string; alias: string }> = []
if (Array.isArray(raw)) {
for (const x of raw) {
if (x && typeof x === 'object' && 'sessionId' in x && 'hostId' in x) {
entries.push({
sessionId: String((x as any).sessionId),
hostId: String((x as any).hostId),
alias: typeof (x as any).alias === 'string' ? (x as any).alias : '',
})
}
}
}
// 不再按 hostId 去重(移除 [...new Set(hostIds)]),同一主机多会话逐条恢复
const valid = entries.filter(e => hosts.value.some(h => h.id === e.hostId))
if (!valid.length) return
const restored: Session[] = valid.map((e, i) => ({
id: e.sessionId, // 用持久化的 sessionId
host: hosts.value.find(h => h.id === e.hostId)!,
state: (i === valid.length - 1 ? 'connecting' : 'idle') as SessionState,
startedAt: Date.now(),
disconnectReason: '',
retryAt: null,
resetSeq: 0,
alias: e.alias,
}))
// ... 其余不变
}3.4 后端 UiStateSection 类型
文件:src-tauri/src/applog/persisted.rs 第 339-342 行
rust
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct SessionEntry {
pub session_id: String,
pub host_id: String,
#[serde(default)]
pub alias: String,
}
pub(crate) struct UiStateSection {
// ... 其余字段不变
#[serde(default)]
sessions: Vec<SessionEntry>,
}3.5 后端 config_io.rs 移除 remap_sessions
文件:src-tauri/src/config_io.rs
- 删除
remap_sessions闭包(第 466-481 行) - 删除导入流程中对
sessions字段的重映射调用(第 504-510 行附近) - 导出流程不包含
sessions(导出的ui_state已剔除 sessions,确认现有逻辑) - 更新相关测试(第 936、950、1091 行附近的 sessions 断言改为新格式或直接忽略)
3.6 验收
- [ ]
Session对象均有alias字段,类型为string - [ ] 重启应用后别名正确恢复
- [ ] 旧
string[]格式数据升级后 sessions 为空(不崩溃) - [ ] 配置导出文件不含
sessions字段 - [ ] 配置导入时文件中的
sessions被忽略 - [ ] 后端编译通过
4. 阶段 2:store 批量关闭函数
目标:新增 5 个批量关闭函数 + setSessionAlias,全部复用 closeSession。
文件:frontend/src/stores/session.ts
4.1 新增函数(紧接 closeSession 之后,约第 963 行)
ts
/** 关闭除 id 外的全部会话;完成后激活 id */
export function closeOtherSessions(id: string) {
const targets = sessions.value.filter(s => s.id !== id).map(s => s.id)
targets.forEach(closeSession)
activeSessionId.value = id
}
/** 关闭 id 左侧(按当前标签顺序)全部会话 */
export function closeSessionsToLeft(id: string) {
const idx = sessions.value.findIndex(s => s.id === id)
if (idx <= 0) return
sessions.value.slice(0, idx).map(s => s.id).forEach(closeSession)
}
/** 关闭 id 右侧全部会话 */
export function closeSessionsToRight(id: string) {
const idx = sessions.value.findIndex(s => s.id === id)
if (idx < 0) return
sessions.value.slice(idx + 1).map(s => s.id).forEach(closeSession)
}
/** 关闭全部 offline 标签;返回实际关闭数量(toast 用) */
export function closeDisconnectedSessions(): number {
const targets = sessions.value.filter(s => s.state === 'offline').map(s => s.id)
targets.forEach(closeSession)
return targets.length
}
/** 关闭全部标签,进入工作台空态 */
export function closeAllSessions() {
sessions.value.map(s => s.id).forEach(closeSession)
}
/** 设置标签别名;空字符串 "" 表示清除别名,UI 回落展示 host.id。 */
export function setSessionAlias(sessionId: string, alias: string) {
const item = sessions.value.find(s => s.id === sessionId)
if (!item) return
item.alias = alias
persistSessions()
}4.2 验收
- [ ]
closeSessionsToLeft(id)关闭 id 左侧所有标签,最左侧标签调用无效果 - [ ]
closeOtherSessions(id)关闭除 id 外所有标签,活动标签为 id - [ ]
closeDisconnectedSessions()只关闭offline态,不关闭idle,返回关闭数量 - [ ]
closeAllSessions()关闭全部,activeSessionId为null - [ ]
setSessionAlias(id, 'xxx')修改别名并持久化;setSessionAlias(id, '')清除别名 - [ ] 批量关闭 20 个标签 UI 无卡死
5. 阶段 3:复制会话
目标:duplicateSession 创建独立会话,connecting 态加入,8s 超时兜底。
文件:frontend/src/stores/session.ts
5.1 新增 duplicateSession
ts
const duplicating = new Set<string>()
const DUPLICATE_TIMEOUT_MS = 8000
/** 复制会话:基于源会话 id,创建全新独立会话,返回新 sessionId。
* 新会话立即以 connecting 态加入 sessions;连接成功转 online,失败转 offline + toast。
* 并发保护:duplicating 集合 + 8s setTimeout 兜底。 */
export async function duplicateSession(sourceSessionId: string): Promise<string | null> {
if (duplicating.has(sourceSessionId)) return null
duplicating.add(sourceSessionId)
const timer = setTimeout(() => duplicating.delete(sourceSessionId), DUPLICATE_TIMEOUT_MS)
try {
const source = sessions.value.find(s => s.id === sourceSessionId)
if (!source) return null
// 基于源主机配置创建全新独立会话(复用 openSession 的连接逻辑但不复用会话)
const newId = crypto.randomUUID()
sessions.value.push({
id: newId,
host: source.host, // 复用源主机配置
state: 'connecting', // 立即进入 connecting 态
startedAt: Date.now(),
disconnectReason: '',
retryAt: null,
resetSeq: 0,
alias: '', // 新会话无别名
})
activeSessionId.value = newId
persistSessions()
// 发起连接(复用既有连接通道);成功 → online,失败 → offline + toast
// 连接实现参考 openSession 内的 connect_ssh 调用路径
// ...
return newId
} finally {
clearTimeout(timer)
duplicating.delete(sourceSessionId)
}
}实现注意:复制会话的连接发起逻辑需复用
openSession中connect_ssh的调用路径,但不复用openSession的"同主机复用"判断。可将连接逻辑抽取为内部函数connectSession(id)供openSession和duplicateSession共同调用。
5.2 验收
- [ ]
duplicateSession后新标签立即出现,状态为connecting - [ ] 源会话状态不变
- [ ] 同一源会话快速连点只创建一个(
duplicating互斥生效,菜单项 disabled) - [ ] 连接失败后新会话转
offline,toast 提示 - [ ] 模拟 IPC 异常(如 8s 无响应)后
duplicating集合自动清理,菜单项恢复可用
6. 阶段 4:ContextMenu 按键拦截增强
目标:菜单可见期间区分三类按键,防止普通字符穿透到终端,同时放行系统快捷键。
文件:frontend/src/components/wb/ContextMenu.vue 第 67-83 行 onKeydown
6.1 修改 onKeydown
在现有导航键处理分支末尾追加 e.stopPropagation(),并在函数末尾追加非导航/非修饰键的 stopPropagation:
ts
function onKeydown(e: KeyboardEvent) {
if (!visible.value) return
const isNav = ['Escape', 'ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)
const isModifierCombo = e.ctrlKey || e.metaKey || e.altKey
if (e.key === 'Escape') {
closeMenu()
e.stopPropagation() // 新增:阻止 Escape 穿透到终端
return
}
if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); e.stopPropagation(); return }
if (e.key === 'ArrowDown') { e.preventDefault(); move(1); e.stopPropagation(); return }
if (e.key === 'Enter') { pick(activeIdx.value); e.stopPropagation(); return }
// 非导航键:修饰键组合放行(复制粘贴等);普通单字符阻止穿透
if (!isModifierCombo) {
e.stopPropagation()
}
}6.2 验收
- [ ] 菜单打开时按
a/b/空格等字符,终端不收到输入 - [ ] 菜单打开时按
Ctrl+C/Cmd+V,终端复制粘贴正常 - [ ] 菜单打开时按
ArrowUp/ArrowDown,菜单项导航,终端光标不移动 - [ ] 菜单打开时按
Enter,选中菜单项,终端不收到回车 - [ ] 菜单打开时按
Escape,菜单关闭,终端不受影响 - [ ] 菜单关闭后键盘事件恢复正常(监听已移除)
7. 阶段 5:WbTabs 右键菜单 + 中键关闭
目标:标签右键菜单、空白区菜单、中键关闭,复用 ContextMenu。
文件:frontend/src/components/wb/WbTabs.vue、frontend/src/style.css
7.1 引入依赖与状态
ts
import { ref, computed } from 'vue'
import ContextMenu from './ContextMenu.vue'
import {
sessions, activeSessionId, closeSession, openSession,
closeOtherSessions, closeSessionsToLeft, closeSessionsToRight,
closeDisconnectedSessions, closeAllSessions,
reconnectBackend, disconnectSession, setSessionAlias, duplicateSession,
} from '../../stores/session'
import { showNewConn } from '../../stores/ui' // 按实际 store 名调整
import { openDock } from '../../stores/dock' // 按实际 store 名调整
import { toast } from '../../composables/useToast' // 按实际工具调整
interface MenuState { x: number; y: number; targetId: string | null }
const menu = ref<MenuState | null>(null)
const editingId = ref<string | null>(null) // inline 重命名编辑中的 session id
const skipSave = ref(false)7.2 标签右键菜单构建(computed)
ts
const tabMenuItems = computed(() => {
const s = sessions.value.find(x => x.id === menu.value?.targetId)
if (!s) return []
const isConnecting = s.state === 'connecting' || s.state === 'reconnecting'
const hasOther = sessions.value.length > 1
const idx = sessions.value.findIndex(x => x.id === s.id)
const hasLeft = idx > 0
const hasRight = idx < sessions.value.length - 1
const hasOffline = sessions.value.some(x => x.state === 'offline')
const isDuplicating = duplicating.has(s.id) // 从 session store 导出 duplicating 或提供 getter
return [
{ label: '重命名标签', action: () => startRename(s.id) },
{ label: '复制会话(新建独立连接)', disabled: isConnecting || isDuplicating,
action: () => { void duplicateSession(s.id) } },
{ label: '重新连接', disabled: !['idle','offline','reconnecting'].includes(s.state),
action: () => { activeSessionId.value = s.id; void reconnectBackend(s.id) } },
{ label: '断开连接(不关闭标签)', disabled: !['online','connecting','reconnecting'].includes(s.state),
action: () => { void disconnectSession(s.id) } },
{ type: 'separator' },
{ label: '关闭标签', action: () => closeSession(s.id) },
{ label: '关闭其他标签页', disabled: !hasOther, action: () => closeOtherSessions(s.id) },
{ label: '关闭左侧标签页', disabled: !hasLeft, action: () => closeSessionsToLeft(s.id) },
{ label: '关闭右侧标签页', disabled: !hasRight, action: () => closeSessionsToRight(s.id) },
{ label: '关闭已断开的标签', disabled: !hasOffline,
action: () => { const n = closeDisconnectedSessions(); if (n > 0) toast(`已关闭 ${n} 个标签`) } },
{ label: '关闭所有标签页', action: () => closeAllSessions() },
{ type: 'separator' },
{ label: '打开 SFTP 面板', disabled: s.state !== 'online',
action: () => { activeSessionId.value = s.id; openDock('sftp') } },
{ label: '会话属性', action: () => openSessionProps(s.id) },
]
})7.3 空白区菜单
ts
const barMenuItems = computed(() => [
{ label: '新建连接…', action: () => { showNewConn.value = true } },
{ type: 'separator' },
{ label: '关闭已断开的标签',
disabled: !sessions.value.some(s => s.state === 'offline'),
action: () => { const n = closeDisconnectedSessions(); if (n > 0) toast(`已关闭 ${n} 个标签`) } },
{ label: '关闭全部标签',
disabled: sessions.value.length === 0,
action: () => closeAllSessions() },
])7.4 事件绑定
模板中 .tab 增加:
html
<div
v-for="s in sessions"
:key="s.id"
class="tab"
:class="{ active: activeSessionId === s.id, editing: editingId === s.id }"
@click="activeSessionId = s.id"
@contextmenu.prevent="onTabContext($event, s)"
@mousedown="onTabMouseDown($event, s)"
>ts
function onTabContext(e: MouseEvent, s: Session) {
// 右键不切换活动标签
menu.value = { x: e.clientX, y: e.clientY, targetId: s.id }
}
function onTabMouseDown(e: MouseEvent, s: Session) {
if (e.button === 1) {
e.preventDefault() // 抑制中键自动滚动
closeSession(s.id)
}
}
function onBarContext(e: MouseEvent) {
// 命中 .tab / .tab-add / .quick-menu 不弹
const target = e.target as HTMLElement
if (target.closest('.tab, .tab-add, .quick-menu')) return
menu.value = { x: e.clientX, y: e.clientY, targetId: null }
}7.5 ContextMenu 挂载
模板末尾:
html
<ContextMenu
v-if="menu"
v-model:visible="menuVisible"
:x="menu.x"
:y="menu.y"
:items="menu.targetId ? tabMenuItems : barMenuItems"
@close="menu = null"
/>7.6 标签别名展示
.tab .name 改为:
html
<span class="name">{{ s.alias || s.host.id }}</span>7.7 验收
- [ ] 标签右键弹出菜单,菜单内容随会话状态正确禁用/启用
- [ ] 空白区右键弹出空白区菜单
- [ ] 中键点击标签关闭该标签,不触发页面滚动
- [ ] 右键不切换活动标签
- [ ] 菜单项动作全部走 store 函数
- [ ] 标签显示别名(无别名回落 host.id)
8. 阶段 6:inline 重命名编辑
目标:标签原地编辑,Enter/blur 保存,ESC 取消,超长不撑破布局。
文件:frontend/src/components/wb/WbTabs.vue
8.1 模板编辑态
html
<span v-if="editingId !== s.id" class="name">{{ s.alias || s.host.id }}</span>
<input
v-else
ref="renameInput"
class="rename-input"
type="text"
:maxlength="50"
:value="s.alias || s.host.id"
@keydown.enter="commitRename"
@keydown.esc="cancelRename"
@blur="commitRename"
/>8.2 编辑逻辑
ts
const renameInput = ref<HTMLInputElement | null>(null)
const renameOriginal = ref('')
function startRename(id: string) {
editingId.value = id
skipSave.value = false
const s = sessions.value.find(x => x.id === id)
renameOriginal.value = s?.alias || s?.host.id || ''
// 下一帧聚焦 + 全选
nextTick(() => {
const el = renameInput.value
if (el) { el.focus(); el.select() }
})
}
function commitRename() {
if (skipSave.value) { restoreRename(); return }
const el = renameInput.value
const id = editingId.value
if (!el || !id) { editingId.value = null; return }
const trimmed = el.value.trim()
setSessionAlias(id, trimmed) // 空串清除别名
editingId.value = null
}
function cancelRename() {
skipSave.value = true
renameInput.value?.blur() // 触发 blur → commitRename 读取 skipSave 放弃
}
function restoreRename() {
editingId.value = null
}
// 切换标签时丢弃修改
watch(activeSessionId, (newId) => {
if (editingId.value && newId !== editingId.value) {
skipSave.value = true
editingId.value = null
}
})8.3 样式
文件:frontend/src/style.css
css
.tab .rename-input {
width: 100%;
max-width: 100%;
background: transparent;
border: 1px solid var(--accent);
color: inherit;
font-size: inherit;
padding: 0 4px;
outline: none;
}
.tab { overflow: hidden; } /* 确保输入框不超出标签 */8.4 验收
- [ ] 右键→重命名,标签文本变输入框,预填充并全选
- [ ] Enter 保存,别名显示在标签上
- [ ] 点击输入框外(blur)保存
- [ ] ESC 放弃,恢复原文本
- [ ] 空/全空格保存后清除别名,显示 host.id
- [ ] 输入超过 50 字符被截断
- [ ] 输入框不超出标签宽度(max-width + overflow:hidden 生效)
- [ ] 编辑中切换其他标签,修改被丢弃
- [ ] 窗口 resize / 标签栏滚动时输入框不错位
- [ ] 重启应用别名持久化
9. 阶段 7:会话属性弹窗 + SFTP
目标:会话属性弹窗展示元信息;SFTP 面板唤起。
9.1 会话属性弹窗
复用全局弹窗挂载点(App.vue)。弹窗内容只读,展示:
- 会话 ID(
session.id) - 主机 ID / 名称(
session.host) - 连接状态(
session.state) - 端口 / 用户名(来自
host) - 连接时长(
Date.now() - session.startedAt) - 后端会话 ID(
session.backendId,未连接为"未连接")
不展示密码、私钥内容。
9.2 SFTP 面板
菜单动作 openDock('sftp'),确保活动会话已设为目标会话。
9.3 验收
- [ ] 不同会话状态打开弹窗,元信息正确
- [ ] 弹窗内不可编辑主机配置
- [ ] online 态打开 SFTP 面板正常
- [ ] 非 online 态"打开 SFTP 面板"禁用
10. 阶段 8:联调与验证
10.1 全量手工验证清单
- 重命名标签(见 §8.4)
- 复制会话:源会话 online 复制;生成独立标签;源会话不受影响;两个会话可独立重连/断开;connecting/reconnecting 态复制禁用;连接失败 toast
- 关闭左侧标签页:中间标签右键左侧全关;最左侧标签右键"关闭左侧"置灰;活动标签落在关闭区间自动切换后继
- 会话属性弹窗:元信息正确,只读
- 按键不穿透:菜单打开时普通字符不进终端,Ctrl+C/V 正常
- 中键关闭:中键点击标签关闭,不触发滚动
- 空白区菜单:新建连接、关闭已断开、关闭全部
- 持久化:重命名/重排后重启,别名与会话顺序恢复
- 同主机多会话:复制同主机两次,两个标签独立,重启均恢复
- 配置导入导出:导出不含 sessions,导入忽略 sessions
10.2 回归验证
- [ ] 左侧主机列表点击打开主机仍走
openSession(同主机复用) - [ ]
+快速连接、空白区"新建连接…"正常 - [ ] 终端输入、复制粘贴正常
- [ ] 配置导入导出功能正常
11. 风险与注意事项
| 风险 | 应对 |
|---|---|
openSession 与 duplicateSession 连接逻辑重复 | 抽取内部 connectSession(id) 共用,避免分叉 |
restoreSessions 旧格式读取崩溃 | 严格类型判断,非 SessionEntry[] 直接清空,不抛异常 |
| inline 编辑输入框撑破布局 | maxlength=50 + max-width:100% + 父 overflow:hidden 三重保障 |
| ContextMenu 按键拦截误杀快捷键 | 修饰键组合(ctrl/meta/alt)一律放行,仅拦截无修饰键的普通字符 |
复制会话 IPC 异常导致 duplicating 永久锁死 | 8s setTimeout 兜底,finally 清理 + 超时清理双保险 |
批量关闭时 sessions 数组遍历索引漂移 | 先快照 id 数组,再逐个 closeSession |
12. 提交顺序
建议按阶段顺序提交,每阶段独立 commit:
feat(tabs): session alias 字段 + sessions 持久化格式改为 SessionEntry[]feat(tabs): 新增批量关闭函数 closeSessionsToLeft/Right/Other/Disconnected/All + setSessionAliasfeat(tabs): 新增 duplicateSession 复制会话(含并发保护与超时兜底)feat(context-menu): 菜单可见期间按键不穿透终端,放行系统快捷键feat(tabs): 标签右键菜单 + 空白区菜单 + 中键关闭feat(tabs): 标签 inline 原地重命名编辑feat(tabs): 会话属性弹窗 + SFTP 面板入口docs: 登记开发计划到 config.mts