feat(frontend): 新增全局服务器快捷检索并优化工作台
新增 Ctrl+Shift+F 全局服务器检索面板,支持对 SSH、 RDP、VNC 连接进行本地模糊搜索、键盘导航与直接连接, 并统一复用现有 sessionStore 连接链路 同时将 Workbench 导航从左侧竖排 icon rail 调整为标题上方 横向纯图标栏,并补充终端标签页“关闭全部”菜单项
This commit is contained in:
@@ -8,6 +8,8 @@
|
||||
- 2026-03-25:继续微调 `/workspace` Workbench,新增默认“快捷指令”标签、调整三栏宽度到更接近 xterminal 参考图,并修复终端区域鼠标悬停时指针异常消失的问题。
|
||||
|
||||
### 修复
|
||||
- **[frontend]**: 将 `/workspace` Workbench 的导航从左侧竖排 icon rail 调整为 `Workbench` header 上方的横向纯图标栏,保留原有四面板切换逻辑与信息头部层级 — by yinjianm
|
||||
- 方案: [202603300206_workspace-workbench-top-tabs](archive/2026-03/202603300206_workspace-workbench-top-tabs/)
|
||||
- **[frontend]**: 将 `/workspace` 的 SSH 多终端展示从顶部组头胶囊改为“顶部只切服务器、终端面板内部切换同服务器多个终端”,修正服务器与终端的视觉层级 - by yinjianm
|
||||
- 方案: [202603292139_terminal-server-internal-tabs](archive/2026-03/202603292139_terminal-server-internal-tabs/)
|
||||
- **[frontend]**: 修复文件管理器右键菜单的回归关闭竞态,避免“终端 / 上传 / 压缩”子菜单在展开或点击前被捕获阶段监听提前关闭 - by yinjianm
|
||||
@@ -59,6 +61,8 @@
|
||||
- 文件: packages/frontend/src/components/AddEditQuickCommandForm.vue:9,184-185,242-245
|
||||
|
||||
### 新增
|
||||
- **[frontend]**: 为已登录页面新增 `Ctrl+Shift+F` 全局服务器快捷检索面板,支持模糊搜索并直接复用既有 SSH / RDP / VNC 连接链路 — by yinjianm
|
||||
- 方案: [202603300204_global-server-quick-search](archive/2026-03/202603300204_global-server-quick-search/)
|
||||
- **[frontend]**: 为文件管理器补齐“上传文件夹”入口,选择目录后会先在浏览器端打包为 zip,再上传并自动触发远端解压 — by yinjianm
|
||||
- 方案: [202603260234_folder-upload-auto-zip](archive/2026-03/202603260234_folder-upload-auto-zip/)
|
||||
- **[frontend]**: 为工作台文件面板补齐左侧多根目录资源管理器,支持收藏路径与当前路径同屏作为多个根目录展开浏览 — by yinjianm
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"status":"completed","completed":6,"failed":0,"pending":0,"total":6,"done":6,"percent":100,"current":"全局服务器快捷检索与连接链路已完成","updated_at":"2026-03-30 02:28:00"}
|
||||
@@ -0,0 +1,127 @@
|
||||
# 变更提案: global-server-quick-search
|
||||
|
||||
## 元信息
|
||||
```yaml
|
||||
类型: 新功能
|
||||
方案类型: implementation
|
||||
优先级: P1
|
||||
状态: 开发中
|
||||
状态说明: 为已登录用户新增全局服务器快捷检索面板,复用现有连接链路自动进入工作区或打开远程桌面弹窗
|
||||
创建: 2026-03-30
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求
|
||||
|
||||
### 背景
|
||||
当前工作区只在局部位置提供服务器搜索与连接能力:顶部 `+` 弹窗里的连接列表支持搜索,`WorkspaceConnectionList.vue` 也支持本地筛选和键盘上下切换,但用户无法在任意已登录页面直接用快捷键唤起“搜索服务器并立即连接”的全局入口。
|
||||
|
||||
### 目标
|
||||
- 为已登录状态新增全局快捷键 `Ctrl+Shift+F`
|
||||
- 按下后弹出独立输入框/面板,可按关键词模糊检索所有连接类型的服务器
|
||||
- 支持 `ArrowUp` / `ArrowDown` 在候选结果中切换,`Enter` 选中后自动连接
|
||||
- 连接动作必须复用现有 `sessionStore.handleConnectRequest()` 链路,保持 SSH / RDP / VNC 的既有行为一致
|
||||
|
||||
### 约束条件
|
||||
```yaml
|
||||
时间约束: 本轮完成前端最小闭环,不扩展到后端接口和持久化最近搜索
|
||||
性能约束: 搜索在前端本地完成,结果限制在少量高相关候选,避免每次输入都触发远程请求
|
||||
兼容性约束: 不破坏现有 Alt 焦点切换、工作区连接列表搜索和既有 SSH/RDP/VNC 连接逻辑
|
||||
业务约束: 搜索范围固定为所有连接类型(SSH / RDP / VNC)
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
- [x] 已登录页面按下 `Ctrl+Shift+F` 能打开全局服务器检索面板
|
||||
- [x] 输入任意关键词后,能对 `SSH / RDP / VNC` 连接做模糊匹配并按相关度排序
|
||||
- [x] 面板支持 `ArrowUp` / `ArrowDown` 切换高亮项,`Enter` 自动连接,`Esc` 关闭
|
||||
- [x] 选中 `SSH` 时自动进入 `/workspace` 并按既有逻辑打开/激活会话
|
||||
- [x] 选中 `RDP / VNC` 时复用现有弹窗逻辑,不破坏原有连接行为
|
||||
- [ ] 前端构建通过
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 技术方案
|
||||
在 `App.vue` 注册全局快捷键监听,新增一个独立的全局连接检索组件承载输入框、结果列表和键盘导航。组件内部使用轻量模糊评分函数,对连接名称、主机、用户名和类型做本地排序;`App.vue` 在面板打开时确保连接缓存已加载,并在用户确认后直接调用 `sessionStore.handleConnectRequest(connection)`,利用现有逻辑处理 SSH 新建/重连、RDP 弹窗、VNC 弹窗和自动路由跳转。
|
||||
|
||||
### 影响范围
|
||||
```yaml
|
||||
涉及模块:
|
||||
- frontend: App.vue 负责全局快捷键监听、面板生命周期和连接提交
|
||||
- frontend: 新增全局连接检索组件/工具,负责模糊检索、结果渲染与键盘导航
|
||||
- frontend: locale 文案,补齐面板标题、占位符、空态和提示语
|
||||
预计变更文件: 5-7
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
| 风险 | 等级 | 应对 |
|
||||
|------|------|------|
|
||||
| 全局键盘监听与现有 Alt 快捷键或输入框事件冲突 | 中 | 面板打开时优先消费快捷键,并对 Alt 逻辑加早退保护 |
|
||||
| 不引入第三方库时模糊匹配排序不稳定 | 低 | 使用“精确包含优先 + 子序列匹配兜底”的轻量评分策略,并限制结果数量 |
|
||||
| 非工作区页面直接连接遗漏既有跳转逻辑 | 低 | 统一走 `sessionStore.handleConnectRequest()`,由既有 router 负责跳转到 `/workspace` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术设计(可选)
|
||||
|
||||
> 涉及架构变更、API设计、数据模型变更时填写
|
||||
|
||||
### 架构设计
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[App.vue 全局快捷键] --> B[GlobalConnectionQuickSearch]
|
||||
B --> C[connectionSearch 工具]
|
||||
B --> D[sessionStore.handleConnectRequest]
|
||||
D --> E[Workspace / RDP / VNC 既有链路]
|
||||
```
|
||||
|
||||
### API设计
|
||||
N/A,复用现有前端 store 与路由逻辑,不新增接口。
|
||||
|
||||
### 数据模型
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `GlobalSearchItem` | 前端派生结构 | 由 `ConnectionInfo` 派生,用于面板渲染和提交连接 |
|
||||
| `query` | `string` | 当前检索关键词 |
|
||||
| `selectedIndex` | `number` | 当前高亮候选索引 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心场景
|
||||
|
||||
> 执行完成后同步到对应模块文档
|
||||
|
||||
### 场景: 全局快捷检索并自动连接服务器
|
||||
**模块**: frontend
|
||||
**条件**: 用户已登录,位于任意受保护页面,连接数据可从缓存或接口获取
|
||||
**行为**: 用户按下 `Ctrl+Shift+F` 打开全局检索面板,输入关键词后通过键盘上下选择目标服务器,按下 `Enter` 后提交现有连接链路
|
||||
**结果**: SSH 自动进入工作区并打开/激活会话,RDP / VNC 继续使用既有弹窗连接逻辑
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术决策
|
||||
|
||||
> 本方案涉及的技术决策,归档后成为决策的唯一完整记录
|
||||
|
||||
### global-server-quick-search#D001: 全局快捷检索直接复用 sessionStore 连接入口
|
||||
**日期**: 2026-03-30
|
||||
**状态**: ✅采纳
|
||||
**背景**: 全局检索需要在工作区外也能发起连接,如果复用 workspace 事件总线,会受当前视图是否挂载相关组件影响
|
||||
**选项分析**:
|
||||
| 选项 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| A: 直接调用 `sessionStore.handleConnectRequest()` | 可跨页面工作,自动复用 SSH/RDP/VNC 与路由逻辑,入口单一 | 需要在 App 层拿到 store 并管理连接数据 |
|
||||
| B: 复用 `workspaceEmitter` 的 `connection:connect` 事件 | 能沿用部分工作区现有事件流 | 非工作区页面不稳定,依赖 `TerminalTabBar` 等订阅方已挂载 |
|
||||
**决策**: 选择方案 A
|
||||
**理由**: 该需求强调“全局”唤起,必须保证不论当前处于仪表盘、连接管理还是工作区都能直达既有连接逻辑,因此直接走 `sessionStore` 是最稳定的主入口
|
||||
**影响**: 影响 `App.vue`、全局检索组件以及连接缓存加载时机
|
||||
|
||||
---
|
||||
|
||||
## 6. 成果设计
|
||||
|
||||
> 含视觉产出的任务由 DESIGN Phase2 填充。非视觉任务整节标注"N/A"。
|
||||
|
||||
N/A,本次为现有前端界面内的功能型弹层增强,沿用项目既有主题变量与组件风格,不单独引入新的视觉体系。
|
||||
@@ -0,0 +1,57 @@
|
||||
# 任务清单: global-server-quick-search
|
||||
|
||||
> **@status:** completed | 2026-03-30 02:15
|
||||
|
||||
```yaml
|
||||
@feature: global-server-quick-search
|
||||
@created: 2026-03-30
|
||||
@status: completed
|
||||
@mode: R2
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 6 | 0 | 0 | 6 |
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
### 1. 方案与范围确认
|
||||
|
||||
- [√] 1.1 创建全局服务器快捷检索方案包并锁定为前端实现 | depends_on: []
|
||||
|
||||
### 2. 全局检索能力实现
|
||||
|
||||
- [√] 2.1 新增全局服务器检索面板与本地模糊匹配排序 | depends_on: [1.1]
|
||||
- [√] 2.2 在 `App.vue` 接入 `Ctrl+Shift+F` 打开/关闭逻辑并管理连接数据加载 | depends_on: [2.1]
|
||||
- [√] 2.3 接通上下键切换、回车自动连接与现有 `sessionStore` 连接链路 | depends_on: [2.2]
|
||||
|
||||
### 3. 文案与验证
|
||||
|
||||
- [√] 3.1 补齐多语言文案并执行前端构建验证 | depends_on: [2.3]
|
||||
- [√] 3.2 同步前端知识库与 CHANGELOG 记录并完成归档 | depends_on: [3.1]
|
||||
|
||||
---
|
||||
|
||||
## 执行日志
|
||||
|
||||
| 时间 | 任务 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 2026-03-30 02:04 | 1.1 | 完成 | 创建 implementation 方案包,范围锁定为前端全局快捷检索与现有连接链路复用 |
|
||||
| 2026-03-30 02:12 | 2.1 | 完成 | 新增 `GlobalConnectionQuickSearch.vue` 与 `connectionSearch.ts`,提供本地模糊搜索和结果排序 |
|
||||
| 2026-03-30 02:15 | 2.2 | 完成 | `App.vue` 接入 `Ctrl+Shift+F` 全局快捷键、面板开关和连接列表加载 |
|
||||
| 2026-03-30 02:17 | 2.3 | 完成 | 接通上下键、回车自动连接与 `sessionStore.handleConnectRequest()` 复用链路 |
|
||||
| 2026-03-30 02:22 | 3.1 | 完成 | 补齐中英日文案,并通过 `npm run build --workspace @nexus-terminal/frontend` |
|
||||
| 2026-03-30 02:28 | 3.2 | 完成 | 已同步 `frontend` 模块文档与 CHANGELOG,准备归档方案包 |
|
||||
|
||||
---
|
||||
|
||||
## 执行备注
|
||||
|
||||
> 记录执行过程中的重要说明、决策变更、风险提示等
|
||||
|
||||
- 当前选择范围: 搜索所有连接类型(SSH / RDP / VNC)
|
||||
- 默认策略: 全局唤起、局部实现,不新增后端接口
|
||||
@@ -0,0 +1,117 @@
|
||||
# 变更提案: workspace-workbench-top-tabs
|
||||
|
||||
## 元信息
|
||||
```yaml
|
||||
类型: 优化
|
||||
方案类型: implementation
|
||||
优先级: P2
|
||||
状态: 已完成
|
||||
状态说明: 已完成实现、构建验证通过,待归档
|
||||
创建: 2026-03-30
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求
|
||||
|
||||
### 背景
|
||||
当前 `WorkspaceWorkbench.vue` 把四个入口做成最左侧窄 icon rail,竖向贴在内容区左侧;用户希望保留纯图标按钮风格,但把导航挪到 `Workbench` 标题区上方,改成横向排列,以更符合预期的顶部工作台切换方式。
|
||||
|
||||
### 目标
|
||||
- 保持 Workbench 的四个入口仍为纯图标按钮。
|
||||
- 将 `快捷指令 / 文件 / 历史命令 / 编辑器` 导航从左侧竖排 rail 改为顶部横向栏。
|
||||
- 保留现有 `Workbench` 头部信息与下方内容区切换逻辑,不改变三栏主布局与面板能力。
|
||||
|
||||
### 约束条件
|
||||
```yaml
|
||||
范围约束: 仅调整 `packages/frontend/src/components/WorkspaceWorkbench.vue` 的导航结构与对应样式
|
||||
交互约束: 保持 `v-show` 常驻切换,不引入新的状态管理或路由逻辑
|
||||
视觉约束: 保留当前高对比深色渐变工作台风格,按钮仍以纯图标为主
|
||||
兼容性约束: 不改动 Workbench 四个面板的组件挂载关系,避免影响文件管理器与编辑器状态保持
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
- [ ] Workbench 导航显示在 header 上方,并按横向排列展示四个图标按钮
|
||||
- [ ] 当前激活态、hover 态和默认快捷指令初始选中行为保持不变
|
||||
- [ ] `Workbench` 标题、会话名和“工作台”标签仍保留在横向导航下方
|
||||
- [ ] 前端构建通过,无新增类型错误
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 技术方案
|
||||
将 `WorkspaceWorkbench.vue` 的根布局从“左右两栏”改为“上下三段”:顶部 `workbench-rail` 横向图标栏、中部原有 `Workbench` header、底部内容区。复用现有 `workbenchTabs` 数据与 `activeWorkbenchTab` 切换逻辑,仅调整模板结构和 `workbench-rail` 样式方向,避免触碰四个面板组件的生命周期与数据流。
|
||||
|
||||
### 影响范围
|
||||
```yaml
|
||||
涉及模块:
|
||||
- frontend: 更新 Workbench 导航结构、样式与知识库文档描述
|
||||
预计变更文件: 4
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
| 风险 | 等级 | 应对 |
|
||||
|------|------|------|
|
||||
| 横向栏在窄宽度 pane 中压缩过度 | 低 | 保持纯图标按钮尺寸,并让导航容器支持横向排布 |
|
||||
| 调整容器层级后影响内容区高度计算 | 低 | 维持根容器 `flex-col` + 内容区 `flex-1 min-h-0` 结构 |
|
||||
| 知识库仍保留“左侧竖排 rail”旧描述 | 低 | 同步更新 `frontend.md` 与 `CHANGELOG.md` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术设计(可选)
|
||||
|
||||
N/A,本次不涉及 API、数据模型或跨组件数据流改造。
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心场景
|
||||
|
||||
> 执行完成后同步到对应模块文档
|
||||
|
||||
### 场景: 工作台顶部导航切换
|
||||
**模块**: frontend
|
||||
**条件**: 用户进入 `/workspace`,左侧 Workbench pane 已展示。
|
||||
**行为**: 用户在 `Workbench` 标题区上方使用横向纯图标导航切换 `快捷指令 / 文件 / 历史命令 / 编辑器` 面板。
|
||||
**结果**: 内容区沿用现有常驻切换逻辑,仅切换可见面板,不重构整体三栏布局。
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术决策
|
||||
|
||||
> 本方案涉及的技术决策,归档后成为决策的唯一完整记录
|
||||
|
||||
### workspace-workbench-top-tabs#D001: Workbench 导航改为顶部独立横向图标栏
|
||||
**日期**: 2026-03-30
|
||||
**状态**: ✅采纳
|
||||
**背景**: 用户明确要求将当前位于内容区左侧的竖排图标 rail 移到 `Workbench` header 上方,同时保留纯图标表现。
|
||||
**选项分析**:
|
||||
| 选项 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| A: 顶部独立一行横向图标栏 | 保留现有图标按钮密度;与 header 信息分层清晰;改动范围最小 | 占用少量垂直空间 |
|
||||
| B: 并入 header 同一行 | 视觉更紧凑 | 会挤压会话标题区域,窄宽度下更容易拥挤 |
|
||||
**决策**: 选择方案 A
|
||||
**理由**: 这是用户明确确认的方案,且只需调整模板层级与 rail 样式方向,不影响现有 header 文案布局和内容区状态保持。
|
||||
**影响**: 影响 `WorkspaceWorkbench.vue` 的 DOM 结构、样式方向,以及 `frontend.md` 中对 Workbench 导航形态的描述。
|
||||
|
||||
---
|
||||
|
||||
## 6. 成果设计
|
||||
|
||||
> 含视觉产出的任务由 DESIGN Phase2 填充。非视觉任务整节标注"N/A"。
|
||||
|
||||
### 设计方向
|
||||
- **美学基调**: 深色控制台工具条,像设备面板顶部功能拨片一样把四个入口压缩成紧凑横向导航
|
||||
- **记忆点**: 顶部一条窄而亮的图标导航带,与下方信息型 header 形成明显分层
|
||||
- **参考**: 用户提供的现有 Workbench 截图与“放到标题上方、横着排”的明确要求
|
||||
|
||||
### 视觉要素
|
||||
- **配色**: 继续沿用现有深蓝黑渐变工具条,激活项保持 `primary` 高亮,非激活项维持低饱和灰阶
|
||||
- **字体**: 沿用项目现有字体体系,本次视觉重点不在字形,而在图标导航层级
|
||||
- **布局**: 顶部横向图标栏单独占一行,下方保留信息型 header,再下方为内容区
|
||||
- **动效**: 延续现有按钮 hover/active 过渡,不新增额外动画
|
||||
- **氛围**: rail 保留深色渐变与内阴影,但阴影方向从纵向分隔改为横向分隔
|
||||
|
||||
### 技术约束
|
||||
- **可访问性**: 保留现有 `title` 与 `aria-label`,确保纯图标按钮仍可被识别
|
||||
- **响应式**: 顶部导航需在窄 pane 宽度下仍保持横向排列,不引入文字标签导致拥挤
|
||||
@@ -0,0 +1,52 @@
|
||||
# 任务清单: workspace-workbench-top-tabs
|
||||
|
||||
> **@status:** completed | 2026-03-30 02:12
|
||||
|
||||
```yaml
|
||||
@feature: workspace-workbench-top-tabs
|
||||
@created: 2026-03-30
|
||||
@status: completed
|
||||
@mode: R2
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 5 | 0 | 0 | 5 |
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
### 1. 方案与上下文
|
||||
|
||||
- [√] 1.1 创建 Workbench 顶部横向导航方案包并记录用户确认的纯图标方案 | depends_on: []
|
||||
|
||||
### 2. 前端实现
|
||||
|
||||
- [√] 2.1 在 `packages/frontend/src/components/WorkspaceWorkbench.vue` 中将左侧竖排 rail 改为顶部横向图标导航栏 | depends_on: [1.1]
|
||||
- [√] 2.2 校准 `workbench-rail` 样式方向与容器层级,确保 header 与内容区高度计算不回归 | depends_on: [2.1]
|
||||
|
||||
### 3. 验证与知识库同步
|
||||
|
||||
- [√] 3.1 运行前端构建验证,确认本次 Workbench 结构调整无类型或打包错误 | depends_on: [2.2]
|
||||
- [√] 3.2 同步更新 `.helloagents/modules/frontend.md` 与 `.helloagents/CHANGELOG.md` 的 Workbench 导航描述 | depends_on: [3.1]
|
||||
|
||||
---
|
||||
|
||||
## 执行日志
|
||||
|
||||
| 时间 | 任务 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 2026-03-30 02:06 | 1.1 | 完成 | 创建 implementation 方案包,并记录用户确认采用顶部横向纯图标导航 |
|
||||
| 2026-03-30 02:08 | 2.1 / 2.2 | 完成 | 将 Workbench 左侧竖排 icon rail 调整为 header 上方横向 icon rail,并同步 rail 渐变方向 |
|
||||
| 2026-03-30 02:09 | 3.1 | 完成 | 执行 `npm run build --workspace @nexus-terminal/frontend`,构建通过,仅保留既有 chunk 体积与 dynamic import 警告 |
|
||||
| 2026-03-30 02:10 | 3.2 | 完成 | 更新 frontend 模块文档与 CHANGELOG,准备归档 |
|
||||
|
||||
---
|
||||
|
||||
## 执行备注
|
||||
|
||||
- 本次只调整 Workbench 内部导航层级,不改三栏主布局和四个面板的组件关系。
|
||||
- 用户已确认保留纯图标风格,不切到“图标 + 文本”标签样式。
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|
||||
|--------|------|------|---------|------|------|
|
||||
| 202603300204 | global-server-quick-search | - | - | - | ✅完成 |
|
||||
| 202603300206 | workspace-workbench-top-tabs | implementation | frontend | workspace-workbench-top-tabs#D001 | ✅完成 |
|
||||
| 202603292139 | terminal-server-internal-tabs | - | - | - | ✅完成 |
|
||||
| 202603292247 | workbench-left-icon-rail | - | - | - | ✅完成 |
|
||||
| 202603292300 | terminal-tab-close-all | implementation | frontend | terminal-tab-close-all#D001 | ✅完成 |
|
||||
@@ -43,6 +45,7 @@
|
||||
## 按月归档
|
||||
|
||||
### 2026-03
|
||||
- [202603300206_workspace-workbench-top-tabs](./2026-03/202603300206_workspace-workbench-top-tabs/) - 将 Workbench 的导航从左侧竖排 icon rail 调整为 `Workbench` header 上方的横向纯图标栏
|
||||
- [202603292300_terminal-tab-close-all](./2026-03/202603292300_terminal-tab-close-all/) - 为终端标签右键菜单补充“关闭全部”,并复用现有工作区会话清理链路
|
||||
- [202603260527_file-manager-context-submenu-regression](./2026-03/202603260527_file-manager-context-submenu-regression/) - 修复文件管理器右键菜单回归关闭竞态,恢复终端、上传、压缩等子菜单展开与点击
|
||||
- [202603260324_file-manager-delete-upload-stability](./2026-03/202603260324_file-manager-delete-upload-stability/) - 修复文件管理器右键子菜单点击、拖拽上传目标确认、目录删除模式选择与删除后路径失效回退
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -9,12 +9,14 @@ import { useAppearanceStore } from './stores/appearance.store';
|
||||
import { useLayoutStore } from './stores/layout.store';
|
||||
import { useFocusSwitcherStore } from './stores/focusSwitcher.store';
|
||||
import { useSessionStore } from './stores/session.store';
|
||||
import { useConnectionsStore } from './stores/connections.store';
|
||||
import { useFavoritePathsStore } from './stores/favoritePaths.store';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import UINotificationDisplay from './components/UINotificationDisplay.vue';
|
||||
import FileEditorOverlay from './components/FileEditorOverlay.vue';
|
||||
import StyleCustomizer from './components/StyleCustomizer.vue';
|
||||
import FocusSwitcherConfigurator from './components/FocusSwitcherConfigurator.vue';
|
||||
import GlobalConnectionQuickSearch from './components/GlobalConnectionQuickSearch.vue';
|
||||
import RemoteDesktopModal from './components/RemoteDesktopModal.vue';
|
||||
import VncModal from './components/VncModal.vue';
|
||||
import ConfirmDialog from './components/common/ConfirmDialog.vue';
|
||||
@@ -27,10 +29,12 @@ const appearanceStore = useAppearanceStore();
|
||||
const layoutStore = useLayoutStore();
|
||||
const focusSwitcherStore = useFocusSwitcherStore(); // +++ 实例化焦点切换 Store +++
|
||||
const sessionStore = useSessionStore(); // +++ 实例化 Session Store +++
|
||||
const connectionsStore = useConnectionsStore();
|
||||
const dialogStore = useDialogStore(); // +++ 实例化 DialogStore +++
|
||||
const { state: dialogState } = storeToRefs(dialogStore);
|
||||
const favoritePathsStore = useFavoritePathsStore(); // +++ 实例化 favoritePathsStore +++
|
||||
const { isAuthenticated } = storeToRefs(authStore);
|
||||
const { connections, isLoading: connectionsLoading } = storeToRefs(connectionsStore);
|
||||
const { showPopupFileEditorBoolean } = storeToRefs(settingsStore);
|
||||
const { isStyleCustomizerVisible } = storeToRefs(appearanceStore);
|
||||
const { isLayoutVisible, isHeaderVisible } = storeToRefs(layoutStore); // 添加 isHeaderVisible
|
||||
@@ -46,6 +50,7 @@ const underlineRef = ref<HTMLElement | null>(null);
|
||||
const lastFocusedIdBySwitcher = ref<string | null>(null);
|
||||
const isAltPressed = ref(false); // 跟踪 Alt 键是否按下
|
||||
const altShortcutKey = ref<string | null>(null);
|
||||
const isGlobalConnectionSearchVisible = ref(false);
|
||||
// --- 移除 shortcutTriggeredInKeyDown 标志 ---
|
||||
|
||||
const updateUnderline = async () => {
|
||||
@@ -70,6 +75,7 @@ onMounted(() => {
|
||||
setTimeout(updateUnderline, 100);
|
||||
|
||||
// +++ 全局 Alt 键监听器 +++
|
||||
window.addEventListener('keydown', handleGlobalShortcutKeyDown);
|
||||
window.addEventListener('keydown', handleAltKeyDown); // +++ 监听 keydown 设置状态 +++
|
||||
window.addEventListener('keyup', handleGlobalKeyUp); // +++ 监听 keyup 执行切换 +++
|
||||
|
||||
@@ -96,6 +102,7 @@ watch(isAuthenticated, (loggedIn) => {
|
||||
|
||||
// +++ 卸载钩子以移除监听器 +++
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleGlobalShortcutKeyDown);
|
||||
window.removeEventListener('keydown', handleAltKeyDown); // +++ 移除 keydown 监听 +++
|
||||
window.removeEventListener('keyup', handleGlobalKeyUp); // +++ 移除 keyup 监听 +++
|
||||
});
|
||||
@@ -108,6 +115,12 @@ watch(route, () => {
|
||||
updateUnderline();
|
||||
}, { immediate: true }); // *** 确保 immediate: true 存在 ***
|
||||
|
||||
watch(isAuthenticated, (loggedIn) => {
|
||||
if (!loggedIn) {
|
||||
isGlobalConnectionSearchVisible.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout();
|
||||
@@ -123,8 +136,43 @@ const closeStyleCustomizer = () => {
|
||||
appearanceStore.toggleStyleCustomizer(false);
|
||||
};
|
||||
|
||||
const openGlobalConnectionSearch = () => {
|
||||
if (!isAuthenticated.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isGlobalConnectionSearchVisible.value = true;
|
||||
void connectionsStore.fetchConnections();
|
||||
};
|
||||
|
||||
const closeGlobalConnectionSearch = () => {
|
||||
isGlobalConnectionSearchVisible.value = false;
|
||||
};
|
||||
|
||||
const handleGlobalConnectionSelect = (connection: (typeof connections.value)[number]) => {
|
||||
closeGlobalConnectionSearch();
|
||||
sessionStore.handleConnectRequest(connection);
|
||||
};
|
||||
|
||||
const handleGlobalShortcutKeyDown = (event: KeyboardEvent) => {
|
||||
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||||
if (!(event.ctrlKey && event.shiftKey && key === 'f')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAuthenticated.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
if (!isGlobalConnectionSearchVisible.value) {
|
||||
openGlobalConnectionSearch();
|
||||
}
|
||||
};
|
||||
|
||||
// +++ 处理 Alt 键按下的事件处理函数,并记录快捷键 +++
|
||||
const handleAltKeyDown = async (event: KeyboardEvent) => { // +++ 改为 async +++
|
||||
if (isGlobalConnectionSearchVisible.value) return;
|
||||
if (!isWorkspaceRoute.value) return; // 只在 workspace 路由下执行
|
||||
// 只在 Alt 键首次按下时设置状态
|
||||
if (event.key === 'Alt' && !event.repeat) {
|
||||
@@ -178,6 +226,7 @@ const handleAltKeyDown = async (event: KeyboardEvent) => { // +++ 改为 async +
|
||||
|
||||
// +++ 全局键盘事件处理函数,监听 keyup,优先处理快捷键 +++
|
||||
const handleGlobalKeyUp = async (event: KeyboardEvent) => {
|
||||
if (isGlobalConnectionSearchVisible.value) return;
|
||||
if (!isWorkspaceRoute.value) return; // 只在 workspace 路由下执行
|
||||
if (event.key === 'Alt') {
|
||||
const altWasPressed = isAltPressed.value;
|
||||
@@ -363,6 +412,14 @@ const isElementVisibleAndFocusable = (element: HTMLElement): boolean => {
|
||||
@update:visible="(val: boolean) => dialogStore.state.visible = val"
|
||||
/>
|
||||
|
||||
<GlobalConnectionQuickSearch
|
||||
v-if="isGlobalConnectionSearchVisible"
|
||||
:connections="connections"
|
||||
:is-loading="connectionsLoading"
|
||||
@close="closeGlobalConnectionSearch"
|
||||
@select="handleGlobalConnectionSelect"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { ConnectionInfo } from '../stores/connections.store';
|
||||
import { searchConnections } from '../utils/connectionSearch';
|
||||
|
||||
const props = defineProps<{
|
||||
connections: ConnectionInfo[];
|
||||
isLoading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
(e: 'select', connection: ConnectionInfo): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
const query = ref('');
|
||||
const selectedIndex = ref(0);
|
||||
|
||||
const results = computed(() => searchConnections(props.connections, query.value, 8));
|
||||
|
||||
watch(results, async (nextResults) => {
|
||||
if (nextResults.length === 0) {
|
||||
selectedIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedIndex.value < 0 || selectedIndex.value >= nextResults.length) {
|
||||
selectedIndex.value = 0;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
inputRef.value?.focus();
|
||||
inputRef.value?.select();
|
||||
});
|
||||
|
||||
const close = () => emit('close');
|
||||
|
||||
const selectResult = (index: number) => {
|
||||
const target = results.value[index];
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('select', target.connection);
|
||||
};
|
||||
|
||||
const moveSelection = (direction: 1 | -1) => {
|
||||
if (results.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedIndex.value === -1) {
|
||||
selectedIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
selectedIndex.value = (selectedIndex.value + direction + results.value.length) % results.value.length;
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
moveSelection(1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
moveSelection(-1);
|
||||
break;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (selectedIndex.value >= 0) {
|
||||
selectResult(selectedIndex.value);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
close();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionLabel = (connection: ConnectionInfo): string => connection.name || connection.host;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="fixed inset-0 z-[10001] flex items-start justify-center px-4 pt-[12vh]"
|
||||
:style="{ backgroundColor: 'var(--overlay-bg-color)' }"
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="w-full max-w-2xl overflow-hidden rounded-2xl border border-border bg-background shadow-2xl">
|
||||
<div class="border-b border-border/70 px-5 py-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-text-secondary">
|
||||
{{ t('globalConnectionSearch.shortcut') }}
|
||||
</p>
|
||||
<h2 class="mt-1 text-xl font-semibold text-foreground">
|
||||
{{ t('globalConnectionSearch.title') }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-2 py-1 text-sm text-text-secondary transition-colors duration-150 hover:bg-border hover:text-foreground"
|
||||
@click="close"
|
||||
>
|
||||
Esc
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="query"
|
||||
type="text"
|
||||
class="mt-4 w-full rounded-xl border border-border/70 bg-input px-4 py-3 text-base text-foreground shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-text-secondary focus:border-primary focus:ring-2 focus:ring-primary/40"
|
||||
:placeholder="t('globalConnectionSearch.placeholder')"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[60vh] overflow-y-auto px-3 py-3">
|
||||
<div v-if="isLoading && connections.length === 0" class="px-3 py-8 text-center text-sm text-text-secondary">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>{{ t('globalConnectionSearch.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="results.length === 0" class="px-3 py-8 text-center text-sm text-text-secondary">
|
||||
<i class="fas fa-search mb-3 block text-xl"></i>
|
||||
<p v-if="query">{{ t('globalConnectionSearch.noResults', { query }) }}</p>
|
||||
<p v-else>{{ t('globalConnectionSearch.emptyHint') }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="(item, index) in results"
|
||||
:key="item.connection.id"
|
||||
type="button"
|
||||
class="mb-2 flex w-full items-center gap-3 rounded-xl border px-4 py-3 text-left transition-all duration-150 last:mb-0"
|
||||
:class="index === selectedIndex
|
||||
? 'border-primary/60 bg-primary/10 shadow-[0_0_0_1px_rgba(34,197,94,0.12)]'
|
||||
: 'border-border/60 bg-header/40 hover:border-primary/30 hover:bg-primary/5'"
|
||||
@mouseenter="selectedIndex = index"
|
||||
@click="selectResult(index)"
|
||||
>
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-primary/12 text-primary">
|
||||
<i :class="['fas', item.connection.type === 'RDP' ? 'fa-desktop' : (item.connection.type === 'VNC' ? 'fa-plug' : 'fa-server')]"></i>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-sm font-semibold text-foreground">
|
||||
{{ getConnectionLabel(item.connection) }}
|
||||
</span>
|
||||
<span class="rounded-full bg-border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-text-secondary">
|
||||
{{ item.connection.type }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-text-secondary">
|
||||
<span>{{ item.connection.host }}:{{ item.connection.port }}</span>
|
||||
<span>{{ item.connection.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between border-t border-border/70 px-5 py-3 text-xs text-text-secondary">
|
||||
<span>{{ t('globalConnectionSearch.footerHint') }}</span>
|
||||
<span>{{ t('globalConnectionSearch.footerActions') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -243,6 +243,9 @@ const handleContextMenuAction = (payload: { action: string; targetId: string | n
|
||||
case 'close':
|
||||
emitWorkspaceEvent('session:close', { sessionId: targetId });
|
||||
break;
|
||||
case 'close-all':
|
||||
emitWorkspaceEvent('session:closeAll');
|
||||
break;
|
||||
case 'close-others':
|
||||
emitWorkspaceEvent('session:closeOthers', { targetSessionId: targetId });
|
||||
break;
|
||||
@@ -324,6 +327,7 @@ const contextMenuItems = computed(() => {
|
||||
items.push({ label: 'tabs.contextMenu.close', action: 'close' });
|
||||
|
||||
if (totalTabs > 1) {
|
||||
items.push({ label: 'tabs.contextMenu.closeAll', action: 'close-all' });
|
||||
items.push({ label: 'tabs.contextMenu.closeOthers', action: 'close-others' });
|
||||
}
|
||||
|
||||
|
||||
@@ -97,8 +97,8 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 overflow-hidden bg-background">
|
||||
<aside class="workbench-rail flex w-14 flex-shrink-0 flex-col items-center gap-2 border-r border-border px-2 py-3">
|
||||
<div class="flex h-full min-h-0 flex-col overflow-hidden bg-background">
|
||||
<div class="workbench-rail flex flex-shrink-0 items-center gap-2 overflow-x-auto border-b border-border px-3 py-2">
|
||||
<button
|
||||
v-for="tab in workbenchTabs"
|
||||
:key="tab.id"
|
||||
@@ -115,9 +115,8 @@ watch(
|
||||
>
|
||||
<i :class="tab.icon"></i>
|
||||
</button>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col overflow-hidden bg-background">
|
||||
<div class="border-b border-border bg-header px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
@@ -175,14 +174,13 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workbench-rail {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(30, 41, 59, 0.94) 0%, rgba(17, 24, 39, 0.98) 100%);
|
||||
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.04);
|
||||
linear-gradient(90deg, rgba(30, 41, 59, 0.94) 0%, rgba(17, 24, 39, 0.98) 100%);
|
||||
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.workbench-quick-commands {
|
||||
|
||||
@@ -1632,9 +1632,20 @@
|
||||
"serverEntryTitle": "{name} · {count} terminals",
|
||||
"terminalCount": "{count} terminals"
|
||||
},
|
||||
"globalConnectionSearch": {
|
||||
"shortcut": "Ctrl+Shift+F",
|
||||
"title": "Global Server Search",
|
||||
"placeholder": "Search by name, host, username, or type...",
|
||||
"loading": "Loading connections...",
|
||||
"emptyHint": "Type any keyword to fuzzy-search servers, or pick a recent connection directly.",
|
||||
"noResults": "No servers matched “{query}”.",
|
||||
"footerHint": "Quick connect for SSH / RDP / VNC",
|
||||
"footerActions": "↑↓ Navigate · Enter Connect · Esc Close"
|
||||
},
|
||||
"tabs": {
|
||||
"contextMenu": {
|
||||
"close": "Close Tab",
|
||||
"closeAll": "Close All Tabs",
|
||||
"closeOthers": "Close Other Tabs",
|
||||
"closeRight": "Close Tabs to the Right",
|
||||
"closeLeft": "Close Tabs to the Left",
|
||||
|
||||
@@ -1590,6 +1590,16 @@
|
||||
"openConnectionPickerTooltip": "別のサーバーを選択",
|
||||
"terminalBadge": "端末 {index}"
|
||||
},
|
||||
"globalConnectionSearch": {
|
||||
"shortcut": "Ctrl+Shift+F",
|
||||
"title": "グローバルサーバー検索",
|
||||
"placeholder": "名前、ホスト、ユーザー名、種類で検索...",
|
||||
"loading": "接続一覧を読み込み中...",
|
||||
"emptyHint": "キーワードを入力してサーバーをあいまい検索するか、最近の接続を直接選択してください。",
|
||||
"noResults": "「{query}」に一致するサーバーはありません。",
|
||||
"footerHint": "SSH / RDP / VNC をすばやく接続",
|
||||
"footerActions": "↑↓ 移動 · Enter 接続 · Esc 閉じる"
|
||||
},
|
||||
"tabs": {
|
||||
"contextMenu": {
|
||||
"close": "タブを閉じる",
|
||||
|
||||
@@ -1636,9 +1636,20 @@
|
||||
"serverEntryTitle": "{name} · {count} 个终端",
|
||||
"terminalCount": "{count} 个终端"
|
||||
},
|
||||
"globalConnectionSearch": {
|
||||
"shortcut": "Ctrl+Shift+F",
|
||||
"title": "全局服务器检索",
|
||||
"placeholder": "输入名称、主机、用户名或类型...",
|
||||
"loading": "正在加载连接列表...",
|
||||
"emptyHint": "输入任意关键词开始模糊检索服务器,或直接选择最近连接。",
|
||||
"noResults": "没有找到与 “{query}” 匹配的服务器。",
|
||||
"footerHint": "支持 SSH / RDP / VNC 全类型快速连接",
|
||||
"footerActions": "↑↓ 切换 · Enter 连接 · Esc 关闭"
|
||||
},
|
||||
"tabs": {
|
||||
"contextMenu": {
|
||||
"close": "关闭标签页",
|
||||
"closeAll": "关闭全部标签页",
|
||||
"closeOthers": "关闭其他标签页",
|
||||
"closeRight": "关闭右侧标签页",
|
||||
"closeLeft": "关闭左侧标签页",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { ConnectionInfo } from '../stores/connections.store';
|
||||
|
||||
export interface ConnectionSearchResult {
|
||||
connection: ConnectionInfo;
|
||||
score: number;
|
||||
}
|
||||
|
||||
const normalize = (value: string | null | undefined): string => (value ?? '').trim().toLowerCase();
|
||||
|
||||
const getDisplayName = (connection: ConnectionInfo): string => connection.name?.trim() || connection.host;
|
||||
|
||||
const getEmptyQuerySortValue = (connection: ConnectionInfo): number => connection.last_connected_at ?? 0;
|
||||
|
||||
const getFieldScore = (text: string, query: string): number => {
|
||||
if (!text || !query) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (text === query) {
|
||||
return 320;
|
||||
}
|
||||
|
||||
if (text.startsWith(query)) {
|
||||
return 260 - Math.min(text.length - query.length, 40);
|
||||
}
|
||||
|
||||
const includeIndex = text.indexOf(query);
|
||||
if (includeIndex >= 0) {
|
||||
return 220 - Math.min(includeIndex * 6, 90);
|
||||
}
|
||||
|
||||
let queryIndex = 0;
|
||||
let firstMatchIndex = -1;
|
||||
let previousMatchIndex = -1;
|
||||
let gapPenalty = 0;
|
||||
|
||||
for (let index = 0; index < text.length && queryIndex < query.length; index += 1) {
|
||||
if (text[index] !== query[queryIndex]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (firstMatchIndex === -1) {
|
||||
firstMatchIndex = index;
|
||||
}
|
||||
|
||||
if (previousMatchIndex >= 0) {
|
||||
gapPenalty += index - previousMatchIndex - 1;
|
||||
}
|
||||
|
||||
previousMatchIndex = index;
|
||||
queryIndex += 1;
|
||||
}
|
||||
|
||||
if (queryIndex !== query.length || firstMatchIndex === -1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(70, 180 - firstMatchIndex * 4 - gapPenalty * 3);
|
||||
};
|
||||
|
||||
const scoreConnection = (connection: ConnectionInfo, query: string): number => {
|
||||
const fields: Array<[string, number]> = [
|
||||
[normalize(connection.name), 40],
|
||||
[normalize(connection.host), 28],
|
||||
[normalize(connection.username), 16],
|
||||
[normalize(connection.type), 10],
|
||||
];
|
||||
|
||||
let bestScore = 0;
|
||||
|
||||
for (const [field, weight] of fields) {
|
||||
const fieldScore = getFieldScore(field, query);
|
||||
if (fieldScore <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
bestScore = Math.max(bestScore, fieldScore + weight);
|
||||
}
|
||||
|
||||
return bestScore;
|
||||
};
|
||||
|
||||
export const searchConnections = (
|
||||
connections: ConnectionInfo[],
|
||||
rawQuery: string,
|
||||
limit = 8,
|
||||
): ConnectionSearchResult[] => {
|
||||
const query = normalize(rawQuery);
|
||||
|
||||
if (!query) {
|
||||
return [...connections]
|
||||
.sort((left, right) => {
|
||||
const recentDiff = getEmptyQuerySortValue(right) - getEmptyQuerySortValue(left);
|
||||
if (recentDiff !== 0) {
|
||||
return recentDiff;
|
||||
}
|
||||
|
||||
return getDisplayName(left).localeCompare(getDisplayName(right));
|
||||
})
|
||||
.slice(0, limit)
|
||||
.map((connection) => ({ connection, score: 0 }));
|
||||
}
|
||||
|
||||
return connections
|
||||
.map((connection) => ({
|
||||
connection,
|
||||
score: scoreConnection(connection, query),
|
||||
}))
|
||||
.filter((item) => item.score > 0)
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) {
|
||||
return right.score - left.score;
|
||||
}
|
||||
|
||||
const recentDiff = getEmptyQuerySortValue(right.connection) - getEmptyQuerySortValue(left.connection);
|
||||
if (recentDiff !== 0) {
|
||||
return recentDiff;
|
||||
}
|
||||
|
||||
return getDisplayName(left.connection).localeCompare(getDisplayName(right.connection));
|
||||
})
|
||||
.slice(0, limit);
|
||||
};
|
||||
Reference in New Issue
Block a user