feat(frontend): 添加 SSH 终端运行中标记
为 SSH 顶部服务器标签和内部终端标签补充 `%` 运行中提示, 并基于发送命令、shell prompt、断连与错误链路派生运行态。
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **[frontend]**: 为 SSH 顶部服务器标签与服务器内终端标签补充 `%` 命令运行中提示,并基于前端发送链路与 shell prompt 输出派生运行态 — by yinjianm
|
||||
- 方案: [202604192106_terminal-running-indicator](archive/2026-04/202604192106_terminal-running-indicator/)
|
||||
- 决策: terminal-running-indicator#D001(运行态继续作为前端派生状态实现), terminal-running-indicator#D002(采用发送置位加 prompt 清除的混合检测策略)
|
||||
|
||||
- **[frontend]**: 移除状态监控默认 CPU 卡里重复的 `CPU 使用率` 标题,并修正 CPU 摘要区固定高度导致的卡片/按钮截断问题 — by yinjianm
|
||||
- 类型: 快速修改(无方案包)
|
||||
- 文件: packages/frontend/src/components/StatusMonitor.vue:61-88,1035-1100,1565-1580
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"status":"completed","completed":6,"failed":0,"pending":0,"total":6,"done":6,"percent":100,"current":"开发实施、构建验证与知识库同步已完成,待归档","updated_at":"2026-04-19 21:30:56"}
|
||||
@@ -0,0 +1,167 @@
|
||||
# 变更提案: terminal-running-indicator
|
||||
|
||||
## 元信息
|
||||
```yaml
|
||||
类型: 优化
|
||||
方案类型: implementation
|
||||
优先级: P1
|
||||
状态: 进行中
|
||||
创建: 2026-04-19
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求
|
||||
|
||||
### 背景
|
||||
当前工作区顶部的 SSH 服务器标签与终端面板内部的子终端标签,只能表达连接状态和终端数量,无法判断某个终端是否仍在执行命令。用户希望在这两层标签上增加一个轻量的“运行中”标记,优先采用 `%` 这种很接近终端语义的提示,而不是额外堆叠新的大块状态卡片。
|
||||
|
||||
现有前端状态链路里,`session.store` 会保留连接状态、活动会话和底部命令输入框草稿,但没有后端提供的“命令正在执行”权威字段;因此这次需求需要在不改后端协议的前提下,基于前端已有的“发送命令”和“终端输出”信号推导一个足够稳定的运行态。
|
||||
|
||||
### 目标
|
||||
- 在 SSH 会话发送非空命令后,把对应终端标记为“运行中”。
|
||||
- 在终端输出重新出现常见 shell prompt 时,自动清除该终端的“运行中”标记。
|
||||
- 顶部服务器级标签在该服务器下任一终端运行时显示 `%` 标记,终端面板内部子标签只给对应终端显示 `%` 标记。
|
||||
- 保持现有连接状态圆点、关闭按钮、RDP/VNC 标签行为与后端 WebSocket 协议不变。
|
||||
|
||||
### 约束条件
|
||||
```yaml
|
||||
时间约束: 本轮只做前端局部增强,保持可直接回滚
|
||||
性能约束: 不引入高频全量扫描,仅在发送命令和收到终端输出时做轻量判定
|
||||
兼容性约束: RDP/VNC 顶部标签继续沿用现有行为,SSH 多终端模型与 keep-alive 渲染链路不重构
|
||||
业务约束: 运行态为前端派生值,不新增后端协议字段;检测不到 prompt 时允许保守依赖兜底清除
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
- [ ] SSH 会话通过命令输入框、快捷命令或终端内回车发送非空命令后,对应终端标签出现 `%` 运行中标记。
|
||||
- [ ] 同一 SSH 服务器下任一终端处于运行中时,顶部服务器标签同步出现 `%` 标记;运行全部结束后自动消失。
|
||||
- [ ] 常见 shell prompt 返回时会清除运行中标记;`Ctrl+C`、断连或再次输入时不会把旧运行态永久残留在标签上。
|
||||
- [ ] `npm --workspace @nexus-terminal/frontend run build` 通过,且没有新增模板/类型错误。
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 技术方案
|
||||
继续复用现有 `session.store -> WorkspaceView -> terminalManager/useSshTerminal -> TerminalTabBar/LayoutRenderer` 的前端数据流,不新增后端字段,也不引入新的全局 store。具体分为三层:
|
||||
|
||||
1. 在 SSH `SessionState` 上补充响应式运行态字段与终端行输入缓存。
|
||||
2. 在 `WorkspaceView.vue` 的命令发送与终端键入链路里,把“发送非空命令”和“Ctrl+C/回车提交”转成运行态更新。
|
||||
3. 在 `useSshTerminal.ts` 的 `ssh:output` 处理链路里,对末尾输出做常见 shell prompt 判定,命中后清除运行态;同时在断连、错误等链路做兜底清理。
|
||||
|
||||
标签展示层只消费派生好的 `isCommandRunning` 字段:
|
||||
- `TerminalTabBar.vue` 对 SSH 服务器标签按 `connectionId` 聚合,任一子终端运行时显示 `%`。
|
||||
- `LayoutRenderer.vue` 对当前服务器内部终端标签逐个显示 `%`。
|
||||
|
||||
### 影响范围
|
||||
```yaml
|
||||
涉及模块:
|
||||
- frontend/session: 为 SSH 会话补充命令运行态与输入缓存字段
|
||||
- frontend/workspace: 在发送命令与终端输出链路中维护运行态
|
||||
- frontend/ui: 在顶部服务器标签与内部终端标签展示 `%` 提示
|
||||
- frontend/i18n: 补充运行中提示文案
|
||||
预计变更文件: 7-9
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
| 风险 | 等级 | 应对 |
|
||||
|------|------|------|
|
||||
| Prompt 正则误判,把普通输出误当成 shell prompt | 中 | 仅对输出末尾的最后非空行做判定,并要求匹配常见提示符结尾形态 |
|
||||
| 只靠 prompt 检测时,某些交互程序或异常输出不会自动清除运行态 | 中 | 增加 `Ctrl+C`、断连、错误和再次输入时的兜底清除,允许保守回退 |
|
||||
| 在终端输入链路里新增输入缓存可能影响现有输入转发 | 低 | 缓存只做本地字符串更新,不改变原有 `sendData()` 转发与 `keep-alive` 机制 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术设计
|
||||
|
||||
### 架构设计
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[CommandInputBar or QuickCommands] --> B[WorkspaceView handleSendCommand]
|
||||
C[Terminal keyboard input] --> D[WorkspaceView handleTerminalInput]
|
||||
B --> E[session.isCommandRunning = true]
|
||||
D --> E
|
||||
E --> F[useSshTerminal handleSshOutput]
|
||||
F --> G{检测到 shell prompt?}
|
||||
G -- 是 --> H[session.isCommandRunning = false]
|
||||
G -- 否 --> I[保持运行态]
|
||||
E --> J[TerminalTabBar / LayoutRenderer]
|
||||
```
|
||||
|
||||
### 数据模型
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `isCommandRunning` | `Ref<boolean>` | 当前 SSH 会话是否处于命令运行中 |
|
||||
| `terminalInputBuffer` | `Ref<string>` | 终端内当前尚未提交的一行输入缓存,用于回车时判断是否发送了非空命令 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心场景
|
||||
|
||||
### 场景: 底部命令输入框发送命令后出现运行中标记
|
||||
**模块**: frontend
|
||||
**条件**: 用户已打开某个 SSH 会话,并通过底部命令输入框、快捷命令或历史命令发送非空命令。
|
||||
**行为**: `WorkspaceView.vue` 在发送数据给 `terminalManager` 前,将该会话标记为运行中。
|
||||
**结果**: 顶部服务器标签和该服务器内部对应终端标签出现 `%` 标记。
|
||||
|
||||
### 场景: 服务器下有多个终端时聚合显示运行态
|
||||
**模块**: frontend
|
||||
**条件**: 同一 `connectionId` 下存在多个 SSH 终端,且其中至少一个终端仍在执行命令。
|
||||
**行为**: `TerminalTabBar.vue` 对当前服务器组内终端的 `isCommandRunning` 做聚合。
|
||||
**结果**: 顶部服务器级标签显示 `%`,内部子终端标签只在对应终端上显示 `%`。
|
||||
|
||||
### 场景: shell prompt 返回后自动清除运行态
|
||||
**模块**: frontend
|
||||
**条件**: 终端收到新的输出块,且末尾出现常见 shell prompt。
|
||||
**行为**: `useSshTerminal.ts` 在 `ssh:output` 处理完成后清除运行态。
|
||||
**结果**: `%` 标记自动消失,不需要用户手动切换标签或刷新页面。
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术决策
|
||||
|
||||
### terminal-running-indicator#D001: 运行态继续作为前端派生状态实现,而不是扩展后端协议
|
||||
**日期**: 2026-04-19
|
||||
**状态**: ✅采纳
|
||||
**背景**: 这次需求只涉及标签层提示,不要求服务端对命令生命周期建立权威状态机;后端当前也没有现成字段可直接复用。
|
||||
**选项分析**:
|
||||
| 选项 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| A: 新增后端 WebSocket 运行态字段 | 语义最权威 | 改动范围扩散到后端协议和前后端联调,超出本轮 UI 增强需求 |
|
||||
| B: 复用前端发送与输出事件派生命令运行态 | 改动集中在现有前端链路,回滚简单 | 对 prompt 识别准确度有依赖 |
|
||||
**决策**: 选择方案 B
|
||||
**理由**: 当前目标是给标签增加“是否还在跑”的轻量感知,不是建设完整任务管理协议。前端派生方案足以满足交互需求,且改动边界与现有架构一致。
|
||||
**影响**: 主要影响 `session` 派生状态、`WorkspaceView.vue`、`useSshTerminal.ts` 与两个标签组件。
|
||||
|
||||
### terminal-running-indicator#D002: 采用“发送非空命令置位 + 常见 prompt 清除 + 中断/断连兜底”的混合检测策略
|
||||
**日期**: 2026-04-19
|
||||
**状态**: ✅采纳
|
||||
**背景**: 单靠“发送命令后一直高亮”会产生残留;单靠输出判定又会遗漏异常结束和交互式输入场景。
|
||||
**选项分析**:
|
||||
| 选项 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| A: 只要发送过命令就保持 `%`,直到下次输入再清除 | 实现最简单 | 状态滞后严重,用户很难判断命令是否真的结束 |
|
||||
| B: 发送非空命令置位,常见 prompt 返回时清除,并用 `Ctrl+C` / 断连 / 新输入兜底 | 贴近真实 shell 行为,误残留更少 | 需要维护输入缓存和轻量 prompt 正则 |
|
||||
**决策**: 选择方案 B
|
||||
**理由**: 该策略能在不改后端的前提下最大化接近“命令结束”的真实时机,同时把无法完全识别的 shell 差异控制在可接受范围内。
|
||||
**影响**: 需要在 `WorkspaceView.vue` 与 `useSshTerminal.ts` 增加少量运行态同步逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 6. 成果设计
|
||||
|
||||
### 设计方向
|
||||
- **美学基调**: 延续现有深色运维工作台,新增提示应是“终端感很强的细小信号”,而不是抢占层级的大号徽标。
|
||||
- **记忆点**: 当前正在运行的标签旁边出现一个琥珀色 `%`,让用户一眼联想到 shell prompt 和命令执行现场。
|
||||
- **参考**: 现有 `TerminalTabBar` / `LayoutRenderer` 终端标签样式 + 用户明确提出的 `%` 符号偏好。
|
||||
|
||||
### 视觉要素
|
||||
- **配色**: 沿用现有绿色激活态与深色背景,`%` 使用偏琥珀/暖黄的强调色,与连接状态圆点形成层级区分。
|
||||
- **字体**: 继续使用项目现有字体体系,不额外引入新字体;`%` 保持和标签文字一致的紧凑终端感。
|
||||
- **布局**: `%` 放在服务器数量徽标前或终端标题后,作为紧凑 inline 状态标记,不改变现有关闭按钮区域。
|
||||
- **动效**: 不新增复杂动画,仅保留轻量颜色过渡,避免把“运行中”误做成 loading spinner。
|
||||
- **氛围**: 保持当前终端工作台的克制风格,让 `%` 像 shell 的即时信号灯,而不是独立组件块。
|
||||
|
||||
### 技术约束
|
||||
- **可访问性**: `%` 标记需带 tooltip/title 文案,避免只靠颜色表达运行态。
|
||||
- **响应式**: 移动端和窄宽度下优先保留 `%` 本体,不强依赖长文案。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 任务清单: terminal-running-indicator
|
||||
|
||||
> **@status:** completed | 2026-04-19 21:31
|
||||
|
||||
```yaml
|
||||
@feature: terminal-running-indicator
|
||||
@created: 2026-04-19
|
||||
@status: completed
|
||||
@mode: R2
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 6 | 0 | 0 | 6 |
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
### 1. 运行态链路补齐
|
||||
|
||||
- [√] 1.1 在 `packages/frontend/src/stores/session/types.ts` 与 `packages/frontend/src/stores/session/actions/sessionActions.ts` 中为 SSH 会话补充命令运行态与终端输入缓存字段,并完成新会话初始化 | depends_on: []
|
||||
- [√] 1.2 在 `packages/frontend/src/stores/session/getters.ts` 与 `packages/frontend/src/composables/useSshTerminal.ts` 中接入“发送非空命令置位、prompt/中断/断连清除”的派生逻辑 | depends_on: [1.1]
|
||||
|
||||
### 2. 标签 UI 展示
|
||||
|
||||
- [√] 2.1 在 `packages/frontend/src/components/TerminalTabBar.vue` 中为 SSH 顶部服务器标签补充按连接聚合的 `%` 运行中提示 | depends_on: [1.2]
|
||||
- [√] 2.2 在 `packages/frontend/src/components/LayoutRenderer.vue` 中为当前服务器内部终端标签补充逐终端 `%` 运行中提示 | depends_on: [1.2]
|
||||
- [√] 2.3 在 `packages/frontend/src/locales/zh-CN.json`、`packages/frontend/src/locales/en-US.json` 与 `packages/frontend/src/locales/ja-JP.json` 中补充运行态 tooltip 文案 | depends_on: [2.1, 2.2]
|
||||
|
||||
### 3. 验证与同步
|
||||
|
||||
- [√] 3.1 运行 `npm --workspace @nexus-terminal/frontend run build` 验证编译通过,并同步知识库与方案状态 | depends_on: [2.3]
|
||||
|
||||
---
|
||||
|
||||
## 执行日志
|
||||
|
||||
| 时间 | 任务 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 2026-04-19 21:06 | 方案包创建 | 完成 | 已创建 `202604192106_terminal-running-indicator`,按 R2 流程进入开发实施 |
|
||||
| 2026-04-19 21:19 | 1.1 / 1.2 | 完成 | 已为 SSH 会话补充 `isCommandRunning` 与 `terminalInputBuffer`,并在 `useSshTerminal.ts` 中接入发送置位、prompt/中断/断连清除逻辑 |
|
||||
| 2026-04-19 21:23 | 2.1 / 2.2 / 2.3 | 完成 | 顶部服务器标签与服务器内终端标签已显示 `%` 运行态提示,并补齐中英日 tooltip 文案 |
|
||||
| 2026-04-19 21:30 | 3.1 | 完成 | `npm --workspace @nexus-terminal/frontend run build` 通过,并同步 `frontend.md` 与 `CHANGELOG.md` |
|
||||
|
||||
---
|
||||
|
||||
## 执行备注
|
||||
|
||||
- 本轮范围限制在 `packages/frontend`,不改 backend WebSocket 协议与 SSH session 模型。
|
||||
- 运行态以前端派生值为准,允许在无法识别 prompt 的极少数 shell 场景中退化为“中断/新输入/断连清除”。
|
||||
- 顶部服务器标签只做按连接聚合展示,内部终端标签负责表达具体哪一个终端仍在运行。
|
||||
- 方案设计时预估会在 `WorkspaceView.vue` 接入运行态,但最终为了覆盖文件管理器等直接调用 `terminalManager.sendData()` 的链路,将输入跟踪统一收口到了 `useSshTerminal.ts`。
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|
||||
|--------|------|------|---------|------|------|
|
||||
| 202604192106 | terminal-running-indicator | - | - | - | ✅完成 |
|
||||
| 202604190520 | status-monitor-cpu-summary-modal | - | - | - | ✅完成 |
|
||||
| 202604190351 | status-monitor-cpu-total-and-per-core | implementation | frontend, backend | status-monitor-cpu-total-and-per-core#D001 | ✅完成 |
|
||||
| 202604190358 | status-monitor-network-vertical-stack | implementation | frontend | - | ✅完成 |
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -654,6 +654,13 @@ onBeforeUnmount(() => {
|
||||
<span class="whitespace-nowrap">
|
||||
{{ t('terminalTabBar.terminalBadge', { index: session.terminalIndex }) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="session.isCommandRunning"
|
||||
class="ml-2 rounded-sm px-1 text-[10px] font-semibold uppercase tracking-wide text-amber-400"
|
||||
:title="t('terminalTabBar.commandRunningIndicator')"
|
||||
>
|
||||
%
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 rounded-full p-0.5 text-text-secondary opacity-0 transition-opacity duration-150 hover:bg-header hover:text-foreground group-hover:opacity-100"
|
||||
|
||||
@@ -100,6 +100,14 @@ const getRepresentativeSessionId = (connectionId: string, fallbackSessionId: str
|
||||
|
||||
const getConnectionSessionCount = (connectionId: string) => getConnectionSessions(connectionId).length;
|
||||
|
||||
const getConnectionRunningSessionCount = (connectionId: string) =>
|
||||
getConnectionSessions(connectionId).filter((session) => session.isCommandRunning).length;
|
||||
|
||||
const isConnectionRunning = (connectionId: string) => getConnectionRunningSessionCount(connectionId) > 0;
|
||||
|
||||
const getConnectionRunningIndicatorTitle = (connectionId: string) =>
|
||||
t('terminalTabBar.commandRunningIndicatorCount', { count: getConnectionRunningSessionCount(connectionId) });
|
||||
|
||||
const shouldRenderTopLevelItem = (session: SessionTabInfoWithStatus, index: number) => {
|
||||
if (!isSshConnection(session.connectionId)) {
|
||||
return true;
|
||||
@@ -564,6 +572,13 @@ onBeforeUnmount(() => {
|
||||
<span class="max-w-[180px] truncate text-xs font-semibold tracking-wide">
|
||||
{{ session.connectionName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isConnectionRunning(session.connectionId)"
|
||||
class="rounded-sm px-1 text-[10px] font-semibold uppercase tracking-wide text-amber-400"
|
||||
:title="getConnectionRunningIndicatorTitle(session.connectionId)"
|
||||
>
|
||||
%
|
||||
</span>
|
||||
<span
|
||||
:class="[
|
||||
'rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
|
||||
|
||||
@@ -1,18 +1,129 @@
|
||||
import { ref, readonly, type Ref, ComputedRef } from 'vue';
|
||||
import { ref, readonly, ComputedRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { sessions as globalSessionsRef } from '../stores/session/state'; // +++ 导入全局 sessions state +++
|
||||
// import { useWebSocketConnection } from './useWebSocketConnection'; // 移除全局导入
|
||||
import { sessions as globalSessionsRef } from '../stores/session/state';
|
||||
import type { Terminal } from 'xterm';
|
||||
import type { SearchAddon, ISearchOptions } from '@xterm/addon-search'; // *** 移除 ISearchResult 导入 ***
|
||||
import type { SearchAddon, ISearchOptions } from '@xterm/addon-search';
|
||||
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types';
|
||||
|
||||
// 定义与 WebSocket 相关的依赖接口
|
||||
export interface SshTerminalDependencies {
|
||||
sendMessage: (message: WebSocketMessage) => void;
|
||||
onMessage: (type: string, handler: (payload: any, fullMessage?: WebSocketMessage) => void) => () => void;
|
||||
isConnected: ComputedRef<boolean>;
|
||||
}
|
||||
|
||||
const OSC_SEQUENCE_RE = /\x1B\][^\x07]*(?:\x07|\x1B\\)/g;
|
||||
const CSI_SEQUENCE_RE = /\x1B\[[0-?]*[ -/]*[@-~]/g;
|
||||
const SINGLE_ESC_RE = /\x1B[@-Z\\-_]/g;
|
||||
const INPUT_ESCAPE_RE = /(?:\x1B\[[0-?]*[ -/]*[@-~])|(?:\x1B[@-Z\\-_])/g;
|
||||
const SHELL_PROMPT_PATTERNS = [
|
||||
/^(?:\[[^\]]+\]\s*)?[\w.-]+@[\w.-]+(?::[^\r\n]*)?[#$%>] ?$/,
|
||||
/^(?:[A-Za-z]:)?[\\/][^>\r\n]*> ?$/,
|
||||
/^PS [^>\r\n]+> ?$/,
|
||||
/^(?:~|\/)[^#$%>\r\n]*[#$%>] ?$/,
|
||||
/^[\w.-]+\s[%#] ?$/,
|
||||
/^[#$%>] ?$/,
|
||||
];
|
||||
|
||||
const stripTerminalControlSequences = (text: string): string =>
|
||||
text
|
||||
.replace(OSC_SEQUENCE_RE, '')
|
||||
.replace(CSI_SEQUENCE_RE, '')
|
||||
.replace(SINGLE_ESC_RE, '');
|
||||
|
||||
const getSessionState = (sessionId: string) => globalSessionsRef.value.get(sessionId);
|
||||
|
||||
const resetSessionCommandRuntime = (sessionId: string) => {
|
||||
const session = getSessionState(sessionId);
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.isCommandRunning.value = false;
|
||||
session.terminalInputBuffer.value = '';
|
||||
};
|
||||
|
||||
const syncSessionCommandRuntimeFromInput = (sessionId: string, data: string) => {
|
||||
const session = getSessionState(sessionId);
|
||||
if (!session) {
|
||||
return { submittedCommand: false, interrupted: false };
|
||||
}
|
||||
|
||||
const normalizedData = data.replace(INPUT_ESCAPE_RE, '');
|
||||
if (!normalizedData) {
|
||||
return { submittedCommand: false, interrupted: false };
|
||||
}
|
||||
|
||||
let nextBuffer = session.terminalInputBuffer.value;
|
||||
let submittedCommand = false;
|
||||
let interrupted = false;
|
||||
|
||||
for (const char of normalizedData) {
|
||||
if (char === '\x03') {
|
||||
session.isCommandRunning.value = false;
|
||||
nextBuffer = '';
|
||||
interrupted = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\r' || char === '\n') {
|
||||
if (nextBuffer.trim().length > 0) {
|
||||
session.isCommandRunning.value = true;
|
||||
submittedCommand = true;
|
||||
}
|
||||
nextBuffer = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\b' || char === '\u007f') {
|
||||
nextBuffer = nextBuffer.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char < ' ') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextBuffer.length === 0 && session.isCommandRunning.value) {
|
||||
session.isCommandRunning.value = false;
|
||||
}
|
||||
|
||||
nextBuffer += char;
|
||||
}
|
||||
|
||||
session.terminalInputBuffer.value = nextBuffer;
|
||||
return { submittedCommand, interrupted };
|
||||
};
|
||||
|
||||
const getPromptProbeText = (outputData: string | Uint8Array): string => {
|
||||
if (typeof outputData === 'string') {
|
||||
return outputData;
|
||||
}
|
||||
|
||||
try {
|
||||
return new TextDecoder().decode(outputData);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const isPromptTail = (tail: string): boolean => {
|
||||
const normalizedTail = stripTerminalControlSequences(tail);
|
||||
const lines = normalizedTail
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.replace(/\r/g, '').trimEnd());
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const line = lines[index].trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return SHELL_PROMPT_PATTERNS.some((pattern) => pattern.test(line));
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建一个 SSH 终端管理器实例
|
||||
* @param sessionId 会话唯一标识符
|
||||
@@ -20,74 +131,57 @@ export interface SshTerminalDependencies {
|
||||
* @param t i18n 翻译函数,从父组件传入
|
||||
* @returns SSH 终端管理器实例
|
||||
*/
|
||||
export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalDependencies, t: ReturnType<typeof useI18n>['t']) { // +++ Update type of t +++
|
||||
// 使用依赖注入的 WebSocket 函数
|
||||
export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalDependencies, t: ReturnType<typeof useI18n>['t']) {
|
||||
const { sendMessage, onMessage, isConnected } = wsDeps;
|
||||
|
||||
const terminalInstance = ref<Terminal | null>(null);
|
||||
const searchAddon = ref<SearchAddon | null>(null); // Keep searchAddon ref
|
||||
// Removed search result state refs
|
||||
// const searchResultCount = ref(0);
|
||||
// const currentSearchResultIndex = ref(-1);
|
||||
const terminalOutputBuffer = ref<(string | Uint8Array)[]>([]); // 缓冲 WebSocket 消息直到终端准备好
|
||||
const isSshConnected = ref(false); // 跟踪 SSH 连接状态
|
||||
const searchAddon = ref<SearchAddon | null>(null);
|
||||
const terminalOutputBuffer = ref<(string | Uint8Array)[]>([]);
|
||||
const isSshConnected = ref(false);
|
||||
const promptProbeBuffer = ref('');
|
||||
|
||||
// 辅助函数:获取终端消息文本
|
||||
const getTerminalText = (key: string, params?: Record<string, any>): string => {
|
||||
// 确保 i18n key 存在,否则返回原始 key
|
||||
const getTerminalText = (key: string, params?: Record<string, unknown>): string => {
|
||||
const translationKey = `workspace.terminal.${key}`;
|
||||
const translated = t(translationKey, params || {});
|
||||
return translated === translationKey ? key : translated;
|
||||
};
|
||||
|
||||
// --- 终端事件处理 ---
|
||||
|
||||
// *** 更新 handleTerminalReady 签名以接收 searchAddon ***
|
||||
const handleTerminalReady = (payload: { terminal: Terminal; searchAddon: SearchAddon | null }) => {
|
||||
const { terminal: term, searchAddon: addon } = payload;
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 终端实例已就绪。SearchAddon 实例:`, addon ? '存在' : '不存在');
|
||||
terminalInstance.value = term;
|
||||
searchAddon.value = addon; // *** 存储 searchAddon 实例 ***
|
||||
searchAddon.value = addon;
|
||||
|
||||
|
||||
// 1. 处理 SessionState.pendingOutput (来自 SSH_OUTPUT_CACHED_CHUNK 的早期数据)
|
||||
const currentSessionState = globalSessionsRef.value.get(sessionId);
|
||||
if (currentSessionState && currentSessionState.pendingOutput && currentSessionState.pendingOutput.length > 0) {
|
||||
// console.log(`[会话 ${sessionId}][SSH终端模块] 发现 SessionState.pendingOutput,长度: ${currentSessionState.pendingOutput.length}。正在写入...`);
|
||||
currentSessionState.pendingOutput.forEach(data => {
|
||||
if (currentSessionState?.pendingOutput?.length) {
|
||||
currentSessionState.pendingOutput.forEach((data) => {
|
||||
term.write(data);
|
||||
});
|
||||
currentSessionState.pendingOutput = []; // 清空
|
||||
// console.log(`[会话 ${sessionId}][SSH终端模块] SessionState.pendingOutput 处理完毕。`);
|
||||
// 如果之前因为 pendingOutput 而将 isResuming 保持为 true,现在可以考虑更新
|
||||
currentSessionState.pendingOutput = [];
|
||||
|
||||
if (currentSessionState.isResuming) {
|
||||
// 检查 isLastChunk 是否已收到 (这部分逻辑在 handleSshOutputCachedChunk 中,这里仅作标记清除)
|
||||
// 假设所有缓存块都已处理完毕
|
||||
// console.log(`[会话 ${sessionId}][SSH终端模块] 所有 pendingOutput 已写入,清除 isResuming 标记。`);
|
||||
currentSessionState.isResuming = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 将此管理器内部缓冲的输出 (terminalOutputBuffer, 来自 ssh:output) 写入终端
|
||||
if (terminalOutputBuffer.value.length > 0) {
|
||||
terminalOutputBuffer.value.forEach(data => {
|
||||
term.write(data);
|
||||
terminalOutputBuffer.value.forEach((data) => {
|
||||
term.write(data);
|
||||
});
|
||||
terminalOutputBuffer.value = []; // 清空内部缓冲区
|
||||
terminalOutputBuffer.value = [];
|
||||
}
|
||||
|
||||
// 可以在这里自动聚焦或执行其他初始化操作
|
||||
// term.focus(); // 也许在 ssh:connected 时聚焦更好
|
||||
};
|
||||
|
||||
const handleTerminalData = (data: string) => {
|
||||
// console.debug(`[会话 ${sessionId}][SSH终端模块] 接收到终端输入:`, data);
|
||||
const runtimeUpdate = syncSessionCommandRuntimeFromInput(sessionId, data);
|
||||
if (runtimeUpdate.submittedCommand || runtimeUpdate.interrupted) {
|
||||
promptProbeBuffer.value = '';
|
||||
}
|
||||
sendMessage({ type: 'ssh:input', sessionId, payload: { data } });
|
||||
};
|
||||
|
||||
const handleTerminalResize = (dimensions: { cols: number; rows: number }) => {
|
||||
console.log(`[SSH ${sessionId}] handleTerminalResize called with:`, dimensions);
|
||||
// 只有在连接状态下才发送 resize 命令给后端
|
||||
if (isConnected.value) {
|
||||
sendMessage({ type: 'ssh:resize', sessionId, payload: dimensions });
|
||||
} else {
|
||||
@@ -95,76 +189,62 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
||||
}
|
||||
};
|
||||
|
||||
// --- WebSocket 消息处理 ---
|
||||
|
||||
const handleSshOutput = (payload: MessagePayload, message?: WebSocketMessage) => {
|
||||
// 检查消息是否属于此会话
|
||||
if (message?.sessionId && message.sessionId !== sessionId) {
|
||||
return; // 忽略不属于此会话的消息
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = payload;
|
||||
// 检查是否为 Base64 编码 (需要后端配合发送 encoding 字段)
|
||||
if (message?.encoding === 'base64' && typeof outputData === 'string') {
|
||||
try {
|
||||
// 使用更安全的Base64解码方式,保证中文字符正确解码
|
||||
const base64String = outputData;
|
||||
// 先用atob获取二进制字符串
|
||||
const binaryString = atob(base64String);
|
||||
// 创建Uint8Array存储二进制数据
|
||||
const binaryString = atob(outputData);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
for (let index = 0; index < binaryString.length; index += 1) {
|
||||
bytes[index] = binaryString.charCodeAt(index);
|
||||
}
|
||||
// 直接使用原始二进制数据作为 Uint8Array 写入终端,避免编码转换问题
|
||||
outputData = bytes;
|
||||
} catch (e) {
|
||||
console.error(`[会话 ${sessionId}][SSH终端模块] Base64 解码失败:`, e, '原始数据:', message.payload);
|
||||
outputData = `\r\n[解码错误: ${e}]\r\n`; // 在终端显示解码错误
|
||||
} catch (error) {
|
||||
console.error(`[会话 ${sessionId}][SSH终端模块] Base64 解码失败:`, error, '原始数据:', message.payload);
|
||||
outputData = `\r\n[解码错误: ${error}]\r\n`;
|
||||
}
|
||||
} else if (typeof outputData !== 'string') {
|
||||
console.warn(`[会话 ${sessionId}][SSH终端模块] 收到非字符串 ssh:output payload:`, outputData);
|
||||
try {
|
||||
outputData = JSON.stringify(outputData);
|
||||
} catch {
|
||||
outputData = String(outputData);
|
||||
}
|
||||
}
|
||||
// 如果不是 base64 或解码失败,确保它是字符串
|
||||
else if (typeof outputData !== 'string') {
|
||||
console.warn(`[会话 ${sessionId}][SSH终端模块] 收到非字符串 ssh:output payload:`, outputData);
|
||||
try {
|
||||
outputData = JSON.stringify(outputData); // 尝试序列化
|
||||
} catch {
|
||||
outputData = String(outputData); // 最后手段:强制转字符串
|
||||
}
|
||||
}
|
||||
|
||||
// 由于直接使用原始二进制数据,不再需要过滤 OSC 184 序列
|
||||
// 相关代码已移除
|
||||
|
||||
// --- 添加前端日志 ---
|
||||
// console.log(`[会话 ${sessionId}][SSH前端] 收到 ssh:output 原始 payload (解码前):`, payload);
|
||||
// console.log(`[会话 ${sessionId}][SSH前端] 解码后的数据 (尝试写入):`, outputData);
|
||||
// --------------------
|
||||
|
||||
if (terminalInstance.value) {
|
||||
// console.log(`[会话 ${sessionId}][SSH前端] 终端实例存在,尝试写入...`);
|
||||
terminalInstance.value.write(outputData);
|
||||
// console.log(`[会话 ${sessionId}][SSH前端] 写入完成。`);
|
||||
} else {
|
||||
// 如果终端还没准备好,先缓冲输出
|
||||
terminalOutputBuffer.value.push(outputData);
|
||||
}
|
||||
|
||||
if (getSessionState(sessionId)?.isCommandRunning.value) {
|
||||
const promptProbeText = getPromptProbeText(outputData);
|
||||
if (promptProbeText) {
|
||||
promptProbeBuffer.value = `${promptProbeBuffer.value}${promptProbeText}`.slice(-320);
|
||||
if (isPromptTail(promptProbeBuffer.value)) {
|
||||
resetSessionCommandRuntime(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSshConnected = (payload: MessagePayload, message?: WebSocketMessage) => {
|
||||
// 检查消息是否属于此会话
|
||||
if (message?.sessionId && message.sessionId !== sessionId) {
|
||||
return; // 忽略不属于此会话的消息
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已连接。 Payload:`, payload, 'Full message:', message); // 更详细的日志
|
||||
isSshConnected.value = true; // 更新状态
|
||||
// 连接成功后聚焦终端
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已连接。 Payload:`, payload, 'Full message:', message);
|
||||
isSshConnected.value = true;
|
||||
promptProbeBuffer.value = '';
|
||||
terminalInstance.value?.focus();
|
||||
|
||||
if (terminalInstance.value) {
|
||||
const currentDimensions = { cols: terminalInstance.value.cols, rows: terminalInstance.value.rows };
|
||||
// 检查尺寸是否有效
|
||||
if (currentDimensions.cols > 0 && currentDimensions.rows > 0) {
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 连接成功,主动发送初始尺寸:`, currentDimensions);
|
||||
sendMessage({ type: 'ssh:resize', sessionId, payload: currentDimensions });
|
||||
@@ -172,62 +252,55 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
||||
console.warn(`[会话 ${sessionId}][SSH终端模块] SSH 连接成功,但获取到的初始尺寸无效,跳过发送 resize:`, currentDimensions);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[会话 ${sessionId}][SSH终端模块] SSH 连接成功,但 terminalInstance 不可用,无法发送初始 resize。`);
|
||||
console.warn(`[会话 ${sessionId}][SSH终端模块] SSH 连接成功,但 terminalInstance 不可用,无法发送初始 resize。`);
|
||||
}
|
||||
|
||||
|
||||
// 清空可能存在的旧缓冲(虽然理论上此时应该已经 ready 了)
|
||||
if (terminalOutputBuffer.value.length > 0) {
|
||||
console.warn(`[会话 ${sessionId}][SSH终端模块] SSH 连接时仍有缓冲数据,正在写入...`);
|
||||
terminalOutputBuffer.value.forEach(data => terminalInstance.value?.write(data));
|
||||
terminalOutputBuffer.value = [];
|
||||
console.warn(`[会话 ${sessionId}][SSH终端模块] SSH 连接时仍有缓冲数据,正在写入...`);
|
||||
terminalOutputBuffer.value.forEach((data) => terminalInstance.value?.write(data));
|
||||
terminalOutputBuffer.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleSshDisconnected = (payload: MessagePayload, message?: WebSocketMessage) => {
|
||||
// 检查消息是否属于此会话
|
||||
if (message?.sessionId && message.sessionId !== sessionId) {
|
||||
return; // 忽略不属于此会话的消息
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = payload || t('workspace.terminal.unknownReason'); // 使用 i18n 获取未知原因文本
|
||||
const reason = payload || t('workspace.terminal.unknownReason');
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已断开:`, reason);
|
||||
isSshConnected.value = false; // 更新状态
|
||||
isSshConnected.value = false;
|
||||
promptProbeBuffer.value = '';
|
||||
resetSessionCommandRuntime(sessionId);
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('disconnectMsg', { reason })}\x1b[0m`);
|
||||
// 可以在这里添加其他清理逻辑,例如禁用输入
|
||||
};
|
||||
|
||||
const handleSshError = (payload: MessagePayload, message?: WebSocketMessage) => {
|
||||
// 检查消息是否属于此会话
|
||||
if (message?.sessionId && message.sessionId !== sessionId) {
|
||||
return; // 忽略不属于此会话的消息
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMsg = payload || t('workspace.terminal.unknownSshError'); // 使用 i18n
|
||||
const errorMsg = payload || t('workspace.terminal.unknownSshError');
|
||||
console.error(`[会话 ${sessionId}][SSH终端模块] SSH 错误:`, errorMsg);
|
||||
isSshConnected.value = false; // 更新状态
|
||||
isSshConnected.value = false;
|
||||
promptProbeBuffer.value = '';
|
||||
resetSessionCommandRuntime(sessionId);
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('genericErrorMsg', { message: errorMsg })}\x1b[0m`);
|
||||
};
|
||||
|
||||
const handleSshStatus = (payload: MessagePayload, message?: WebSocketMessage) => {
|
||||
// 检查消息是否属于此会话
|
||||
if (message?.sessionId && message.sessionId !== sessionId) {
|
||||
return; // 忽略不属于此会话的消息
|
||||
return;
|
||||
}
|
||||
|
||||
// 这个消息现在由 useWebSocketConnection 处理以更新全局状态栏消息
|
||||
// 这里可以保留日志或用于其他特定于终端的 UI 更新(如果需要)
|
||||
const statusKey = payload?.key || 'unknown';
|
||||
const statusParams = payload?.params || {};
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 收到 SSH 状态更新:`, statusKey, statusParams);
|
||||
// 可以在终端打印一些状态信息吗?
|
||||
// terminalInstance.value?.writeln(`\r\n\x1b[34m[状态: ${statusKey}]\x1b[0m`);
|
||||
};
|
||||
|
||||
const handleInfoMessage = (payload: MessagePayload, message?: WebSocketMessage) => {
|
||||
// 检查消息是否属于此会话
|
||||
if (message?.sessionId && message.sessionId !== sessionId) {
|
||||
return; // 忽略不属于此会话的消息
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 收到后端信息:`, payload);
|
||||
@@ -235,19 +308,15 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
||||
};
|
||||
|
||||
const handleErrorMessage = (payload: MessagePayload, message?: WebSocketMessage) => {
|
||||
// 检查消息是否属于此会话
|
||||
if (message?.sessionId && message.sessionId !== sessionId) {
|
||||
return; // 忽略不属于此会话的消息
|
||||
return;
|
||||
}
|
||||
|
||||
// 通用错误也可能需要显示在终端
|
||||
const errorMsg = payload || t('workspace.terminal.unknownGenericError'); // 使用 i18n
|
||||
const errorMsg = payload || t('workspace.terminal.unknownGenericError');
|
||||
console.error(`[会话 ${sessionId}][SSH终端模块] 收到后端通用错误:`, errorMsg);
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('errorPrefix')} ${errorMsg}\x1b[0m`);
|
||||
};
|
||||
|
||||
|
||||
// --- 注册 WebSocket 消息处理器 ---
|
||||
const unregisterHandlers: (() => void)[] = [];
|
||||
|
||||
const registerSshHandlers = () => {
|
||||
@@ -257,61 +326,53 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
||||
unregisterHandlers.push(onMessage('ssh:error', handleSshError));
|
||||
unregisterHandlers.push(onMessage('ssh:status', handleSshStatus));
|
||||
unregisterHandlers.push(onMessage('info', handleInfoMessage));
|
||||
unregisterHandlers.push(onMessage('error', handleErrorMessage)); // 也处理通用错误
|
||||
unregisterHandlers.push(onMessage('error', handleErrorMessage));
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 已注册 SSH 相关消息处理器。`);
|
||||
};
|
||||
|
||||
const unregisterAllSshHandlers = () => {
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 注销 SSH 相关消息处理器...`);
|
||||
unregisterHandlers.forEach(unregister => unregister?.());
|
||||
unregisterHandlers.length = 0; // 清空数组
|
||||
unregisterHandlers.forEach((unregister) => unregister?.());
|
||||
unregisterHandlers.length = 0;
|
||||
};
|
||||
|
||||
// 初始化时自动注册处理程序
|
||||
registerSshHandlers();
|
||||
|
||||
// --- 清理函数 ---
|
||||
const cleanup = () => {
|
||||
unregisterAllSshHandlers();
|
||||
// terminalInstance.value?.dispose(); // 终端实例的销毁由 TerminalComponent 负责
|
||||
terminalInstance.value = null;
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 已清理。`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 直接发送数据到 SSH 会话 (例如,从命令输入栏)
|
||||
* 直接发送数据到 SSH 会话(例如,从命令输入栏)
|
||||
* @param data 要发送的字符串数据
|
||||
*/
|
||||
const sendData = (data: string) => {
|
||||
// console.debug(`[会话 ${sessionId}][SSH终端模块] 直接发送数据:`, data);
|
||||
const runtimeUpdate = syncSessionCommandRuntimeFromInput(sessionId, data);
|
||||
if (runtimeUpdate.submittedCommand || runtimeUpdate.interrupted) {
|
||||
promptProbeBuffer.value = '';
|
||||
}
|
||||
sendMessage({ type: 'ssh:input', sessionId, payload: { data } });
|
||||
};
|
||||
|
||||
// --- 搜索相关方法 (移除计数逻辑) ---
|
||||
|
||||
// Removed countOccurrences helper function
|
||||
|
||||
const searchNext = (term: string, options?: ISearchOptions): boolean => {
|
||||
if (searchAddon.value) {
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 执行 searchNext: "${term}"`);
|
||||
const found = searchAddon.value.findNext(term, options);
|
||||
// Removed manual count and state update
|
||||
return found;
|
||||
return searchAddon.value.findNext(term, options);
|
||||
}
|
||||
|
||||
console.warn(`[会话 ${sessionId}][SSH终端模块] searchNext 调用失败,searchAddon 不可用。`);
|
||||
// Removed state reset on failure
|
||||
return false;
|
||||
};
|
||||
|
||||
const searchPrevious = (term: string, options?: ISearchOptions): boolean => {
|
||||
if (searchAddon.value) {
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 执行 searchPrevious: "${term}"`);
|
||||
const found = searchAddon.value.findPrevious(term, options);
|
||||
// Removed manual count and state update
|
||||
return found;
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 执行 searchPrevious: "${term}"`);
|
||||
return searchAddon.value.findPrevious(term, options);
|
||||
}
|
||||
console.warn(`[会话 ${sessionId}][SSH终端模块] searchPrevious 调用失败,searchAddon 不可用。`);
|
||||
// Removed state reset on failure
|
||||
|
||||
console.warn(`[会话 ${sessionId}][SSH终端模块] searchPrevious 调用失败,searchAddon 不可用。`);
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -320,31 +381,25 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 清除搜索高亮。`);
|
||||
searchAddon.value.clearDecorations();
|
||||
}
|
||||
// Removed state reset
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 搜索高亮已清除 (状态不再管理)。`);
|
||||
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 搜索高亮已清除(状态不再管理)。`);
|
||||
};
|
||||
|
||||
|
||||
// 返回工厂实例
|
||||
return {
|
||||
// 公共接口
|
||||
handleTerminalReady,
|
||||
handleTerminalData, // 这个处理来自 xterm.js 的输入
|
||||
handleTerminalData,
|
||||
handleTerminalResize,
|
||||
sendData, // 允许外部直接发送数据
|
||||
sendData,
|
||||
cleanup,
|
||||
// --- 搜索方法 ---
|
||||
searchNext,
|
||||
searchPrevious,
|
||||
clearTerminalSearch,
|
||||
// --- 暴露状态 ---
|
||||
isSshConnected: readonly(isSshConnected), // 暴露 SSH 连接状态 (只读)
|
||||
terminalInstance, // 暴露 terminal 实例,以便 WorkspaceView 可以写入提示信息
|
||||
isSshConnected: readonly(isSshConnected),
|
||||
terminalInstance,
|
||||
};
|
||||
}
|
||||
|
||||
// 保留兼容旧代码的函数(将在完全迁移后移除)
|
||||
export function useSshTerminal(t: (key: string) => string) {
|
||||
export function useSshTerminal() {
|
||||
console.warn('⚠️ 使用已弃用的 useSshTerminal() 全局单例。请迁移到 createSshTerminalManager() 工厂函数。');
|
||||
|
||||
const terminalInstance = ref<Terminal | null>(null);
|
||||
@@ -354,15 +409,14 @@ export function useSshTerminal(t: (key: string) => string) {
|
||||
terminalInstance.value = term;
|
||||
};
|
||||
|
||||
const handleTerminalData = (data: string) => {
|
||||
const handleTerminalData = () => {
|
||||
console.warn('[SSH终端模块][旧] 收到终端数据,但使用了已弃用的单例模式,无法发送。');
|
||||
};
|
||||
|
||||
const handleTerminalResize = (dimensions: { cols: number; rows: number }) => {
|
||||
const handleTerminalResize = () => {
|
||||
console.warn('[SSH终端模块][旧] 收到终端大小调整,但使用了已弃用的单例模式,无法发送。');
|
||||
};
|
||||
|
||||
// 返回与旧接口兼容的空函数,以避免错误
|
||||
return {
|
||||
terminalInstance,
|
||||
handleTerminalReady,
|
||||
|
||||
@@ -1700,7 +1700,9 @@
|
||||
"openConnectionPickerTooltip": "Choose another server",
|
||||
"terminalBadge": "Terminal {index}",
|
||||
"serverEntryTitle": "{name} · {count} terminals",
|
||||
"terminalCount": "{count} terminals"
|
||||
"terminalCount": "{count} terminals",
|
||||
"commandRunningIndicator": "Command running",
|
||||
"commandRunningIndicatorCount": "{count} terminal(s) running"
|
||||
},
|
||||
"globalConnectionSearch": {
|
||||
"shortcut": "Ctrl+Shift+F",
|
||||
|
||||
@@ -1624,7 +1624,9 @@
|
||||
"newTerminalTooltip": "現在のサーバーに新しいターミナルを追加",
|
||||
"closeConnectionGroupTooltip": "{name} の端末をすべて閉じる ({count} 件)",
|
||||
"openConnectionPickerTooltip": "別のサーバーを選択",
|
||||
"terminalBadge": "端末 {index}"
|
||||
"terminalBadge": "端末 {index}",
|
||||
"commandRunningIndicator": "コマンド実行中",
|
||||
"commandRunningIndicatorCount": "{count} 個の端末でコマンド実行中"
|
||||
},
|
||||
"globalConnectionSearch": {
|
||||
"shortcut": "Ctrl+Shift+F",
|
||||
|
||||
@@ -1704,7 +1704,9 @@
|
||||
"openConnectionPickerTooltip": "选择其他服务器",
|
||||
"terminalBadge": "终端 {index}",
|
||||
"serverEntryTitle": "{name} · {count} 个终端",
|
||||
"terminalCount": "{count} 个终端"
|
||||
"terminalCount": "{count} 个终端",
|
||||
"commandRunningIndicator": "命令正在运行中",
|
||||
"commandRunningIndicatorCount": "{count} 个终端正在运行中"
|
||||
},
|
||||
"globalConnectionSearch": {
|
||||
"shortcut": "Ctrl+Shift+F",
|
||||
|
||||
@@ -148,6 +148,8 @@ export const openNewSession = (
|
||||
editorTabs: ref([]),
|
||||
activeEditorTabId: ref(null),
|
||||
commandInputContent: ref(''),
|
||||
isCommandRunning: ref(false),
|
||||
terminalInputBuffer: ref(''),
|
||||
isMarkedForSuspend: false,
|
||||
createdAt: Date.now(),
|
||||
disposables: [],
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// packages/frontend/src/stores/session/getters.ts
|
||||
|
||||
import { computed } from 'vue';
|
||||
import { sessions, activeSessionId } from './state';
|
||||
import type { SessionState, SessionTabInfoWithStatus } from './types';
|
||||
|
||||
export const sessionTabs = computed(() => {
|
||||
return Array.from(sessions.value.values()).map(session => ({
|
||||
return Array.from(sessions.value.values()).map((session) => ({
|
||||
sessionId: session.sessionId,
|
||||
connectionId: session.connectionId,
|
||||
connectionName: session.connectionName,
|
||||
@@ -13,56 +11,57 @@ export const sessionTabs = computed(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
// 包含状态的标签页信息
|
||||
export const sessionTabsWithStatus = computed((): SessionTabInfoWithStatus[] => {
|
||||
const sessionOrderStr = localStorage.getItem('sessionOrder');
|
||||
let sessionOrder: string[] = [];
|
||||
|
||||
if (sessionOrderStr) {
|
||||
try {
|
||||
sessionOrder = JSON.parse(sessionOrderStr);
|
||||
console.log('[SessionGetters] 使用本地存储的用户自定义标签顺序');
|
||||
} catch (e) {
|
||||
console.error('[SessionGetters] 解析本地存储的标签顺序失败:', e);
|
||||
console.log('[SessionGetters] 使用本地存储的用户自定义标签页顺序');
|
||||
} catch (error) {
|
||||
console.error('[SessionGetters] 解析本地存储的标签页顺序失败', error);
|
||||
sessionOrder = [];
|
||||
}
|
||||
}
|
||||
|
||||
const sessionList = Array.from(sessions.value.values());
|
||||
if (sessionOrder.length > 0) {
|
||||
// 按照用户自定义顺序排序
|
||||
return sessionList
|
||||
.sort((a, b) => {
|
||||
const indexA = sessionOrder.indexOf(a.sessionId);
|
||||
const indexB = sessionOrder.indexOf(b.sessionId);
|
||||
if (indexA === -1 && indexB === -1) return a.createdAt - b.createdAt;
|
||||
if (indexA === -1) return 1;
|
||||
if (indexB === -1) return -1;
|
||||
return indexA - indexB;
|
||||
const orderedSessions = sessionOrder.length > 0
|
||||
? sessionList.sort((left, right) => {
|
||||
const leftIndex = sessionOrder.indexOf(left.sessionId);
|
||||
const rightIndex = sessionOrder.indexOf(right.sessionId);
|
||||
|
||||
if (leftIndex === -1 && rightIndex === -1) {
|
||||
return left.createdAt - right.createdAt;
|
||||
}
|
||||
|
||||
if (leftIndex === -1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (rightIndex === -1) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return leftIndex - rightIndex;
|
||||
})
|
||||
.map(session => ({
|
||||
sessionId: session.sessionId,
|
||||
connectionId: session.connectionId,
|
||||
connectionName: session.connectionName,
|
||||
terminalIndex: session.terminalIndex,
|
||||
status: session.wsManager.connectionStatus.value, // 从 wsManager 获取状态
|
||||
isMarkedForSuspend: session.isMarkedForSuspend,
|
||||
}));
|
||||
} else {
|
||||
// 如果没有自定义顺序,则按照创建时间排序
|
||||
return sessionList
|
||||
.sort((a, b) => a.createdAt - b.createdAt)
|
||||
.map(session => ({
|
||||
sessionId: session.sessionId,
|
||||
connectionId: session.connectionId,
|
||||
connectionName: session.connectionName,
|
||||
terminalIndex: session.terminalIndex,
|
||||
status: session.wsManager.connectionStatus.value, // 从 wsManager 获取状态
|
||||
isMarkedForSuspend: session.isMarkedForSuspend,
|
||||
}));
|
||||
}
|
||||
: sessionList.sort((left, right) => left.createdAt - right.createdAt);
|
||||
|
||||
return orderedSessions.map((session) => ({
|
||||
sessionId: session.sessionId,
|
||||
connectionId: session.connectionId,
|
||||
connectionName: session.connectionName,
|
||||
terminalIndex: session.terminalIndex,
|
||||
status: session.wsManager.connectionStatus.value,
|
||||
isMarkedForSuspend: session.isMarkedForSuspend,
|
||||
isCommandRunning: session.isCommandRunning.value,
|
||||
}));
|
||||
});
|
||||
|
||||
export const activeSession = computed((): SessionState | null => {
|
||||
if (!activeSessionId.value) return null;
|
||||
if (!activeSessionId.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sessions.value.get(activeSessionId.value) || null;
|
||||
});
|
||||
|
||||
@@ -1,58 +1,47 @@
|
||||
|
||||
|
||||
import type { Ref } from 'vue';
|
||||
import type { FileTab as OriginalFileTab } from '../fileEditor.store';
|
||||
import type { WsConnectionStatus } from '../../composables/useWebSocketConnection';
|
||||
import type { DockerManagerInstance as OriginalDockerManagerInstance } from '../../composables/useDockerManager';
|
||||
|
||||
// 导入工厂函数仅用于通过 ReturnType 推导实例类型
|
||||
// 这些导入仅用于类型推断,不在运行时使用
|
||||
import type { createWebSocketConnectionManager } from '../../composables/useWebSocketConnection';
|
||||
import type { createSftpActionsManager } from '../../composables/useSftpActions';
|
||||
import type { createSshTerminalManager } from '../../composables/useSshTerminal';
|
||||
import type { createStatusMonitorManager } from '../../composables/useStatusMonitor';
|
||||
|
||||
|
||||
// 使用 ReturnType 定义其他管理器实例类型
|
||||
export type WsManagerInstance = ReturnType<typeof createWebSocketConnectionManager>;
|
||||
export type SftpManagerInstance = ReturnType<typeof createSftpActionsManager>;
|
||||
export type SshTerminalInstance = ReturnType<typeof createSshTerminalManager>;
|
||||
export type StatusMonitorInstance = ReturnType<typeof createStatusMonitorManager>;
|
||||
|
||||
// 为 DockerManagerInstance 创建一个本地类型别名,并导出它
|
||||
export type DockerManagerInstance = OriginalDockerManagerInstance;
|
||||
|
||||
// 重新导出 FileTab 类型,使其可用于其他模块
|
||||
export type FileTab = OriginalFileTab;
|
||||
|
||||
export interface SessionState {
|
||||
sessionId: string;
|
||||
connectionId: string; // 数据库中的连接 ID
|
||||
connectionName: string; // 用于显示
|
||||
terminalIndex: number; // 同一连接下的终端序号,从 1 开始
|
||||
connectionId: string;
|
||||
connectionName: string;
|
||||
terminalIndex: number;
|
||||
wsManager: WsManagerInstance;
|
||||
sftpManagers: Map<string, SftpManagerInstance>; // 使用 Map 管理多个实例
|
||||
sftpManagers: Map<string, SftpManagerInstance>;
|
||||
terminalManager: SshTerminalInstance;
|
||||
statusMonitorManager: StatusMonitorInstance;
|
||||
dockerManager: DockerManagerInstance; // 现在应该可以找到 DockerManagerInstance
|
||||
// --- 独立编辑器状态 ---
|
||||
editorTabs: Ref<FileTab[]>; // 编辑器标签页列表
|
||||
activeEditorTabId: Ref<string | null>; // 当前活动的编辑器标签页 ID
|
||||
// --- 命令输入框内容 ---
|
||||
commandInputContent: Ref<string>; // 当前会话的命令输入框内容
|
||||
isResuming?: boolean; // 标记会话是否正在从挂起状态恢复
|
||||
isMarkedForSuspend?: boolean; // +++ 标记会话是否已被用户请求标记为待挂起 +++
|
||||
createdAt: number; // 记录会话创建的时间戳,用于排序
|
||||
disposables?: (() => void)[]; // 用于存储清理函数,例如取消注册消息处理器
|
||||
pendingOutput?: string[]; // 用于暂存恢复会话时,在终端实例准备好之前收到的输出
|
||||
dockerManager: DockerManagerInstance;
|
||||
editorTabs: Ref<FileTab[]>;
|
||||
activeEditorTabId: Ref<string | null>;
|
||||
commandInputContent: Ref<string>;
|
||||
isCommandRunning: Ref<boolean>;
|
||||
terminalInputBuffer: Ref<string>;
|
||||
isResuming?: boolean;
|
||||
isMarkedForSuspend?: boolean;
|
||||
createdAt: number;
|
||||
disposables?: (() => void)[];
|
||||
pendingOutput?: string[];
|
||||
}
|
||||
|
||||
// 为标签栏定义包含状态的类型
|
||||
export interface SessionTabInfoWithStatus {
|
||||
sessionId: string;
|
||||
connectionId: string;
|
||||
connectionName: string;
|
||||
terminalIndex: number;
|
||||
status: WsConnectionStatus; // 添加状态字段
|
||||
isMarkedForSuspend?: boolean; // +++ 用于UI指示会话是否已标记待挂起 +++
|
||||
status: WsConnectionStatus;
|
||||
isMarkedForSuspend?: boolean;
|
||||
isCommandRunning: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user