feat(ui): 重设计文件管理器书签与传输面板
新增书签作用域与连接关联,后端为 favorite_paths 补充 scope 和 connection_id 字段及查询写入支持 前端重构书签弹窗与编辑表单,支持本地/云端筛选、 作用域选择与多语言文案更新 文件管理器工具栏改为紧凑图标样式,上传入口合并为 下拉菜单,并新增底部传输面板统一展示上传任务 同时优化 SSH 终端运行态为显式状态机,并为短命令 补充最短可见时间,避免运行中标记闪烁难以感知
This commit is contained in:
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- **[frontend]**: 将 SSH 终端 `%` 运行中提示从单布尔派生升级为显式 `commandRuntimePhase` 状态机,并为极短命令补上最短可见窗口,避免标签提示一闪而过几乎不可感知 — by yinjianm
|
||||||
|
- 方案: [202604210531_ssh-terminal-runtime-state-machine](archive/2026-04/202604210531_ssh-terminal-runtime-state-machine/)
|
||||||
|
|
||||||
- **[frontend]**: 将 `packages/frontend` 的 Vite 开发代理改为支持通过 `VITE_DEV_PROXY_TARGET`、`VITE_DEV_WS_PROXY_TARGET` 与 `VITE_API_BASE_URL` 切换远端联调目标,并验证 `focus-switcher-sequence`、登录链路与默认白色主题可在本地前端联调时正常工作 — by yinjianm
|
- **[frontend]**: 将 `packages/frontend` 的 Vite 开发代理改为支持通过 `VITE_DEV_PROXY_TARGET`、`VITE_DEV_WS_PROXY_TARGET` 与 `VITE_API_BASE_URL` 切换远端联调目标,并验证 `focus-switcher-sequence`、登录链路与默认白色主题可在本地前端联调时正常工作 — by yinjianm
|
||||||
- 方案: [202604210440_frontend-dev-api-theme-verification](archive/2026-04/202604210440_frontend-dev-api-theme-verification/)
|
- 方案: [202604210440_frontend-dev-api-theme-verification](archive/2026-04/202604210440_frontend-dev-api-theme-verification/)
|
||||||
|
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"completed": 5,
|
||||||
|
"failed": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
"pending": 0,
|
||||||
|
"total": 5,
|
||||||
|
"done": 5,
|
||||||
|
"percent": 100,
|
||||||
|
"current": "-",
|
||||||
|
"updated_at": "2026-04-21 05:43:00"
|
||||||
|
}
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
# 变更提案: ssh-terminal-runtime-state-machine
|
||||||
|
|
||||||
|
## 元信息
|
||||||
|
```yaml
|
||||||
|
类型: 修复/优化
|
||||||
|
方案类型: implementation
|
||||||
|
优先级: P1
|
||||||
|
状态: 实施中
|
||||||
|
创建: 2026-04-21
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 需求
|
||||||
|
|
||||||
|
### 背景
|
||||||
|
仓库已经在 2026-04-19 做过一轮 `%` 运行中提示增强,当前 [TerminalTabBar.vue](/E:/code/vue/nexus-terminal/packages/frontend/src/components/TerminalTabBar.vue) 和 [LayoutRenderer.vue](/E:/code/vue/nexus-terminal/packages/frontend/src/components/LayoutRenderer.vue) 也已经接上了 `%` UI。但现有实现仍把运行态建模成单个 `isCommandRunning` 布尔值,再叠加 `terminalInputBuffer` 和 prompt 正则做启停判断。这个模型无法区分“刚提交命令但还没输出”“命令正在持续输出”“连接异常断开”“错误导致提前结束”等阶段,导致一些真实场景下 `%` 要么瞬时闪烁,要么被过早清掉,用户体感上接近“没有实际作用”。
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
- 将 SSH 命令运行态从单个布尔值升级成显式状态机,至少能区分 `idle / typing / pending / running / disconnected / error`。
|
||||||
|
- 继续基于发送命令、shell prompt、断连与错误链路派生运行态,但收口到统一的状态转移逻辑,而不是分散地手改布尔值。
|
||||||
|
- 让顶部服务器标签和服务器内部终端标签继续显示 `%`,但由“运行态处于活动阶段”统一派生,确保两层显示一致。
|
||||||
|
- 解决“快速命令根本看不到 `%`”的问题,让极短命令也能有足够短但可感知的可视反馈。
|
||||||
|
|
||||||
|
### 约束条件
|
||||||
|
```yaml
|
||||||
|
时间约束: 本轮限定在 packages/frontend 内完成,不扩展 backend WebSocket 协议
|
||||||
|
性能约束: 继续沿用轻量字符串缓冲与尾部 prompt 检测,不引入全终端内容扫描
|
||||||
|
兼容性约束: RDP/VNC 标签行为不变,现有 WorkspaceView / Terminal / session getter 数据流尽量少破坏
|
||||||
|
业务约束: `%` 仍是前端派生提示,不承诺成为服务端权威任务状态
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
- [ ] SSH 会话在底部命令输入框、快捷指令、文件管理器和终端内回车等现有发送入口触发后,会进入显式运行态活动阶段并显示 `%`
|
||||||
|
- [ ] shell prompt 返回、连接断开、SSH 错误和 `Ctrl+C` 中断后,运行态会按统一状态机退出活动阶段,顶部服务器标签和内部终端标签同步消失 `%`
|
||||||
|
- [ ] 极短命令不会因为“提交后立刻命中 prompt”而完全看不到 `%`,标签上至少存在一个可感知的短暂运行中提示
|
||||||
|
- [ ] `npm --workspace @nexus-terminal/frontend run build` 通过,且没有新增类型或模板错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 方案
|
||||||
|
|
||||||
|
### 技术方案
|
||||||
|
本轮不再继续扩展旧的 `isCommandRunning` 布尔值,而是引入一层显式 SSH 运行态模型,并把发送输入、输出探测、断连和错误全部收口到同一套 reducer 风格的状态转移函数中。
|
||||||
|
|
||||||
|
实现分成三层:
|
||||||
|
|
||||||
|
1. 在 `session` 模块中新增 SSH 运行态类型与状态字段,至少包含 `phase`、`lastTransitionAt`、`lastCompletedAt` 和输入缓冲;`isCommandRunning` 改为从 `phase` 派生,而不是作为主状态独立维护。
|
||||||
|
2. 在 `useSshTerminal.ts` 中把“发送非空命令”“收到输出”“命中 prompt”“中断”“断连”“错误”统一转换成状态机事件,避免旧实现那种一边写布尔、一边清空输入缓存的分散逻辑。
|
||||||
|
3. 在标签 UI 层继续复用 `%`,但由 `isRuntimeActive(phase)` 统一判定。对于极快结束的命令,增加一个很短的最小可见窗口,让 `%` 不至于只闪过一个渲染帧。
|
||||||
|
|
||||||
|
### 影响范围
|
||||||
|
```yaml
|
||||||
|
涉及模块:
|
||||||
|
- frontend/session: 调整 SSH 会话运行态模型与 getter 派生字段
|
||||||
|
- frontend/composables: 重写 useSshTerminal.ts 内的运行态转移逻辑
|
||||||
|
- frontend/ui: 顶部服务器标签与服务器内终端标签改为消费新的派生活动态
|
||||||
|
- frontend/knowledge: 同步 frontend.md 与 CHANGELOG.md 中的运行态描述
|
||||||
|
预计变更文件: 6-8
|
||||||
|
```
|
||||||
|
|
||||||
|
### 风险评估
|
||||||
|
| 风险 | 等级 | 应对 |
|
||||||
|
|------|------|------|
|
||||||
|
| prompt 检测仍可能覆盖不到个别定制 shell | 中 | 保持 prompt 识别只负责“退出活动态”,同时由断连、错误和中断链路兜底清理 |
|
||||||
|
| 新状态机与旧 getter 混用导致 UI 不更新或语义冲突 | 中 | 收敛 getter,只保留一个派生活动态出口,模板层继续吃布尔结果以减小改动面 |
|
||||||
|
| 为了让短命令可见而增加最短展示时间后,可能让极快命令多亮几百毫秒 | 低 | 将最短窗口控制在短阈值,只解决“完全看不到”的问题,不把标签做成长时延迟态 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 技术设计
|
||||||
|
|
||||||
|
### 架构设计
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[CommandInputBar / QuickCommands / FileManager / Terminal] --> B[terminalManager.sendData or handleTerminalData]
|
||||||
|
B --> C[SSH Runtime Reducer]
|
||||||
|
C --> D[session.commandRuntimePhase]
|
||||||
|
E[ssh:output] --> C
|
||||||
|
F[ssh:disconnected / ssh:error / Ctrl+C] --> C
|
||||||
|
D --> G[session getter 派生 isCommandRunning]
|
||||||
|
G --> H[TerminalTabBar 服务器标签 %]
|
||||||
|
G --> I[LayoutRenderer 内部终端标签 %]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据模型
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `commandRuntimePhase` | `Ref<'idle' \| 'typing' \| 'pending' \| 'running' \| 'disconnected' \| 'error'>` | SSH 终端当前所处的显式运行阶段 |
|
||||||
|
| `commandRuntimeReason` | `Ref<'init' \| 'input' \| 'submit' \| 'output' \| 'prompt' \| 'interrupt' \| 'disconnect' \| 'error' \| 'connected'>` | 最近一次状态迁移的原因,便于调试与后续扩展 |
|
||||||
|
| `commandRuntimeVisibleUntil` | `Ref<number>` | 运行中提示至少显示到的时间点,用于避免极短命令完全不可见 |
|
||||||
|
| `terminalInputBuffer` | `Ref<string>` | 当前一行尚未提交的终端输入缓冲,继续用于判断回车是否提交了非空命令 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 核心场景
|
||||||
|
|
||||||
|
### 场景: 快捷命令或底部命令输入框发送非空命令
|
||||||
|
**模块**: frontend
|
||||||
|
**条件**: 用户在某个 SSH 会话上通过底部命令输入框、快捷指令、命令历史或文件管理器触发 `terminalManager.sendData(...)`
|
||||||
|
**行为**: 状态机收到“提交命令”事件,切换到 `pending`,并立即打开 `%` 运行中提示
|
||||||
|
**结果**: 顶部服务器标签和当前服务器内部终端标签都能同步看到 `%`
|
||||||
|
|
||||||
|
### 场景: 命令快速结束但提示仍可见
|
||||||
|
**模块**: frontend
|
||||||
|
**条件**: 用户发送一个几乎立即返回 prompt 的短命令
|
||||||
|
**行为**: 状态机在提交后进入 `pending`,即使命中 prompt 也会遵守最短可见窗口再退出活动阶段
|
||||||
|
**结果**: `%` 不会只闪过一个渲染帧,用户能明确感知“刚刚执行过”
|
||||||
|
|
||||||
|
### 场景: shell prompt、断连和错误统一退出活动态
|
||||||
|
**模块**: frontend
|
||||||
|
**条件**: SSH 会话收到 prompt 尾部、`ssh:disconnected`、`ssh:error` 或 `Ctrl+C`
|
||||||
|
**行为**: 状态机依据事件原因切换到 `idle / disconnected / error` 等非活动阶段
|
||||||
|
**结果**: 顶部服务器标签与内部终端标签的 `%` 同步消失,不再残留旧状态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 技术决策
|
||||||
|
|
||||||
|
### ssh-terminal-runtime-state-machine#D001: 用显式运行态阶段替代单一 `isCommandRunning` 布尔值
|
||||||
|
**日期**: 2026-04-21
|
||||||
|
**状态**: ✅采纳
|
||||||
|
**背景**: 当前 `%` 标签虽然已经渲染到 UI,但旧实现只能用 `true/false` 表达“正在运行”,导致“刚提交命令但还没输出”“命令执行中”“连接已断开”“错误终止”等语义全部混在一个布尔值里,既难维护,也难稳定派生 UI。
|
||||||
|
**选项分析**:
|
||||||
|
| 选项 | 优点 | 缺点 |
|
||||||
|
|------|------|------|
|
||||||
|
| A: 继续沿用布尔值并追加更多 if/else | 改动最少 | 状态语义继续混乱,问题很容易回归 |
|
||||||
|
| B: 引入显式运行态阶段和统一转移函数 | 状态来源清晰,便于覆盖 prompt/断连/错误等链路 | 需要调整 session 类型和 useSshTerminal 逻辑 |
|
||||||
|
**决策**: 选择方案 B
|
||||||
|
**理由**: 用户当前反馈的根因不是“少一个 if 判断”,而是模型过弱。只有把 SSH 运行态从布尔提升为阶段状态,才能真正稳定地驱动 `%`。
|
||||||
|
**影响**: 影响 `session` 类型定义、getter 派生逻辑、`useSshTerminal.ts` 的输入/输出处理以及两个标签组件的消费方式
|
||||||
|
|
||||||
|
### ssh-terminal-runtime-state-machine#D002: 为极短命令增加最短可见窗口,而不是 prompt 一到就立刻灭掉 `%`
|
||||||
|
**日期**: 2026-04-21
|
||||||
|
**状态**: ✅采纳
|
||||||
|
**背景**: 用户明确反馈“整个没看到实际作用”,说明仅靠“提交置位、prompt 清除”的旧策略在短命令场景下可见性太差,即便逻辑成立也没有体感价值。
|
||||||
|
**选项分析**:
|
||||||
|
| 选项 | 优点 | 缺点 |
|
||||||
|
|------|------|------|
|
||||||
|
| A: prompt 命中后立即清除 `%` | 语义最直接 | 短命令常常一闪而过,用户几乎感知不到 |
|
||||||
|
| B: 运行态活动阶段增加一个很短的最小可见窗口 | 保持真实链路派生,同时确保用户能看到反馈 | 极快命令会多保留极短时间 |
|
||||||
|
**决策**: 选择方案 B
|
||||||
|
**理由**: 本轮目标不是做“理论上存在过”的状态,而是让用户真的看到 `%` 起作用。短暂的最小展示时间能显著提升感知质量,而且不需要改变后端协议。
|
||||||
|
**影响**: 需要在前端状态机里记录时间戳,并在 prompt/错误/断连清理时考虑延迟退出
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 成果设计
|
||||||
|
|
||||||
|
N/A。本轮不新增视觉体系,只保持现有深色终端工作台内的 `%` 提示语义,并增强其可见性与状态来源准确度。
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 任务清单: ssh-terminal-runtime-state-machine
|
||||||
|
|
||||||
|
> **@status:** completed | 2026-04-21 05:47
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
@feature: ssh-terminal-runtime-state-machine
|
||||||
|
@created: 2026-04-21
|
||||||
|
@status: completed
|
||||||
|
@mode: R2
|
||||||
|
```
|
||||||
|
|
||||||
|
## 进度概览
|
||||||
|
|
||||||
|
| 完成 | 失败 | 跳过 | 总数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 5 | 0 | 0 | 5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务列表
|
||||||
|
|
||||||
|
### 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/composables/useSshTerminal.ts` 中收口发送、prompt、断连、错误与中断链路,改为统一状态机转移逻辑 | depends_on: [1.1]
|
||||||
|
|
||||||
|
### 2. 标签派生与可见性修复
|
||||||
|
|
||||||
|
- [√] 2.1 在 `packages/frontend/src/stores/session/getters.ts` 中改为从 `commandRuntimePhase` 派生活动态,并保持 `TerminalTabBar.vue` 与 `LayoutRenderer.vue` 继续复用现有 `%` 模板显示一致 | depends_on: [1.2]
|
||||||
|
- [√] 2.2 为极短命令加入最短可见窗口,确保 `%` 不会只闪烁一个渲染帧 | depends_on: [1.2]
|
||||||
|
|
||||||
|
### 3. 验证与知识库同步
|
||||||
|
|
||||||
|
- [√] 3.1 运行 `npm --workspace @nexus-terminal/frontend run build` 验证前端编译通过,并同步 `.helloagents/modules/frontend.md` 与 `.helloagents/CHANGELOG.md` | depends_on: [2.1, 2.2]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行日志
|
||||||
|
|
||||||
|
| 时间 | 任务 | 状态 | 备注 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 2026-04-21 05:31 | 方案包创建 | 完成 | 已创建 `202604210531_ssh-terminal-runtime-state-machine`,准备进入开发实施 |
|
||||||
|
| 2026-04-21 05:40 | 1.1 / 1.2 | 完成 | 已引入 `commandRuntimePhase` 显式状态,并将发送、prompt、断连、错误与中断链路统一收口到状态转移逻辑 |
|
||||||
|
| 2026-04-21 05:41 | 2.1 / 2.2 | 完成 | getter 现已从 `commandRuntimePhase` 派生活动态,并为极短命令补上最短可见窗口,保持两层 `%` 继续复用现有模板 |
|
||||||
|
| 2026-04-21 05:43 | 3.1 | 完成 | `npm --workspace @nexus-terminal/frontend run build` 通过,并同步 `frontend.md` 与 `CHANGELOG.md` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行备注
|
||||||
|
|
||||||
|
- 本轮为前端单包修复,不修改 backend WebSocket 协议。
|
||||||
|
- 历史方案 `202604192106_terminal-running-indicator` 已完成,但现场反馈显示旧布尔模型体感无效,本轮在其基础上做显式状态机收敛。
|
||||||
|
- 顶部服务器标签与内部终端标签继续保留 `%` 设计,不新增新的运行态 UI 组件。
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|
||||||
|--------|------|------|---------|------|------|
|
|--------|------|------|---------|------|------|
|
||||||
|
| 202604210531 | ssh-terminal-runtime-state-machine | - | - | - | ✅完成 |
|
||||||
| 202604210440 | frontend-dev-api-theme-verification | - | - | - | ✅完成 |
|
| 202604210440 | frontend-dev-api-theme-verification | - | - | - | ✅完成 |
|
||||||
| 202604192106 | terminal-running-indicator | - | - | - | ✅完成 |
|
| 202604192106 | terminal-running-indicator | - | - | - | ✅完成 |
|
||||||
| 202604190520 | status-monitor-cpu-summary-modal | - | - | - | ✅完成 |
|
| 202604190520 | status-monitor-cpu-summary-modal | - | - | - | ✅完成 |
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -9,5 +9,5 @@
|
|||||||
"done": 6,
|
"done": 6,
|
||||||
"percent": 100,
|
"percent": 100,
|
||||||
"current": "-",
|
"current": "-",
|
||||||
"updated_at": "2026-04-21 04:28:38"
|
"updated_at": "2026-05-01 21:07:56"
|
||||||
}
|
}
|
||||||
@@ -41,8 +41,8 @@
|
|||||||
|
|
||||||
| 时间 | 事件 | 详情 |
|
| 时间 | 事件 | 详情 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 2026-03-26 04:05 | 2.2 | 完成 | 拖拽上传前新增目标目录确认,并在当前可见目录上传完成后主动刷新 |
|
|
||||||
| 2026-03-26 04:08 | 3.1 | 完成 | 目录删除改为“仅删空目录 / 强制递归删除”双确认,后端 `sftp:rmdir` 接收 `recursive` 标志 |
|
|
||||||
| 2026-03-26 04:10 | 3.2 | 完成 | 删除目录后若当前/待加载路径失效,前端自动回退父目录,终止持续 `No such file` 重试 |
|
|
||||||
| 2026-03-26 04:14 | 4.1 | 完成 | `npm run build --workspace @nexus-terminal/frontend` 与 `@nexus-terminal/backend` 均通过 |
|
| 2026-03-26 04:14 | 4.1 | 完成 | `npm run build --workspace @nexus-terminal/frontend` 与 `@nexus-terminal/backend` 均通过 |
|
||||||
| 2026-04-21 04:28:38 | 进度快照(自动) | 完成:6 失败:0 跳过:0 待做:0 (100%) |
|
| 2026-04-21 04:28:38 | 进度快照(自动) | 完成:6 失败:0 跳过:0 待做:0 (100%) |
|
||||||
|
| 2026-05-01 21:01:18 | 进度快照(自动) | 完成:6 失败:0 跳过:0 待做:0 (100%) |
|
||||||
|
| 2026-05-01 21:02:20 | 进度快照(自动) | 完成:6 失败:0 跳过:0 待做:0 (100%) |
|
||||||
|
| 2026-05-01 21:07:56 | PreCompact快照 | 完成:6 失败:0 跳过:0 待做:0 (100%) |
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"status": "pending",
|
||||||
|
"completed": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
"pending": 10,
|
||||||
|
"uncertain": 0,
|
||||||
|
"total": 10,
|
||||||
|
"done": 0,
|
||||||
|
"percent": 0,
|
||||||
|
"current": "重设计目录树样式 — 紧凑树视图",
|
||||||
|
"updated_at": "2026-05-01 21:35:15"
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# 方案包: file-manager-ui-redesign
|
||||||
|
|
||||||
|
- 创建日期: 2026-05-01 21:11
|
||||||
|
- 类型: implementation
|
||||||
|
- 决策ID: file-manager-ui-redesign#D001
|
||||||
|
|
||||||
|
## 1. 需求
|
||||||
|
|
||||||
|
### 背景
|
||||||
|
用户提供了 5 张目标截图,要求重新设计工作台文件管理器的 UI,涵盖目录树、工具栏、书签系统、新增书签弹窗、传输管理面板五个区域。当前实现使用卡片式行布局和文字+图标按钮,与目标的紧凑树视图和纯图标工具栏存在较大差异。
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
- 目录树:从卡片行布局转为紧凑传统文件树(黄色文件夹图标、绿色高亮活动目录、符号链接显示目标、更紧密间距、无卡片边框)
|
||||||
|
- 工具栏:从文字+图标按钮转为紧凑纯图标工具栏(tooltip 提示),上传按钮合并为下拉菜单("上传文件"/"上传文件夹")
|
||||||
|
- 书签系统:重新设计为"书签列表 N"头部、"本地"/"云端"标签切换(本地=仅当前服务器、云端=全局共享)、scope 标签、更丰富的卡片布局和操作按钮
|
||||||
|
- 新增书签弹窗:新增"记录位置"scope 选择器(仅当前服务器/全局共享)
|
||||||
|
- 传输管理面板:新增底部抽屉面板,含"全部/上传/下载"标签,统一展示上传和传输任务,空态"暂无传输任务"
|
||||||
|
|
||||||
|
### 约束条件
|
||||||
|
- 使用项目现有技术栈:Vue 3 + Composition API、Pinia、Tailwind CSS、vue-i18n
|
||||||
|
- 书签 scope 功能需要后端数据库 migration(新增 scope 和 connection_id 字段)
|
||||||
|
- 保持所有现有功能不变,仅改变 UI 呈现和增加 scope 功能
|
||||||
|
- 遵循项目现有主题变量系统
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
- 目录树视觉效果匹配目标截图:紧凑行高、黄色文件夹图标、绿色活动行高亮
|
||||||
|
- 工具栏为纯图标按钮,上传按钮为下拉菜单
|
||||||
|
- 书签支持本地/云端 scope 切换和筛选
|
||||||
|
- 传输面板在底部以抽屉形式展开,支持三个 tab 筛选
|
||||||
|
- 所有 i18n 键已添加(zh-CN、en-US、ja-JP)
|
||||||
|
|
||||||
|
## 2. 方案
|
||||||
|
|
||||||
|
### 技术方案
|
||||||
|
|
||||||
|
#### 任务1:目录树 UI 重设计
|
||||||
|
- 修改 `FileManager.vue` 模板中的 `explorerTreeRows` 渲染区域
|
||||||
|
- 移除卡片样式(`rounded-lg border`),改为紧凑行(`py-0.5`)
|
||||||
|
- 图标颜色:目录用 `text-yellow-500`(黄色文件夹),文件保持现有图标
|
||||||
|
- 活动行:使用 `bg-green-600/20 text-green-400` 高亮
|
||||||
|
- 符号链接:在文件名后显示 `→ target`
|
||||||
|
- 调整树头部样式使其更紧凑
|
||||||
|
|
||||||
|
#### 任务2:工具栏重设计
|
||||||
|
- 将工具栏区域的文字+图标按钮改为纯图标按钮(统一 `w-7 h-7`)
|
||||||
|
- 为每个按钮添加 `title` tooltip
|
||||||
|
- 上传文件和上传文件夹合并为单个按钮 + 下拉菜单
|
||||||
|
- 新增 `uploadMenuOpen` ref 控制下拉菜单显隐
|
||||||
|
|
||||||
|
#### 任务3:书签系统重构
|
||||||
|
- 后端:`favorite_paths` 表新增 `scope` (TEXT, DEFAULT 'global') 和 `connection_id` (INTEGER, NULLABLE) 字段
|
||||||
|
- 后端:API 支持 `?scope=local&connectionId=X` 查询参数
|
||||||
|
- 前端 store:扩展 `FavoritePathItem` 类型,添加 scope 和 connectionId 字段
|
||||||
|
- 前端 `FavoritePathsModal.vue`:重设计为含头部计数、本地/云端 tab、scope 标签的布局
|
||||||
|
- 前端 `AddEditFavoritePathForm.vue`:新增 scope 选择器
|
||||||
|
|
||||||
|
#### 任务4:传输管理面板
|
||||||
|
- 新建 `TransferPanel.vue` 组件:底部抽屉面板
|
||||||
|
- 含"全部/上传/下载"标签切换
|
||||||
|
- 统一展示 `uploads`(来自 FileUploadPopup)和 `transferTasks`(来自 TransferProgressModal)
|
||||||
|
- 空态显示"暂无传输任务"
|
||||||
|
- 集成到 `FileManager.vue` 底部
|
||||||
|
|
||||||
|
### 影响范围
|
||||||
|
- `packages/frontend/src/components/FileManager.vue` — 目录树+工具栏+传输面板集成
|
||||||
|
- `packages/frontend/src/components/FavoritePathsModal.vue` — 书签列表重设计
|
||||||
|
- `packages/frontend/src/components/AddEditFavoritePathForm.vue` — 新增 scope 选择器
|
||||||
|
- `packages/frontend/src/components/TransferPanel.vue` — 新组件
|
||||||
|
- `packages/frontend/src/stores/favoritePaths.store.ts` — scope 支持
|
||||||
|
- `packages/backend/src/database/schema.ts` — 数据库 migration
|
||||||
|
- `packages/backend/src/favorite-paths/` — API 扩展
|
||||||
|
- `packages/frontend/src/locales/` — i18n 键
|
||||||
|
|
||||||
|
### 风险评估
|
||||||
|
- 数据库 migration 需谨慎处理现有数据兼容性(现有书签默认 scope='global')
|
||||||
|
- FileManager.vue 文件较大(~2570 行),修改需精确定位避免副作用
|
||||||
|
- 传输面板需同时消费两个不同数据源(uploads + transferTasks)
|
||||||
|
|
||||||
|
### 方案取舍
|
||||||
|
- 选择在 `FileManager.vue` 内直接修改而非拆分子组件,因为目录树渲染与父组件状态紧密耦合,拆分成本高于收益
|
||||||
|
- 书签 scope 选择直接内联到现有表单而非独立组件,保持简单
|
||||||
|
- 传输面板选择新建独立组件,因为它的数据源和生命周期独立于文件管理器其他部分
|
||||||
|
|
||||||
|
### 验证策略
|
||||||
|
- verifyMode: review-first
|
||||||
|
- reviewerFocus: UI 视觉匹配目标截图、样式一致性、响应式表现
|
||||||
|
- testerFocus: 书签 CRUD 功能完整性、scope 筛选正确性、传输面板数据展示
|
||||||
|
- 风险边界: 数据库 migration 回滚方案为手动删除新增字段
|
||||||
|
|
||||||
|
## 成果设计
|
||||||
|
|
||||||
|
### 美学基调
|
||||||
|
延续项目现有的暗色终端风格,紧凑高信息密度,使用项目主题变量系统保持一致性。
|
||||||
|
|
||||||
|
### 视觉要素
|
||||||
|
- 配色:遵循项目现有 `--color-*` CSS 变量体系;目录树活动行使用绿色系,文件夹图标使用黄色系
|
||||||
|
- 布局:紧凑垂直排列,减少 padding 和 margin,提升信息密度
|
||||||
|
- 交互:hover 状态使用 `bg-background` 微弱高亮,保持轻量
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
@feature: file-manager-ui-redesign
|
||||||
|
@created: 2026-05-01 21:11
|
||||||
|
@status: pending
|
||||||
|
@mode: implementation
|
||||||
|
|
||||||
|
## 进度概览
|
||||||
|
|
||||||
|
- 完成: 0 / 失败: 0 / 跳过: 0 / 总数: 9
|
||||||
|
|
||||||
|
## 任务列表
|
||||||
|
|
||||||
|
### 阶段1: 目录树与工具栏 UI 重设计
|
||||||
|
|
||||||
|
- [ ] 1.1 重设计目录树样式 — 紧凑树视图
|
||||||
|
- 文件: `packages/frontend/src/components/FileManager.vue` (模板 lines 2476-2530)
|
||||||
|
- 预期变更: 移除卡片边框和圆角,改为紧凑行布局;文件夹图标改为黄色;活动行改为绿色高亮;调整缩进和行高;优化树头部样式
|
||||||
|
- 完成标准: 目录树视觉匹配目标截图(紧凑行、黄色文件夹、绿色活动高亮、无卡片边框)
|
||||||
|
- 验证方式: 视觉对比目标截图
|
||||||
|
- depends_on: []
|
||||||
|
|
||||||
|
- [ ] 1.2 重设计工具栏 — 紧凑纯图标按钮
|
||||||
|
- 文件: `packages/frontend/src/components/FileManager.vue` (模板 lines 2371-2441)
|
||||||
|
- 预期变更: 移除按钮文字标签,保留纯图标;统一按钮尺寸 `w-7 h-7`;上传文件和上传文件夹合并为下拉菜单按钮
|
||||||
|
- 完成标准: 工具栏为纯图标按钮行,上传为下拉菜单
|
||||||
|
- 验证方式: 视觉对比目标截图,检查 tooltip 显示
|
||||||
|
- depends_on: []
|
||||||
|
|
||||||
|
### 阶段2: 传输管理面板
|
||||||
|
|
||||||
|
- [ ] 2.1 创建 TransferPanel.vue 组件
|
||||||
|
- 文件: `packages/frontend/src/components/TransferPanel.vue` (新建)
|
||||||
|
- 预期变更: 底部抽屉面板组件,含"全部/上传/下载"tab,统一展示上传和传输任务,空态"暂无传输任务"
|
||||||
|
- 完成标准: 组件独立运行,支持 tab 切换和空态展示
|
||||||
|
- 验证方式: 组件接收 props 正确渲染
|
||||||
|
- depends_on: []
|
||||||
|
|
||||||
|
- [ ] 2.2 集成 TransferPanel 到 FileManager
|
||||||
|
- 文件: `packages/frontend/src/components/FileManager.vue`
|
||||||
|
- 预期变更: 在文件管理器底部集成 TransferPanel,替代原 FileUploadPopup 的固定定位弹窗;添加传输面板展开/收起切换
|
||||||
|
- 完成标准: 传输面板在文件管理器底部正确展示,上传任务和传输任务统一显示
|
||||||
|
- 验证方式: 触发上传后传输面板展示任务
|
||||||
|
- depends_on: [2.1]
|
||||||
|
|
||||||
|
### 阶段3: 书签系统重构
|
||||||
|
|
||||||
|
- [ ] 3.1 后端数据库 migration — 添加 scope 字段
|
||||||
|
- 文件: `packages/backend/src/database/schema.ts`, `packages/backend/src/database/migrations/`
|
||||||
|
- 预期变更: `favorite_paths` 表新增 `scope` TEXT 字段(默认 'global')和 `connection_id` INTEGER 字段(nullable);编写 migration 脚本确保现有数据兼容
|
||||||
|
- 完成标准: 数据库表结构包含新字段,现有数据 scope 默认为 'global'
|
||||||
|
- 验证方式: 检查 schema 定义和 migration 逻辑
|
||||||
|
- depends_on: []
|
||||||
|
|
||||||
|
- [ ] 3.2 后端 API 扩展 — scope 查询支持
|
||||||
|
- 文件: `packages/backend/src/favorite-paths/favorite-paths.routes.ts`, `packages/backend/src/favorite-paths/favorite-paths.repository.ts`
|
||||||
|
- 预期变更: GET /favorite-paths 支持 `?scope=local&connectionId=X` 查询参数;POST/PUT 支持 scope 和 connection_id 字段
|
||||||
|
- 完成标准: API 正确按 scope 和 connectionId 过滤/保存书签
|
||||||
|
- 验证方式: API 请求测试
|
||||||
|
- depends_on: [3.1]
|
||||||
|
|
||||||
|
- [ ] 3.3 前端 store 扩展 — scope 支持
|
||||||
|
- 文件: `packages/frontend/src/stores/favoritePaths.store.ts`
|
||||||
|
- 预期变更: `FavoritePathItem` 类型添加 `scope` 和 `connectionId` 字段;CRUD 方法传递 scope 参数;新增 `activeTab` 状态和 `fetchByScope` 方法
|
||||||
|
- 完成标准: Store 支持按 scope 筛选和保存书签
|
||||||
|
- 验证方式: Store 方法调用正确传参
|
||||||
|
- depends_on: [3.2]
|
||||||
|
|
||||||
|
- [ ] 3.4 重设计 FavoritePathsModal — 书签列表 UI
|
||||||
|
- 文件: `packages/frontend/src/components/FavoritePathsModal.vue`
|
||||||
|
- 预期变更: 头部显示"书签列表 N";添加"本地"/"云端"tab 切换;每个书签卡片显示 scope 标签和操作按钮
|
||||||
|
- 完成标准: 书签列表 UI 匹配目标截图,支持 tab 切换筛选
|
||||||
|
- 验证方式: 视觉对比目标截图
|
||||||
|
- depends_on: [3.3]
|
||||||
|
|
||||||
|
- [ ] 3.5 重设计 AddEditFavoritePathForm — 添加 scope 选择器
|
||||||
|
- 文件: `packages/frontend/src/components/AddEditFavoritePathForm.vue`
|
||||||
|
- 预期变更: 在表单中新增"记录位置"scope 选择器(仅当前服务器/全局共享),保存时传递 scope 和 connectionId
|
||||||
|
- 完成标准: 表单支持选择书签 scope
|
||||||
|
- 验证方式: 创建书签时 scope 正确保存
|
||||||
|
- depends_on: [3.3]
|
||||||
|
|
||||||
|
### 阶段4: i18n 与收尾
|
||||||
|
|
||||||
|
- [ ] 4.1 添加 i18n 键
|
||||||
|
- 文件: `packages/frontend/src/locales/zh-CN.json`, `en-US.json`, `ja-JP.json`
|
||||||
|
- 预期变更: 添加传输面板、书签 scope、工具栏 tooltip 相关的所有新 i18n 键
|
||||||
|
- 完成标准: 所有新增 UI 文本均使用 i18n 键,三种语言文件同步更新
|
||||||
|
- 验证方式: 搜索硬编码字符串
|
||||||
|
- depends_on: [1.1, 1.2, 2.1, 3.4, 3.5]
|
||||||
|
|
||||||
|
## 执行日志
|
||||||
|
|
||||||
|
| 时间 | 事件 | 详情 |
|
||||||
|
|------|------|------|
|
||||||
|
| 2026-05-01 21:25:50 | 进度快照(自动) | 完成:0 失败:0 跳过:0 待做:10 (0%) |
|
||||||
|
| 2026-05-01 21:27:46 | 进度快照(自动) | 完成:0 失败:0 跳过:0 待做:10 (0%) |
|
||||||
|
| 2026-05-01 21:28:56 | 进度快照(自动) | 完成:0 失败:0 跳过:0 待做:10 (0%) |
|
||||||
|
| 2026-05-01 21:33:06 | 进度快照(自动) | 完成:0 失败:0 跳过:0 待做:10 (0%) |
|
||||||
|
| 2026-05-01 21:35:15 | 进度快照(自动) | 完成:0 失败:0 跳过:0 待做:10 (0%) |
|
||||||
|
|
||||||
|
## 执行备注
|
||||||
|
|
||||||
|
- FileManager.vue 约 2570 行,修改时需精确定位模板区域避免副作用
|
||||||
|
- 数据库 migration 需确保 SQLite 兼容(ALTER TABLE ADD COLUMN)
|
||||||
|
- 传输面板需同时消费 uploads(本地 ref)和 transferTasks(API 轮询)两个数据源
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
2026-05-01T21:36:14.234134
|
||||||
@@ -370,6 +370,18 @@ const definedMigrations: Migration[] = [
|
|||||||
ALTER TABLE quick_command_tag_associations ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
|
ALTER TABLE quick_command_tag_associations ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
|
||||||
UPDATE quick_command_tag_associations SET sort_order = rowid WHERE sort_order = 0;
|
UPDATE quick_command_tag_associations SET sort_order = rowid WHERE sort_order = 0;
|
||||||
`
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 15,
|
||||||
|
name: 'Add scope and connection_id columns to favorite_paths table',
|
||||||
|
check: async (db: Database): Promise<boolean> => {
|
||||||
|
const scopeExists = await columnExists(db, 'favorite_paths', 'scope');
|
||||||
|
return !scopeExists;
|
||||||
|
},
|
||||||
|
sql: `
|
||||||
|
ALTER TABLE favorite_paths ADD COLUMN scope TEXT NOT NULL DEFAULT 'global';
|
||||||
|
ALTER TABLE favorite_paths ADD COLUMN connection_id INTEGER NULL;
|
||||||
|
`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -261,9 +261,11 @@ CREATE TABLE IF NOT EXISTS appearance_settings (
|
|||||||
export const createFavoritePathsTableSQL = `
|
export const createFavoritePathsTableSQL = `
|
||||||
CREATE TABLE IF NOT EXISTS favorite_paths (
|
CREATE TABLE IF NOT EXISTS favorite_paths (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NULL,
|
name TEXT NULL,
|
||||||
path TEXT NOT NULL,
|
path TEXT NOT NULL,
|
||||||
last_used_at INTEGER NULL,
|
scope TEXT NOT NULL DEFAULT 'global',
|
||||||
|
connection_id INTEGER NULL,
|
||||||
|
last_used_at INTEGER NULL,
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { FavoritePathSortBy } from '../favorite-paths/favorite-paths.service';
|
|||||||
* 处理添加新收藏路径的请求
|
* 处理添加新收藏路径的请求
|
||||||
*/
|
*/
|
||||||
export const createFavoritePath = async (req: Request, res: Response): Promise<void> => {
|
export const createFavoritePath = async (req: Request, res: Response): Promise<void> => {
|
||||||
const { name, path } = req.body;
|
const { name, path, scope, connectionId } = req.body;
|
||||||
|
|
||||||
if (!path || typeof path !== 'string' || path.trim().length === 0) {
|
if (!path || typeof path !== 'string' || path.trim().length === 0) {
|
||||||
res.status(400).json({ message: '路径内容不能为空' });
|
res.status(400).json({ message: '路径内容不能为空' });
|
||||||
@@ -18,7 +18,7 @@ export const createFavoritePath = async (req: Request, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newId = await FavoritePathsService.addFavoritePath(name, path);
|
const newId = await FavoritePathsService.addFavoritePath(name, path, scope || 'global', connectionId ?? null);
|
||||||
const newFavoritePath = await FavoritePathsService.getFavoritePathById(newId);
|
const newFavoritePath = await FavoritePathsService.getFavoritePathById(newId);
|
||||||
if (newFavoritePath) {
|
if (newFavoritePath) {
|
||||||
res.status(201).json({ message: '收藏路径已添加', favoritePath: newFavoritePath });
|
res.status(201).json({ message: '收藏路径已添加', favoritePath: newFavoritePath });
|
||||||
@@ -37,11 +37,14 @@ export const createFavoritePath = async (req: Request, res: Response): Promise<v
|
|||||||
*/
|
*/
|
||||||
export const getAllFavoritePaths = async (req: Request, res: Response): Promise<void> => {
|
export const getAllFavoritePaths = async (req: Request, res: Response): Promise<void> => {
|
||||||
const sortBy = req.query.sortBy as FavoritePathSortBy | undefined;
|
const sortBy = req.query.sortBy as FavoritePathSortBy | undefined;
|
||||||
|
const scope = req.query.scope as string | undefined;
|
||||||
|
const connectionIdStr = req.query.connectionId as string | undefined;
|
||||||
|
const connectionId = connectionIdStr ? parseInt(connectionIdStr, 10) : undefined;
|
||||||
const validSortByOptions: FavoritePathSortBy[] = ['name', 'last_used_at'];
|
const validSortByOptions: FavoritePathSortBy[] = ['name', 'last_used_at'];
|
||||||
const validSortBy: FavoritePathSortBy = sortBy && validSortByOptions.includes(sortBy) ? sortBy : 'name';
|
const validSortBy: FavoritePathSortBy = sortBy && validSortByOptions.includes(sortBy) ? sortBy : 'name';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const favoritePaths = await FavoritePathsService.getAllFavoritePaths(validSortBy);
|
const favoritePaths = await FavoritePathsService.getAllFavoritePaths(validSortBy, scope, connectionId);
|
||||||
res.status(200).json(favoritePaths);
|
res.status(200).json(favoritePaths);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('获取收藏路径控制器出错:', error);
|
console.error('获取收藏路径控制器出错:', error);
|
||||||
@@ -79,7 +82,7 @@ export const getFavoritePathById = async (req: Request, res: Response): Promise<
|
|||||||
*/
|
*/
|
||||||
export const updateFavoritePath = async (req: Request, res: Response): Promise<void> => {
|
export const updateFavoritePath = async (req: Request, res: Response): Promise<void> => {
|
||||||
const id = parseInt(req.params.id, 10);
|
const id = parseInt(req.params.id, 10);
|
||||||
const { name, path } = req.body;
|
const { name, path, scope, connectionId } = req.body;
|
||||||
|
|
||||||
if (isNaN(id)) {
|
if (isNaN(id)) {
|
||||||
res.status(400).json({ message: '无效的 ID' });
|
res.status(400).json({ message: '无效的 ID' });
|
||||||
@@ -95,7 +98,7 @@ export const updateFavoritePath = async (req: Request, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const success = await FavoritePathsService.updateFavoritePath(id, name, path);
|
const success = await FavoritePathsService.updateFavoritePath(id, name, path, scope, connectionId);
|
||||||
if (success) {
|
if (success) {
|
||||||
const updatedFavoritePath = await FavoritePathsService.getFavoritePathById(id);
|
const updatedFavoritePath = await FavoritePathsService.getFavoritePathById(id);
|
||||||
if (updatedFavoritePath) {
|
if (updatedFavoritePath) {
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/conn
|
|||||||
// 定义收藏路径接口
|
// 定义收藏路径接口
|
||||||
export interface FavoritePath {
|
export interface FavoritePath {
|
||||||
id: number;
|
id: number;
|
||||||
name: string | null; // 名称可选
|
name: string | null;
|
||||||
path: string;
|
path: string;
|
||||||
last_used_at?: number | null; // 上次使用时间,允许为空
|
scope: string;
|
||||||
created_at: number; // Unix 时间戳 (秒)
|
connection_id: number | null;
|
||||||
updated_at: number; // Unix 时间戳 (秒)
|
last_used_at?: number | null;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,11 +18,11 @@ export interface FavoritePath {
|
|||||||
* @param path - 路径内容
|
* @param path - 路径内容
|
||||||
* @returns 返回插入记录的 ID
|
* @returns 返回插入记录的 ID
|
||||||
*/
|
*/
|
||||||
export const addFavoritePath = async (name: string | null, path: string): Promise<number> => {
|
export const addFavoritePath = async (name: string | null, path: string, scope: string = 'global', connectionId: number | null = null): Promise<number> => {
|
||||||
const sql = `INSERT INTO favorite_paths (name, path, created_at, updated_at) VALUES (?, ?, strftime('%s', 'now'), strftime('%s', 'now'))`;
|
const sql = `INSERT INTO favorite_paths (name, path, scope, connection_id, created_at, updated_at) VALUES (?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))`;
|
||||||
try {
|
try {
|
||||||
const db = await getDbInstance();
|
const db = await getDbInstance();
|
||||||
const result = await runDb(db, sql, [name, path]);
|
const result = await runDb(db, sql, [name, path, scope, connectionId]);
|
||||||
if (typeof result.lastID !== 'number' || result.lastID <= 0) {
|
if (typeof result.lastID !== 'number' || result.lastID <= 0) {
|
||||||
throw new Error('添加收藏路径后未能获取有效的 lastID');
|
throw new Error('添加收藏路径后未能获取有效的 lastID');
|
||||||
}
|
}
|
||||||
@@ -38,11 +40,22 @@ export const addFavoritePath = async (name: string | null, path: string): Promis
|
|||||||
* @param path - 新的路径内容
|
* @param path - 新的路径内容
|
||||||
* @returns 返回是否成功更新 (true/false)
|
* @returns 返回是否成功更新 (true/false)
|
||||||
*/
|
*/
|
||||||
export const updateFavoritePath = async (id: number, name: string | null, path: string): Promise<boolean> => {
|
export const updateFavoritePath = async (id: number, name: string | null, path: string, scope?: string, connectionId?: number | null): Promise<boolean> => {
|
||||||
const sql = `UPDATE favorite_paths SET name = ?, path = ?, updated_at = strftime('%s', 'now') WHERE id = ?`;
|
const fields = ['name = ?', 'path = ?', "updated_at = strftime('%s', 'now')"];
|
||||||
|
const params: any[] = [name, path];
|
||||||
|
if (scope !== undefined) {
|
||||||
|
fields.push('scope = ?');
|
||||||
|
params.push(scope);
|
||||||
|
}
|
||||||
|
if (connectionId !== undefined) {
|
||||||
|
fields.push('connection_id = ?');
|
||||||
|
params.push(connectionId);
|
||||||
|
}
|
||||||
|
params.push(id);
|
||||||
|
const sql = `UPDATE favorite_paths SET ${fields.join(', ')} WHERE id = ?`;
|
||||||
try {
|
try {
|
||||||
const db = await getDbInstance();
|
const db = await getDbInstance();
|
||||||
const result = await runDb(db, sql, [name, path, id]);
|
const result = await runDb(db, sql, params);
|
||||||
return result.changes > 0;
|
return result.changes > 0;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('更新收藏路径时出错:', err.message);
|
console.error('更新收藏路径时出错:', err.message);
|
||||||
@@ -72,15 +85,26 @@ export const deleteFavoritePath = async (id: number): Promise<boolean> => {
|
|||||||
* @param sortBy - 排序字段 ('name' 或 'usage_count')
|
* @param sortBy - 排序字段 ('name' 或 'usage_count')
|
||||||
* @returns 返回包含所有收藏路径条目的数组
|
* @returns 返回包含所有收藏路径条目的数组
|
||||||
*/
|
*/
|
||||||
export const getAllFavoritePaths = async (sortBy: 'name' | 'last_used_at' = 'name'): Promise<FavoritePath[]> => {
|
export const getAllFavoritePaths = async (sortBy: 'name' | 'last_used_at' = 'name', scope?: string, connectionId?: number): Promise<FavoritePath[]> => {
|
||||||
let orderByClause = 'ORDER BY name ASC'; // 默认按名称升序
|
let orderByClause = 'ORDER BY name ASC';
|
||||||
if (sortBy === 'last_used_at') {
|
if (sortBy === 'last_used_at') {
|
||||||
orderByClause = 'ORDER BY last_used_at DESC, name ASC'; // 按上次使用时间降序,同时间的按名称升序
|
orderByClause = 'ORDER BY last_used_at DESC, name ASC';
|
||||||
}
|
}
|
||||||
const sql = `SELECT id, name, path, last_used_at, created_at, updated_at FROM favorite_paths ${orderByClause}`;
|
const conditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
if (scope) {
|
||||||
|
conditions.push('scope = ?');
|
||||||
|
params.push(scope);
|
||||||
|
}
|
||||||
|
if (connectionId !== undefined) {
|
||||||
|
conditions.push('connection_id = ?');
|
||||||
|
params.push(connectionId);
|
||||||
|
}
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
const sql = `SELECT id, name, path, scope, connection_id, last_used_at, created_at, updated_at FROM favorite_paths ${whereClause} ${orderByClause}`;
|
||||||
try {
|
try {
|
||||||
const db = await getDbInstance();
|
const db = await getDbInstance();
|
||||||
const rows = await allDb<FavoritePath>(db, sql);
|
const rows = await allDb<FavoritePath>(db, sql, params);
|
||||||
return rows;
|
return rows;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('获取收藏路径时出错:', err.message);
|
console.error('获取收藏路径时出错:', err.message);
|
||||||
|
|||||||
@@ -10,13 +10,12 @@ export type FavoritePathSortBy = 'name' | 'last_used_at';
|
|||||||
* @param path - 路径内容
|
* @param path - 路径内容
|
||||||
* @returns 返回添加记录的 ID
|
* @returns 返回添加记录的 ID
|
||||||
*/
|
*/
|
||||||
export const addFavoritePath = async (name: string | null, path: string): Promise<number> => {
|
export const addFavoritePath = async (name: string | null, path: string, scope: string = 'global', connectionId: number | null = null): Promise<number> => {
|
||||||
if (!path || path.trim().length === 0) {
|
if (!path || path.trim().length === 0) {
|
||||||
throw new Error('路径内容不能为空');
|
throw new Error('路径内容不能为空');
|
||||||
}
|
}
|
||||||
// 如果 name 是空字符串,则视为 null
|
|
||||||
const finalName = name && name.trim().length > 0 ? name.trim() : null;
|
const finalName = name && name.trim().length > 0 ? name.trim() : null;
|
||||||
const favoritePathId = await FavoritePathsRepository.addFavoritePath(finalName, path.trim());
|
const favoritePathId = await FavoritePathsRepository.addFavoritePath(finalName, path.trim(), scope, connectionId);
|
||||||
return favoritePathId;
|
return favoritePathId;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,12 +26,12 @@ export const addFavoritePath = async (name: string | null, path: string): Promis
|
|||||||
* @param path - 新的路径内容
|
* @param path - 新的路径内容
|
||||||
* @returns 返回是否成功更新 (更新行数 > 0)
|
* @returns 返回是否成功更新 (更新行数 > 0)
|
||||||
*/
|
*/
|
||||||
export const updateFavoritePath = async (id: number, name: string | null, path: string): Promise<boolean> => {
|
export const updateFavoritePath = async (id: number, name: string | null, path: string, scope?: string, connectionId?: number | null): Promise<boolean> => {
|
||||||
if (!path || path.trim().length === 0) {
|
if (!path || path.trim().length === 0) {
|
||||||
throw new Error('路径内容不能为空');
|
throw new Error('路径内容不能为空');
|
||||||
}
|
}
|
||||||
const finalName = name && name.trim().length > 0 ? name.trim() : null;
|
const finalName = name && name.trim().length > 0 ? name.trim() : null;
|
||||||
const pathUpdated = await FavoritePathsRepository.updateFavoritePath(id, finalName, path.trim());
|
const pathUpdated = await FavoritePathsRepository.updateFavoritePath(id, finalName, path.trim(), scope, connectionId);
|
||||||
return pathUpdated;
|
return pathUpdated;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,8 +50,8 @@ export const deleteFavoritePath = async (id: number): Promise<boolean> => {
|
|||||||
* @param sortBy - 排序字段 ('name' 或 'usage_count')
|
* @param sortBy - 排序字段 ('name' 或 'usage_count')
|
||||||
* @returns 返回排序后的收藏路径数组
|
* @returns 返回排序后的收藏路径数组
|
||||||
*/
|
*/
|
||||||
export const getAllFavoritePaths = async (sortBy: FavoritePathSortBy = 'name'): Promise<FavoritePath[]> => {
|
export const getAllFavoritePaths = async (sortBy: FavoritePathSortBy = 'name', scope?: string, connectionId?: number): Promise<FavoritePath[]> => {
|
||||||
return FavoritePathsRepository.getAllFavoritePaths(sortBy);
|
return FavoritePathsRepository.getAllFavoritePaths(sortBy, scope, connectionId);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,24 +23,25 @@ const form = ref({
|
|||||||
id: '',
|
id: '',
|
||||||
path: '',
|
path: '',
|
||||||
name: '',
|
name: '',
|
||||||
|
scope: 'global' as 'global' | 'local',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isEditMode = computed(() => !!props.pathData?.id);
|
const isEditMode = computed(() => !!props.pathData?.id);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const errorMessage = ref<string | null>(null);
|
const errorMessage = ref<string | null>(null);
|
||||||
|
|
||||||
|
|
||||||
watch(() => props.isVisible, (newValue) => {
|
watch(() => props.isVisible, (newValue) => {
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
errorMessage.value = null; // Reset error on open
|
errorMessage.value = null;
|
||||||
if (props.pathData) {
|
if (props.pathData) {
|
||||||
form.value = {
|
form.value = {
|
||||||
id: props.pathData.id,
|
id: props.pathData.id,
|
||||||
path: props.pathData.path,
|
path: props.pathData.path,
|
||||||
name: props.pathData.name || ''
|
name: props.pathData.name || '',
|
||||||
|
scope: (props.pathData.scope as 'global' | 'local') || 'global',
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
form.value = { id: '', path: '', name: '' };
|
form.value = { id: '', path: '', name: '', scope: 'global' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
@@ -50,7 +51,6 @@ const validateForm = (): boolean => {
|
|||||||
errorMessage.value = t('favoritePaths.addEditForm.validation.pathRequired', 'Path is required.');
|
errorMessage.value = t('favoritePaths.addEditForm.validation.pathRequired', 'Path is required.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Add other validation rules if needed
|
|
||||||
errorMessage.value = null;
|
errorMessage.value = null;
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -65,12 +65,14 @@ const handleSubmit = async () => {
|
|||||||
if (isEditMode.value && form.value.id) {
|
if (isEditMode.value && form.value.id) {
|
||||||
await favoritePathsStore.updateFavoritePath(form.value.id, {
|
await favoritePathsStore.updateFavoritePath(form.value.id, {
|
||||||
path: form.value.path,
|
path: form.value.path,
|
||||||
name: form.value.name || undefined, // Send undefined if empty to allow backend to handle
|
name: form.value.name || undefined,
|
||||||
|
scope: form.value.scope,
|
||||||
}, t);
|
}, t);
|
||||||
} else {
|
} else {
|
||||||
await favoritePathsStore.addFavoritePath({
|
await favoritePathsStore.addFavoritePath({
|
||||||
path: form.value.path,
|
path: form.value.path,
|
||||||
name: form.value.name || undefined,
|
name: form.value.name || undefined,
|
||||||
|
scope: form.value.scope,
|
||||||
}, t);
|
}, t);
|
||||||
}
|
}
|
||||||
emit('saveSuccess');
|
emit('saveSuccess');
|
||||||
@@ -78,37 +80,35 @@ const handleSubmit = async () => {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error saving favorite path:', error);
|
console.error('Error saving favorite path:', error);
|
||||||
errorMessage.value = error.message || t('favoritePaths.addEditForm.errors.genericSaveError', 'Failed to save favorite path.');
|
errorMessage.value = error.message || t('favoritePaths.addEditForm.errors.genericSaveError', 'Failed to save favorite path.');
|
||||||
// Notification is usually handled by the store, but we can show a local error too.
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
if (!isLoading.value) { // Prevent closing while loading
|
if (!isLoading.value) {
|
||||||
emit('close');
|
emit('close');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="isVisible"
|
v-if="isVisible"
|
||||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-[var(--overlay-bg-color)]"
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-[var(--overlay-bg-color)]"
|
||||||
@click.self="closeModal"
|
@click.self="closeModal"
|
||||||
>
|
>
|
||||||
<div class="bg-background text-foreground shadow-xl rounded-lg w-full max-w-md flex flex-col overflow-hidden m-4 p-6">
|
<div class="bg-background text-foreground shadow-xl rounded-lg w-full max-w-md flex flex-col overflow-hidden m-4 p-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<h2 class="m-0 mb-6 text-center text-xl font-semibold">
|
<h2 class="m-0 mb-6 text-center text-xl font-semibold">
|
||||||
{{ isEditMode ? t('favoritePaths.addEditForm.editTitle', 'Edit Favorite Path') : t('favoritePaths.addEditForm.addTitle', 'Add New Favorite Path') }}
|
{{ isEditMode ? t('favoritePaths.addEditForm.editTitle', '编辑书签') : t('favoritePaths.addEditForm.addTitle', '添加书签') }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- Form Body -->
|
<!-- Form Body -->
|
||||||
<form @submit.prevent="handleSubmit" class="space-y-4 flex-grow overflow-y-auto">
|
<form @submit.prevent="handleSubmit" class="space-y-4 flex-grow overflow-y-auto">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="favPath-name" class="block text-sm font-medium text-text-secondary mb-1">
|
<label for="favPath-name" class="block text-sm font-medium text-text-secondary mb-1">
|
||||||
{{ t('favoritePaths.addEditForm.nameLabel', 'Name (Optional)') }}
|
{{ t('favoritePaths.addEditForm.nameLabel', '名称(可选)') }}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="favPath-name"
|
id="favPath-name"
|
||||||
@@ -121,7 +121,7 @@ const closeModal = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="favPath-path" class="block text-sm font-medium text-text-secondary mb-1">
|
<label for="favPath-path" class="block text-sm font-medium text-text-secondary mb-1">
|
||||||
{{ t('favoritePaths.addEditForm.pathLabel', 'Path') }}
|
{{ t('favoritePaths.addEditForm.pathLabel', '路径') }}
|
||||||
<span class="text-danger ml-0.5">*</span>
|
<span class="text-danger ml-0.5">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -135,6 +135,41 @@ const closeModal = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Scope Selector -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-text-secondary mb-1.5">
|
||||||
|
{{ t('favoritePaths.addEditForm.scopeLabel', '记录位置') }}
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="form.scope = 'local'"
|
||||||
|
:class="[
|
||||||
|
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm border transition-colors',
|
||||||
|
form.scope === 'local'
|
||||||
|
? 'border-blue-500/50 bg-blue-500/10 text-blue-400'
|
||||||
|
: 'border-border bg-input text-text-secondary hover:border-border hover:bg-white/5'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<i class="fas fa-server text-xs"></i>
|
||||||
|
{{ t('favoritePaths.scopeLocalLabel', '仅当前服务器') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="form.scope = 'global'"
|
||||||
|
:class="[
|
||||||
|
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm border transition-colors',
|
||||||
|
form.scope === 'global'
|
||||||
|
? 'border-emerald-500/50 bg-emerald-500/10 text-emerald-400'
|
||||||
|
: 'border-border bg-input text-text-secondary hover:border-border hover:bg-white/5'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<i class="fas fa-cloud text-xs"></i>
|
||||||
|
{{ t('favoritePaths.scopeGlobalLabel', '全局共享') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="errorMessage" class="text-danger text-sm p-2 bg-danger/10 rounded-md">
|
<div v-if="errorMessage" class="text-danger text-sm p-2 bg-danger/10 rounded-md">
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</div>
|
</div>
|
||||||
@@ -142,30 +177,21 @@ const closeModal = () => {
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="flex justify-end mt-8 pt-4 border-t border-border/50">
|
<div class="flex justify-end mt-8 pt-4 border-t border-border/50">
|
||||||
<!-- Secondary/Cancel Button -->
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="closeModal"
|
@click="closeModal"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
class="py-2 px-5 rounded-lg text-sm font-medium transition-colors duration-150 bg-background border border-border/50 text-text-secondary hover:bg-border hover:text-foreground mr-3">
|
class="py-2 px-5 rounded-lg text-sm font-medium transition-colors duration-150 bg-background border border-border/50 text-text-secondary hover:bg-border hover:text-foreground mr-3">
|
||||||
{{ t('common.cancel', 'Cancel') }}
|
{{ t('common.cancel', '取消') }}
|
||||||
</button>
|
</button>
|
||||||
<!-- Primary/Submit Button -->
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@click="handleSubmit"
|
@click="handleSubmit"
|
||||||
:disabled="isLoading || !form.path.trim()"
|
:disabled="isLoading || !form.path.trim()"
|
||||||
class="py-2 px-5 rounded-lg text-sm font-semibold transition-colors duration-150 bg-primary text-white border-none shadow-md hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:bg-gray-400 disabled:opacity-70 disabled:cursor-not-allowed">
|
class="py-2 px-5 rounded-lg text-sm font-semibold transition-colors duration-150 bg-primary text-white border-none shadow-md hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:bg-gray-400 disabled:opacity-70 disabled:cursor-not-allowed">
|
||||||
{{ isLoading ? t('common.saving', 'Saving...') : t('common.save', 'Save') }}
|
{{ isLoading ? t('common.saving', '保存中...') : t('common.save', '保存') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Styles are primarily Tailwind based */
|
|
||||||
.bg-background-hover:hover {
|
|
||||||
background-color: var(--color-bg-hover); /* Ensure this CSS variable is defined globally or in Tailwind config */
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -7,7 +7,7 @@ import AddEditFavoritePathForm from './AddEditFavoritePathForm.vue';
|
|||||||
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents';
|
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents';
|
||||||
import { useConfirmDialog } from '../composables/useConfirmDialog';
|
import { useConfirmDialog } from '../composables/useConfirmDialog';
|
||||||
|
|
||||||
const PADDING = 8; // px
|
const PADDING = 8;
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
isVisible: {
|
isVisible: {
|
||||||
@@ -34,28 +34,27 @@ const editingPathItem = ref<FavoritePathItem | null>(null);
|
|||||||
const modalContentRef = ref<HTMLElement | null>(null);
|
const modalContentRef = ref<HTMLElement | null>(null);
|
||||||
const modalStyle = ref<Record<string, string>>({});
|
const modalStyle = ref<Record<string, string>>({});
|
||||||
|
|
||||||
|
const scopeTabs = computed(() => [
|
||||||
|
{ key: 'all' as const, label: t('favoritePaths.scopeAll', '全部') },
|
||||||
|
{ key: 'local' as const, label: t('favoritePaths.scopeLocal', '本地') },
|
||||||
|
{ key: 'global' as const, label: t('favoritePaths.scopeGlobal', '云端') },
|
||||||
|
]);
|
||||||
|
|
||||||
const filteredPaths = computed(() => {
|
const filteredPaths = computed(() => {
|
||||||
if (!searchTerm.value) {
|
return favoritePathsStore.filteredFavoritePaths.filter(p => {
|
||||||
return favoritePathsStore.favoritePaths;
|
if (!searchTerm.value) return true;
|
||||||
}
|
const lowerSearchTerm = searchTerm.value.toLowerCase();
|
||||||
const lowerSearchTerm = searchTerm.value.toLowerCase();
|
return p.path.toLowerCase().includes(lowerSearchTerm) ||
|
||||||
return favoritePathsStore.favoritePaths.filter(
|
(p.name && p.name.toLowerCase().includes(lowerSearchTerm));
|
||||||
(p) =>
|
});
|
||||||
p.path.toLowerCase().includes(lowerSearchTerm) ||
|
|
||||||
(p.name && p.name.toLowerCase().includes(lowerSearchTerm))
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Computed property for sort button icon and title
|
|
||||||
const currentSortBy = computed(() => favoritePathsStore.currentSortBy);
|
const currentSortBy = computed(() => favoritePathsStore.currentSortBy);
|
||||||
|
|
||||||
const sortButtonIcon = computed(() => {
|
const sortButtonIcon = computed(() => {
|
||||||
return currentSortBy.value === 'name' ? 'fas fa-sort-alpha-down' : 'fas fa-clock';
|
return currentSortBy.value === 'name' ? 'fas fa-sort-alpha-down' : 'fas fa-clock';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const toggleSort = () => {
|
const toggleSort = () => {
|
||||||
const newSortBy = currentSortBy.value === 'name' ? 'last_used_at' : 'name';
|
const newSortBy = currentSortBy.value === 'name' ? 'last_used_at' : 'name';
|
||||||
favoritePathsStore.setSortBy(newSortBy);
|
favoritePathsStore.setSortBy(newSortBy);
|
||||||
@@ -63,11 +62,9 @@ const toggleSort = () => {
|
|||||||
|
|
||||||
const handleItemClick = async (pathItem: FavoritePathItem) => {
|
const handleItemClick = async (pathItem: FavoritePathItem) => {
|
||||||
try {
|
try {
|
||||||
// Mark path as used before navigating
|
|
||||||
await favoritePathsStore.markPathAsUsed(pathItem.id, t);
|
await favoritePathsStore.markPathAsUsed(pathItem.id, t);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to mark path as used:', error);
|
console.error('Failed to mark path as used:', error);
|
||||||
// Optionally, inform the user about the failure, though navigation will still proceed.
|
|
||||||
}
|
}
|
||||||
emit('navigateToPath', pathItem.path);
|
emit('navigateToPath', pathItem.path);
|
||||||
closeModal();
|
closeModal();
|
||||||
@@ -109,7 +106,7 @@ const handleSendToTerminal = (pathItem: FavoritePathItem) => {
|
|||||||
} else {
|
} else {
|
||||||
console.warn('[FavoritePathsModal] No active session with a terminal manager found to send path to.');
|
console.warn('[FavoritePathsModal] No active session with a terminal manager found to send path to.');
|
||||||
}
|
}
|
||||||
closeModal();
|
closeModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
@@ -125,58 +122,47 @@ const updatePosition = () => {
|
|||||||
const modalWidth = modalContentRef.value.offsetWidth;
|
const modalWidth = modalContentRef.value.offsetWidth;
|
||||||
const modalHeight = modalContentRef.value.offsetHeight;
|
const modalHeight = modalContentRef.value.offsetHeight;
|
||||||
|
|
||||||
// If dimensions are zero when modal is supposed to be visible,
|
|
||||||
// it might mean content affecting size isn't ready. Retry once.
|
|
||||||
if (modalWidth === 0 && modalHeight === 0 && props.isVisible) {
|
if (modalWidth === 0 && modalHeight === 0 && props.isVisible) {
|
||||||
nextTick(updatePosition);
|
nextTick(updatePosition);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewportWidth = window.innerWidth;
|
const viewportWidth = window.innerWidth;
|
||||||
const viewportHeight = window.innerHeight;
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
let top = triggerRect.bottom + 2; // Default position below trigger, with a small 2px gap
|
let top = triggerRect.bottom + 2;
|
||||||
let left = triggerRect.left;
|
let left = triggerRect.left;
|
||||||
|
|
||||||
// Check for bottom overflow
|
|
||||||
if (top + modalHeight + PADDING > viewportHeight) {
|
if (top + modalHeight + PADDING > viewportHeight) {
|
||||||
// Try to position above the trigger
|
top = triggerRect.top - modalHeight - 2;
|
||||||
top = triggerRect.top - modalHeight - 2; // Position above trigger, with a small 2px gap
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If positioning above also causes top overflow (e.g., trigger is near the top and modal is tall)
|
|
||||||
if (top < PADDING) {
|
if (top < PADDING) {
|
||||||
top = PADDING; // Align to viewport top with padding
|
top = PADDING;
|
||||||
// Note: If modalHeight is still greater than viewportHeight - 2*PADDING,
|
|
||||||
// it will overflow downwards. The `max-h-80` class on the modal
|
|
||||||
// should generally prevent the modal itself from being excessively tall.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for right overflow
|
|
||||||
if (left + modalWidth + PADDING > viewportWidth) {
|
if (left + modalWidth + PADDING > viewportWidth) {
|
||||||
left = viewportWidth - modalWidth - PADDING; // Align to viewport right edge
|
left = viewportWidth - modalWidth - PADDING;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for left overflow (less likely with initial left alignment to trigger, but good for robustness)
|
|
||||||
if (left < PADDING) {
|
if (left < PADDING) {
|
||||||
left = PADDING; // Align to viewport left edge
|
left = PADDING;
|
||||||
}
|
}
|
||||||
|
|
||||||
modalStyle.value = {
|
modalStyle.value = {
|
||||||
position: 'fixed', // Position relative to the viewport
|
position: 'fixed',
|
||||||
top: `${top}px`,
|
top: `${top}px`,
|
||||||
left: `${left}px`,
|
left: `${left}px`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Click Outside Logic ---
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (props.triggerElement && props.triggerElement.contains(event.target as Node)) {
|
if (props.triggerElement && props.triggerElement.contains(event.target as Node)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modalContentRef.value && !modalContentRef.value.contains(event.target as Node)) {
|
if (modalContentRef.value && !modalContentRef.value.contains(event.target as Node)) {
|
||||||
if (!showAddEditModal.value) {
|
if (!showAddEditModal.value) {
|
||||||
closeModal();
|
closeModal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,21 +172,21 @@ watch(() => props.isVisible, (newValue: boolean) => {
|
|||||||
if (newValue) {
|
if (newValue) {
|
||||||
searchTerm.value = '';
|
searchTerm.value = '';
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
nextTick(() => { // Ensure DOM is ready for measurements
|
nextTick(() => {
|
||||||
updatePosition(); // Calculate initial position
|
updatePosition();
|
||||||
window.addEventListener('resize', updatePosition); // Adjust position on window resize
|
window.addEventListener('resize', updatePosition);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
window.removeEventListener('resize', updatePosition); // Clean up resize listener
|
window.removeEventListener('resize', updatePosition);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (props.isVisible) {
|
if (props.isVisible) {
|
||||||
searchTerm.value = '';
|
searchTerm.value = '';
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
updatePosition();
|
updatePosition();
|
||||||
window.addEventListener('resize', updatePosition);
|
window.addEventListener('resize', updatePosition);
|
||||||
});
|
});
|
||||||
@@ -209,94 +195,119 @@ onMounted(() => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
window.removeEventListener('resize', updatePosition); // Ensure resize listener is cleaned up
|
window.removeEventListener('resize', updatePosition);
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- New single root element -->
|
|
||||||
<div>
|
<div>
|
||||||
<!-- Favorite Paths Dropdown -->
|
|
||||||
<div
|
<div
|
||||||
v-if="isVisible"
|
v-if="isVisible"
|
||||||
ref="modalContentRef"
|
ref="modalContentRef"
|
||||||
:style="modalStyle"
|
:style="modalStyle"
|
||||||
class="z-50 w-72 md:w-80 rounded-md bg-background shadow-lg border border-border/50 max-h-80 flex flex-col overflow-hidden"
|
class="z-50 w-72 md:w-80 rounded-md bg-background shadow-lg border border-border/50 max-h-80 flex flex-col overflow-hidden"
|
||||||
>
|
>
|
||||||
<!-- Toolbar: Search and Add Button -->
|
<!-- Header -->
|
||||||
<div class="p-2 flex-shrink-0 flex items-center gap-2">
|
<div class="flex items-center justify-between px-3 py-2 border-b border-border/40 flex-shrink-0">
|
||||||
<div class="relative flex-grow">
|
<div class="flex items-center gap-2">
|
||||||
<input
|
<span class="text-sm font-medium text-foreground">{{ t('favoritePaths.title', '书签列表') }}</span>
|
||||||
type="text"
|
<span class="text-[10px] bg-primary/20 text-primary px-1.5 rounded-full">{{ favoritePathsStore.favoritePaths.length }}</span>
|
||||||
v-model="searchTerm"
|
|
||||||
:placeholder="t('favoritePaths.searchPlaceholder', 'Search by name or path...')"
|
|
||||||
class="w-full bg-input border border-border rounded-md pl-2.5 pr-2 py-1.5 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
@click="toggleSort"
|
||||||
|
class="flex items-center justify-center w-6 h-6 text-text-secondary rounded hover:text-primary hover:bg-white/5 transition-colors"
|
||||||
|
:title="currentSortBy === 'name' ? t('favoritePaths.sortByUsage', '按使用排序') : t('favoritePaths.sortByName', '按名称排序')"
|
||||||
|
>
|
||||||
|
<i :class="sortButtonIcon" class="text-[11px]"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="openAddModal"
|
||||||
|
class="flex items-center justify-center w-6 h-6 text-primary rounded hover:bg-primary/10 transition-colors"
|
||||||
|
:title="t('favoritePaths.addNew', '添加书签')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-plus text-[11px]"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scope Tabs -->
|
||||||
|
<div class="flex items-center gap-0.5 px-2 py-1 border-b border-border/30 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
@click="toggleSort"
|
v-for="tab in scopeTabs"
|
||||||
class="flex items-center justify-center w-8 h-8 bg-background border border-border text-text-secondary rounded-lg text-sm cursor-pointer shadow-sm transition-colors duration-200 ease-in-out hover:bg-primary/10 hover:text-primary focus:outline-none flex-shrink-0"
|
:key="tab.key"
|
||||||
|
@click="favoritePathsStore.setActiveScope(tab.key)"
|
||||||
|
:class="[
|
||||||
|
'px-2 py-0.5 text-[11px] rounded transition-colors',
|
||||||
|
favoritePathsStore.activeScope === tab.key
|
||||||
|
? 'bg-primary/15 text-primary font-medium'
|
||||||
|
: 'text-text-secondary hover:text-foreground hover:bg-white/5'
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<i :class="sortButtonIcon"></i>
|
{{ tab.label }}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="openAddModal"
|
|
||||||
class="flex items-center justify-center w-8 h-8 bg-primary text-white border-none rounded-lg text-sm font-semibold cursor-pointer shadow-md transition-colors duration-200 ease-in-out hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary flex-shrink-0"
|
|
||||||
:title="t('favoritePaths.addNew', 'Add new favorite path')"
|
|
||||||
>
|
|
||||||
<i class="fas fa-plus text-base"></i>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="px-2 py-1.5 flex-shrink-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="searchTerm"
|
||||||
|
:placeholder="t('favoritePaths.searchPlaceholder', '搜索书签...')"
|
||||||
|
class="w-full bg-input border border-border rounded px-2 py-1 text-xs outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Path List -->
|
<!-- Path List -->
|
||||||
<div class="overflow-y-auto flex-grow p-1 text-sm">
|
<div class="overflow-y-auto flex-grow px-1 pb-1 text-xs">
|
||||||
<div v-if="favoritePathsStore.isLoading && filteredPaths.length === 0" class="p-3 text-center text-text-secondary">
|
<div v-if="favoritePathsStore.isLoading && filteredPaths.length === 0" class="p-3 text-center text-text-secondary">
|
||||||
<i class="fas fa-spinner fa-spin mr-1"></i>
|
<i class="fas fa-spinner fa-spin mr-1"></i>
|
||||||
{{ t('favoritePaths.loading', 'Loading favorites...') }}
|
{{ t('favoritePaths.loading', 'Loading favorites...') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!favoritePathsStore.isLoading && filteredPaths.length === 0" class="p-3 text-center text-text-secondary">
|
<div v-else-if="!favoritePathsStore.isLoading && filteredPaths.length === 0" class="py-6 text-center text-text-secondary">
|
||||||
<i class="fas fa-star-half-alt mr-1"></i>
|
<i class="fas fa-bookmark text-lg mb-1 block opacity-40"></i>
|
||||||
{{ searchTerm ? t('favoritePaths.noResults', 'No matching favorites found.') : t('favoritePaths.noFavorites', 'No favorite paths yet. Add one!') }}
|
{{ searchTerm ? t('favoritePaths.noResults', '未找到匹配的书签') : t('favoritePaths.noFavorites', '暂无书签') }}
|
||||||
</div>
|
</div>
|
||||||
<ul v-else-if="filteredPaths.length > 0" class="list-none m-0 p-0">
|
<div v-else class="space-y-0.5">
|
||||||
<li
|
<div
|
||||||
v-for="favPath in filteredPaths"
|
v-for="favPath in filteredPaths"
|
||||||
:key="favPath.id"
|
:key="favPath.id"
|
||||||
class="p-2 hover:bg-primary/10 cursor-pointer group flex items-center justify-between rounded-md transition-colors duration-150"
|
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white/5 cursor-pointer group transition-colors"
|
||||||
@click="handleItemClick(favPath)"
|
@click="handleItemClick(favPath)"
|
||||||
:title="favPath.path"
|
:title="favPath.path"
|
||||||
>
|
>
|
||||||
<div class="flex-grow overflow-hidden mr-2">
|
<i class="fas fa-bookmark text-[10px] text-primary/60 flex-shrink-0"></i>
|
||||||
<p class="font-medium truncate text-foreground">
|
<div class="flex-1 overflow-hidden min-w-0">
|
||||||
{{ favPath.name || favPath.path }}
|
<div class="flex items-center gap-1.5">
|
||||||
</p>
|
<span class="truncate text-foreground text-[13px]">{{ favPath.name || favPath.path }}</span>
|
||||||
<p v-if="favPath.name" class="text-xs text-text-secondary truncate">
|
<span v-if="favPath.scope === 'local'" class="text-[9px] px-1 rounded bg-blue-500/15 text-blue-400 flex-shrink-0">{{ t('favoritePaths.scopeLocal', '本地') }}</span>
|
||||||
{{ favPath.path }}
|
<span v-else class="text-[9px] px-1 rounded bg-emerald-500/15 text-emerald-400 flex-shrink-0">{{ t('favoritePaths.scopeGlobal', '云端') }}</span>
|
||||||
</p>
|
</div>
|
||||||
|
<p v-if="favPath.name" class="text-[11px] text-text-secondary/70 truncate mt-0.5">{{ favPath.path }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity duration-150">
|
<div class="flex-shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
@click.stop="handleSendToTerminal(favPath)"
|
@click.stop="handleSendToTerminal(favPath)"
|
||||||
class="p-1.5 rounded text-text-secondary hover:text-primary hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
class="p-1 rounded text-text-secondary hover:text-primary hover:bg-white/10 transition-colors"
|
||||||
:title="t('favoritePaths.sendToTerminal', 'Send to Terminal')">
|
:title="t('favoritePaths.sendToTerminal', '发送到终端')">
|
||||||
<i class="fas fa-terminal text-xs"></i>
|
<i class="fas fa-terminal text-[10px]"></i>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click.stop="openEditModal(favPath)"
|
@click.stop="openEditModal(favPath)"
|
||||||
class="p-1.5 rounded text-text-secondary hover:text-primary hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
class="p-1 rounded text-text-secondary hover:text-primary hover:bg-white/10 transition-colors"
|
||||||
:title="t('common.edit')">
|
:title="t('common.edit')">
|
||||||
<i class="fas fa-pencil-alt text-xs"></i>
|
<i class="fas fa-pencil-alt text-[10px]"></i>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click.stop="handleDelete(favPath)"
|
@click.stop="handleDelete(favPath)"
|
||||||
class="p-1.5 rounded text-text-secondary hover:text-error hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
class="p-1 rounded text-text-secondary hover:text-red-400 hover:bg-white/10 transition-colors"
|
||||||
:title="t('common.delete')">
|
:title="t('common.delete')">
|
||||||
<i class="fas fa-trash-alt text-xs"></i>
|
<i class="fas fa-trash-alt text-[10px]"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -308,7 +319,5 @@ onBeforeUnmount(() => {
|
|||||||
@close="showAddEditModal = false"
|
@close="showAddEditModal = false"
|
||||||
@save-success="() => { favoritePathsStore.fetchFavoritePaths(t); showAddEditModal = false; }"
|
@save-success="() => { favoritePathsStore.fetchFavoritePaths(t); showAddEditModal = false; }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ import { useFileManagerDragAndDrop } from '../composables/file-manager/useFileMa
|
|||||||
import { useFileManagerKeyboardNavigation } from '../composables/file-manager/useFileManagerKeyboardNavigation';
|
import { useFileManagerKeyboardNavigation } from '../composables/file-manager/useFileManagerKeyboardNavigation';
|
||||||
import { createFolderArchive, type FolderArchiveSource } from '../composables/file-manager/useFolderArchiveUpload';
|
import { createFolderArchive, type FolderArchiveSource } from '../composables/file-manager/useFolderArchiveUpload';
|
||||||
import FileUploadPopup from './FileUploadPopup.vue';
|
import FileUploadPopup from './FileUploadPopup.vue';
|
||||||
|
import TransferPanel from './TransferPanel.vue';
|
||||||
import FileManagerContextMenu from './FileManagerContextMenu.vue';
|
import FileManagerContextMenu from './FileManagerContextMenu.vue';
|
||||||
import FileManagerActionModal from './FileManagerActionModal.vue';
|
import FileManagerActionModal from './FileManagerActionModal.vue';
|
||||||
import type { FileListItem } from '../types/sftp.types';
|
import type { FileListItem } from '../types/sftp.types';
|
||||||
@@ -150,6 +151,8 @@ const editablePath = ref('');
|
|||||||
const fileListContainerRef = ref<HTMLDivElement | null>(null); // 文件列表容器引用
|
const fileListContainerRef = ref<HTMLDivElement | null>(null); // 文件列表容器引用
|
||||||
const dropOverlayRef = ref<HTMLDivElement | null>(null); // +++ 拖拽蒙版引用 +++
|
const dropOverlayRef = ref<HTMLDivElement | null>(null); // +++ 拖拽蒙版引用 +++
|
||||||
const isFolderUploadBusy = ref(false);
|
const isFolderUploadBusy = ref(false);
|
||||||
|
const uploadMenuOpen = ref(false);
|
||||||
|
const showTransferPanel = ref(false);
|
||||||
|
|
||||||
// +++ Favorite Paths Modal State +++
|
// +++ Favorite Paths Modal State +++
|
||||||
const showFavoritePathsModal = ref(false);
|
const showFavoritePathsModal = ref(false);
|
||||||
@@ -1741,6 +1744,7 @@ onMounted(() => {
|
|||||||
};
|
};
|
||||||
unregisterPathFocusAction = focusSwitcherStore.registerFocusAction('fileManagerPathInput', focusPathActionWrapper);
|
unregisterPathFocusAction = focusSwitcherStore.registerFocusAction('fileManagerPathInput', focusPathActionWrapper);
|
||||||
document.addEventListener('click', handleClickOutsidePathInput);
|
document.addEventListener('click', handleClickOutsidePathInput);
|
||||||
|
document.addEventListener('click', handleClickOutsideUploadMenu);
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -1758,6 +1762,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
unregisterPathFocusAction = null;
|
unregisterPathFocusAction = null;
|
||||||
document.removeEventListener('click', handleClickOutsidePathInput);
|
document.removeEventListener('click', handleClickOutsidePathInput);
|
||||||
|
document.removeEventListener('click', handleClickOutsideUploadMenu);
|
||||||
sessionStore.removeSftpManager(props.sessionId, props.instanceId);
|
sessionStore.removeSftpManager(props.sessionId, props.instanceId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2000,6 +2005,12 @@ const handleClickOutsidePathInput = (event: MouseEvent) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClickOutsideUploadMenu = (event: MouseEvent) => {
|
||||||
|
if (uploadMenuOpen.value) {
|
||||||
|
uploadMenuOpen.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// --- 搜索框激活/取消逻辑 ---
|
// --- 搜索框激活/取消逻辑 ---
|
||||||
const activateSearch = () => {
|
const activateSearch = () => {
|
||||||
@@ -2368,7 +2379,7 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div> <!-- End Wrapper -->
|
</div> <!-- End Wrapper -->
|
||||||
<!-- Main Actions Bar -->
|
<!-- Main Actions Bar -->
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-0.5 flex-shrink-0">
|
||||||
<input type="file" ref="fileInputRef" @change="handleFileSelected" multiple class="hidden" />
|
<input type="file" ref="fileInputRef" @change="handleFileSelected" multiple class="hidden" />
|
||||||
<input type="file" ref="folderInputRef" @change="handleFolderSelected" webkitdirectory directory multiple class="hidden" />
|
<input type="file" ref="folderInputRef" @change="handleFolderSelected" webkitdirectory directory multiple class="hidden" />
|
||||||
<!-- 打开编辑器按钮 -->
|
<!-- 打开编辑器按钮 -->
|
||||||
@@ -2377,62 +2388,63 @@ watch(
|
|||||||
@click="openPopupEditor"
|
@click="openPopupEditor"
|
||||||
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
|
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
|
||||||
:title="t('fileManager.actions.openEditor', 'Open Popup Editor')"
|
:title="t('fileManager.actions.openEditor', 'Open Popup Editor')"
|
||||||
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
|
class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-black/10 hover:enabled:text-foreground"
|
||||||
:class="{ 'px-1.5': props.isMobile }"
|
|
||||||
>
|
>
|
||||||
<i class="far fa-edit text-sm"></i> <!-- 使用编辑图标 -->
|
<i class="far fa-edit text-sm"></i>
|
||||||
<span v-if="!props.isMobile">{{ t('fileManager.actions.openEditor', 'Open Editor') }}</span> <!-- 添加 i18n key -->
|
|
||||||
</button>
|
|
||||||
<!-- 上传按钮 -->
|
|
||||||
<button
|
|
||||||
@click="triggerFileUpload"
|
|
||||||
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
|
|
||||||
:title="t('fileManager.actions.uploadFile')"
|
|
||||||
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
|
|
||||||
:class="{ 'px-1.5': props.isMobile }"
|
|
||||||
>
|
|
||||||
<i class="fas fa-upload text-sm"></i>
|
|
||||||
<span v-if="!props.isMobile">{{ t('fileManager.actions.uploadFile') }}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="triggerFolderUpload"
|
|
||||||
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value || isFolderUploadBusy"
|
|
||||||
:title="t('fileManager.actions.uploadFolder')"
|
|
||||||
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
|
|
||||||
:class="{ 'px-1.5': props.isMobile }"
|
|
||||||
>
|
|
||||||
<i :class="isFolderUploadBusy ? 'fas fa-spinner fa-spin text-sm' : 'fas fa-folder-open text-sm'"></i>
|
|
||||||
<span v-if="!props.isMobile">{{ t('fileManager.actions.uploadFolder') }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
<!-- 上传下拉菜单 -->
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
@click="uploadMenuOpen = !uploadMenuOpen"
|
||||||
|
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
|
||||||
|
:title="t('fileManager.actions.uploadFile')"
|
||||||
|
class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-black/10 hover:enabled:text-foreground"
|
||||||
|
>
|
||||||
|
<i class="fas fa-upload text-sm"></i>
|
||||||
|
</button>
|
||||||
|
<div v-if="uploadMenuOpen" class="absolute right-0 top-full mt-1 bg-background border border-border rounded-md shadow-lg z-50 py-1 min-w-[140px]">
|
||||||
|
<button
|
||||||
|
@click="triggerFileUpload(); uploadMenuOpen = false"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-xs text-foreground hover:bg-primary/10 hover:text-primary transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<i class="fas fa-file-upload w-4 text-center"></i>
|
||||||
|
{{ t('fileManager.actions.uploadFile') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="triggerFolderUpload(); uploadMenuOpen = false"
|
||||||
|
:disabled="isFolderUploadBusy"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-xs text-foreground hover:bg-primary/10 hover:text-primary transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<i :class="isFolderUploadBusy ? 'fas fa-spinner fa-spin w-4 text-center' : 'fas fa-folder-open w-4 text-center'"></i>
|
||||||
|
{{ t('fileManager.actions.uploadFolder') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="handleNewFolderContextMenuClick"
|
@click="handleNewFolderContextMenuClick"
|
||||||
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
|
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
|
||||||
:title="t('fileManager.actions.newFolder')"
|
:title="t('fileManager.actions.newFolder')"
|
||||||
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
|
class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-black/10 hover:enabled:text-foreground"
|
||||||
:class="{ 'px-1.5': props.isMobile }"
|
|
||||||
>
|
>
|
||||||
<i class="fas fa-folder-plus text-sm"></i>
|
<i class="fas fa-folder-plus text-sm"></i>
|
||||||
<span v-if="!props.isMobile">{{ t('fileManager.actions.newFolder') }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="handleNewFileContextMenuClick"
|
@click="handleNewFileContextMenuClick"
|
||||||
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
|
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
|
||||||
:title="t('fileManager.actions.newFile')"
|
:title="t('fileManager.actions.newFile')"
|
||||||
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
|
class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-black/10 hover:enabled:text-foreground"
|
||||||
:class="{ 'px-1.5': props.isMobile }"
|
|
||||||
>
|
>
|
||||||
<i class="far fa-file-alt text-sm"></i>
|
<i class="far fa-file-alt text-sm"></i>
|
||||||
<span v-if="!props.isMobile">{{ t('fileManager.actions.newFile') }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
<!-- 多选模式切换按钮 (仅移动端) -->
|
<!-- 多选模式切换按钮 (仅移动端) -->
|
||||||
<button
|
<button
|
||||||
v-if="props.isMobile"
|
v-if="props.isMobile"
|
||||||
@click="toggleMultiSelectMode"
|
@click="toggleMultiSelectMode"
|
||||||
:title="isMultiSelectMode ? t('fileManager.actions.exitMultiSelect', 'Exit Multi-Select Mode') : t('fileManager.actions.multiSelect', 'Enter Multi-Select Mode')"
|
:title="isMultiSelectMode ? t('fileManager.actions.exitMultiSelect', 'Exit Multi-Select Mode') : t('fileManager.actions.multiSelect', 'Enter Multi-Select Mode')"
|
||||||
class="flex items-center gap-1 px-1.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
class="flex items-center justify-center w-7 h-7 rounded text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
:class="{
|
:class="{
|
||||||
'hover:bg-header hover:border-primary hover:text-primary': !isMultiSelectMode,
|
'text-text-secondary hover:bg-black/10 hover:text-foreground': !isMultiSelectMode,
|
||||||
'bg-primary text-white border-primary': isMultiSelectMode
|
'bg-primary text-white': isMultiSelectMode
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<i class="fas fa-check-square text-sm"></i>
|
<i class="fas fa-check-square text-sm"></i>
|
||||||
@@ -2442,13 +2454,8 @@ watch(
|
|||||||
|
|
||||||
<div class="flex flex-grow min-h-0 overflow-hidden border-t border-border/60">
|
<div class="flex flex-grow min-h-0 overflow-hidden border-t border-border/60">
|
||||||
<div class="flex-1 bg-header/20 flex flex-col min-h-0">
|
<div class="flex-1 bg-header/20 flex flex-col min-h-0">
|
||||||
<div class="px-3 py-3 border-b border-border/60">
|
<div class="px-2 py-1.5 border-b border-border/60">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="text-[11px] uppercase tracking-[0.15em] text-text-secondary font-medium">{{ t('fileManager.explorer.title', '目录资源管理器') }}</div>
|
||||||
<div>
|
|
||||||
<div class="text-[11px] uppercase tracking-[0.18em] text-text-secondary">{{ t('fileManager.explorer.title', '目录资源管理器') }}</div>
|
|
||||||
<div class="mt-1 text-xs text-text-secondary">1 {{ t('fileManager.explorer.rootCount', '个根目录') }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -2473,24 +2480,24 @@ watch(
|
|||||||
{{ t('fileManager.dropFilesHere', 'Drop files here to upload') }}
|
{{ t('fileManager.dropFilesHere', 'Drop files here to upload') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-2 space-y-1">
|
<div class="px-1 py-0.5">
|
||||||
<div
|
<div
|
||||||
v-for="row in explorerTreeRows"
|
v-for="row in explorerTreeRows"
|
||||||
:key="row.id"
|
:key="row.id"
|
||||||
:data-drop-path="row.path"
|
:data-drop-path="row.path"
|
||||||
:data-is-directory="row.isDirectory"
|
:data-is-directory="row.isDirectory"
|
||||||
:class="[
|
:class="[
|
||||||
'group flex items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors cursor-pointer',
|
'group flex items-center gap-1.5 px-1 py-[3px] transition-colors cursor-pointer rounded-sm',
|
||||||
showExternalDropOverlay && externalDropTargetPath === row.path
|
showExternalDropOverlay && externalDropTargetPath === row.path
|
||||||
? 'border-primary bg-primary/15 text-foreground'
|
? 'bg-primary/15 text-foreground'
|
||||||
: '',
|
: '',
|
||||||
isExplorerRowActive(row)
|
isExplorerRowActive(row)
|
||||||
? 'bg-primary text-white border-primary shadow-sm'
|
? 'bg-emerald-600/20 text-emerald-400'
|
||||||
: isExplorerRowRelated(row)
|
: isExplorerRowRelated(row)
|
||||||
? 'border-primary/20 bg-primary/8 text-foreground'
|
? 'bg-primary/8 text-foreground'
|
||||||
: 'border-transparent text-text-secondary hover:bg-background hover:text-foreground'
|
: 'text-text-secondary hover:bg-white/5 hover:text-foreground'
|
||||||
]"
|
]"
|
||||||
:style="{ paddingLeft: `${0.6 + row.depth * 0.85}rem` }"
|
:style="{ paddingLeft: `${0.25 + row.depth * 0.75}rem` }"
|
||||||
@click="handleExplorerSelect(row)"
|
@click="handleExplorerSelect(row)"
|
||||||
@dblclick="handleExplorerOpen(row)"
|
@dblclick="handleExplorerOpen(row)"
|
||||||
@contextmenu.prevent.stop="handleExplorerContextMenu($event, row)"
|
@contextmenu.prevent.stop="handleExplorerContextMenu($event, row)"
|
||||||
@@ -2498,42 +2505,38 @@ watch(
|
|||||||
<button
|
<button
|
||||||
v-if="row.isDirectory"
|
v-if="row.isDirectory"
|
||||||
@click.stop="handleExplorerToggle(row)"
|
@click.stop="handleExplorerToggle(row)"
|
||||||
class="w-4 h-4 flex items-center justify-center flex-shrink-0 text-[10px]"
|
class="w-3.5 h-3.5 flex items-center justify-center flex-shrink-0 text-[9px] opacity-60 hover:opacity-100"
|
||||||
>
|
>
|
||||||
<i :class="row.expanded ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"></i>
|
<i :class="row.expanded ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"></i>
|
||||||
</button>
|
</button>
|
||||||
<span v-else class="w-4 h-4 flex items-center justify-center flex-shrink-0 text-[9px] opacity-60">
|
<span v-else class="w-3.5 h-3.5 flex-shrink-0"></span>
|
||||||
<i class="fas fa-minus"></i>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<i
|
<i
|
||||||
:class="[
|
:class="[
|
||||||
row.isDirectory
|
row.isDirectory
|
||||||
? (row.isRoot ? 'fas fa-folder-tree' : 'fas fa-folder')
|
? (row.expanded ? 'fas fa-folder-open' : 'fas fa-folder')
|
||||||
: getFileIconClassBase(row.name),
|
: getFileIconClassBase(row.name),
|
||||||
'w-4 text-center flex-shrink-0',
|
'w-4 text-center flex-shrink-0 text-xs',
|
||||||
isExplorerRowActive(row) ? 'text-white' : (row.isDirectory ? 'text-primary' : 'text-text-secondary')
|
isExplorerRowActive(row)
|
||||||
|
? 'text-emerald-400'
|
||||||
|
: (row.isDirectory ? 'text-yellow-500' : 'text-text-secondary/70')
|
||||||
]"
|
]"
|
||||||
></i>
|
></i>
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
<span class="truncate text-[13px] leading-tight" :title="row.description || row.path">{{ row.name }}<span v-if="row.item?.attrs?.isSymbolicLink && row.description" class="text-text-secondary/60 ml-1 text-[11px]">→ {{ row.description }}</span></span>
|
||||||
<div class="truncate text-sm font-medium" :title="row.description || row.path">{{ row.name }}</div>
|
|
||||||
<div
|
|
||||||
v-if="row.isRoot || !row.isDirectory"
|
|
||||||
class="truncate text-[10px]"
|
|
||||||
:class="isExplorerRowActive(row) ? 'text-white/75' : 'text-text-secondary/80'"
|
|
||||||
>
|
|
||||||
{{ row.path }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 使用 FileUploadPopup 组件 -->
|
<!-- Transfer Panel -->
|
||||||
<FileUploadPopup :uploads="uploads" @cancel-upload="cancelUpload" />
|
<TransferPanel
|
||||||
|
:uploads="uploads"
|
||||||
|
:visible="showTransferPanel"
|
||||||
|
@update:visible="showTransferPanel = $event"
|
||||||
|
@cancel-upload="cancelUpload"
|
||||||
|
/>
|
||||||
|
|
||||||
<FileManagerContextMenu
|
<FileManagerContextMenu
|
||||||
ref="contextMenuRef"
|
ref="contextMenuRef"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="chartHostRef" class="cpu-history-chart">
|
<div ref="chartHostRef" class="cpu-history-chart" :class="{ 'cpu-history-chart--compact': compact }">
|
||||||
<div class="cpu-history-chart__header">
|
<div v-if="!compact" class="cpu-history-chart__header">
|
||||||
<div>
|
<div>
|
||||||
<h6 class="cpu-history-chart__title">{{ t('statusMonitor.cpuUsageTitle') }}</h6>
|
<h6 class="cpu-history-chart__title">{{ t('statusMonitor.cpuUsageTitle') }}</h6>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,6 +47,10 @@ const props = defineProps({
|
|||||||
type: Array as PropType<readonly (number | null)[]>,
|
type: Array as PropType<readonly (number | null)[]>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
compact: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -98,7 +102,7 @@ const cpuChartOptions = computed<ChartOptions<'line'>>(() => ({
|
|||||||
display: false,
|
display: false,
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
enabled: true,
|
enabled: !props.compact,
|
||||||
mode: 'index',
|
mode: 'index',
|
||||||
intersect: false,
|
intersect: false,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
@@ -121,6 +125,7 @@ const cpuChartOptions = computed<ChartOptions<'line'>>(() => ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
|
display: !props.compact,
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
@@ -254,4 +259,17 @@ onBeforeUnmount(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cpu-history-chart--compact {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
gap: 0;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cpu-history-chart--compact .cpu-history-chart__canvas {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="chartHostRef" class="network-history-chart">
|
<div ref="chartHostRef" class="network-history-chart" :class="{ 'network-history-chart--compact': compact }">
|
||||||
<div class="network-history-chart__header">
|
<div v-if="!compact" class="network-history-chart__header">
|
||||||
<div>
|
<div>
|
||||||
<h6 class="network-history-chart__title">
|
<h6 class="network-history-chart__title">
|
||||||
{{ t('statusMonitor.networkSpeedTitleUnit', { unit: networkRateUnitIsMB ? 'MB/s' : 'KB/s' }) }}
|
{{ t('statusMonitor.networkSpeedTitleUnit', { unit: networkRateUnitIsMB ? 'MB/s' : 'KB/s' }) }}
|
||||||
@@ -64,6 +64,10 @@ const props = defineProps({
|
|||||||
type: Array as PropType<readonly (number | null)[]>,
|
type: Array as PropType<readonly (number | null)[]>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
compact: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -167,7 +171,7 @@ const networkChartOptions = computed<ChartOptions<'line'>>(() => ({
|
|||||||
display: false,
|
display: false,
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
enabled: true,
|
enabled: !props.compact,
|
||||||
mode: 'index',
|
mode: 'index',
|
||||||
intersect: false,
|
intersect: false,
|
||||||
displayColors: true,
|
displayColors: true,
|
||||||
@@ -193,6 +197,7 @@ const networkChartOptions = computed<ChartOptions<'line'>>(() => ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
|
display: !props.compact,
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: suggestedYAxisMax.value,
|
max: suggestedYAxisMax.value,
|
||||||
@@ -348,4 +353,17 @@ onBeforeUnmount(() => {
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.network-history-chart--compact {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
gap: 0;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-history-chart--compact .network-history-chart__canvas {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { UploadItem } from '../types/upload.types';
|
||||||
|
|
||||||
|
interface TransferTask {
|
||||||
|
taskId: string;
|
||||||
|
status: string;
|
||||||
|
sourceItemName?: string;
|
||||||
|
progress?: number;
|
||||||
|
type: 'upload' | 'download' | 'transfer';
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
uploads: Record<string, UploadItem>;
|
||||||
|
visible: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:visible', val: boolean): void;
|
||||||
|
(e: 'cancel-upload', uploadId: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const activeTab = ref<'all' | 'upload' | 'download'>('all');
|
||||||
|
|
||||||
|
const normalizedUploadTasks = computed<TransferTask[]>(() => {
|
||||||
|
return Object.values(props.uploads)
|
||||||
|
.filter(u => u.status !== 'success' && u.status !== 'cancelled')
|
||||||
|
.map(u => ({
|
||||||
|
taskId: u.id,
|
||||||
|
status: u.status,
|
||||||
|
sourceItemName: u.filename,
|
||||||
|
progress: u.progress,
|
||||||
|
type: 'upload' as const,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredTasks = computed(() => {
|
||||||
|
const all = normalizedUploadTasks.value;
|
||||||
|
if (activeTab.value === 'upload') return all.filter(t => t.type === 'upload');
|
||||||
|
if (activeTab.value === 'download') return all.filter(t => t.type === 'download');
|
||||||
|
return all;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabs = computed(() => [
|
||||||
|
{ key: 'all' as const, label: t('transferPanel.tabs.all', '全部'), count: normalizedUploadTasks.value.length },
|
||||||
|
{ key: 'upload' as const, label: t('transferPanel.tabs.upload', '上传'), count: normalizedUploadTasks.value.filter(t => t.type === 'upload').length },
|
||||||
|
{ key: 'download' as const, label: t('transferPanel.tabs.download', '下载'), count: normalizedUploadTasks.value.filter(t => t.type === 'download').length },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const statusLabel = (status: string) => {
|
||||||
|
return t(`fileManager.uploadStatus.${status}`, status);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = (taskId: string) => {
|
||||||
|
emit('cancel-upload', taskId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePanel = () => {
|
||||||
|
emit('update:visible', !props.visible);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="border-t border-border/60 flex-shrink-0 bg-header/30">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-2 py-1 cursor-pointer hover:bg-white/5 transition-colors"
|
||||||
|
@click="togglePanel"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="fas fa-exchange-alt text-xs text-text-secondary"></i>
|
||||||
|
<span class="text-[11px] uppercase tracking-wider text-text-secondary font-medium">{{ t('transferPanel.title', '传输') }}</span>
|
||||||
|
<span v-if="normalizedUploadTasks.length > 0" class="text-[10px] bg-primary/20 text-primary px-1.5 rounded-full">{{ normalizedUploadTasks.length }}</span>
|
||||||
|
</div>
|
||||||
|
<i :class="visible ? 'fas fa-chevron-down' : 'fas fa-chevron-up'" class="text-[10px] text-text-secondary"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="visible" class="max-h-[180px] flex flex-col">
|
||||||
|
<div class="flex items-center gap-0.5 px-2 py-1 border-t border-border/40">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
@click="activeTab = tab.key"
|
||||||
|
:class="[
|
||||||
|
'px-2 py-0.5 text-[11px] rounded transition-colors',
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'bg-primary/15 text-primary font-medium'
|
||||||
|
: 'text-text-secondary hover:text-foreground hover:bg-white/5'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
<span v-if="tab.count > 0" class="ml-1 text-[10px] opacity-70">{{ tab.count }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto px-2 py-1">
|
||||||
|
<div v-if="filteredTasks.length === 0" class="text-center py-6 text-text-secondary text-xs">
|
||||||
|
<i class="fas fa-inbox text-lg mb-1 block opacity-40"></i>
|
||||||
|
{{ t('transferPanel.empty', '暂无传输任务') }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="task in filteredTasks"
|
||||||
|
:key="task.taskId"
|
||||||
|
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-white/5 text-xs group"
|
||||||
|
>
|
||||||
|
<i :class="task.type === 'upload' ? 'fas fa-arrow-up text-emerald-400' : 'fas fa-arrow-down text-blue-400'" class="w-3 text-center flex-shrink-0 text-[10px]"></i>
|
||||||
|
<span class="truncate flex-1 text-foreground" :title="task.sourceItemName">{{ task.sourceItemName }}</span>
|
||||||
|
<span class="text-text-secondary text-[10px] flex-shrink-0">{{ statusLabel(task.status) }}</span>
|
||||||
|
<div v-if="task.progress !== undefined && task.progress < 100" class="w-12 h-1 bg-border rounded-full flex-shrink-0 overflow-hidden">
|
||||||
|
<div class="h-full bg-primary rounded-full transition-all" :style="{ width: task.progress + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<span v-if="task.progress !== undefined" class="text-[10px] text-text-secondary w-8 text-right flex-shrink-0">{{ task.progress }}%</span>
|
||||||
|
<button
|
||||||
|
v-if="['pending', 'uploading', 'compressing'].includes(task.status)"
|
||||||
|
@click.stop="handleCancel(task.taskId)"
|
||||||
|
class="opacity-0 group-hover:opacity-100 text-text-secondary hover:text-red-400 transition-all flex-shrink-0"
|
||||||
|
:title="t('fileManager.actions.cancel')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times text-[10px]"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -4,6 +4,13 @@ import { sessions as globalSessionsRef } from '../stores/session/state';
|
|||||||
import type { Terminal } from 'xterm';
|
import type { Terminal } from 'xterm';
|
||||||
import type { SearchAddon, ISearchOptions } from '@xterm/addon-search';
|
import type { SearchAddon, ISearchOptions } from '@xterm/addon-search';
|
||||||
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types';
|
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types';
|
||||||
|
import {
|
||||||
|
SSH_COMMAND_RUNTIME_MIN_VISIBLE_MS,
|
||||||
|
createSshCommandRuntimeSnapshot,
|
||||||
|
isSshCommandRuntimeActive,
|
||||||
|
type SshCommandRuntimePhase,
|
||||||
|
type SshCommandRuntimeReason,
|
||||||
|
} from '../stores/session/runtime';
|
||||||
|
|
||||||
export interface SshTerminalDependencies {
|
export interface SshTerminalDependencies {
|
||||||
sendMessage: (message: WebSocketMessage) => void;
|
sendMessage: (message: WebSocketMessage) => void;
|
||||||
@@ -32,14 +39,41 @@ const stripTerminalControlSequences = (text: string): string =>
|
|||||||
|
|
||||||
const getSessionState = (sessionId: string) => globalSessionsRef.value.get(sessionId);
|
const getSessionState = (sessionId: string) => globalSessionsRef.value.get(sessionId);
|
||||||
|
|
||||||
const resetSessionCommandRuntime = (sessionId: string) => {
|
const setSessionCommandRuntime = (
|
||||||
|
sessionId: string,
|
||||||
|
nextPhase: SshCommandRuntimePhase,
|
||||||
|
reason: SshCommandRuntimeReason,
|
||||||
|
timestamp: number,
|
||||||
|
visibleUntil?: number,
|
||||||
|
) => {
|
||||||
|
const session = getSessionState(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRuntime = session.commandRuntime.value ?? createSshCommandRuntimeSnapshot();
|
||||||
|
session.commandRuntime.value = {
|
||||||
|
...currentRuntime,
|
||||||
|
phase: nextPhase,
|
||||||
|
reason,
|
||||||
|
lastTransitionAt: timestamp,
|
||||||
|
visibleUntil: visibleUntil ?? currentRuntime.visibleUntil,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSessionCommandRuntime = (
|
||||||
|
sessionId: string,
|
||||||
|
reason: Extract<SshCommandRuntimeReason, 'prompt' | 'interrupt' | 'disconnect' | 'error' | 'connected' | 'input'>,
|
||||||
|
nextPhase: Extract<SshCommandRuntimePhase, 'idle' | 'disconnected' | 'error'>,
|
||||||
|
timestamp: number,
|
||||||
|
) => {
|
||||||
const session = getSessionState(sessionId);
|
const session = getSessionState(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
session.isCommandRunning.value = false;
|
|
||||||
session.terminalInputBuffer.value = '';
|
session.terminalInputBuffer.value = '';
|
||||||
|
setSessionCommandRuntime(sessionId, nextPhase, reason, timestamp, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncSessionCommandRuntimeFromInput = (sessionId: string, data: string) => {
|
const syncSessionCommandRuntimeFromInput = (sessionId: string, data: string) => {
|
||||||
@@ -53,13 +87,14 @@ const syncSessionCommandRuntimeFromInput = (sessionId: string, data: string) =>
|
|||||||
return { submittedCommand: false, interrupted: false };
|
return { submittedCommand: false, interrupted: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const currentPhase = session.commandRuntime.value.phase;
|
||||||
let nextBuffer = session.terminalInputBuffer.value;
|
let nextBuffer = session.terminalInputBuffer.value;
|
||||||
let submittedCommand = false;
|
let submittedCommand = false;
|
||||||
let interrupted = false;
|
let interrupted = false;
|
||||||
|
|
||||||
for (const char of normalizedData) {
|
for (const char of normalizedData) {
|
||||||
if (char === '\x03') {
|
if (char === '\x03') {
|
||||||
session.isCommandRunning.value = false;
|
|
||||||
nextBuffer = '';
|
nextBuffer = '';
|
||||||
interrupted = true;
|
interrupted = true;
|
||||||
continue;
|
continue;
|
||||||
@@ -67,7 +102,6 @@ const syncSessionCommandRuntimeFromInput = (sessionId: string, data: string) =>
|
|||||||
|
|
||||||
if (char === '\r' || char === '\n') {
|
if (char === '\r' || char === '\n') {
|
||||||
if (nextBuffer.trim().length > 0) {
|
if (nextBuffer.trim().length > 0) {
|
||||||
session.isCommandRunning.value = true;
|
|
||||||
submittedCommand = true;
|
submittedCommand = true;
|
||||||
}
|
}
|
||||||
nextBuffer = '';
|
nextBuffer = '';
|
||||||
@@ -83,14 +117,36 @@ const syncSessionCommandRuntimeFromInput = (sessionId: string, data: string) =>
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextBuffer.length === 0 && session.isCommandRunning.value) {
|
|
||||||
session.isCommandRunning.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextBuffer += char;
|
nextBuffer += char;
|
||||||
}
|
}
|
||||||
|
|
||||||
session.terminalInputBuffer.value = nextBuffer;
|
session.terminalInputBuffer.value = nextBuffer;
|
||||||
|
|
||||||
|
if (interrupted) {
|
||||||
|
clearSessionCommandRuntime(sessionId, 'interrupt', 'idle', now);
|
||||||
|
return { submittedCommand: false, interrupted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submittedCommand) {
|
||||||
|
const nextPhase = isSshCommandRuntimeActive(currentPhase) ? 'running' : 'pending';
|
||||||
|
setSessionCommandRuntime(
|
||||||
|
sessionId,
|
||||||
|
nextPhase,
|
||||||
|
'submit',
|
||||||
|
now,
|
||||||
|
Math.max(session.commandRuntime.value.visibleUntil, now + SSH_COMMAND_RUNTIME_MIN_VISIBLE_MS),
|
||||||
|
);
|
||||||
|
return { submittedCommand: true, interrupted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextBuffer.length > 0) {
|
||||||
|
if (!isSshCommandRuntimeActive(currentPhase) && currentPhase !== 'disconnected' && currentPhase !== 'error') {
|
||||||
|
setSessionCommandRuntime(sessionId, 'typing', 'input', now);
|
||||||
|
}
|
||||||
|
} else if (currentPhase === 'typing') {
|
||||||
|
clearSessionCommandRuntime(sessionId, 'input', 'idle', now);
|
||||||
|
}
|
||||||
|
|
||||||
return { submittedCommand, interrupted };
|
return { submittedCommand, interrupted };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -139,6 +195,42 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
|||||||
const terminalOutputBuffer = ref<(string | Uint8Array)[]>([]);
|
const terminalOutputBuffer = ref<(string | Uint8Array)[]>([]);
|
||||||
const isSshConnected = ref(false);
|
const isSshConnected = ref(false);
|
||||||
const promptProbeBuffer = ref('');
|
const promptProbeBuffer = ref('');
|
||||||
|
let runtimeResolutionTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const clearRuntimeResolutionTimer = () => {
|
||||||
|
if (runtimeResolutionTimer !== null) {
|
||||||
|
clearTimeout(runtimeResolutionTimer);
|
||||||
|
runtimeResolutionTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveRuntimePhase = (
|
||||||
|
nextPhase: Extract<SshCommandRuntimePhase, 'idle' | 'disconnected' | 'error'>,
|
||||||
|
reason: Extract<SshCommandRuntimeReason, 'connected' | 'disconnect' | 'error'>,
|
||||||
|
) => {
|
||||||
|
clearRuntimeResolutionTimer();
|
||||||
|
clearSessionCommandRuntime(sessionId, reason, nextPhase, Date.now());
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedulePromptResolution = () => {
|
||||||
|
const session = getSessionState(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearRuntimeResolutionTimer();
|
||||||
|
const now = Date.now();
|
||||||
|
const delay = Math.max(0, session.commandRuntime.value.visibleUntil - now);
|
||||||
|
if (delay === 0) {
|
||||||
|
clearSessionCommandRuntime(sessionId, 'prompt', 'idle', now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeResolutionTimer = setTimeout(() => {
|
||||||
|
runtimeResolutionTimer = null;
|
||||||
|
clearSessionCommandRuntime(sessionId, 'prompt', 'idle', Date.now());
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
const getTerminalText = (key: string, params?: Record<string, unknown>): string => {
|
const getTerminalText = (key: string, params?: Record<string, unknown>): string => {
|
||||||
const translationKey = `workspace.terminal.${key}`;
|
const translationKey = `workspace.terminal.${key}`;
|
||||||
@@ -176,6 +268,10 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
|||||||
const runtimeUpdate = syncSessionCommandRuntimeFromInput(sessionId, data);
|
const runtimeUpdate = syncSessionCommandRuntimeFromInput(sessionId, data);
|
||||||
if (runtimeUpdate.submittedCommand || runtimeUpdate.interrupted) {
|
if (runtimeUpdate.submittedCommand || runtimeUpdate.interrupted) {
|
||||||
promptProbeBuffer.value = '';
|
promptProbeBuffer.value = '';
|
||||||
|
clearRuntimeResolutionTimer();
|
||||||
|
} else if (runtimeResolutionTimer !== null && (getSessionState(sessionId)?.terminalInputBuffer.value.length ?? 0) > 0) {
|
||||||
|
clearRuntimeResolutionTimer();
|
||||||
|
setSessionCommandRuntime(sessionId, 'typing', 'input', Date.now(), 0);
|
||||||
}
|
}
|
||||||
sendMessage({ type: 'ssh:input', sessionId, payload: { data } });
|
sendMessage({ type: 'ssh:input', sessionId, payload: { data } });
|
||||||
};
|
};
|
||||||
@@ -222,12 +318,21 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
|||||||
terminalOutputBuffer.value.push(outputData);
|
terminalOutputBuffer.value.push(outputData);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getSessionState(sessionId)?.isCommandRunning.value) {
|
const session = getSessionState(sessionId);
|
||||||
|
if (session && isSshCommandRuntimeActive(session.commandRuntime.value.phase)) {
|
||||||
const promptProbeText = getPromptProbeText(outputData);
|
const promptProbeText = getPromptProbeText(outputData);
|
||||||
if (promptProbeText) {
|
if (promptProbeText) {
|
||||||
|
clearRuntimeResolutionTimer();
|
||||||
promptProbeBuffer.value = `${promptProbeBuffer.value}${promptProbeText}`.slice(-320);
|
promptProbeBuffer.value = `${promptProbeBuffer.value}${promptProbeText}`.slice(-320);
|
||||||
if (isPromptTail(promptProbeBuffer.value)) {
|
if (isPromptTail(promptProbeBuffer.value)) {
|
||||||
resetSessionCommandRuntime(sessionId);
|
schedulePromptResolution();
|
||||||
|
promptProbeBuffer.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const strippedOutput = stripTerminalControlSequences(promptProbeText).trim();
|
||||||
|
if (strippedOutput && session.commandRuntime.value.phase === 'pending') {
|
||||||
|
setSessionCommandRuntime(sessionId, 'running', 'output', Date.now());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,6 +346,7 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
|||||||
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已连接。 Payload:`, payload, 'Full message:', message);
|
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已连接。 Payload:`, payload, 'Full message:', message);
|
||||||
isSshConnected.value = true;
|
isSshConnected.value = true;
|
||||||
promptProbeBuffer.value = '';
|
promptProbeBuffer.value = '';
|
||||||
|
resolveRuntimePhase('idle', 'connected');
|
||||||
terminalInstance.value?.focus();
|
terminalInstance.value?.focus();
|
||||||
|
|
||||||
if (terminalInstance.value) {
|
if (terminalInstance.value) {
|
||||||
@@ -271,7 +377,7 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
|||||||
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已断开:`, reason);
|
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已断开:`, reason);
|
||||||
isSshConnected.value = false;
|
isSshConnected.value = false;
|
||||||
promptProbeBuffer.value = '';
|
promptProbeBuffer.value = '';
|
||||||
resetSessionCommandRuntime(sessionId);
|
resolveRuntimePhase('disconnected', 'disconnect');
|
||||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('disconnectMsg', { reason })}\x1b[0m`);
|
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('disconnectMsg', { reason })}\x1b[0m`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -284,7 +390,7 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
|||||||
console.error(`[会话 ${sessionId}][SSH终端模块] SSH 错误:`, errorMsg);
|
console.error(`[会话 ${sessionId}][SSH终端模块] SSH 错误:`, errorMsg);
|
||||||
isSshConnected.value = false;
|
isSshConnected.value = false;
|
||||||
promptProbeBuffer.value = '';
|
promptProbeBuffer.value = '';
|
||||||
resetSessionCommandRuntime(sessionId);
|
resolveRuntimePhase('error', 'error');
|
||||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('genericErrorMsg', { message: errorMsg })}\x1b[0m`);
|
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('genericErrorMsg', { message: errorMsg })}\x1b[0m`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -339,6 +445,7 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
|||||||
registerSshHandlers();
|
registerSshHandlers();
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
|
clearRuntimeResolutionTimer();
|
||||||
unregisterAllSshHandlers();
|
unregisterAllSshHandlers();
|
||||||
terminalInstance.value = null;
|
terminalInstance.value = null;
|
||||||
console.log(`[会话 ${sessionId}][SSH终端模块] 已清理。`);
|
console.log(`[会话 ${sessionId}][SSH终端模块] 已清理。`);
|
||||||
@@ -352,6 +459,10 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
|||||||
const runtimeUpdate = syncSessionCommandRuntimeFromInput(sessionId, data);
|
const runtimeUpdate = syncSessionCommandRuntimeFromInput(sessionId, data);
|
||||||
if (runtimeUpdate.submittedCommand || runtimeUpdate.interrupted) {
|
if (runtimeUpdate.submittedCommand || runtimeUpdate.interrupted) {
|
||||||
promptProbeBuffer.value = '';
|
promptProbeBuffer.value = '';
|
||||||
|
clearRuntimeResolutionTimer();
|
||||||
|
} else if (runtimeResolutionTimer !== null && (getSessionState(sessionId)?.terminalInputBuffer.value.length ?? 0) > 0) {
|
||||||
|
clearRuntimeResolutionTimer();
|
||||||
|
setSessionCommandRuntime(sessionId, 'typing', 'input', Date.now(), 0);
|
||||||
}
|
}
|
||||||
sendMessage({ type: 'ssh:input', sessionId, payload: { data } });
|
sendMessage({ type: 'ssh:input', sessionId, payload: { data } });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1861,6 +1861,15 @@
|
|||||||
"unknownTargetPath": "[Unknown Target Path]",
|
"unknownTargetPath": "[Unknown Target Path]",
|
||||||
"taskIdFallback": "Task ID: {taskId}"
|
"taskIdFallback": "Task ID: {taskId}"
|
||||||
},
|
},
|
||||||
|
"transferPanel": {
|
||||||
|
"title": "Transfer",
|
||||||
|
"tabs": {
|
||||||
|
"all": "All",
|
||||||
|
"upload": "Upload",
|
||||||
|
"download": "Download"
|
||||||
|
},
|
||||||
|
"empty": "No transfer tasks"
|
||||||
|
},
|
||||||
"sendFilesModal": {
|
"sendFilesModal": {
|
||||||
"title": "Send Files",
|
"title": "Send Files",
|
||||||
"searchConnectionsPlaceholder": "Search connections...",
|
"searchConnectionsPlaceholder": "Search connections...",
|
||||||
@@ -1885,34 +1894,45 @@
|
|||||||
"transferFailedError": "Failed to initiate transfer. Please try again."
|
"transferFailedError": "Failed to initiate transfer. Please try again."
|
||||||
},
|
},
|
||||||
"favoritePaths": {
|
"favoritePaths": {
|
||||||
|
"title": "Bookmarks",
|
||||||
"addEditForm": {
|
"addEditForm": {
|
||||||
"validation": {
|
"validation": {
|
||||||
"pathRequired": "Path is required."
|
"pathRequired": "Path is required."
|
||||||
},
|
},
|
||||||
"editTitle": "Edit Favorite Path",
|
"editTitle": "Edit Bookmark",
|
||||||
"addTitle": "Add New Favorite Path",
|
"addTitle": "Add Bookmark",
|
||||||
"pathLabel": "Path",
|
"pathLabel": "Path",
|
||||||
"pathPlaceholder": "/example/folder/path",
|
"pathPlaceholder": "/example/folder/path",
|
||||||
"nameLabel": "Name (Optional)",
|
"nameLabel": "Name (Optional)",
|
||||||
"namePlaceholder": "My Documents",
|
"namePlaceholder": "My Documents",
|
||||||
|
"scopeLabel": "Scope",
|
||||||
"errors": {
|
"errors": {
|
||||||
"genericSaveError": "Failed to save favorite path."
|
"genericSaveError": "Failed to save bookmark."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"confirmDelete": "Are you sure you want to delete \"{name}\"?",
|
"confirmDelete": "Are you sure you want to delete \"{name}\"?",
|
||||||
"searchPlaceholder": "Search by name or path...",
|
"searchPlaceholder": "Search bookmarks...",
|
||||||
"addNew": "Add new favorite path",
|
"addNew": "Add Bookmark",
|
||||||
"loading": "Loading favorites...",
|
"loading": "Loading bookmarks...",
|
||||||
"noResults": "No matching favorites found.",
|
"noResults": "No matching bookmarks found.",
|
||||||
"noFavorites": "No favorite paths yet. Add one!",
|
"noFavorites": "No bookmarks yet",
|
||||||
|
"sendToTerminal": "Send to Terminal",
|
||||||
|
"sortByName": "Sort by name",
|
||||||
|
"sortByUsage": "Sort by usage",
|
||||||
|
"scopeAll": "All",
|
||||||
|
"scopeLocal": "Local",
|
||||||
|
"scopeGlobal": "Cloud",
|
||||||
|
"scopeLocalLabel": "This server only",
|
||||||
|
"scopeGlobalLabel": "Global shared",
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"fetchError": "Failed to load favorite paths.",
|
"fetchError": "Failed to load bookmarks.",
|
||||||
"addSuccess": "Favorite path added successfully.",
|
"addSuccess": "Bookmark added successfully.",
|
||||||
"addError": "Failed to add favorite path.",
|
"addError": "Failed to add bookmark.",
|
||||||
"updateSuccess": "Favorite path updated successfully.",
|
"updateSuccess": "Bookmark updated successfully.",
|
||||||
"updateError": "Failed to update favorite path.",
|
"updateError": "Failed to update bookmark.",
|
||||||
"deleteSuccess": "Favorite path deleted successfully.",
|
"deleteSuccess": "Bookmark deleted successfully.",
|
||||||
"deleteError": "Failed to delete favorite path."
|
"deleteError": "Failed to delete bookmark.",
|
||||||
|
"markAsUsedError": "Failed to update usage time."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pathHistory": {
|
"pathHistory": {
|
||||||
|
|||||||
@@ -1799,6 +1799,15 @@
|
|||||||
"unknownTargetPath": "[宛先パス不明]",
|
"unknownTargetPath": "[宛先パス不明]",
|
||||||
"taskIdFallback": "タスクID: {taskId}"
|
"taskIdFallback": "タスクID: {taskId}"
|
||||||
},
|
},
|
||||||
|
"transferPanel": {
|
||||||
|
"title": "転送",
|
||||||
|
"tabs": {
|
||||||
|
"all": "すべて",
|
||||||
|
"upload": "アップロード",
|
||||||
|
"download": "ダウンロード"
|
||||||
|
},
|
||||||
|
"empty": "転送タスクはありません"
|
||||||
|
},
|
||||||
"sendFilesModal": {
|
"sendFilesModal": {
|
||||||
"title": "ファイル送信",
|
"title": "ファイル送信",
|
||||||
"searchConnectionsPlaceholder": "接続を検索...",
|
"searchConnectionsPlaceholder": "接続を検索...",
|
||||||
@@ -1823,34 +1832,45 @@
|
|||||||
"transferFailedError": "転送の開始に失敗しました。もう一度お試しください。"
|
"transferFailedError": "転送の開始に失敗しました。もう一度お試しください。"
|
||||||
},
|
},
|
||||||
"favoritePaths": {
|
"favoritePaths": {
|
||||||
|
"title": "ブックマーク",
|
||||||
"addEditForm": {
|
"addEditForm": {
|
||||||
"validation": {
|
"validation": {
|
||||||
"pathRequired": "Path is required."
|
"pathRequired": "パスは必須です。"
|
||||||
},
|
},
|
||||||
"editTitle": "Edit Favorite Path",
|
"editTitle": "ブックマークを編集",
|
||||||
"addTitle": "Add New Favorite Path",
|
"addTitle": "ブックマークを追加",
|
||||||
"pathLabel": "Path",
|
"pathLabel": "パス",
|
||||||
"pathPlaceholder": "/example/folder/path",
|
"pathPlaceholder": "/example/folder/path",
|
||||||
"nameLabel": "Name (Optional)",
|
"nameLabel": "名前(任意)",
|
||||||
"namePlaceholder": "My Documents",
|
"namePlaceholder": "マイドキュメント",
|
||||||
|
"scopeLabel": "保存場所",
|
||||||
"errors": {
|
"errors": {
|
||||||
"genericSaveError": "Failed to save favorite path."
|
"genericSaveError": "ブックマークの保存に失敗しました。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"confirmDelete": "Are you sure you want to delete \"{name}\"?",
|
"confirmDelete": "「{name}」を削除してもよろしいですか?",
|
||||||
"searchPlaceholder": "Search by name or path...",
|
"searchPlaceholder": "ブックマークを検索...",
|
||||||
"addNew": "Add new favorite path",
|
"addNew": "ブックマークを追加",
|
||||||
"loading": "Loading favorites...",
|
"loading": "ブックマークを読み込み中...",
|
||||||
"noResults": "No matching favorites found.",
|
"noResults": "一致するブックマークが見つかりません。",
|
||||||
"noFavorites": "No favorite paths yet. Add one!",
|
"noFavorites": "ブックマークはありません",
|
||||||
|
"sendToTerminal": "ターミナルに送信",
|
||||||
|
"sortByName": "名前順",
|
||||||
|
"sortByUsage": "使用順",
|
||||||
|
"scopeAll": "すべて",
|
||||||
|
"scopeLocal": "ローカル",
|
||||||
|
"scopeGlobal": "クラウド",
|
||||||
|
"scopeLocalLabel": "このサーバーのみ",
|
||||||
|
"scopeGlobalLabel": "グローバル共有",
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"fetchError": "Failed to load favorite paths.",
|
"fetchError": "ブックマークの読み込みに失敗しました。",
|
||||||
"addSuccess": "Favorite path added successfully.",
|
"addSuccess": "ブックマークを追加しました。",
|
||||||
"addError": "Failed to add favorite path.",
|
"addError": "ブックマークの追加に失敗しました。",
|
||||||
"updateSuccess": "Favorite path updated successfully.",
|
"updateSuccess": "ブックマークを更新しました。",
|
||||||
"updateError": "Failed to update favorite path.",
|
"updateError": "ブックマークの更新に失敗しました。",
|
||||||
"deleteSuccess": "Favorite path deleted successfully.",
|
"deleteSuccess": "ブックマークを削除しました。",
|
||||||
"deleteError": "Failed to delete favorite path."
|
"deleteError": "ブックマークの削除に失敗しました。",
|
||||||
|
"markAsUsedError": "使用時間の更新に失敗しました。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pathHistory": {
|
"pathHistory": {
|
||||||
|
|||||||
@@ -1834,6 +1834,15 @@
|
|||||||
"unknownTargetPath": "[目标路径未知]",
|
"unknownTargetPath": "[目标路径未知]",
|
||||||
"taskIdFallback": "任务ID: {taskId}"
|
"taskIdFallback": "任务ID: {taskId}"
|
||||||
},
|
},
|
||||||
|
"transferPanel": {
|
||||||
|
"title": "传输",
|
||||||
|
"tabs": {
|
||||||
|
"all": "全部",
|
||||||
|
"upload": "上传",
|
||||||
|
"download": "下载"
|
||||||
|
},
|
||||||
|
"empty": "暂无传输任务"
|
||||||
|
},
|
||||||
"sendFilesModal": {
|
"sendFilesModal": {
|
||||||
"title": "发送文件",
|
"title": "发送文件",
|
||||||
"searchConnectionsPlaceholder": "搜索连接...",
|
"searchConnectionsPlaceholder": "搜索连接...",
|
||||||
@@ -1890,34 +1899,45 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favoritePaths": {
|
"favoritePaths": {
|
||||||
|
"title": "书签列表",
|
||||||
"addEditForm": {
|
"addEditForm": {
|
||||||
"validation": {
|
"validation": {
|
||||||
"pathRequired": "路径不能为空。"
|
"pathRequired": "路径不能为空。"
|
||||||
},
|
},
|
||||||
"editTitle": "编辑收藏路径",
|
"editTitle": "编辑书签",
|
||||||
"addTitle": "添加新收藏路径",
|
"addTitle": "添加书签",
|
||||||
"pathLabel": "路径",
|
"pathLabel": "路径",
|
||||||
"pathPlaceholder": "/example/folder/path",
|
"pathPlaceholder": "/example/folder/path",
|
||||||
"nameLabel": "名称 (可选)",
|
"nameLabel": "名称(可选)",
|
||||||
"namePlaceholder": "我的文档",
|
"namePlaceholder": "我的文档",
|
||||||
|
"scopeLabel": "记录位置",
|
||||||
"errors": {
|
"errors": {
|
||||||
"genericSaveError": "保存收藏路径失败。"
|
"genericSaveError": "保存书签失败。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"confirmDelete": "您确定要删除 \"{name}\" 吗?",
|
"confirmDelete": "您确定要删除 \"{name}\" 吗?",
|
||||||
"searchPlaceholder": "按名称或路径搜索...",
|
"searchPlaceholder": "搜索书签...",
|
||||||
"addNew": "添加新收藏路径",
|
"addNew": "添加书签",
|
||||||
"loading": "正在加载收藏...",
|
"loading": "正在加载书签...",
|
||||||
"noResults": "未找到匹配的收藏。",
|
"noResults": "未找到匹配的书签。",
|
||||||
"noFavorites": "还没有收藏路径,快添加一个吧!",
|
"noFavorites": "暂无书签",
|
||||||
|
"sendToTerminal": "发送到终端",
|
||||||
|
"sortByName": "按名称排序",
|
||||||
|
"sortByUsage": "按使用排序",
|
||||||
|
"scopeAll": "全部",
|
||||||
|
"scopeLocal": "本地",
|
||||||
|
"scopeGlobal": "云端",
|
||||||
|
"scopeLocalLabel": "仅当前服务器",
|
||||||
|
"scopeGlobalLabel": "全局共享",
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"fetchError": "加载收藏路径失败。",
|
"fetchError": "加载书签失败。",
|
||||||
"addSuccess": "收藏路径添加成功。",
|
"addSuccess": "书签添加成功。",
|
||||||
"addError": "添加收藏路径失败。",
|
"addError": "添加书签失败。",
|
||||||
"updateSuccess": "收藏路径更新成功。",
|
"updateSuccess": "书签更新成功。",
|
||||||
"updateError": "更新收藏路径失败。",
|
"updateError": "更新书签失败。",
|
||||||
"deleteSuccess": "收藏路径删除成功。",
|
"deleteSuccess": "书签删除成功。",
|
||||||
"deleteError": "删除收藏路径失败。"
|
"deleteError": "删除书签失败。",
|
||||||
|
"markAsUsedError": "更新使用时间失败。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pathHistory": {
|
"pathHistory": {
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ export interface FavoritePathItem {
|
|||||||
id: string;
|
id: string;
|
||||||
path: string;
|
path: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
last_used_at?: number | null; // Added last_used_at
|
scope?: string;
|
||||||
// Add other relevant fields from the API if any
|
connection_id?: number | null;
|
||||||
|
last_used_at?: number | null;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
@@ -20,7 +21,8 @@ export interface FavoritePathsState {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
currentSortBy: FavoritePathSortType;
|
currentSortBy: FavoritePathSortType;
|
||||||
|
activeScope: 'all' | 'local' | 'global';
|
||||||
isInitialized: boolean;
|
isInitialized: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,18 +35,24 @@ export const useFavoritePathsStore = defineStore('favoritePaths', {
|
|||||||
error: null,
|
error: null,
|
||||||
searchTerm: '',
|
searchTerm: '',
|
||||||
currentSortBy: savedSortBy || 'name',
|
currentSortBy: savedSortBy || 'name',
|
||||||
|
activeScope: 'all',
|
||||||
isInitialized: false,
|
isInitialized: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getters: {
|
getters: {
|
||||||
// The filteredFavoritePaths getter will now operate on the already sorted list
|
// The filteredFavoritePaths getter will now operate on the already sorted list
|
||||||
filteredFavoritePaths(state): FavoritePathItem[] {
|
filteredFavoritePaths(state): FavoritePathItem[] {
|
||||||
|
let paths = state.favoritePaths;
|
||||||
|
if (state.activeScope === 'local') {
|
||||||
|
paths = paths.filter(fav => fav.scope === 'local');
|
||||||
|
} else if (state.activeScope === 'global') {
|
||||||
|
paths = paths.filter(fav => !fav.scope || fav.scope === 'global');
|
||||||
|
}
|
||||||
if (!state.searchTerm) {
|
if (!state.searchTerm) {
|
||||||
return state.favoritePaths;
|
return paths;
|
||||||
}
|
}
|
||||||
const lowerCaseSearchTerm = state.searchTerm.toLowerCase();
|
const lowerCaseSearchTerm = state.searchTerm.toLowerCase();
|
||||||
// Note: state.favoritePaths is now always sorted by this.currentSortBy
|
return paths.filter(fav =>
|
||||||
return state.favoritePaths.filter(fav =>
|
|
||||||
fav.path.toLowerCase().includes(lowerCaseSearchTerm) ||
|
fav.path.toLowerCase().includes(lowerCaseSearchTerm) ||
|
||||||
(fav.name && fav.name.toLowerCase().includes(lowerCaseSearchTerm))
|
(fav.name && fav.name.toLowerCase().includes(lowerCaseSearchTerm))
|
||||||
);
|
);
|
||||||
@@ -101,7 +109,10 @@ export const useFavoritePathsStore = defineStore('favoritePaths', {
|
|||||||
setSortBy(sortBy: FavoritePathSortType) {
|
setSortBy(sortBy: FavoritePathSortType) {
|
||||||
this.currentSortBy = sortBy;
|
this.currentSortBy = sortBy;
|
||||||
localStorage.setItem('favoritePathSortBy', sortBy);
|
localStorage.setItem('favoritePathSortBy', sortBy);
|
||||||
this._sortFavoritePaths(); // Re-sort locally
|
this._sortFavoritePaths();
|
||||||
|
},
|
||||||
|
setActiveScope(scope: 'all' | 'local' | 'global') {
|
||||||
|
this.activeScope = scope;
|
||||||
},
|
},
|
||||||
async markPathAsUsed(pathId: string, t: (key: string, defaultMessage: string) => string) {
|
async markPathAsUsed(pathId: string, t: (key: string, defaultMessage: string) => string) {
|
||||||
const notificationsStore = useUiNotificationsStore();
|
const notificationsStore = useUiNotificationsStore();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { createSshTerminalManager, type SshTerminalDependencies } from '../../..
|
|||||||
import { createStatusMonitorManager, type StatusMonitorDependencies } from '../../../composables/useStatusMonitor';
|
import { createStatusMonitorManager, type StatusMonitorDependencies } from '../../../composables/useStatusMonitor';
|
||||||
import { createDockerManager, type DockerManagerDependencies } from '../../../composables/useDockerManager';
|
import { createDockerManager, type DockerManagerDependencies } from '../../../composables/useDockerManager';
|
||||||
import { registerSshSuspendHandlers } from './sshSuspendActions';
|
import { registerSshSuspendHandlers } from './sshSuspendActions';
|
||||||
|
import { createSshCommandRuntimeSnapshot } from '../runtime';
|
||||||
|
|
||||||
const SESSION_ORDER_STORAGE_KEY = 'sessionOrder';
|
const SESSION_ORDER_STORAGE_KEY = 'sessionOrder';
|
||||||
|
|
||||||
@@ -148,7 +149,7 @@ export const openNewSession = (
|
|||||||
editorTabs: ref([]),
|
editorTabs: ref([]),
|
||||||
activeEditorTabId: ref(null),
|
activeEditorTabId: ref(null),
|
||||||
commandInputContent: ref(''),
|
commandInputContent: ref(''),
|
||||||
isCommandRunning: ref(false),
|
commandRuntime: ref(createSshCommandRuntimeSnapshot()),
|
||||||
terminalInputBuffer: ref(''),
|
terminalInputBuffer: ref(''),
|
||||||
isMarkedForSuspend: false,
|
isMarkedForSuspend: false,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { sessions, activeSessionId } from './state';
|
import { sessions, activeSessionId } from './state';
|
||||||
import type { SessionState, SessionTabInfoWithStatus } from './types';
|
import type { SessionState, SessionTabInfoWithStatus } from './types';
|
||||||
|
import { isSshCommandRuntimeActive } from './runtime';
|
||||||
|
|
||||||
export const sessionTabs = computed(() => {
|
export const sessionTabs = computed(() => {
|
||||||
return Array.from(sessions.value.values()).map((session) => ({
|
return Array.from(sessions.value.values()).map((session) => ({
|
||||||
@@ -54,7 +55,8 @@ export const sessionTabsWithStatus = computed((): SessionTabInfoWithStatus[] =>
|
|||||||
terminalIndex: session.terminalIndex,
|
terminalIndex: session.terminalIndex,
|
||||||
status: session.wsManager.connectionStatus.value,
|
status: session.wsManager.connectionStatus.value,
|
||||||
isMarkedForSuspend: session.isMarkedForSuspend,
|
isMarkedForSuspend: session.isMarkedForSuspend,
|
||||||
isCommandRunning: session.isCommandRunning.value,
|
commandRuntimePhase: session.commandRuntime.value.phase,
|
||||||
|
isCommandRunning: isSshCommandRuntimeActive(session.commandRuntime.value.phase),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export const SSH_COMMAND_RUNTIME_MIN_VISIBLE_MS = 350;
|
||||||
|
|
||||||
|
export type SshCommandRuntimePhase =
|
||||||
|
| 'idle'
|
||||||
|
| 'typing'
|
||||||
|
| 'pending'
|
||||||
|
| 'running'
|
||||||
|
| 'disconnected'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
|
export type SshCommandRuntimeReason =
|
||||||
|
| 'init'
|
||||||
|
| 'connected'
|
||||||
|
| 'input'
|
||||||
|
| 'submit'
|
||||||
|
| 'output'
|
||||||
|
| 'prompt'
|
||||||
|
| 'interrupt'
|
||||||
|
| 'disconnect'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
|
export interface SshCommandRuntimeSnapshot {
|
||||||
|
phase: SshCommandRuntimePhase;
|
||||||
|
reason: SshCommandRuntimeReason;
|
||||||
|
lastTransitionAt: number;
|
||||||
|
visibleUntil: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createSshCommandRuntimeSnapshot = (
|
||||||
|
overrides: Partial<SshCommandRuntimeSnapshot> = {},
|
||||||
|
): SshCommandRuntimeSnapshot => ({
|
||||||
|
phase: 'idle',
|
||||||
|
reason: 'init',
|
||||||
|
lastTransitionAt: 0,
|
||||||
|
visibleUntil: 0,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isSshCommandRuntimeActive = (phase: SshCommandRuntimePhase): boolean =>
|
||||||
|
phase === 'pending' || phase === 'running';
|
||||||
@@ -6,6 +6,7 @@ import type { createWebSocketConnectionManager } from '../../composables/useWebS
|
|||||||
import type { createSftpActionsManager } from '../../composables/useSftpActions';
|
import type { createSftpActionsManager } from '../../composables/useSftpActions';
|
||||||
import type { createSshTerminalManager } from '../../composables/useSshTerminal';
|
import type { createSshTerminalManager } from '../../composables/useSshTerminal';
|
||||||
import type { createStatusMonitorManager } from '../../composables/useStatusMonitor';
|
import type { createStatusMonitorManager } from '../../composables/useStatusMonitor';
|
||||||
|
import type { SshCommandRuntimePhase, SshCommandRuntimeSnapshot } from './runtime';
|
||||||
|
|
||||||
export type WsManagerInstance = ReturnType<typeof createWebSocketConnectionManager>;
|
export type WsManagerInstance = ReturnType<typeof createWebSocketConnectionManager>;
|
||||||
export type SftpManagerInstance = ReturnType<typeof createSftpActionsManager>;
|
export type SftpManagerInstance = ReturnType<typeof createSftpActionsManager>;
|
||||||
@@ -27,7 +28,7 @@ export interface SessionState {
|
|||||||
editorTabs: Ref<FileTab[]>;
|
editorTabs: Ref<FileTab[]>;
|
||||||
activeEditorTabId: Ref<string | null>;
|
activeEditorTabId: Ref<string | null>;
|
||||||
commandInputContent: Ref<string>;
|
commandInputContent: Ref<string>;
|
||||||
isCommandRunning: Ref<boolean>;
|
commandRuntime: Ref<SshCommandRuntimeSnapshot>;
|
||||||
terminalInputBuffer: Ref<string>;
|
terminalInputBuffer: Ref<string>;
|
||||||
isResuming?: boolean;
|
isResuming?: boolean;
|
||||||
isMarkedForSuspend?: boolean;
|
isMarkedForSuspend?: boolean;
|
||||||
@@ -43,5 +44,6 @@ export interface SessionTabInfoWithStatus {
|
|||||||
terminalIndex: number;
|
terminalIndex: number;
|
||||||
status: WsConnectionStatus;
|
status: WsConnectionStatus;
|
||||||
isMarkedForSuspend?: boolean;
|
isMarkedForSuspend?: boolean;
|
||||||
|
commandRuntimePhase: SshCommandRuntimePhase;
|
||||||
isCommandRunning: boolean;
|
isCommandRunning: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user